diff --git a/messages/de.json b/messages/de.json
index 22e0b9015..8d03ea71b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -10073,6 +10073,86 @@
"hospital": "Krankenhaus",
"therapy": "Therapietermin",
"other": "Arzttermin"
- }
+ },
+ "sectionTitle": "Termine",
+ "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
+ "add": "Termin",
+ "loadError": "Termine konnten nicht geladen werden.",
+ "emptyTitle": "Noch kein Termin erfasst",
+ "emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.",
+ "upcomingHeading": "Anstehend",
+ "pastHeading": "Vergangen",
+ "createTitle": "Termin erfassen",
+ "editTitle": "Termin bearbeiten",
+ "formDescription": "Nur das Datum ist Pflicht.",
+ "created": "Termin gespeichert.",
+ "updated": "Termin aktualisiert.",
+ "saveFailed": "Termin konnte nicht gespeichert werden.",
+ "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.",
+ "status": {
+ "planned": "Geplant",
+ "done": "Stattgefunden",
+ "cancelled": "Abgesagt",
+ "noShow": "Nicht wahrgenommen"
+ },
+ "card": {
+ "documentCount": "verknüpfte Dokumente",
+ "labCount": "verknüpfte Laborwerte",
+ "conditionCount": "verknüpfte Erkrankungen"
+ },
+ "form": {
+ "date": "Datum und Uhrzeit",
+ "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.",
+ "status": "Status",
+ "practitioner": "Praxis oder Ärztin",
+ "practitionerHint": "Optional — du kannst den Namen später nachtragen.",
+ "practitionerNone": "Keine Angabe",
+ "practitionerSearch": "Name oder Praxis suchen",
+ "practitionerNoMatch": "Kein Eintrag gefunden.",
+ "practitionerAdd": "Neu anlegen",
+ "practitionerClear": "Auswahl entfernen",
+ "kind": "Art",
+ "reason": "Anlass",
+ "outcome": "Ergebnis",
+ "outcomeReveal": "Ergebnis eintragen",
+ "linkDocuments": "Dokumente",
+ "linkLabResults": "Laborwerte",
+ "linkConditions": "Erkrankungen",
+ "linkNothingToOffer": "Nichts zum Verknüpfen vorhanden."
+ },
+ "deleteTitle": "Termin löschen?",
+ "deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.",
+ "deleteFailed": "Termin konnte nicht gelöscht werden."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Vorsorge oder Termine anzeigen",
+ "tabVorsorge": "Vorsorge",
+ "tabVisits": "Termine"
+ },
+ "practitioners": {
+ "title": "Praxen und Ärzte",
+ "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.",
+ "linkLabel": "Adressbuch",
+ "add": "Eintrag",
+ "searchPlaceholder": "Name oder Praxis suchen",
+ "loadError": "Adressbuch konnte nicht geladen werden.",
+ "emptyTitle": "Noch kein Eintrag",
+ "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.",
+ "noMatchTitle": "Kein Treffer",
+ "noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.",
+ "createTitle": "Eintrag anlegen",
+ "editTitle": "Eintrag bearbeiten",
+ "formDescription": "Nur der Name ist Pflicht.",
+ "name": "Name",
+ "specialty": "Fachrichtung",
+ "practice": "Praxis",
+ "location": "Adresse",
+ "phone": "Telefon",
+ "note": "Notiz",
+ "noteHint": "Verschlüsselt gespeichert.",
+ "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."
}
}
diff --git a/messages/en.json b/messages/en.json
index afacc42d2..c62cff149 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -10073,6 +10073,86 @@
"hospital": "Hospital",
"therapy": "Therapy session",
"other": "Appointment"
- }
+ },
+ "sectionTitle": "Visits",
+ "sectionDescription": "Doctor's visits and booked appointments in one place.",
+ "add": "Visit",
+ "loadError": "Visits could not be loaded.",
+ "emptyTitle": "No visit recorded yet",
+ "emptyDescription": "A date is enough. Everything else is optional.",
+ "upcomingHeading": "Upcoming",
+ "pastHeading": "Past",
+ "createTitle": "Record a visit",
+ "editTitle": "Edit visit",
+ "formDescription": "Only the date is required.",
+ "created": "Visit saved.",
+ "updated": "Visit updated.",
+ "saveFailed": "The visit could not be saved.",
+ "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.",
+ "status": {
+ "planned": "Planned",
+ "done": "Attended",
+ "cancelled": "Cancelled",
+ "noShow": "Missed"
+ },
+ "card": {
+ "documentCount": "linked documents",
+ "labCount": "linked lab results",
+ "conditionCount": "linked conditions"
+ },
+ "form": {
+ "date": "Date and time",
+ "dateDerivesStatusHint": "A date in the future is a booked appointment.",
+ "status": "Status",
+ "practitioner": "Practice or doctor",
+ "practitionerHint": "Optional — you can add the name later.",
+ "practitionerNone": "Not specified",
+ "practitionerSearch": "Search name or practice",
+ "practitionerNoMatch": "No entry found.",
+ "practitionerAdd": "Add new",
+ "practitionerClear": "Clear selection",
+ "kind": "Kind",
+ "reason": "Reason",
+ "outcome": "Outcome",
+ "outcomeReveal": "Add an outcome",
+ "linkDocuments": "Documents",
+ "linkLabResults": "Lab results",
+ "linkConditions": "Conditions",
+ "linkNothingToOffer": "Nothing here to link yet."
+ },
+ "deleteTitle": "Delete this visit?",
+ "deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.",
+ "deleteFailed": "The visit could not be deleted."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Show checkups or visits",
+ "tabVorsorge": "Checkups",
+ "tabVisits": "Visits"
+ },
+ "practitioners": {
+ "title": "Practices and doctors",
+ "description": "Your own address book — type a practice once, pick it everywhere.",
+ "linkLabel": "Address book",
+ "add": "Entry",
+ "searchPlaceholder": "Search name or practice",
+ "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.",
+ "noMatchTitle": "No match",
+ "noMatchDescription": "No entry matches this search.",
+ "createTitle": "Add an entry",
+ "editTitle": "Edit entry",
+ "formDescription": "Only the name is required.",
+ "name": "Name",
+ "specialty": "Specialty",
+ "practice": "Practice",
+ "location": "Address",
+ "phone": "Phone",
+ "note": "Note",
+ "noteHint": "Stored encrypted.",
+ "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."
}
}
diff --git a/messages/es.json b/messages/es.json
index 7cec1ba45..a7c38c795 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -10073,6 +10073,86 @@
"hospital": "Hospital",
"therapy": "Sesión de terapia",
"other": "Cita médica"
- }
+ },
+ "sectionTitle": "Citas",
+ "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
+ "add": "Cita",
+ "loadError": "No se pudieron cargar las citas.",
+ "emptyTitle": "Todavía no hay ninguna cita",
+ "emptyDescription": "Basta con una fecha. Todo lo demás es opcional.",
+ "upcomingHeading": "Próximas",
+ "pastHeading": "Pasadas",
+ "createTitle": "Registrar una cita",
+ "editTitle": "Editar la cita",
+ "formDescription": "Solo la fecha es obligatoria.",
+ "created": "Cita guardada.",
+ "updated": "Cita actualizada.",
+ "saveFailed": "No se pudo guardar la cita.",
+ "checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.",
+ "status": {
+ "planned": "Prevista",
+ "done": "Realizada",
+ "cancelled": "Cancelada",
+ "noShow": "No acudida"
+ },
+ "card": {
+ "documentCount": "documentos vinculados",
+ "labCount": "resultados de laboratorio vinculados",
+ "conditionCount": "enfermedades vinculadas"
+ },
+ "form": {
+ "date": "Fecha y hora",
+ "dateDerivesStatusHint": "Una fecha futura es una cita reservada.",
+ "status": "Estado",
+ "practitioner": "Consulta o médico",
+ "practitionerHint": "Opcional: puedes añadir el nombre más tarde.",
+ "practitionerNone": "Sin indicar",
+ "practitionerSearch": "Buscar nombre o consulta",
+ "practitionerNoMatch": "No se encontró ninguna entrada.",
+ "practitionerAdd": "Añadir nueva",
+ "practitionerClear": "Quitar la selección",
+ "kind": "Tipo",
+ "reason": "Motivo",
+ "outcome": "Resultado",
+ "outcomeReveal": "Añadir un resultado",
+ "linkDocuments": "Documentos",
+ "linkLabResults": "Resultados de laboratorio",
+ "linkConditions": "Enfermedades",
+ "linkNothingToOffer": "Todavía no hay nada que vincular."
+ },
+ "deleteTitle": "¿Eliminar esta cita?",
+ "deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.",
+ "deleteFailed": "No se pudo eliminar la cita."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Mostrar revisiones o citas",
+ "tabVorsorge": "Revisiones",
+ "tabVisits": "Citas"
+ },
+ "practitioners": {
+ "title": "Consultas y médicos",
+ "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.",
+ "linkLabel": "Agenda",
+ "add": "Entrada",
+ "searchPlaceholder": "Buscar nombre o consulta",
+ "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.",
+ "noMatchTitle": "Sin coincidencias",
+ "noMatchDescription": "Ninguna entrada coincide con esta búsqueda.",
+ "createTitle": "Añadir una entrada",
+ "editTitle": "Editar la entrada",
+ "formDescription": "Solo el nombre es obligatorio.",
+ "name": "Nombre",
+ "specialty": "Especialidad",
+ "practice": "Consulta",
+ "location": "Dirección",
+ "phone": "Teléfono",
+ "note": "Nota",
+ "noteHint": "Se guarda cifrada.",
+ "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."
}
}
diff --git a/messages/fr.json b/messages/fr.json
index d91f2cf3d..3b69dd66d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -10073,6 +10073,86 @@
"hospital": "Hôpital",
"therapy": "Séance de thérapie",
"other": "Rendez-vous médical"
- }
+ },
+ "sectionTitle": "Rendez-vous",
+ "sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
+ "add": "Rendez-vous",
+ "loadError": "Impossible de charger les rendez-vous.",
+ "emptyTitle": "Aucun rendez-vous enregistré",
+ "emptyDescription": "Une date suffit. Le reste est facultatif.",
+ "upcomingHeading": "À venir",
+ "pastHeading": "Passés",
+ "createTitle": "Enregistrer un rendez-vous",
+ "editTitle": "Modifier le rendez-vous",
+ "formDescription": "Seule la date est obligatoire.",
+ "created": "Rendez-vous enregistré.",
+ "updated": "Rendez-vous mis à jour.",
+ "saveFailed": "Le rendez-vous n'a pas pu être enregistré.",
+ "checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.",
+ "status": {
+ "planned": "Prévu",
+ "done": "Effectué",
+ "cancelled": "Annulé",
+ "noShow": "Non honoré"
+ },
+ "card": {
+ "documentCount": "documents liés",
+ "labCount": "résultats de laboratoire liés",
+ "conditionCount": "maladies liées"
+ },
+ "form": {
+ "date": "Date et heure",
+ "dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.",
+ "status": "Statut",
+ "practitioner": "Cabinet ou médecin",
+ "practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.",
+ "practitionerNone": "Non précisé",
+ "practitionerSearch": "Rechercher un nom ou un cabinet",
+ "practitionerNoMatch": "Aucune entrée trouvée.",
+ "practitionerAdd": "Créer une entrée",
+ "practitionerClear": "Retirer la sélection",
+ "kind": "Type",
+ "reason": "Motif",
+ "outcome": "Résultat",
+ "outcomeReveal": "Ajouter un résultat",
+ "linkDocuments": "Documents",
+ "linkLabResults": "Résultats de laboratoire",
+ "linkConditions": "Maladies",
+ "linkNothingToOffer": "Rien à lier pour le moment."
+ },
+ "deleteTitle": "Supprimer ce rendez-vous ?",
+ "deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.",
+ "deleteFailed": "Le rendez-vous n'a pas pu être supprimé."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous",
+ "tabVorsorge": "Dépistages",
+ "tabVisits": "Rendez-vous"
+ },
+ "practitioners": {
+ "title": "Cabinets et médecins",
+ "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",
+ "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.",
+ "noMatchTitle": "Aucun résultat",
+ "noMatchDescription": "Aucune entrée ne correspond à cette recherche.",
+ "createTitle": "Ajouter une entrée",
+ "editTitle": "Modifier l'entrée",
+ "formDescription": "Seul le nom est obligatoire.",
+ "name": "Nom",
+ "specialty": "Spécialité",
+ "practice": "Cabinet",
+ "location": "Adresse",
+ "phone": "Téléphone",
+ "note": "Note",
+ "noteHint": "Enregistrée chiffrée.",
+ "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."
}
}
diff --git a/messages/it.json b/messages/it.json
index 60bfd0bf4..c79d33ad0 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -10073,6 +10073,86 @@
"hospital": "Ospedale",
"therapy": "Seduta di terapia",
"other": "Appuntamento medico"
- }
+ },
+ "sectionTitle": "Appuntamenti",
+ "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
+ "add": "Appuntamento",
+ "loadError": "Impossibile caricare gli appuntamenti.",
+ "emptyTitle": "Nessun appuntamento registrato",
+ "emptyDescription": "Basta una data. Tutto il resto è facoltativo.",
+ "upcomingHeading": "In arrivo",
+ "pastHeading": "Passati",
+ "createTitle": "Registra un appuntamento",
+ "editTitle": "Modifica appuntamento",
+ "formDescription": "Solo la data è obbligatoria.",
+ "created": "Appuntamento salvato.",
+ "updated": "Appuntamento aggiornato.",
+ "saveFailed": "Non è stato possibile salvare l'appuntamento.",
+ "checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.",
+ "status": {
+ "planned": "Previsto",
+ "done": "Effettuato",
+ "cancelled": "Annullato",
+ "noShow": "Non presentato"
+ },
+ "card": {
+ "documentCount": "documenti collegati",
+ "labCount": "esami di laboratorio collegati",
+ "conditionCount": "malattie collegate"
+ },
+ "form": {
+ "date": "Data e ora",
+ "dateDerivesStatusHint": "Una data futura è un appuntamento fissato.",
+ "status": "Stato",
+ "practitioner": "Studio o medico",
+ "practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.",
+ "practitionerNone": "Non indicato",
+ "practitionerSearch": "Cerca nome o studio",
+ "practitionerNoMatch": "Nessuna voce trovata.",
+ "practitionerAdd": "Aggiungi nuova",
+ "practitionerClear": "Rimuovi la selezione",
+ "kind": "Tipo",
+ "reason": "Motivo",
+ "outcome": "Esito",
+ "outcomeReveal": "Aggiungi un esito",
+ "linkDocuments": "Documenti",
+ "linkLabResults": "Esami di laboratorio",
+ "linkConditions": "Malattie",
+ "linkNothingToOffer": "Per ora non c'è nulla da collegare."
+ },
+ "deleteTitle": "Eliminare questo appuntamento?",
+ "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.",
+ "deleteFailed": "Non è stato possibile eliminare l'appuntamento."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Mostra controlli o appuntamenti",
+ "tabVorsorge": "Controlli",
+ "tabVisits": "Appuntamenti"
+ },
+ "practitioners": {
+ "title": "Studi e medici",
+ "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.",
+ "linkLabel": "Rubrica",
+ "add": "Voce",
+ "searchPlaceholder": "Cerca nome o studio",
+ "loadError": "Impossibile caricare la rubrica.",
+ "emptyTitle": "Nessuna voce",
+ "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.",
+ "noMatchTitle": "Nessuna corrispondenza",
+ "noMatchDescription": "Nessuna voce corrisponde a questa ricerca.",
+ "createTitle": "Aggiungi una voce",
+ "editTitle": "Modifica la voce",
+ "formDescription": "Solo il nome è obbligatorio.",
+ "name": "Nome",
+ "specialty": "Specializzazione",
+ "practice": "Studio",
+ "location": "Indirizzo",
+ "phone": "Telefono",
+ "note": "Nota",
+ "noteHint": "Salvata cifrata.",
+ "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."
}
}
diff --git a/messages/pl.json b/messages/pl.json
index cd1f4c71e..6ac56d55c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -10073,6 +10073,86 @@
"hospital": "Szpital",
"therapy": "Sesja terapeutyczna",
"other": "Wizyta lekarska"
- }
+ },
+ "sectionTitle": "Wizyty",
+ "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
+ "add": "Wizyta",
+ "loadError": "Nie udało się wczytać wizyt.",
+ "emptyTitle": "Brak zapisanych wizyt",
+ "emptyDescription": "Wystarczy data. Reszta jest opcjonalna.",
+ "upcomingHeading": "Nadchodzące",
+ "pastHeading": "Minione",
+ "createTitle": "Zapisz wizytę",
+ "editTitle": "Edytuj wizytę",
+ "formDescription": "Wymagana jest tylko data.",
+ "created": "Wizyta zapisana.",
+ "updated": "Wizyta zaktualizowana.",
+ "saveFailed": "Nie udało się zapisać wizyty.",
+ "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.",
+ "status": {
+ "planned": "Zaplanowana",
+ "done": "Odbyta",
+ "cancelled": "Odwołana",
+ "noShow": "Niewykorzystana"
+ },
+ "card": {
+ "documentCount": "powiązane dokumenty",
+ "labCount": "powiązane wyniki badań",
+ "conditionCount": "powiązane choroby"
+ },
+ "form": {
+ "date": "Data i godzina",
+ "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.",
+ "status": "Status",
+ "practitioner": "Gabinet lub lekarz",
+ "practitionerHint": "Opcjonalne — nazwę możesz dodać później.",
+ "practitionerNone": "Nie podano",
+ "practitionerSearch": "Szukaj nazwiska lub gabinetu",
+ "practitionerNoMatch": "Nie znaleziono wpisu.",
+ "practitionerAdd": "Dodaj nowy",
+ "practitionerClear": "Usuń wybór",
+ "kind": "Rodzaj",
+ "reason": "Powód",
+ "outcome": "Wynik",
+ "outcomeReveal": "Dodaj wynik",
+ "linkDocuments": "Dokumenty",
+ "linkLabResults": "Wyniki badań",
+ "linkConditions": "Choroby",
+ "linkNothingToOffer": "Na razie nie ma nic do powiązania."
+ },
+ "deleteTitle": "Usunąć tę wizytę?",
+ "deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.",
+ "deleteFailed": "Nie udało się usunąć wizyty."
+ },
+ "checkups": {
+ "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty",
+ "tabVorsorge": "Profilaktyka",
+ "tabVisits": "Wizyty"
+ },
+ "practitioners": {
+ "title": "Gabinety i lekarze",
+ "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",
+ "loadError": "Nie udało się wczytać książki adresowej.",
+ "emptyTitle": "Brak wpisów",
+ "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.",
+ "noMatchTitle": "Brak wyników",
+ "noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.",
+ "createTitle": "Dodaj wpis",
+ "editTitle": "Edytuj wpis",
+ "formDescription": "Wymagana jest tylko nazwa.",
+ "name": "Nazwa",
+ "specialty": "Specjalizacja",
+ "practice": "Gabinet",
+ "location": "Adres",
+ "phone": "Telefon",
+ "note": "Notatka",
+ "noteHint": "Zapisywana w postaci zaszyfrowanej.",
+ "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."
}
}
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 8d798ce6a..413863fac 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -388,6 +388,26 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
+ // ── Visits and the address book ─────────────────────────────────────────
+ {
+ file: "hooks/use-encounters.ts",
+ destroys:
+ "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
+ triggers: ["components/encounters/encounter-sheet.tsx"],
+ // The route soft-deletes and a restore route exists, but nothing in the
+ // product reaches it yet, so from a person's side the visit is gone. This
+ // becomes "restorable" the day a surface offers the undo, not before.
+ recovery: "tombstoned-no-restore",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "hooks/use-practitioners.ts",
+ destroys: "one address-book entry",
+ triggers: ["components/practitioners/practitioner-list.tsx"],
+ recovery: "tombstoned-no-restore",
+ confirm: ["AlertDialog"],
+ },
+
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx
index 3413dc1a3..65cf82ca5 100644
--- a/src/__tests__/shared-record-navigation.test.tsx
+++ b/src/__tests__/shared-record-navigation.test.tsx
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
const navigation = resolveSharedRecordNavigation(["profile"]);
- expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
- expect(navigation.destinationHrefs).toEqual(["/profile"]);
+ expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
+ "/profile",
+ "/checkups",
+ ]);
+ expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
expect(navigation.allowsPath("/profile")).toBe(true);
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
});
+ it("presents the checkups page under both domains whose content it shows", () => {
+ // The page holds two things: the preventive-care list, which is a
+ // `measurements` read, and the visits section, which is `profile`. A grant
+ // naming either one must be able to reach it; a grant naming neither must
+ // not. Before the visits section the `measurements` omission was invisible,
+ // because a whole-record grant admits every path regardless.
+ expect(
+ resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
+ ).toBe(true);
+ expect(
+ resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
+ ).toBe(true);
+ expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
+ true,
+ );
+ expect(
+ resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
+ ).toBe(false);
+
+ // Listed twice, offered once: a grant holding both domains must not put
+ // two identical entries in the navigation.
+ expect(
+ resolveSharedRecordNavigation([
+ "measurements",
+ "profile",
+ ]).destinationHrefs.filter((href) => href === "/checkups"),
+ ).toEqual(["/checkups"]);
+ });
+
it("uses the active record scope instead of the actor's module flags", () => {
const actorModules = {
medications: false,
diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts
index 04a495b5a..bac66da4b 100644
--- a/src/app/api/practitioners/route.ts
+++ b/src/app/api/practitioners/route.ts
@@ -50,8 +50,27 @@ 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".
...(parsed.data.q
- ? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
+ ? {
+ OR: [
+ {
+ name: {
+ contains: parsed.data.q,
+ mode: "insensitive" as const,
+ },
+ },
+ {
+ practice: {
+ contains: parsed.data.q,
+ mode: "insensitive" as const,
+ },
+ },
+ ],
+ }
: {}),
},
orderBy: { name: "asc" },
diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx
index 2105fb54b..4b2fbf1c5 100644
--- a/src/app/checkups/page.tsx
+++ b/src/app/checkups/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
-import { queryKeys } from "@/lib/query-keys";
+import { VisitsSection } from "@/components/encounters/visits-section";
+import { useTranslations } from "@/lib/i18n/context";
+import { invalidateKeys, queryKeys } from "@/lib/query-keys";
/**
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
@@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys";
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
* measurements): a pull invalidates the measurement-reminders read so the
* list + server-computed next-due repaint.
+ *
+ * The page now holds two things, split by a segmented control rather than by a
+ * second nav entry: the preventive-care list, and the visits. They answer the
+ * same question from two directions — what do I still have to arrange, and what
+ * did I actually attend — and the page's own sentence already covers both. A
+ * visit carries no module gate for the same reason this page never had one.
+ *
+ * The pull invalidates both reads. Refreshing one and leaving the other stale
+ * is how a page ends up showing a visit that closed a checkup still listed as
+ * due.
*/
export default function CheckupsPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const queryClient = useQueryClient();
+ const { t } = useTranslations();
+ const [view, setView] = useState("vorsorge");
const refresh = useCallback(
() =>
- queryClient.invalidateQueries({
- queryKey: queryKeys.measurementReminders(),
- }),
+ invalidateKeys(queryClient, [
+ queryKeys.measurementReminders(),
+ queryKeys.encounters(),
+ ]),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refresh });
@@ -41,7 +57,24 @@ export default function CheckupsPage() {
return (
-
+
+
+
+ {t("checkups.tabVorsorge")}
+
+
+ {t("checkups.tabVisits")}
+
+
+
+
+
+
+ {/* Mounted only on the active tab, so the visits read does not fire
+ for a person who never opens it. */}
+
+
+
);
}
diff --git a/src/app/checkups/practitioners/page.tsx b/src/app/checkups/practitioners/page.tsx
new file mode 100644
index 000000000..1a4d27e7f
--- /dev/null
+++ b/src/app/checkups/practitioners/page.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
+import { PractitionerList } from "@/components/practitioners/practitioner-list";
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+
+/**
+ * The address book, one level under the checkups page the visits live on.
+ *
+ * Deliberately not in Settings. The Settings IA rule is that a settings surface
+ * configures behaviour; a list of one's own doctors configures nothing and is
+ * record content like every other clinical list. Sitting under `/checkups`
+ * also means it inherits that page's route-family classification, so a
+ * `profile`-scoped delegate reaches it exactly as they reach the visits.
+ */
+export default function PractitionersPage() {
+ const { isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+
+ if (!mounted || isLoading) return ;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx
new file mode 100644
index 000000000..ee7b9a04b
--- /dev/null
+++ b/src/components/encounters/__tests__/encounter-form.test.tsx
@@ -0,0 +1,184 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+/**
+ * The one product constraint this whole feature exists to hold: **a visit
+ * saves with a date and nothing else.**
+ *
+ * Three properties, each of which has an obvious way to get broken by a later
+ * "improvement":
+ *
+ * - no field but the date can block Save;
+ * - the status is DERIVED from the date on a new visit, never asked;
+ * - a link picker whose module is off is BLANK, not empty — an empty picker
+ * for a switched-off module advertises a feature that is not there.
+ */
+
+let modules: Record = {};
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", modules },
+ isLoading: false,
+ isAuthenticated: true,
+ }),
+}));
+
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
+}));
+
+vi.mock("@/lib/api/api-fetch", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/api-fetch",
+ );
+ return {
+ ...actual,
+ apiGet: () => new Promise(() => {}),
+ apiPost: vi.fn(),
+ apiPatch: vi.fn(),
+ apiDelete: vi.fn(),
+ };
+});
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ EncounterForm,
+ derivedStatus,
+ draftInstant,
+ draftToBody,
+ emptyDraft,
+} from "../encounter-form";
+
+/**
+ * The form body, rendered directly rather than through its sheet: the sheet is
+ * a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
+ * test that went through it would assert against an empty string and pass for
+ * the wrong reason.
+ */
+function renderForm(over?: {
+ modules?: Record;
+ isEdit?: boolean;
+}) {
+ modules = over?.modules ?? {};
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderToStaticMarkup(
+
+
+ {}}
+ isEdit={over?.isEdit ?? false}
+ />
+
+ ,
+ );
+}
+
+describe("nothing but the date is required", () => {
+ it("admits a draft carrying only a date", () => {
+ // `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
+ // with no practitioner, no reason, no outcome and no link is saveable.
+ const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
+ expect(draft.practitioner).toBeNull();
+ expect(draft.reason).toBe("");
+ expect(draft.documentIds).toEqual([]);
+ expect(draftInstant(draft.occurredAt)).not.toBeNull();
+ });
+
+ it("refuses only when the date itself cannot be read", () => {
+ expect(draftInstant("not a date")).toBeNull();
+ });
+
+ it("marks the date field required and nothing else", () => {
+ const html = renderForm();
+ const required = html.match(/aria-required="true"/g) ?? [];
+ expect(required).toHaveLength(1);
+ expect(html).toContain('data-testid="encounter-occurred-at"');
+ });
+
+ it("turns a date-only draft into a complete write body", () => {
+ const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
+ isEdit: false,
+ });
+ expect(body).not.toBeNull();
+ expect(body?.practitionerId).toBeNull();
+ expect(body?.reason).toBeNull();
+ expect(body?.outcome).toBeNull();
+ expect(body?.documentIds).toEqual([]);
+ });
+
+ it("refuses to build a body from a date that does not parse", () => {
+ expect(
+ draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
+ ).toBeNull();
+ });
+});
+
+describe("the status follows the date on a new visit", () => {
+ const now = Date.parse("2026-08-08T12:00:00.000Z");
+
+ it("reads a future instant as a booked appointment", () => {
+ expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
+ });
+
+ it("reads a past instant as a visit that happened", () => {
+ expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
+ });
+
+ it("never asks for the status on a new visit", () => {
+ // The picker is what "asked" would look like; its absence is the property.
+ expect(renderForm()).not.toContain('id="encounter-status"');
+ });
+
+ it("carries the derived status into the body rather than the draft default", () => {
+ const body = draftToBody(
+ emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
+ { isEdit: false },
+ );
+ expect(body?.status).toBe("PLANNED");
+ });
+
+ it("uses the draft's own status on an edit, where it is editable", () => {
+ const body = draftToBody(
+ emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
+ { isEdit: true },
+ );
+ expect(body?.status).toBe("CANCELLED");
+ });
+});
+
+describe("the link pickers follow the modules that own their targets", () => {
+ it("renders no link block at all when all three modules are off", () => {
+ const html = renderForm({ modules: {} });
+ expect(html).not.toContain('data-slot="encounter-link-pickers"');
+ expect(html).not.toContain('data-slot="encounter-link-documents"');
+ expect(html).not.toContain('data-slot="encounter-link-labs"');
+ expect(html).not.toContain('data-slot="encounter-link-conditions"');
+ });
+
+ it("blanks the block of a disabled module while keeping its neighbours", () => {
+ const html = renderForm({
+ modules: { inboundDocuments: true, labs: false, illness: false },
+ });
+ expect(html).toContain('data-slot="encounter-link-documents"');
+ // Not "renders an empty list" — absent.
+ expect(html).not.toContain('data-slot="encounter-link-labs"');
+ expect(html).not.toContain('data-slot="encounter-link-conditions"');
+ });
+
+ it("offers all three when all three modules are on", () => {
+ const html = renderForm({
+ modules: { inboundDocuments: true, labs: true, illness: true },
+ });
+ expect(html).toContain('data-slot="encounter-link-documents"');
+ expect(html).toContain('data-slot="encounter-link-labs"');
+ expect(html).toContain('data-slot="encounter-link-conditions"');
+ // The positive control: without it the three assertions above would pass
+ // against a component that never renders any block.
+ expect(html).toContain('data-slot="encounter-link-pickers"');
+ });
+});
diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx
new file mode 100644
index 000000000..a7da7ed91
--- /dev/null
+++ b/src/components/encounters/__tests__/visits-section.test.tsx
@@ -0,0 +1,127 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+/**
+ * The visits surface's designed states, pinned by single-pass static renders.
+ *
+ * - an account with no visits paints the empty state and one action, not an
+ * error and not a teaching wall;
+ * - upcoming sorts above past, in the two lists the SERVER split — the
+ * client renders the order it was given rather than re-deriving one;
+ * - a soft-deleted visit never arrives, so it cannot be rendered. That is a
+ * server property and the API test owns it; what this file pins is that
+ * the section renders exactly what the response carried, with nothing
+ * filtered or re-sorted on this side that could disagree with it.
+ */
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", modules: {} },
+ isLoading: false,
+ isAuthenticated: true,
+ }),
+}));
+
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
+}));
+
+vi.mock("@/lib/api/api-fetch", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/api-fetch",
+ );
+ return {
+ ...actual,
+ // Never resolves inside a synchronous render, so an unseeded query stays
+ // pending and cannot accidentally satisfy an assertion.
+ apiGet: () => new Promise(() => {}),
+ apiPost: vi.fn(),
+ apiPatch: vi.fn(),
+ apiDelete: vi.fn(),
+ };
+});
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import type { EncounterDTO } from "@/lib/encounters/dto";
+import { VisitsSection } from "../visits-section";
+
+function visit(over: Partial & { id: string }): EncounterDTO {
+ return {
+ occurredAt: "2026-08-01T09:00:00.000Z",
+ status: "DONE",
+ kind: "ROUTINE",
+ practitioner: null,
+ reason: null,
+ outcome: null,
+ reminderNextDueAt: null,
+ createdAt: "2026-08-01T09:00:00.000Z",
+ updatedAt: "2026-08-01T09:00:00.000Z",
+ ...over,
+ };
+}
+
+function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ queryClient.setQueryData(
+ queryKeys.encounterList(null, null, undefined),
+ list,
+ );
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe("", () => {
+ it("paints the empty state, with one action, when there is no visit", () => {
+ const html = render({ upcoming: [], past: [] });
+ expect(html).toContain("No visit recorded yet");
+ expect(html).toContain("A date is enough");
+ expect(html).toContain('data-slot="visits-add"');
+ // An empty account is not an error, and the two states never share copy.
+ expect(html).not.toContain("Visits could not be loaded.");
+ expect(html).not.toContain('data-slot="visits-upcoming"');
+ });
+
+ it("puts the upcoming list above the past one", () => {
+ const html = render({
+ upcoming: [visit({ id: "later", status: "PLANNED" })],
+ past: [visit({ id: "earlier" })],
+ });
+ const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
+ const pastAt = html.indexOf('data-slot="visits-past"');
+ expect(upcomingAt).toBeGreaterThan(-1);
+ expect(pastAt).toBeGreaterThan(-1);
+ expect(upcomingAt).toBeLessThan(pastAt);
+ expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
+ html.indexOf('data-encounter-id="earlier"'),
+ );
+ });
+
+ it("renders the server's order inside a list rather than re-sorting it", () => {
+ // Two rows deliberately handed over in an order the client has no reason
+ // to like. Re-sorting here would be the second place the ordering lives.
+ const html = render({
+ upcoming: [],
+ past: [
+ visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
+ visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
+ ],
+ });
+ expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
+ html.indexOf('data-encounter-id="first"'),
+ );
+ });
+
+ it("offers the address book beside the add action", () => {
+ const html = render({ upcoming: [], past: [] });
+ expect(html).toContain('href="/checkups/practitioners"');
+ });
+});
diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx
new file mode 100644
index 000000000..5c611a276
--- /dev/null
+++ b/src/components/encounters/encounter-form.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+/**
+ * The visit form.
+ *
+ * **A visit saves with a date and nothing else.** No required practitioner, no
+ * required kind, no required link, and no Save button disabled until something
+ * is picked. That is the whole product constraint this feature exists to hold:
+ * a required field on a form somebody fills after a doctor's appointment is the
+ * bureaucracy it was built to avoid. The only thing that can block Save is a
+ * missing or unparseable date, because a visit with no instant is not a record
+ * of anything.
+ *
+ * Status is DERIVED from the date on a new visit, never asked: a future instant
+ * means PLANNED and a past one means DONE. A person booking an appointment
+ * should not have to also tell the app that it is in the future. On an existing
+ * visit the status becomes editable, because moving a planned visit to done,
+ * cancelled or a no-show is the transition that closes a checkup.
+ *
+ * The three link pickers are gated by the modules that own what they point at,
+ * and the gate BLANKS the block rather than post-filtering it — an empty
+ * picker for a module the account turned off advertises a feature that is not
+ * there.
+ */
+import { useMemo, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { DateTimeField } from "@/components/ui/date-time-field";
+import { FieldGroup } from "@/components/ui/field-group";
+import { NativeSelect } from "@/components/ui/native-select";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@/hooks/use-auth";
+import { useTranslations } from "@/lib/i18n/context";
+import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
+import type { Practitioner } from "@/hooks/use-practitioners";
+import {
+ ENCOUNTER_KINDS,
+ ENCOUNTER_STATUSES,
+ encounterKindText,
+ encounterStatusText,
+} from "./encounter-labels";
+import { PractitionerCombobox } from "./practitioner-combobox";
+import { EncounterLinkPickers } from "./encounter-link-pickers";
+
+export interface EncounterDraft {
+ /** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
+ occurredAt: string;
+ status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
+ kind: string;
+ practitioner: Practitioner | null;
+ reason: string;
+ outcome: string;
+ documentIds: string[];
+ labResultIds: string[];
+ episodeIds: string[];
+}
+
+function localValue(instant: Date): string {
+ const offset = instant.getTimezoneOffset();
+ return new Date(instant.getTime() - offset * 60_000)
+ .toISOString()
+ .slice(0, 16);
+}
+
+export function nowLocalValue(): string {
+ return localValue(new Date());
+}
+
+/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
+export function draftInstant(value: string): string | null {
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+}
+
+export function emptyDraft(
+ overrides?: Partial,
+): EncounterDraft {
+ return {
+ occurredAt: nowLocalValue(),
+ status: "DONE",
+ kind: "OTHER",
+ practitioner: null,
+ reason: "",
+ outcome: "",
+ documentIds: [],
+ labResultIds: [],
+ episodeIds: [],
+ ...overrides,
+ };
+}
+
+export function draftFromEncounter(row: Encounter): EncounterDraft {
+ return {
+ occurredAt: localValue(new Date(row.occurredAt)),
+ status: row.status,
+ kind: row.kind,
+ practitioner: row.practitioner,
+ reason: row.reason ?? "",
+ outcome: row.outcome ?? "",
+ documentIds: row.links?.documents.map((link) => link.id) ?? [],
+ labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
+ episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
+ };
+}
+
+/**
+ * The status a NEW visit carries, read off its own date.
+ *
+ * Exported so the sheet and its tests agree on the derivation rather than each
+ * spelling out the comparison.
+ */
+export function derivedStatus(
+ occurredAt: string,
+ now = Date.now(),
+): "PLANNED" | "DONE" {
+ const parsed = new Date(occurredAt).getTime();
+ return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
+}
+
+/** The body a draft becomes, with the empty strings turned into absences. */
+export function draftToBody(
+ draft: EncounterDraft,
+ options: { isEdit: boolean; reminderId?: string | null },
+): EncounterWriteBody | null {
+ const occurredAt = draftInstant(draft.occurredAt);
+ if (!occurredAt) return null;
+ return {
+ occurredAt,
+ status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
+ kind: draft.kind,
+ practitionerId: draft.practitioner?.id ?? null,
+ reason: draft.reason.trim() || null,
+ outcome: draft.outcome.trim() || null,
+ ...(options.reminderId ? { reminderId: options.reminderId } : {}),
+ documentIds: draft.documentIds,
+ labResultIds: draft.labResultIds,
+ episodeIds: draft.episodeIds,
+ };
+}
+
+export function EncounterForm({
+ draft,
+ onChange,
+ isEdit,
+}: {
+ draft: EncounterDraft;
+ onChange: (next: EncounterDraft) => void;
+ isEdit: boolean;
+}) {
+ const { t } = useTranslations();
+ const { user } = useAuth();
+ const patch = (part: Partial) =>
+ onChange({ ...draft, ...part });
+
+ const planned = useMemo(
+ () =>
+ isEdit
+ ? draft.status === "PLANNED"
+ : derivedStatus(draft.occurredAt) === "PLANNED",
+ [isEdit, draft.status, draft.occurredAt],
+ );
+
+ // The outcome field starts collapsed on a planned visit: there is nothing to
+ // write about a thing that has not happened. Derived rather than
+ // synchronised — a visit that already carries an outcome is never collapsed
+ // over it, and expressing that as an effect would be a second source of
+ // truth for something the draft already knows.
+ const [revealed, setRevealed] = useState(false);
+ const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
+
+ return (
+
+ );
+}
diff --git a/src/components/encounters/encounter-labels.ts b/src/components/encounters/encounter-labels.ts
new file mode 100644
index 000000000..3a55339dd
--- /dev/null
+++ b/src/components/encounters/encounter-labels.ts
@@ -0,0 +1,74 @@
+"use client";
+
+/**
+ * Browser-side labels for a visit's kind and status.
+ *
+ * The server has its own `encounterKindLabel` and the two do not share code on
+ * purpose: the server's answer becomes a push body in the RECORD OWNER's
+ * language, and this one renders in the language of whoever is looking. Sharing
+ * a resolver would force one of the two to read the wrong locale.
+ *
+ * Both switches spell out literal `t()` calls rather than interpolating a key.
+ * An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
+ * or missing label would ship as raw dot notation instead of failing CI.
+ */
+import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
+
+type Translate = (key: string) => string;
+
+/** Every kind, in the order the picker offers them. */
+export const ENCOUNTER_KINDS: EncounterKind[] = [
+ "ROUTINE",
+ "ACUTE",
+ "SPECIALIST",
+ "PREVENTIVE",
+ "EMERGENCY",
+ "HOSPITAL",
+ "THERAPY",
+ "OTHER",
+];
+
+/** Every status a visit can be edited into. */
+export const ENCOUNTER_STATUSES: EncounterStatus[] = [
+ "PLANNED",
+ "DONE",
+ "CANCELLED",
+ "NO_SHOW",
+];
+
+export function encounterKindText(t: Translate, kind: EncounterKind): string {
+ switch (kind) {
+ case "ROUTINE":
+ return t("encounters.kind.routine");
+ case "ACUTE":
+ return t("encounters.kind.acute");
+ case "SPECIALIST":
+ return t("encounters.kind.specialist");
+ case "PREVENTIVE":
+ return t("encounters.kind.preventive");
+ case "EMERGENCY":
+ return t("encounters.kind.emergency");
+ case "HOSPITAL":
+ return t("encounters.kind.hospital");
+ case "THERAPY":
+ return t("encounters.kind.therapy");
+ default:
+ return t("encounters.kind.other");
+ }
+}
+
+export function encounterStatusText(
+ t: Translate,
+ status: EncounterStatus,
+): string {
+ switch (status) {
+ case "PLANNED":
+ return t("encounters.status.planned");
+ case "DONE":
+ return t("encounters.status.done");
+ case "CANCELLED":
+ return t("encounters.status.cancelled");
+ default:
+ return t("encounters.status.noShow");
+ }
+}
diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx
new file mode 100644
index 000000000..bc7986a36
--- /dev/null
+++ b/src/components/encounters/encounter-link-pickers.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+/**
+ * The visit form's three optional link blocks: documents, lab results,
+ * conditions.
+ *
+ * **The gate blanks the block, it does not post-filter it.** A module the
+ * account turned off leaves nothing behind here — no heading, no empty list,
+ * no "0 selected". An empty picker for a switched-off module advertises a
+ * feature that is not there and invites a person to wonder what is wrong. This
+ * is the same shape the dashboard snapshot uses for a disabled module.
+ *
+ * The encounter itself carries NO module gate. A visit is core, like the
+ * checkups page it lives on; what it may point AT follows the modules that own
+ * those things, which is why the gate lives here and not on the route.
+ *
+ * Nothing in this block can block a save. Every list starts empty and stays
+ * valid empty.
+ */
+import { useQuery } from "@tanstack/react-query";
+import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { apiGet } from "@/lib/api/api-fetch";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import type { ModuleKey } from "@/lib/modules/registry";
+import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
+import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
+
+/** How many candidates one block offers before the person must use the sheet. */
+const OPTION_LIMIT = 25;
+
+interface Option {
+ id: string;
+ label: string;
+ /** ISO-8601, or null for a target with no date of its own. */
+ date: string | null;
+}
+
+function LinkBlock({
+ icon: Icon,
+ title,
+ options,
+ selected,
+ onToggle,
+ pending,
+ slot,
+}: {
+ icon: LucideIcon;
+ title: string;
+ options: Option[];
+ selected: string[];
+ onToggle: (id: string) => void;
+ pending: boolean;
+ slot: string;
+}) {
+ const { t } = useTranslations();
+ const format = useFormatters();
+
+ return (
+
+ );
+}
diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx
new file mode 100644
index 000000000..26e2a8d0e
--- /dev/null
+++ b/src/components/encounters/encounter-sheet.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+/**
+ * The sheet the visit form lives in — create and edit, one surface.
+ *
+ * Save is disabled only when the date cannot be parsed. Nothing else on the
+ * form can block it, which is the property the whole feature is checked
+ * against.
+ *
+ * Two entry points pre-fill it and neither changes what is required:
+ *
+ * - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
+ * that checkup through the server's forward-only, idempotent path, so a
+ * person who both ticks the box and files a visit gets one satisfaction and
+ * no error.
+ * - `episodeId` — opened from a condition's "Visits about this" card, with
+ * the episode already linked.
+ *
+ * A delegate who may file the visit but not close the checkup gets the visit
+ * saved and a plain sentence saying the checkup stayed open. That is reported
+ * rather than skipped in silence: somebody who believes a checkup was closed
+ * will not look at it again.
+ */
+import { useState } from "react";
+import { Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
+import { useTranslations } from "@/lib/i18n/context";
+import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
+import {
+ EncounterForm,
+ draftFromEncounter,
+ draftInstant,
+ draftToBody,
+ emptyDraft,
+ nowLocalValue,
+ type EncounterDraft,
+} from "./encounter-form";
+
+export function EncounterSheet({
+ open,
+ onOpenChange,
+ encounter,
+ reminderId,
+ episodeId,
+ onSaved,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** `null` creates a visit; a row edits it. */
+ encounter: Encounter | null;
+ /** The due checkup this visit is being filed against, when there is one. */
+ reminderId?: string | null;
+ /** A condition to pre-link, when the sheet was opened from an episode. */
+ episodeId?: string | null;
+ onSaved?: (saved: Encounter) => void;
+}) {
+ const { t } = useTranslations();
+ const { create, update, remove } = useEncounterMutations();
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+ // Seeded once, at mount. The caller remounts this component on every open by
+ // varying its `key`, so a second edit never shows the first one's values and
+ // a create after an edit starts empty — without an effect that copies props
+ // into state and briefly renders the stale pair.
+ const [draft, setDraft] = useState(() =>
+ encounter
+ ? draftFromEncounter(encounter)
+ : emptyDraft({
+ occurredAt: nowLocalValue(),
+ episodeIds: episodeId ? [episodeId] : [],
+ }),
+ );
+ const [error, setError] = useState(null);
+
+ const isEdit = encounter !== null;
+ const pending = create.isPending || update.isPending;
+ // The one gate. A date that does not parse is not a visit; everything else
+ // on this form is optional by design.
+ const dateUsable = draftInstant(draft.occurredAt) !== null;
+
+ const submit = async () => {
+ const body = draftToBody(draft, { isEdit, reminderId });
+ if (!body) return;
+ try {
+ const saved = isEdit
+ ? await update.mutateAsync({ id: encounter.id, body })
+ : await create.mutateAsync(body);
+ // The server names what its grant could not reach. Today that is only a
+ // checkup closure, and a delegate seeing nothing would believe it closed
+ // — so a write that did part of what was asked reports `partial`, not a
+ // green tick, through the one module that owns the affordance.
+ const wroteEverything = (saved.skipped?.links ?? 0) === 0;
+ toastWrittenOutcome(
+ wroteEverything ? "success" : "partial",
+ wroteEverything
+ ? t(isEdit ? "encounters.updated" : "encounters.created")
+ : t("encounters.checkupNotClosed"),
+ );
+ onSaved?.(saved);
+ onOpenChange(false);
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : t("encounters.saveFailed");
+ setError(message);
+ toast.error(message);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {isEdit ? (
+
+
+
+
+
+
+
+ {t("encounters.deleteTitle")}
+
+ {/* Says what goes with it. Deleting a booked visit retires its
+ reminder, and a person who booked one deserves to know the
+ nudge stops rather than discover it by silence. */}
+
+ {t("encounters.deleteDescription")}
+
+
+
+ {t("common.cancel")}
+ {
+ remove.mutate(encounter.id, {
+ onSuccess: () => onOpenChange(false),
+ onError: () => toast.error(t("encounters.deleteFailed")),
+ });
+ }}
+ >
+ {t("common.delete")}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/encounters/practitioner-combobox.tsx b/src/components/encounters/practitioner-combobox.tsx
new file mode 100644
index 000000000..453f0de31
--- /dev/null
+++ b/src/components/encounters/practitioner-combobox.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+/**
+ * The practice picker on the visit form.
+ *
+ * A combobox rather than a select, because the address book grows past the
+ * length a dropdown reads well at and a person types the first three letters
+ * of a practice name faster than they scroll to it. The search runs in SQL over
+ * the two plaintext columns — that is why they are plaintext.
+ *
+ * "Add new" opens a nested sheet rather than navigating away. A person filling
+ * a half-finished visit form who is sent to another page to add a doctor comes
+ * back to an empty form, so the affordance that looks helpful is the one that
+ * loses their work.
+ *
+ * The field is OPTIONAL and says so: a person can log "the emergency room on
+ * the 3rd" before they know whose name was on the door.
+ */
+import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
+
+export function PractitionerCombobox({
+ value,
+ onChange,
+ /** The already-resolved entry, so the closed trigger names it without a read. */
+ selected,
+ disabled,
+}: {
+ value: string | null;
+ onChange: (next: Practitioner | null) => void;
+ selected: Practitioner | null;
+ disabled?: boolean;
+}) {
+ const { t } = useTranslations();
+ const [open, setOpen] = useState(false);
+ const [term, setTerm] = useState("");
+ const [adding, setAdding] = useState(false);
+ // Bumped on each open so the nested sheet remounts empty rather than showing
+ // whatever the last attempt left in it.
+ const [session, setSession] = useState(0);
+ const debounced = useDebouncedValue(term, 250);
+
+ const list = usePractitioners(debounced, open);
+ const rows = list.data ?? [];
+
+ return (
+ <>
+
+
+ {/* Nested, so the half-filled visit form behind it stays mounted. */}
+ onChange(created)}
+ />
+ >
+ );
+}
diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx
new file mode 100644
index 000000000..7f4ecdf32
--- /dev/null
+++ b/src/components/encounters/visit-card.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+/**
+ * One visit, as a row in the list.
+ *
+ * The date, the practice and the reason are what the person wrote, so all three
+ * are `text-foreground`; the counts and the relative distance are meta and
+ * muted. Status is carried by the SECTION rather than repeated on every card —
+ * a card in the upcoming list is planned by construction, and a badge saying so
+ * on every row is noise that hides the two rows where the status is a surprise.
+ * A cancelled or missed visit is the exception and says so, because those are
+ * the ones a person is scanning for.
+ */
+import {
+ CalendarClock,
+ FileText,
+ FlaskConical,
+ Stethoscope,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import type { Encounter } from "@/hooks/use-encounters";
+import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
+import { encounterKindText, encounterStatusText } from "./encounter-labels";
+
+/** The two statuses worth calling out on a row; the rest the section implies. */
+function isNoteworthy(status: EncounterStatus): boolean {
+ return status === "CANCELLED" || status === "NO_SHOW";
+}
+
+export function VisitCard({
+ encounter,
+ onOpen,
+}: {
+ encounter: Encounter;
+ onOpen: (encounter: Encounter) => void;
+}) {
+ const { t } = useTranslations();
+ const format = useFormatters();
+
+ const counts = [
+ {
+ icon: FileText,
+ n: encounter.links?.documents.length ?? 0,
+ label: t("encounters.card.documentCount"),
+ },
+ {
+ icon: FlaskConical,
+ n: encounter.links?.labResults.length ?? 0,
+ label: t("encounters.card.labCount"),
+ },
+ {
+ icon: Stethoscope,
+ n: encounter.links?.conditions.length ?? 0,
+ label: t("encounters.card.conditionCount"),
+ },
+ ].filter((entry) => entry.n > 0);
+
+ // The practice, or the kind when there is none. A visit with neither is not
+ // nameless — the kind always resolves, because it defaults to one.
+ const heading =
+ encounter.practitioner?.name ??
+ encounterKindText(t, encounter.kind as EncounterKind);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx
new file mode 100644
index 000000000..a1d2e1cee
--- /dev/null
+++ b/src/components/encounters/visits-section.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+/**
+ * "Termine" — the visits half of the checkups page.
+ *
+ * Two lists rather than one, and the split is the server's: upcoming reads
+ * soonest-first, past reads newest-first, and the API resolves both so the
+ * ordering lives in one place. A client that split and re-sorted one array
+ * would be the second place it lives.
+ *
+ * No new nav entry and no new route. `/checkups` already owns exactly the right
+ * sentence — when do I have to do what, where — and is deliberately not module
+ * gated, which is also why a visit carries no module key of its own.
+ */
+import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { EmptyState } from "@/components/ui/empty-state";
+import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { SectionHeading } from "@/components/ui/section-heading";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
+import { useEncounters, type Encounter } from "@/hooks/use-encounters";
+import { useTranslations } from "@/lib/i18n/context";
+import { EncounterSheet } from "./encounter-sheet";
+import { VisitCard } from "./visit-card";
+
+export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
+ const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
+ const list = useEncounters(enabled);
+
+ // `null` closed, `"new"` create, a row edits it.
+ const [editing, setEditing] = useState(null);
+ const [session, setSession] = useState(0);
+ const openSheet = (target: Encounter | "new") => {
+ setEditing(target);
+ setSession((n) => n + 1);
+ };
+
+ const upcoming = list.data?.upcoming ?? [];
+ const past = list.data?.past ?? [];
+ const empty = upcoming.length === 0 && past.length === 0;
+
+ const addButton = canManage ? (
+
+ ) : null;
+
+ return (
+
+
+
+ {addButton}
+ >
+ }
+ />
+
+ {list.isError ? (
+ void list.refetch()}
+ />
+ ) : list.isPending ? (
+
+ )}
+
+ {/* `key` changes on every open, so the sheet remounts with a fresh draft
+ instead of copying props into state from an effect. It stays mounted
+ while closing, which is what keeps the exit animation. */}
+ {
+ if (!open) setEditing(null);
+ }}
+ encounter={editing === "new" ? null : editing}
+ />
+
+ );
+}
diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx
new file mode 100644
index 000000000..0503dc4b4
--- /dev/null
+++ b/src/components/practitioners/practitioner-list.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+/**
+ * The account's own address book of doctors and practices.
+ *
+ * **Record content, not configuration**, so it does not live in Settings: a
+ * settings surface configures behaviour, and a list of one's own doctors
+ * configures nothing. It sits beside the visits that name it.
+ *
+ * Deleting is a soft delete and the visits that referenced the entry keep it.
+ * The confirmation says so in one sentence, because a person deleting a doctor
+ * has every reason to fear their history goes with it.
+ */
+import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Input } from "@/components/ui/input";
+import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
+import {
+ usePractitionerMutations,
+ usePractitioners,
+ type Practitioner,
+} from "@/hooks/use-practitioners";
+import { useTranslations } from "@/lib/i18n/context";
+import { PractitionerSheet } from "./practitioner-sheet";
+
+export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
+ const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
+ const [term, setTerm] = useState("");
+ const debounced = useDebouncedValue(term, 250);
+ const list = usePractitioners(debounced, enabled);
+ const { remove } = usePractitionerMutations();
+
+ const [editing, setEditing] = useState(null);
+ const [deleting, setDeleting] = useState(null);
+ // Bumped on every open so the sheet remounts with a fresh draft rather than
+ // copying props into state from an effect.
+ const [session, setSession] = useState(0);
+ const openSheet = (target: Practitioner | "new") => {
+ setEditing(target);
+ setSession((n) => n + 1);
+ };
+
+ const rows = list.data ?? [];
+
+ const addButton = canManage ? (
+
+ ) : null;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts
new file mode 100644
index 000000000..7a777ec5f
--- /dev/null
+++ b/src/hooks/use-encounters.ts
@@ -0,0 +1,117 @@
+"use client";
+
+/**
+ * Data hooks for visits and the address book behind them.
+ *
+ * Every value the surfaces render arrives resolved from the server — the
+ * practitioner as an object, the links with a label and a date, the
+ * appointment's next-due instant computed. Nothing here re-derives one. A
+ * client that recomputed a server-side derivation drifts from it the first
+ * time the derivation changes on one side only.
+ *
+ * Writes reach two roots (`encounterDependentKeys`), because renaming a
+ * practice changes the label every visit that names it renders.
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import {
+ encounterDependentKeys,
+ invalidateKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
+import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
+
+export type Encounter = EncounterDTO;
+export type EncounterList = EncounterListDTO;
+
+export interface EncounterWriteBody {
+ occurredAt?: string;
+ status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
+ kind?: string;
+ practitionerId?: string | null;
+ reason?: string | null;
+ outcome?: string | null;
+ /** The checkup this visit closes, when it was filed from a due one. */
+ reminderId?: string | null;
+ documentIds?: string[];
+ labResultIds?: string[];
+ episodeIds?: string[];
+}
+
+const BASE = "/api/encounters";
+
+export function useEncounters(
+ enabled = true,
+ window?: { from?: string; to?: string; status?: string },
+) {
+ const from = window?.from ?? null;
+ const to = window?.to ?? null;
+ return useQuery({
+ queryKey: queryKeys.encounterList(from, to, window?.status),
+ queryFn: () => {
+ const sp = new URLSearchParams();
+ if (from) sp.set("from", from);
+ if (to) sp.set("to", to);
+ if (window?.status) sp.set("status", window.status);
+ const qs = sp.toString();
+ return apiGet(qs ? `${BASE}?${qs}` : BASE);
+ },
+ enabled,
+ });
+}
+
+/** One visit with its three link families resolved. */
+export function useEncounter(id: string | null) {
+ return useQuery({
+ queryKey: queryKeys.encounter(id ?? ""),
+ queryFn: () => apiGet(`${BASE}/${id}`),
+ enabled: Boolean(id),
+ });
+}
+
+/**
+ * "Does this belong to a visit?" for one anchor date.
+ *
+ * The verdict is resolved server-side so the document moment and the two lab
+ * moments cannot answer the question differently. `anchor` being null means
+ * the surface has no date yet and there is nothing to ask about.
+ */
+export function useEncounterSuggestion(anchor: string | null, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
+ queryFn: () =>
+ apiGet(
+ `${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
+ ),
+ enabled: enabled && Boolean(anchor),
+ });
+}
+
+export function useEncounterMutations() {
+ const qc = useQueryClient();
+ const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
+
+ const create = useMutation({
+ mutationKey: queryKeys.encounterCreate(),
+ mutationFn: (body: EncounterWriteBody) => apiPost(BASE, body),
+ onSuccess: invalidate,
+ });
+
+ const update = useMutation({
+ mutationKey: queryKeys.encounterUpdate(),
+ mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
+ apiPatch(`${BASE}/${id}`, body),
+ onSuccess: invalidate,
+ });
+
+ const remove = useMutation({
+ mutationKey: queryKeys.encounterDelete(),
+ mutationFn: (id: string) =>
+ apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove };
+}
diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts
new file mode 100644
index 000000000..a70944b13
--- /dev/null
+++ b/src/hooks/use-practitioners.ts
@@ -0,0 +1,72 @@
+"use client";
+
+/**
+ * Data hooks for the account's own address book of doctors and practices.
+ *
+ * The search runs in SQL over the two plaintext columns rather than in the
+ * browser over a downloaded list: the columns are plaintext precisely so the
+ * picker can search and sort them there, and a client-side filter would need
+ * the whole book in memory to do it.
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import {
+ encounterDependentKeys,
+ invalidateKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+import type { PractitionerDTO } from "@/lib/practitioners/dto";
+
+export type Practitioner = PractitionerDTO;
+
+export interface PractitionerWriteBody {
+ name?: string;
+ specialty?: string | null;
+ practice?: string | null;
+ location?: string | null;
+ phone?: string | null;
+ note?: string | null;
+}
+
+const BASE = "/api/practitioners";
+
+export function usePractitioners(q?: string, enabled = true) {
+ const term = q?.trim() || undefined;
+ return useQuery({
+ queryKey: queryKeys.practitionerList(term),
+ queryFn: () =>
+ apiGet(
+ term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
+ ),
+ enabled,
+ });
+}
+
+export function usePractitionerMutations() {
+ const qc = useQueryClient();
+ const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
+
+ const create = useMutation({
+ mutationKey: queryKeys.practitionerCreate(),
+ mutationFn: (body: PractitionerWriteBody) =>
+ apiPost(BASE, body),
+ onSuccess: invalidate,
+ });
+
+ const update = useMutation({
+ mutationKey: queryKeys.practitionerUpdate(),
+ mutationFn: ({ id, body }: { id: string; body: PractitionerWriteBody }) =>
+ apiPatch(`${BASE}/${id}`, body),
+ onSuccess: invalidate,
+ });
+
+ const remove = useMutation({
+ mutationKey: queryKeys.practitionerDelete(),
+ mutationFn: (id: string) =>
+ apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove };
+}
diff --git a/src/lib/navigation/shared-record.ts b/src/lib/navigation/shared-record.ts
index 63a1fdca8..cb4b0d55c 100644
--- a/src/lib/navigation/shared-record.ts
+++ b/src/lib/navigation/shared-record.ts
@@ -15,10 +15,22 @@ import {
* fail closed in chrome until it has been intentionally classified.
*/
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
- measurements: ["/measurements"],
+ // `/checkups` carries content from two domains and is listed under both.
+ // The preventive-care list has always been a `measurements` read
+ // (`/api/measurement-reminders` declares it), and the page was in neither
+ // list — under a whole-record grant it painted anyway, because a null
+ // section set admits everything, so the omission stayed invisible for as
+ // long as a scoped grant had no reason to open it. The visits section makes
+ // it visible: a delegate scoped to `profile` is granted the visit data by
+ // the API and would be denied the only page that shows it. The table is
+ // built by a flatMap over (domain, href) pairs, so one href under two
+ // domains means a grant naming either presents the page — and the handler
+ // still evaluates the grant on every read, because this is a presentation
+ // inventory and not an authorization table.
+ measurements: ["/measurements", "/checkups"],
medications: ["/medications"],
labs: ["/labs"],
- profile: ["/profile"],
+ profile: ["/profile", "/checkups"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
@@ -78,7 +90,10 @@ export function resolveSharedRecordNavigation(
);
return {
- destinationHrefs: routeFamilies.map((family) => family.href),
+ // Deduplicated: an href listed under two domains is one destination, and a
+ // grant holding both would otherwise offer it twice — and make
+ // `sharedRecordLandingHref` count a page as two doorways.
+ destinationHrefs: [...new Set(routeFamilies.map((family) => family.href))],
allowsPath(pathname: string): boolean {
return routeFamilies.some((family) =>
matchesRouteFamily(pathname, family.href),
diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts
index d46141083..13b916da7 100644
--- a/src/lib/openapi/routes/encounters.ts
+++ b/src/lib/openapi/routes/encounters.ts
@@ -84,7 +84,7 @@ practitionerUpdateSchema.meta({
practitionerListQuerySchema.meta({
id: "ListPractitionersQuery",
description:
- "Query params for the address book: `q` (case-insensitive substring of the name) and `limit` (1–200, default 100). Name-ordered.",
+ "Query params for the address book: `q` (case-insensitive substring of the name OR the practice — a person looks one up by whichever of the two they remember) and `limit` (1–200, default 100). Name-ordered.",
});
const practitioner = z
diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts
index a5ea82533..61bfd6d2f 100644
--- a/src/lib/query-keys/encounters.ts
+++ b/src/lib/query-keys/encounters.ts
@@ -51,4 +51,13 @@ export const encounterKeys = {
practitionerList: (q?: string) =>
["practitioners", "list", q ?? null] as const,
practitioner: (id: string) => ["practitioners", "detail", id] as const,
+
+ /* ── mutation keys ──────────────────────────────────────────────── */
+
+ encounterCreate: () => ["encounters", "create"] as const,
+ encounterUpdate: () => ["encounters", "update"] as const,
+ encounterDelete: () => ["encounters", "delete"] as const,
+ practitionerCreate: () => ["practitioners", "create"] as const,
+ practitionerUpdate: () => ["practitioners", "update"] as const,
+ practitionerDelete: () => ["practitioners", "delete"] as const,
};