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 ( +
+ + patch({ occurredAt: value })} + required + aria-required="true" + data-testid="encounter-occurred-at" + /> + + + {isEdit ? ( + + + patch({ + status: event.target.value as EncounterDraft["status"], + }) + } + > + {ENCOUNTER_STATUSES.map((status) => ( + + ))} + + + ) : null} + + + patch({ practitioner: next })} + /> + + + + patch({ kind: event.target.value })} + > + {ENCOUNTER_KINDS.map((kind) => ( + + ))} + + + + +