From a9b40c7e6a9151413f8ed22b8a504bee9f085051 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 27 Aug 2026 17:07:48 +0200 Subject: [PATCH] Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../swyxweb/storage/AddressEntry.java | 12 +- .../swyxweb/storage/ArchiveService.java | 46 ++- .../swyxweb/web/AddressController.java | 14 +- .../swyxweb/web/WebhookController.java | 4 +- .../swyxweb/webhook/WebhookService.java | 13 +- .../swyxweb/AddressControllerTests.java | 23 +- .../swyxweb/WebhookControllerTests.java | 10 +- frontend/src/addresses.ts | 28 ++ frontend/src/components/ContactsPanel.tsx | 355 +++++++++--------- 9 files changed, 275 insertions(+), 230 deletions(-) diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java index a62df39..7117f9b 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java @@ -1,6 +1,5 @@ package de.appcreation.swyxweb.storage; -import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -32,20 +31,15 @@ import org.springframework.data.mongodb.core.mapping.Document; * * @param id Kennung des Dokuments, vergibt die MongoDB * @param number Rufnummer, so wie sie gewählt wird – erkennt die Dublette - * @param receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des - * letzten Eingangs - * @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere}, - * {@code webhook} (generischer Webhook) oder {@code swyxtray} * @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)" * @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164" - * oder "Kurier | NL Hamburg | PKW | cr 2024" + * oder "Kurier | NL Hamburg | PKW | cr 2024" – die Herkunft + * eines Eintrags steht damit im Eintrag selbst */ @Document("addresses") public record AddressEntry( @Id String id, String number, - Instant receivedAt, - String source, String name, String description) { @@ -72,7 +66,7 @@ public record AddressEntry( if (!node.isObject()) return; String number = text(node, "number"); if (number == null) return; - entries.add(new AddressEntry(null, number, null, null, text(node, "name"), text(node, "description"))); + entries.add(new AddressEntry(null, number, text(node, "name"), text(node, "description"))); } /** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */ diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java index 8d56b17..e98169f 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -1,8 +1,8 @@ package de.appcreation.swyxweb.storage; -import java.time.Instant; import java.util.List; import java.util.concurrent.ExecutorService; +import java.util.regex.Pattern; import java.util.concurrent.Executors; import jakarta.annotation.PreDestroy; @@ -50,32 +50,46 @@ public class ArchiveService { } /** - * Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die - * Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer - * bekannten Rufnummer wird aktualisiert statt verdoppelt – die - * SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch. - * - * @param receivedAt Eingangszeit im Backend - * @param source {@code kunden}, {@code kuriere}, {@code webhook} - * (generischer Webhook) oder {@code swyxtray} + * Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab – alle + * Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben + * Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung + * ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten + * Rufnummer wird aktualisiert statt verdoppelt – die SwyxTray-App schickt + * bei jeder Cache-Änderung das ganze Telefonbuch. */ - public void saveAddresses(Instant receivedAt, String source, List entries) { + public void saveAddresses(List entries) { if (entries.isEmpty()) return; // Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder // legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB. submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert( new Query(Criteria.where("number").is(entry.number())), new Update() - .set("receivedAt", receivedAt) - .set("source", source) .set("name", entry.name()) - .set("description", entry.description()), + .set("description", entry.description()) + // Altbestand angleichen: Diese Felder wurden früher + // mitgeschrieben und sollen aus der Sammlung verschwinden. + .unset("receivedAt") + .unset("source"), AddressEntry.class))); } - /** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */ - public List addresses() { - return mongo.find(new Query().with(Sort.by("name")), AddressEntry.class); + /** + * Die gespeicherten Adressdaten, nach Namen sortiert – gelesen direkt aus + * der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff + * wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht, + * ohne Beachtung der Groß-/Kleinschreibung. + */ + public List addresses(String query) { + Query find = new Query().with(Sort.by("name")); + if (query != null && !query.isBlank()) { + // Der Begriff ist Text, kein Muster – Sonderzeichen werden zitiert. + String regex = Pattern.quote(query.trim()); + find.addCriteria(new Criteria().orOperator( + Criteria.where("name").regex(regex, "i"), + Criteria.where("number").regex(regex, "i"), + Criteria.where("description").regex(regex, "i"))); + } + return mongo.find(find, AddressEntry.class); } /** diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java index 09f4374..080775b 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java @@ -1,6 +1,5 @@ package de.appcreation.swyxweb.web; -import java.time.Instant; import java.util.List; import tools.jackson.databind.JsonNode; @@ -11,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -53,13 +53,17 @@ public class AddressController { if (entries.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast."); } - archive.saveAddresses(Instant.now(), "swyxtray", entries); + archive.saveAddresses(entries); return entries.size(); } - /** Die gespeicherten Adressdaten – gelesen direkt aus der MongoDB. */ + /** + * Die gespeicherten Adressdaten – gelesen direkt aus der MongoDB, jede + * Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht + * (Teilzeichenkette in Name, Rufnummer und Beschreibung). + */ @GetMapping - public List list() { - return archive.addresses(); + public List list(@RequestParam(name = "q", required = false) String query) { + return archive.addresses(query); } } diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java index 5db248e..bd428c5 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java @@ -81,7 +81,7 @@ public class WebhookController { return accept(payload, token, null); } - /** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */ + /** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */ @PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE) public Receipt receiveKunden( @RequestBody JsonNode payload, @@ -89,7 +89,7 @@ public class WebhookController { return accept(payload, token, "kunden"); } - /** Adressdaten der Kuriere; abgelegt mit {@code source: "kuriere"}. */ + /** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */ @PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE) public Receipt receiveKuriere( @RequestBody JsonNode payload, diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java index 5aa5a08..67ede29 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java @@ -133,22 +133,21 @@ public class WebhookService { } /** - * Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die - * Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim - * generischen Webhook ({@code channel == null}) entscheidet die Nutzlast - * selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten. + * Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs + * sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet + * die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind + * Adressdaten. Alle Adressdaten landen in derselben Sammlung. */ private void archiveByChannel(WebhookEvent event, String channel) { JsonNode payload = event.payload(); switch (channel == null ? "" : channel) { - case "kunden", "kuriere" -> - archive.saveAddresses(event.receivedAt(), channel, AddressEntry.manyFrom(payload)); + case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload)); case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt())); default -> { if (JobEntry.isJob(payload)) { archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt())); } else { - archive.saveAddresses(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload)); + archive.saveAddresses(AddressEntry.manyFrom(payload)); } } } diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java index bd87739..cdd5c18 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java @@ -1,13 +1,15 @@ package de.appcreation.swyxweb; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.List; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -16,6 +18,7 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import de.appcreation.swyxweb.storage.AddressEntry; import de.appcreation.swyxweb.storage.ArchiveService; /** @@ -44,7 +47,21 @@ class AddressControllerTests { .andExpect(status().isAccepted()) .andExpect(jsonPath("$").value(2)); - verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2)); + verify(archive).saveAddresses(argThat(entries -> entries.size() == 2)); + } + + /** Die Suche läuft in der Datenbank – der Begriff wird durchgereicht. */ + @Test + void listPassesQueryToArchive() throws Exception { + when(archive.addresses("muster")) + .thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null))); + + mvc.perform(get("/api/addresses").param("q", "muster")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Muster GmbH")) + .andExpect(jsonPath("$[0].number").value("+493012345")); + + verify(archive).addresses("muster"); } @Test diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java index 7394208..91a2219 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java @@ -1,9 +1,7 @@ package de.appcreation.swyxweb; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -118,8 +116,10 @@ class WebhookControllerTests { .containsExactly("kunden", "kuriere", "jobs"); // Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job. - verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1)); - verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1)); + verify(archive).saveAddresses(argThat(entries -> + entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name()))); + verify(archive).saveAddresses(argThat(entries -> + entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name()))); verify(archive).saveJob(argThat(job -> job.id() == 4711)); } @@ -132,7 +132,7 @@ class WebhookControllerTests { .andExpect(status().isOk()); assertThat(service.history().getFirst().channel()).isNull(); - verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1)); + verify(archive).saveAddresses(argThat(entries -> entries.size() == 1)); } /** Ohne Token in der Konfiguration darf der Kopf fehlen. */ diff --git a/frontend/src/addresses.ts b/frontend/src/addresses.ts index 070d1e8..06bedab 100644 --- a/frontend/src/addresses.ts +++ b/frontend/src/addresses.ts @@ -8,6 +8,34 @@ import type { Contact } from './swyx/protocol' * diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den * Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter. */ +/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */ +interface StoredAddress { + number?: string | null + name?: string | null + description?: string | null +} + +/** + * Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab – + * den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem + * SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in + * Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten. + */ +export async function fetchAddresses(query = ''): Promise { + const wanted = query.trim() + const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses' + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`) + } + const entries = (await response.json()) as StoredAddress[] + return entries.map((entry) => ({ + name: entry.name ?? undefined, + number: entry.number ?? undefined, + description: entry.description ?? undefined, + })) +} + export function reportAddresses(contacts: Contact[]): void { if (contacts.length === 0) return void fetch('/api/addresses', { diff --git a/frontend/src/components/ContactsPanel.tsx b/frontend/src/components/ContactsPanel.tsx index 2b24642..3234b61 100644 --- a/frontend/src/components/ContactsPanel.tsx +++ b/frontend/src/components/ContactsPanel.tsx @@ -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 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(null) + // Das jeweils letzte Abfrageergebnis – kein Bestand, nur die Anzeige. + const [results, setResults] = useState(null) + const [loadError, setLoadError] = useState(null) + // Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge. + const [incomplete, setIncomplete] = useState(false) const [progress, setProgress] = useState(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(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 - {disabled ? ( -

Erst mit der SwyxTray-App verbinden.

+ {/* 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. */} +
+ +
+ + {loadError &&

{loadError}

} + + {results === null ? ( + !loadError &&

Adressdaten werden abgefragt …

) : ( <> - {/* 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. */} -
- -
+

+ {filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`} + {' · direkt aus der Adress-Ablage des Backends'} +

- {directory === null ? ( -

{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}

+ {incomplete && ( +

+ Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen. + „Neu laden" versucht es erneut. +

+ )} + + {results.length === 0 ? ( +

+ {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.'} +

) : ( - <> -

- {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`} -

- - {!directory.complete && ( -

- {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. -

- )} - - {visible.length === 0 ? ( -

Kein Eintrag zu „{filter.trim()}".

- ) : ( -
    - {visible.map((contact, index) => ( - // Namen sind nicht eindeutig – dieselbe Person kommt mit - // mehreren Durchwahlen vor; deshalb die Position mit hinein. -
  • + {results.map((contact, index) => ( + // Namen sind nicht eindeutig – dieselbe Person kommt mit + // mehreren Durchwahlen vor; deshalb die Position mit hinein. +
  • + + {describeContact(contact)} + {contact.description && ( + {contact.description} + )} + + {contact.number && {contact.number}} + {contact.number && ( + - )} -
  • - ))} -
- )} - + Anrufen + + )} + + ))} + )} )}

- 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.

{loading && ( 0 ? `${progress.found} Einträge bisher` : undefined}