From 40e53b14d69116aca68d9d1923354a4a5e7096c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 06:53:57 +0200 Subject: [PATCH] feat(practitioners): search and group the address book by specialty Search now covers the specialty column alongside name and practice, so a field name like "Zahnmedizin" finds the practice even when the name and practice hold nothing of the sort. The same OR reaches the visit form's practitioner picker, which now shows the specialty beside the practice so a specialty hit is legible. The list groups its rows under their specialty, sorted alphabetically with the unspecified rows gathered into one group at the end; when nothing carries a specialty the headings stay suppressed and the list reads flat as before. --- CHANGELOG.md | 1 + messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- src/app/api/practitioners/route.ts | 15 +- .../encounters/practitioner-combobox.tsx | 6 +- .../__tests__/practitioner-list.test.tsx | 106 +++++++++++++ .../practitioners/practitioner-list.tsx | 144 +++++++++++------- .../practitioner-specialty-search.test.ts | 134 ++++++++++++++++ 12 files changed, 366 insertions(+), 70 deletions(-) create mode 100644 src/components/practitioners/__tests__/practitioner-list.test.tsx create mode 100644 tests/integration/practitioner-specialty-search.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 04387ea8b..443200110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - A read that fails now shows a clear, recoverable error across the app instead of an empty list or a silent gap. The sharing and token screens, the ECG detail and list, the cycle and mood insights, the dashboard preventive-care tile, the checkups list, and the admin console all say when a read failed and offer a retry, rather than reading as "nothing here". - The AI consent and connector notes in Settings render as normal text now, not faint fine print, so the copy you are meant to read before turning a feature on is legible. - The record-a-visit action on the checkups list now appears only where there is a real practice visit behind the reminder. A reminder you satisfy yourself in the app, like weighing in or a mood check-in, no longer offers to file a visit it never involved. +- The practices and doctors address book searches by specialty now, so typing "Zahnmedizin" finds the dentist even when the name and practice hold nothing of the sort. The list groups its entries under their specialty, with the ones you left unspecified gathered at the end, and the visit form's picker shows each contact's field beside the practice. - The respiratory-rate tile on the dashboard shows its unit in your own language instead of a fixed English label, and the personalized greeting carries its comma. ### Removed diff --git a/messages/de.json b/messages/de.json index 2ff4e1815..ce64f5c1a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10276,7 +10276,7 @@ "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", "linkLabel": "Adressbuch", "add": "Eintrag", - "searchPlaceholder": "Name oder Praxis suchen", + "searchPlaceholder": "Name, Praxis oder Fachrichtung suchen", "loadError": "Adressbuch konnte nicht geladen werden.", "emptyTitle": "Noch kein Eintrag", "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", @@ -10295,6 +10295,7 @@ "saveFailed": "Eintrag konnte nicht gespeichert werden.", "deleteTitle": "Eintrag löschen?", "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", - "deleteFailed": "Eintrag konnte nicht gelöscht werden." + "deleteFailed": "Eintrag konnte nicht gelöscht werden.", + "noSpecialtyGroup": "Ohne Fachrichtung" } } diff --git a/messages/en.json b/messages/en.json index 8d9ee2611..8924bd4bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10276,7 +10276,7 @@ "description": "Your own address book — type a practice once, pick it everywhere.", "linkLabel": "Address book", "add": "Entry", - "searchPlaceholder": "Search name or practice", + "searchPlaceholder": "Search name, practice or specialty", "loadError": "The address book could not be loaded.", "emptyTitle": "No entry yet", "emptyDescription": "Add a practice so you can pick it when you record a visit.", @@ -10295,6 +10295,7 @@ "saveFailed": "The entry could not be saved.", "deleteTitle": "Delete this entry?", "deleteDescription": "Your existing visits stay, and they keep this name.", - "deleteFailed": "The entry could not be deleted." + "deleteFailed": "The entry could not be deleted.", + "noSpecialtyGroup": "No specialty" } } diff --git a/messages/es.json b/messages/es.json index 4d0782b1c..58506f46d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10276,7 +10276,7 @@ "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", "linkLabel": "Agenda", "add": "Entrada", - "searchPlaceholder": "Buscar nombre o consulta", + "searchPlaceholder": "Buscar nombre, consulta o especialidad", "loadError": "No se pudo cargar la agenda.", "emptyTitle": "Todavía no hay entradas", "emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.", @@ -10295,6 +10295,7 @@ "saveFailed": "No se pudo guardar la entrada.", "deleteTitle": "¿Eliminar esta entrada?", "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", - "deleteFailed": "No se pudo eliminar la entrada." + "deleteFailed": "No se pudo eliminar la entrada.", + "noSpecialtyGroup": "Sin especialidad" } } diff --git a/messages/fr.json b/messages/fr.json index 8ddc304d6..bafbe30a3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10276,7 +10276,7 @@ "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", "linkLabel": "Carnet d'adresses", "add": "Entrée", - "searchPlaceholder": "Rechercher un nom ou un cabinet", + "searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité", "loadError": "Impossible de charger le carnet d'adresses.", "emptyTitle": "Aucune entrée", "emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.", @@ -10295,6 +10295,7 @@ "saveFailed": "L'entrée n'a pas pu être enregistrée.", "deleteTitle": "Supprimer cette entrée ?", "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", - "deleteFailed": "L'entrée n'a pas pu être supprimée." + "deleteFailed": "L'entrée n'a pas pu être supprimée.", + "noSpecialtyGroup": "Sans spécialité" } } diff --git a/messages/it.json b/messages/it.json index b0cf8492f..8b19b3dda 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10276,7 +10276,7 @@ "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", "linkLabel": "Rubrica", "add": "Voce", - "searchPlaceholder": "Cerca nome o studio", + "searchPlaceholder": "Cerca nome, studio o specializzazione", "loadError": "Impossibile caricare la rubrica.", "emptyTitle": "Nessuna voce", "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.", @@ -10295,6 +10295,7 @@ "saveFailed": "Non è stato possibile salvare la voce.", "deleteTitle": "Eliminare questa voce?", "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", - "deleteFailed": "Non è stato possibile eliminare la voce." + "deleteFailed": "Non è stato possibile eliminare la voce.", + "noSpecialtyGroup": "Senza specializzazione" } } diff --git a/messages/pl.json b/messages/pl.json index d8bf1893d..570364f6c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10276,7 +10276,7 @@ "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", "linkLabel": "Książka adresowa", "add": "Wpis", - "searchPlaceholder": "Szukaj nazwiska lub gabinetu", + "searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji", "loadError": "Nie udało się wczytać książki adresowej.", "emptyTitle": "Brak wpisów", "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.", @@ -10295,6 +10295,7 @@ "saveFailed": "Nie udało się zapisać wpisu.", "deleteTitle": "Usunąć ten wpis?", "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", - "deleteFailed": "Nie udało się usunąć wpisu." + "deleteFailed": "Nie udało się usunąć wpisu.", + "noSpecialtyGroup": "Bez specjalizacji" } } diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index bac66da4b..cbd8fcabc 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -50,10 +50,11 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, - // Both plaintext columns, because a person looks a practice up by - // whichever of the two they remember: the doctor's name on the referral - // or the practice name on the door. Searching only the name made the - // second attempt return nothing and read as "not in the address book". + // Three plaintext columns, because a person looks a practice up by + // whichever they remember: the doctor's name on the referral, the + // practice name on the door, or the field they need ("Zahnmedizin"). + // Searching only name + practice made the specialty attempt return + // nothing and read as "not in the address book". ...(parsed.data.q ? { OR: [ @@ -69,6 +70,12 @@ export const GET = apiHandler(async (request: NextRequest) => { mode: "insensitive" as const, }, }, + { + specialty: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, ], } : {}), diff --git a/src/components/encounters/practitioner-combobox.tsx b/src/components/encounters/practitioner-combobox.tsx index c28dded64..aafdcdea2 100644 --- a/src/components/encounters/practitioner-combobox.tsx +++ b/src/components/encounters/practitioner-combobox.tsx @@ -128,9 +128,11 @@ export function PractitionerCombobox({ {row.name} - {row.practice ? ( + {row.practice || row.specialty ? ( - {row.practice} + {[row.practice, row.specialty] + .filter(Boolean) + .join(" · ")} ) : null} diff --git a/src/components/practitioners/__tests__/practitioner-list.test.tsx b/src/components/practitioners/__tests__/practitioner-list.test.tsx new file mode 100644 index 000000000..3da0158f8 --- /dev/null +++ b/src/components/practitioners/__tests__/practitioner-list.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Practitioner } from "@/hooks/use-practitioners"; + +/** + * The address book groups its rows by specialty. A long list reads by field + * ("Zahnmedizin", "Hausarzt", …) instead of one undifferentiated column, the + * unspecified rows collect into one group that sits last, and when nothing + * carries a specialty the headings are suppressed so a lone "no specialty" + * heading never floats over every row. + */ + +const listMock = vi.fn(); +vi.mock("@/hooks/use-practitioners", () => ({ + usePractitioners: () => listMock(), + usePractitionerMutations: () => ({ + remove: { mutate: vi.fn(), isPending: false }, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true }), +})); + +vi.mock("@/hooks/use-debounced-value", () => ({ + useDebouncedValue: (value: string) => value, +})); + +vi.mock("../practitioner-sheet", () => ({ + PractitionerSheet: () => null, +})); + +import { PractitionerList } from "../practitioner-list"; + +function row(partial: Partial & { id: string }): Practitioner { + return { + name: partial.id, + specialty: null, + practice: null, + location: null, + phone: null, + note: null, + createdAt: "2030-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + ...partial, + } as Practitioner; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" specialty grouping", () => { + it("renders a heading per specialty with the unspecified group last", () => { + listMock.mockReturnValue({ + data: [ + row({ id: "a", name: "Dr. Meyer", specialty: "Zahnmedizin" }), + row({ id: "b", name: "Dr. Braun", specialty: "Allgemeinmedizin" }), + row({ id: "c", name: "Dr. Klein", specialty: null }), + ], + isPending: false, + isError: false, + }); + + const html = render(); + + // Named groups render, alphabetically sorted, plus the fallback group. + expect(html).toContain('data-slot="practitioner-group-heading"'); + expect(html).toContain('data-specialty="Zahnmedizin"'); + expect(html).toContain('data-specialty="Allgemeinmedizin"'); + expect(html).toContain("No specialty"); + + // Allgemeinmedizin precedes Zahnmedizin (alphabetical), and the + // unspecified group sits after both. + const allgemein = html.indexOf('data-specialty="Allgemeinmedizin"'); + const zahn = html.indexOf('data-specialty="Zahnmedizin"'); + const unspecified = html.indexOf("No specialty"); + expect(allgemein).toBeLessThan(zahn); + expect(zahn).toBeLessThan(unspecified); + }); + + it("suppresses headings when no row carries a specialty", () => { + listMock.mockReturnValue({ + data: [ + row({ id: "a", name: "Dr. Meyer" }), + row({ id: "b", name: "Dr. Braun" }), + ], + isPending: false, + isError: false, + }); + + const html = render(); + + expect(html).not.toContain('data-slot="practitioner-group-heading"'); + expect(html).not.toContain("No specialty"); + // The rows themselves still render. + expect(html).toContain("Dr. Meyer"); + expect(html).toContain("Dr. Braun"); + }); +}); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 0503dc4b4..153d334b8 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -12,7 +12,7 @@ * has every reason to fear their history goes with it. */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -60,7 +60,32 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { setSession((n) => n + 1); }; - const rows = list.data ?? []; + const rows = useMemo(() => list.data ?? [], [list.data]); + + // Group the flat list by specialty so a long address book reads by field + // ("Zahnmedizin", "Hausarzt", …) instead of one undifferentiated column. + // Specialty is exact free-text: each distinct string is its own group, + // named groups sorted alphabetically, the unspecified rows collected into + // one group that always sits last. When nothing carries a specialty the + // headers are suppressed — a lone "no specialty" heading over every row is + // noise, so the list degrades to the flat form it had before. + const groups = useMemo(() => { + const bySpecialty = new Map(); + for (const row of rows) { + const key = row.specialty?.trim() ? row.specialty.trim() : null; + const bucket = bySpecialty.get(key); + if (bucket) bucket.push(row); + else bySpecialty.set(key, [row]); + } + const named = [...bySpecialty.entries()] + .filter((entry): entry is [string, Practitioner[]] => entry[0] !== null) + .sort((a, b) => a[0].localeCompare(b[0])); + const unspecified = bySpecialty.get(null); + return unspecified + ? [...named, [null, unspecified] as [null, Practitioner[]]] + : named; + }, [rows]); + const showGroupHeadings = groups.some(([specialty]) => specialty !== null); const addButton = canManage ? ( - - - ) : null} - - - +
+ {groups.map(([specialty, groupRows]) => ( +
+ {showGroupHeadings ? ( +

+ {specialty ?? t("practitioners.noSpecialtyGroup")} +

+ ) : null} +
    + {groupRows.map((row) => ( +
  • + + +
    +

    + {row.name} +

    + {row.practice || row.specialty ? ( +

    + {[row.practice, row.specialty] + .filter(Boolean) + .join(" · ")} +

    + ) : null} + {row.location ? ( +

    + {row.location} +

    + ) : null} +
    + {canManage ? ( +
    + + +
    + ) : null} +
    +
    +
  • + ))} +
+
))} - +
)} { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "practitioner-search-owner"; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "practitioner-search-owner", + email: "practitioner-search-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function search(q: string): Promise<{ id: string; name: string }[]> { + const res = await listPractitioners( + new Request( + `http://localhost/api/practitioners?q=${encodeURIComponent(q)}`, + ) as never, + ); + expect(res.status).toBe(200); + return ((await res.json()) as { data: { id: string; name: string }[] }).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("practitioner search by specialty", () => { + it("returns a row whose only 'Zahn' hit is its specialty", async () => { + const prisma = getPrismaClient(); + // Name + practice deliberately hold no "Zahn"; the field does. + await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Meyer", + practice: "Praxis am Park", + specialty: "Zahnmedizin", + }, + }); + // A control row that shares nothing with the query on any column. + await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Braun", + practice: "Hausarztpraxis Nord", + specialty: "Allgemeinmedizin", + }, + }); + + const hits = await search("Zahn"); + expect(hits).toHaveLength(1); + expect(hits[0].name).toBe("Dr. Meyer"); + }); + + it("still matches on name and practice, case-insensitively", async () => { + const prisma = getPrismaClient(); + await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Meyer", + practice: "Praxis am Park", + specialty: "Zahnmedizin", + }, + }); + await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Braun", + practice: "Hausarztpraxis Nord", + specialty: "Allgemeinmedizin", + }, + }); + + expect(await search("meyer")).toHaveLength(1); + expect(await search("nord")).toHaveLength(1); + expect(await search("medizin")).toHaveLength(2); + }); +});