Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt
Die Sammlung addresses führt für alle Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) nur noch Rufnummer, Name und Beschreibung; alte Felder receivedAt/source werden beim Upsert entfernt. GET /api/addresses sucht mit ?q= als Teilzeichenkette in allen drei Feldern. Der Bereich "Adressdaten" hält keinen Bestand mehr im Browser: Jede Eingabe fragt entprellt die Datenbank ab, das Suchfeld startet leer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,145 +1,142 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describeContact, type Contact } from '../swyx/protocol'
|
||||
import {
|
||||
filterContacts,
|
||||
sortContacts,
|
||||
type Directory,
|
||||
type DirectoryProgress,
|
||||
type SweepOptions,
|
||||
} from '../swyx/directory'
|
||||
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
|
||||
import { fetchAddresses } from '../addresses'
|
||||
import LoadingDialog from './LoadingDialog'
|
||||
|
||||
interface Props {
|
||||
/** Panel ist sichtbar – erst dann wird geladen. */
|
||||
/** Panel ist sichtbar – erst dann wird abgefragt. */
|
||||
active: boolean
|
||||
/** Keine Verbindung zur SwyxTray-App – Anrufen und Telefonbuch-Abgleich entfallen. */
|
||||
disabled: boolean
|
||||
/**
|
||||
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
|
||||
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
|
||||
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
|
||||
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen – das Backend
|
||||
* hat ihn gerade hineingeschrieben.
|
||||
*/
|
||||
cache: Contact[] | null
|
||||
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
||||
onDial: (number: string) => void
|
||||
}
|
||||
|
||||
const STORAGE_FILTER = 'swyxweb.contacts.filter'
|
||||
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
|
||||
const ARCHIVE_WRITE_DELAY_MS = 1500
|
||||
|
||||
function stored(key: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? ''
|
||||
} catch {
|
||||
// Privater Modus o. Ä. – dann eben ohne Gedächtnis.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// absichtlich still
|
||||
}
|
||||
}
|
||||
/** Entprellung der Eingabe – erst dann geht die Abfrage an die Datenbank. */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Adressdaten des Swyx-Clients – der **gesamte** Bestand des globalen
|
||||
* Telefonbuchs.
|
||||
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
|
||||
* `addresses`) – dem zusammengeführten Bestand aus den Webhooks (Kunden,
|
||||
* Kuriere) und dem Telefonbuch des Swyx-Clients.
|
||||
*
|
||||
* Drei Wege, in dieser Reihenfolge:
|
||||
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt –
|
||||
* entprellt – die Datenbank ab, gesucht wird dort als Teilzeichenkette in
|
||||
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
|
||||
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
|
||||
* SwyxTray-App.
|
||||
*
|
||||
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
|
||||
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird – kein
|
||||
* Laden, kein Wartedialog.
|
||||
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
|
||||
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
|
||||
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
|
||||
* einem Wartedialog.
|
||||
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage:
|
||||
*
|
||||
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
|
||||
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) – die Startseite meldet
|
||||
* ihn ans Backend; das Panel fragt die Ablage danach neu ab.
|
||||
* 2. „Neu laden" bei bestehender Verbindung: erst das Telefonbuch der App
|
||||
* holen (Cache oder rund 110 Einzelabfragen), das Backend legt es ab,
|
||||
* dann die Ablage abfragen.
|
||||
*/
|
||||
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
|
||||
const [directory, setDirectory] = useState<Directory | null>(null)
|
||||
// Das jeweils letzte Abfrageergebnis – kein Bestand, nur die Anzeige.
|
||||
const [results, setResults] = useState<Contact[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
// Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge.
|
||||
const [incomplete, setIncomplete] = useState(false)
|
||||
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
|
||||
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
|
||||
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
|
||||
const loadedRef = useRef(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
|
||||
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
|
||||
const loadIdRef = useRef(0)
|
||||
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
|
||||
// mehr überschreiben – Antworten kommen nicht zwingend in Reihenfolge.
|
||||
const queryIdRef = useRef(0)
|
||||
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
|
||||
// (Cache-Push, „Neu laden") – ohne den Effekt neu anzustoßen.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
|
||||
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
|
||||
const supersede = useCallback(() => {
|
||||
loadIdRef.current += 1
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
|
||||
const search = useCallback(async () => {
|
||||
const ticket = ++queryIdRef.current
|
||||
try {
|
||||
const entries = await fetchAddresses(filterRef.current)
|
||||
if (ticket !== queryIdRef.current) return
|
||||
setResults(entries)
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
if (ticket !== queryIdRef.current) return
|
||||
setLoadError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
|
||||
supersede()
|
||||
const ticket = loadIdRef.current
|
||||
// Jede Eingabe fragt die Datenbank ab – entprellt; die erste Abfrage nach
|
||||
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
|
||||
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
|
||||
// Antwort eine weitere Abfrage anstoßen.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, filter, search])
|
||||
|
||||
/**
|
||||
* „Neu laden": Bei bestehender Verbindung zuerst das Telefonbuch der App
|
||||
* holen – das Backend schreibt es dabei in die Ablage –, danach in jedem
|
||||
* Fall die Ablage frisch abfragen.
|
||||
*/
|
||||
const reload = useCallback(async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setProgress({ done: 0, total: 0, found: 0 })
|
||||
try {
|
||||
const result = await onLoad({
|
||||
signal: controller.signal,
|
||||
onProgress: (next) => {
|
||||
if (ticket === loadIdRef.current) setProgress(next)
|
||||
},
|
||||
})
|
||||
if (ticket !== loadIdRef.current) return
|
||||
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||
if (result) setDirectory(result)
|
||||
if (!disabled) {
|
||||
setProgress({ done: 0, total: 0, found: 0 })
|
||||
const result = await onLoad({
|
||||
signal: controller.signal,
|
||||
onProgress: (next) => {
|
||||
if (abortRef.current === controller) setProgress(next)
|
||||
},
|
||||
})
|
||||
if (abortRef.current !== controller) return
|
||||
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||
setIncomplete(result !== null && !result.complete)
|
||||
// Das Backend schreibt nebenbei; kurz warten, damit die Abfrage den
|
||||
// frischen Stand schon sieht.
|
||||
await new Promise((resolve) => setTimeout(resolve, ARCHIVE_WRITE_DELAY_MS))
|
||||
if (abortRef.current !== controller) return
|
||||
}
|
||||
await search()
|
||||
} finally {
|
||||
if (ticket === loadIdRef.current) {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
}, [onLoad, supersede])
|
||||
}, [disabled, onLoad, search])
|
||||
|
||||
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
|
||||
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
|
||||
// dagegen keine Antwort – dann bleibt es beim Laden weiter unten.
|
||||
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
|
||||
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
|
||||
useEffect(() => {
|
||||
if (!cache || cache.length === 0) return
|
||||
loadedRef.current = true
|
||||
supersede()
|
||||
setDirectory({
|
||||
contacts: sortContacts(cache),
|
||||
source: 'cache',
|
||||
queries: 0,
|
||||
complete: true,
|
||||
aborted: false,
|
||||
})
|
||||
}, [cache, supersede])
|
||||
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [cache, search])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
|
||||
supersede()
|
||||
loadedRef.current = false
|
||||
setDirectory(null)
|
||||
return
|
||||
}
|
||||
if (!active || loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
void load()
|
||||
}, [active, disabled, load, supersede])
|
||||
|
||||
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
|
||||
// Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
|
||||
// Seite verschwindet.
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const visible = useMemo(
|
||||
() => (directory ? filterContacts(directory.contacts, filter) : []),
|
||||
[directory, filter],
|
||||
)
|
||||
|
||||
const loading = progress !== null
|
||||
|
||||
return (
|
||||
@@ -149,108 +146,100 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={() => void load()}
|
||||
disabled={disabled || loading}
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Lädt …' : 'Neu laden'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
|
||||
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
|
||||
und würde als direktes Kind der Karte – einer Spalte – in die *Höhe*
|
||||
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
|
||||
die Breite, wie in allen anderen Bereichen auch. */}
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field__label">Suchen</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="search"
|
||||
value={filter}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="Name, Rufnummer oder Kürzel"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loadError && <p className="note note--error">{loadError}</p>}
|
||||
|
||||
{results === null ? (
|
||||
!loadError && <p className="note">Adressdaten werden abgefragt …</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
|
||||
und würde als direktes Kind der Karte – einer Spalte – in die *Höhe*
|
||||
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
|
||||
die Breite, wie in allen anderen Bereichen auch. */}
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field__label">Filtern</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="search"
|
||||
value={filter}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="Name, Rufnummer oder Kürzel"
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
remember(STORAGE_FILTER, e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="note">
|
||||
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
|
||||
{' · direkt aus der Adress-Ablage des Backends'}
|
||||
</p>
|
||||
|
||||
{directory === null ? (
|
||||
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
|
||||
{incomplete && (
|
||||
<p className="note note--error">
|
||||
Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen.
|
||||
„Neu laden" versucht es erneut.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{results.length === 0 ? (
|
||||
<p className="note">
|
||||
{filter.trim()
|
||||
? `Kein Eintrag zu „${filter.trim()}".`
|
||||
: 'Die Ablage ist noch leer – sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="note">
|
||||
{filter.trim()
|
||||
? `${visible.length} von ${directory.contacts.length} Einträgen`
|
||||
: `${directory.contacts.length} Einträge`}
|
||||
{directory.source === 'cache'
|
||||
? ' · aus dem Adress-Cache der App'
|
||||
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
|
||||
</p>
|
||||
|
||||
{!directory.complete && (
|
||||
<p className="note note--error">
|
||||
{directory.aborted
|
||||
? 'Abgebrochen – der Bestand ist unvollständig.'
|
||||
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
|
||||
„Neu laden" versucht es erneut.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
|
||||
) : (
|
||||
<ul className="contacts">
|
||||
{visible.map((contact, index) => (
|
||||
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||
<li
|
||||
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
|
||||
className="contacts__item"
|
||||
<ul className="contacts">
|
||||
{results.map((contact, index) => (
|
||||
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||
<li
|
||||
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
|
||||
className="contacts__item"
|
||||
>
|
||||
<span className="contacts__info">
|
||||
<span className="contacts__name">{describeContact(contact)}</span>
|
||||
{contact.description && (
|
||||
<span className="contacts__description">{contact.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{contact.number && <span className="contacts__number">{contact.number}</span>}
|
||||
{contact.number && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--small"
|
||||
onClick={() => onDial(contact.number!)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="contacts__info">
|
||||
<span className="contacts__name">{describeContact(contact)}</span>
|
||||
{contact.description && (
|
||||
<span className="contacts__description">{contact.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{contact.number && <span className="contacts__number">{contact.number}</span>}
|
||||
{contact.number && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--small"
|
||||
onClick={() => onDial(contact.number!)}
|
||||
>
|
||||
Anrufen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
Anrufen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="note">
|
||||
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App –
|
||||
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
|
||||
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
|
||||
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
|
||||
Abfrage.
|
||||
Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab – die per Webhook
|
||||
gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
|
||||
kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
|
||||
SwyxTray-App in die Ablage ein – beim Verbinden von selbst, bei „Neu laden" auf
|
||||
Anforderung.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<LoadingDialog
|
||||
title="Adressdaten werden geladen"
|
||||
title="Telefonbuch wird abgeglichen"
|
||||
done={progress.done}
|
||||
total={progress.total}
|
||||
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
||||
|
||||
Reference in New Issue
Block a user