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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 06:53:57 +02:00
1 parent 9aefce1625
commit 40e53b14d6
12 files changed
+366 -70

No files matched your search

+1
View File
@@ -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
+3 -2
View File
@@ -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"
}
}
+3 -2
View File
@@ -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"
}
}
+3 -2
View File
@@ -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"
}
}
+3 -2
View File
@@ -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é"
}
}
+3 -2
View File
@@ -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"
}
}
+3 -2
View File
@@ -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"
}
}
+11 -4
View File
@@ -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,
},
},
],
}
: {}),
@@ -128,9 +128,11 @@ export function PractitionerCombobox({
<span className="text-foreground block truncate">
{row.name}
</span>
{row.practice ? (
{row.practice || row.specialty ? (
<span className="text-muted-foreground block truncate text-xs">
{row.practice}
{[row.practice, row.specialty]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
</span>
@@ -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<Practitioner> & { 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(
<I18nProvider initialLocale="en">
<PractitionerList />
</I18nProvider>,
);
}
describe("<PractitionerList> 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");
});
});
@@ -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<string | null, Practitioner[]>();
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 ? (
<Button
@@ -127,57 +152,72 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
ctaSize="lg"
/>
) : (
<ul className="space-y-2" data-slot="practitioner-list">
{rows.map((row) => (
<li key={row.id}>
<Card className="gap-2 py-3 md:py-4">
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{row.name}
</p>
{row.practice || row.specialty ? (
<p className="text-foreground truncate text-sm">
{[row.practice, row.specialty]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{row.location ? (
<p className="text-muted-foreground truncate text-xs">
{row.location}
</p>
) : null}
</div>
{canManage ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.edit")}
onClick={() => openSheet(row)}
>
<Pencil className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.delete")}
onClick={() => setDeleting(row)}
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
) : null}
</CardContent>
</Card>
</li>
<div className="space-y-4" data-slot="practitioner-list">
{groups.map(([specialty, groupRows]) => (
<div key={specialty ?? "__unspecified__"} className="space-y-2">
{showGroupHeadings ? (
<h2
className="text-muted-foreground px-1 text-xs font-medium"
data-slot="practitioner-group-heading"
data-specialty={specialty ?? ""}
>
{specialty ?? t("practitioners.noSpecialtyGroup")}
</h2>
) : null}
<ul className="space-y-2">
{groupRows.map((row) => (
<li key={row.id}>
<Card className="gap-2 py-3 md:py-4">
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{row.name}
</p>
{row.practice || row.specialty ? (
<p className="text-foreground truncate text-sm">
{[row.practice, row.specialty]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{row.location ? (
<p className="text-muted-foreground truncate text-xs">
{row.location}
</p>
) : null}
</div>
{canManage ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.edit")}
onClick={() => openSheet(row)}
>
<Pencil className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.delete")}
onClick={() => setDeleting(row)}
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
) : null}
</CardContent>
</Card>
</li>
))}
</ul>
</div>
))}
</ul>
</div>
)}
<PractitionerSheet
@@ -0,0 +1,134 @@
/**
* Practitioner search covers the specialty column, against real Postgres.
*
* The property lives in SQL: the route's `where` OR filters name, practice
* AND specialty case-insensitively. A mocked client cannot go red on it —
* the filter it is meant to prove runs in the database, not in the handler.
* So this seeds a row whose ONLY hit for "Zahn" is its specialty and asserts
* the route returns it, plus a control row that must not come back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { GET as listPractitioners } from "@/app/api/practitioners/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
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);
});
});