diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8098b4198..572a7a7ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9176,6 +9176,10 @@ paths: enum: - MANUAL - OCR + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - takenAt responses: @@ -9477,6 +9481,10 @@ paths: type: string minLength: 1 maxLength: 64 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - rows responses: @@ -9713,6 +9721,12 @@ paths: type: string minLength: 1 maxLength: 40 + - in: query + name: episodeId + schema: + type: string + minLength: 1 + maxLength: 40 - in: query name: limit schema: @@ -16974,7 +16988,7 @@ components: enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. - maxItems: 8 + maxItems: 9 type: array items: type: string @@ -16987,6 +17001,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -38612,6 +38627,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit description: Closed rail-item kind. itemKey: description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / @@ -38894,7 +38910,7 @@ components: enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. - maxItems: 8 + maxItems: 9 type: array items: type: string @@ -38907,6 +38923,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/messages/de.json b/messages/de.json index d40205bd2..45bbcd82c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -478,7 +478,8 @@ "milestone": "Meilensteine", "ecg_new_recording": "Neue EKG-Aufzeichnungen", "tension_window": "Anspannungsphasen", - "same_time_baseline": "Aktivität zur gleichen Tageszeit" + "same_time_baseline": "Aktivität zur gleichen Tageszeit", + "upcoming_visit": "Termine" }, "layoutReset": "Auf Defaults zurücksetzen", "layoutResetTitle": "Dashboard-Layout zurücksetzen?", @@ -8152,7 +8153,8 @@ "openLink": "Vorsorge öffnen" }, "showMeasurements": "Messergebnisse anzeigen", - "loadError": "Erinnerungen konnten nicht geladen werden." + "loadError": "Erinnerungen konnten nicht geladen werden.", + "fileVisit": "Termin eintragen" }, "moduleList": { "viewToggleLabel": "Ansicht", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Hinzugefügt am {date} · {size}", "share": "Teilen", "labValuesLink": "{count} Laborwerte ansehen", - "visitsLabel": "Gehört zu Termin", - "visitSuggestion": "Gehört zu diesem Termin?", - "visitSuggestionChoose": "Zu welchem Termin gehört das?" + "visitsLabel": "Gehört zu Termin" }, "dropOverlay": { "title": "Zum Hochladen ablegen", @@ -9619,6 +9619,11 @@ "below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.", "above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind." } + }, + "upcomingVisit": { + "title": "Termin steht an", + "bodyToday": "{what} — heute", + "bodyTomorrow": "{what} — morgen" } }, "action": { @@ -10126,7 +10131,19 @@ }, "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." + "deleteFailed": "Termin konnte nicht gelöscht werden.", + "suggestion": { + "single": "Gehört zu diesem Termin?", + "choose": "Zu welchem Termin gehört das?" + }, + "episodeCard": { + "title": "Termine dazu", + "emptyLine": "Noch kein Termin mit dieser Erkrankung verknüpft.", + "add": "Termin hinzufügen" + }, + "dashboard": { + "nextVisit": "Nächster Termin: {what} am {when}" + } }, "checkups": { "viewSwitchLabel": "Vorsorge oder Termine anzeigen", diff --git a/messages/en.json b/messages/en.json index 30a13c858..b7e6f4594 100644 --- a/messages/en.json +++ b/messages/en.json @@ -478,7 +478,8 @@ "milestone": "Milestones", "ecg_new_recording": "New ECG recordings", "tension_window": "Tension windows", - "same_time_baseline": "Same-time activity" + "same_time_baseline": "Same-time activity", + "upcoming_visit": "Appointments" }, "layoutReset": "Reset to defaults", "layoutResetTitle": "Reset the dashboard layout?", @@ -8152,7 +8153,8 @@ "openLink": "Open checkups" }, "showMeasurements": "Show measurements", - "loadError": "Could not load your reminders." + "loadError": "Could not load your reminders.", + "fileVisit": "Record a visit" }, "moduleList": { "viewToggleLabel": "View", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Added {date} · {size}", "share": "Share", "labValuesLink": "View {count} lab values", - "visitsLabel": "Belongs to visit", - "visitSuggestion": "Does this belong to this visit?", - "visitSuggestionChoose": "Which visit does this belong to?" + "visitsLabel": "Belongs to visit" }, "dropOverlay": { "title": "Drop files to upload", @@ -9619,6 +9619,11 @@ "below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.", "above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now." } + }, + "upcomingVisit": { + "title": "Appointment coming up", + "bodyToday": "{what} — today", + "bodyTomorrow": "{what} — tomorrow" } }, "action": { @@ -10126,7 +10131,19 @@ }, "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." + "deleteFailed": "The visit could not be deleted.", + "suggestion": { + "single": "Does this belong to this visit?", + "choose": "Which visit does this belong to?" + }, + "episodeCard": { + "title": "Visits about this", + "emptyLine": "No visit linked to this condition yet.", + "add": "Add a visit" + }, + "dashboard": { + "nextVisit": "Next appointment: {what} on {when}" + } }, "checkups": { "viewSwitchLabel": "Show checkups or visits", diff --git a/messages/es.json b/messages/es.json index 16a08b543..1e36a2261 100644 --- a/messages/es.json +++ b/messages/es.json @@ -478,7 +478,8 @@ "milestone": "Hitos", "ecg_new_recording": "Nuevos registros de ECG", "tension_window": "Periodos de tensión", - "same_time_baseline": "Actividad a la misma hora" + "same_time_baseline": "Actividad a la misma hora", + "upcoming_visit": "Citas" }, "layoutReset": "Restablecer valores por defecto", "layoutResetTitle": "¿Restablecer el diseño del panel?", @@ -8152,7 +8153,8 @@ "openLink": "Abrir revisiones" }, "showMeasurements": "Ver mediciones", - "loadError": "No se pudieron cargar tus recordatorios." + "loadError": "No se pudieron cargar tus recordatorios.", + "fileVisit": "Registrar una cita" }, "moduleList": { "viewToggleLabel": "Vista", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Añadido el {date} · {size}", "share": "Compartir", "labValuesLink": "Ver {count} valores de laboratorio", - "visitsLabel": "Pertenece a la cita", - "visitSuggestion": "¿Pertenece a esta cita?", - "visitSuggestionChoose": "¿A qué cita pertenece?" + "visitsLabel": "Pertenece a la cita" }, "dropOverlay": { "title": "Suelta los archivos para subirlos", @@ -9619,6 +9619,11 @@ "below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.", "above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora." } + }, + "upcomingVisit": { + "title": "Cita próxima", + "bodyToday": "{what}: hoy", + "bodyTomorrow": "{what}: mañana" } }, "action": { @@ -10126,7 +10131,19 @@ }, "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." + "deleteFailed": "No se pudo eliminar la cita.", + "suggestion": { + "single": "¿Pertenece a esta cita?", + "choose": "¿A qué cita pertenece?" + }, + "episodeCard": { + "title": "Citas relacionadas", + "emptyLine": "Todavía no hay ninguna cita vinculada a esta enfermedad.", + "add": "Añadir una cita" + }, + "dashboard": { + "nextVisit": "Próxima cita: {what} el {when}" + } }, "checkups": { "viewSwitchLabel": "Mostrar revisiones o citas", diff --git a/messages/fr.json b/messages/fr.json index 151dc5f57..345d8841b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -478,7 +478,8 @@ "milestone": "Étapes franchies", "ecg_new_recording": "Nouveaux enregistrements ECG", "tension_window": "Périodes de tension", - "same_time_baseline": "Activité à la même heure" + "same_time_baseline": "Activité à la même heure", + "upcoming_visit": "Rendez-vous" }, "layoutReset": "Rétablir les valeurs par défaut", "layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?", @@ -8152,7 +8153,8 @@ "openLink": "Ouvrir les examens" }, "showMeasurements": "Afficher les mesures", - "loadError": "Impossible de charger vos rappels." + "loadError": "Impossible de charger vos rappels.", + "fileVisit": "Enregistrer un rendez-vous" }, "moduleList": { "viewToggleLabel": "Affichage", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Ajouté le {date} · {size}", "share": "Partager", "labValuesLink": "Voir {count} valeurs de laboratoire", - "visitsLabel": "Rattaché au rendez-vous", - "visitSuggestion": "Cela concerne-t-il ce rendez-vous ?", - "visitSuggestionChoose": "À quel rendez-vous cela se rattache-t-il ?" + "visitsLabel": "Rattaché au rendez-vous" }, "dropOverlay": { "title": "Déposez les fichiers pour les téléverser", @@ -9619,6 +9619,11 @@ "below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.", "above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure." } + }, + "upcomingVisit": { + "title": "Rendez-vous à venir", + "bodyToday": "{what} — aujourd'hui", + "bodyTomorrow": "{what} — demain" } }, "action": { @@ -10126,7 +10131,19 @@ }, "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é." + "deleteFailed": "Le rendez-vous n'a pas pu être supprimé.", + "suggestion": { + "single": "Cela concerne-t-il ce rendez-vous ?", + "choose": "À quel rendez-vous cela se rattache-t-il ?" + }, + "episodeCard": { + "title": "Rendez-vous liés", + "emptyLine": "Aucun rendez-vous n'est encore rattaché à cette maladie.", + "add": "Ajouter un rendez-vous" + }, + "dashboard": { + "nextVisit": "Prochain rendez-vous : {what} le {when}" + } }, "checkups": { "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous", diff --git a/messages/it.json b/messages/it.json index 629c44858..91ac79e39 100644 --- a/messages/it.json +++ b/messages/it.json @@ -478,7 +478,8 @@ "milestone": "Traguardi", "ecg_new_recording": "Nuove registrazioni ECG", "tension_window": "Periodi di tensione", - "same_time_baseline": "Attività alla stessa ora" + "same_time_baseline": "Attività alla stessa ora", + "upcoming_visit": "Appuntamenti" }, "layoutReset": "Ripristina i valori predefiniti", "layoutResetTitle": "Reimpostare il layout della dashboard?", @@ -8152,7 +8153,8 @@ "openLink": "Apri controlli" }, "showMeasurements": "Mostra le misurazioni", - "loadError": "Impossibile caricare i promemoria." + "loadError": "Impossibile caricare i promemoria.", + "fileVisit": "Registra un appuntamento" }, "moduleList": { "viewToggleLabel": "Vista", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Aggiunto il {date} · {size}", "share": "Condividi", "labValuesLink": "Vedi {count} valori di laboratorio", - "visitsLabel": "Appartiene all'appuntamento", - "visitSuggestion": "Appartiene a questo appuntamento?", - "visitSuggestionChoose": "A quale appuntamento appartiene?" + "visitsLabel": "Appartiene all'appuntamento" }, "dropOverlay": { "title": "Rilascia i file per caricarli", @@ -9619,6 +9619,11 @@ "below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.", "above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora." } + }, + "upcomingVisit": { + "title": "Appuntamento in arrivo", + "bodyToday": "{what} — oggi", + "bodyTomorrow": "{what} — domani" } }, "action": { @@ -10126,7 +10131,19 @@ }, "deleteTitle": "Eliminare questo appuntamento?", "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.", - "deleteFailed": "Non è stato possibile eliminare l'appuntamento." + "deleteFailed": "Non è stato possibile eliminare l'appuntamento.", + "suggestion": { + "single": "Appartiene a questo appuntamento?", + "choose": "A quale appuntamento appartiene?" + }, + "episodeCard": { + "title": "Appuntamenti collegati", + "emptyLine": "Nessun appuntamento ancora collegato a questa malattia.", + "add": "Aggiungi un appuntamento" + }, + "dashboard": { + "nextVisit": "Prossimo appuntamento: {what} il {when}" + } }, "checkups": { "viewSwitchLabel": "Mostra controlli o appuntamenti", diff --git a/messages/pl.json b/messages/pl.json index e69ef3f34..5d344b36a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -478,7 +478,8 @@ "milestone": "Kamienie milowe", "ecg_new_recording": "Nowe zapisy EKG", "tension_window": "Okresy napięcia", - "same_time_baseline": "Aktywność o tej samej porze" + "same_time_baseline": "Aktywność o tej samej porze", + "upcoming_visit": "Wizyty" }, "layoutReset": "Przywróć wartości domyślne", "layoutResetTitle": "Zresetować układ pulpitu?", @@ -8152,7 +8153,8 @@ "openLink": "Otwórz badania" }, "showMeasurements": "Pokaż pomiary", - "loadError": "Nie udało się załadować przypomnień." + "loadError": "Nie udało się załadować przypomnień.", + "fileVisit": "Zapisz wizytę" }, "moduleList": { "viewToggleLabel": "Widok", @@ -9306,9 +9308,7 @@ "uploadedMeta": "Dodano {date} · {size}", "share": "Udostępnij", "labValuesLink": "Zobacz {count} wyników laboratoryjnych", - "visitsLabel": "Należy do wizyty", - "visitSuggestion": "Czy to należy do tej wizyty?", - "visitSuggestionChoose": "Do której wizyty to należy?" + "visitsLabel": "Należy do wizyty" }, "dropOverlay": { "title": "Upuść pliki, aby je przesłać", @@ -9619,6 +9619,11 @@ "below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.", "above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze." } + }, + "upcomingVisit": { + "title": "Zbliża się wizyta", + "bodyToday": "{what} — dzisiaj", + "bodyTomorrow": "{what} — jutro" } }, "action": { @@ -10126,7 +10131,19 @@ }, "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." + "deleteFailed": "Nie udało się usunąć wizyty.", + "suggestion": { + "single": "Czy to należy do tej wizyty?", + "choose": "Do której wizyty to należy?" + }, + "episodeCard": { + "title": "Powiązane wizyty", + "emptyLine": "Do tej choroby nie przypisano jeszcze żadnej wizyty.", + "add": "Dodaj wizytę" + }, + "dashboard": { + "nextVisit": "Najbliższa wizyta: {what}, {when}" + } }, "checkups": { "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty", diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index ab5067df5..a5bd7d2ba 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -58,6 +58,7 @@ export const GET = apiHandler(async (request: NextRequest) => { to: params.get("to") ?? undefined, status: params.get("status") ?? undefined, practitionerId: params.get("practitionerId") ?? undefined, + episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, }); if (!parsed.success) { @@ -75,6 +76,11 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, ...(query.status ? { status: query.status } : {}), ...(query.practitionerId ? { practitionerId: query.practitionerId } : {}), + // Filtered in SQL rather than in Node: the two arms are separately + // `take`-bounded, so narrowing after the read would page wrongly. + ...(query.episodeId + ? { conditionLinks: { some: { episodeId: query.episodeId } } } + : {}), ...(query.from || query.to ? { occurredAt: { diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index b8700562e..5fd00dc2b 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -39,6 +39,7 @@ import { linkOcrLabsToVaultDocument, type InsertedLabForLink, } from "@/lib/labs/vault-link"; +import { linkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; import { @@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) { ).catch(() => {}); } + // The visit the review step offered, if it offered one. ONE LINK PER RESULT + // ROW rather than one for the panel: a marker re-run on a different day + // belongs to its own visit, which is exactly why the join is m:n and not a + // scalar column on the result. Best-effort like the vault cross-link — the + // readings are the authoritative write and must not be lost to a link that + // could not be made. The link service narrows both ends to this record, so + // an id naming nothing writes nothing. + if (parsed.data.encounterId && linkable.length > 0) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: parsed.data.encounterId, + targetKind: "labResult", + targetIds: linkable.map((row) => row.labResultId), + }).catch(() => {}); + } + await auditLog("labs.ocr.commit", { userId: user.id, ipAddress: getClientIp(request), @@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) { annotate({ action: { name: "labs.ocr.committed" }, - meta: { inserted: inserted.length, skipped: skipped.length }, + meta: { + inserted: inserted.length, + skipped: skipped.length, + linkedVisit: parsed.data.encounterId ? 1 : 0, + }, }); // A lab panel just landed — resolve any "annual blood panel" reminders now diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 60e325499..b420b745e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -21,6 +21,7 @@ import { type ResolvedBiomarker, serialiseLabResult, } from "@/lib/labs/serialise"; +import { linkTargets } from "@/lib/links"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { @@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) { takenAt, note, source, + encounterId, } = parsed.data; // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead @@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) { noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); + // The by-the-way half: file the reading against the visit the form offered, + // if it offered one. Best-effort by design — the reading is the write that + // matters, and a link that could not be made (a visit deleted between the + // suggestion and the save) must not lose it. The link service narrows both + // ends to this record, so an id naming nothing simply writes nothing. + if (encounterId) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: encounterId, + targetKind: "labResult", + targetIds: [created.id], + }).catch(() => {}); + } + invalidateUserHealthScore(user.id); const dto = serialiseLabResult(created, biomarker); diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index a07f4ac5b..080d30412 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -20,6 +20,7 @@ import Link from "next/link"; import { Activity, + CalendarCheck, Award, CalendarClock, Footprints, @@ -55,6 +56,7 @@ const KIND_ICON: Record = { ecg_new_recording: HeartPulse, tension_window: Activity, same_time_baseline: Footprints, + upcoming_visit: CalendarCheck, }; /** diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 1f203e8a1..b2459a76f 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -1041,9 +1041,8 @@ export function DocumentDetailSheet({ doc.encounterLinks.length > 0 && pickedEncounterId === null } disabled={!canManage} - pickedId={pickedEncounterId} - onPick={(id) => setSuggestedEncounter(id)} - onUndo={() => setSuggestedEncounter(null)} + value={pickedEncounterId} + onChange={setSuggestedEncounter} /> {/* "Belongs to visit" — read-only here on purpose. The filing diff --git a/src/components/documents/document-encounter-suggestion.tsx b/src/components/documents/document-encounter-suggestion.tsx index 07679766d..d0c2ea3fc 100644 --- a/src/components/documents/document-encounter-suggestion.tsx +++ b/src/components/documents/document-encounter-suggestion.tsx @@ -1,147 +1,43 @@ "use client"; /** - * "Gehört zu Termin …" — the incidental link, offered at the moment a document - * is being looked at rather than as a filing chore afterwards. + * The document moment of the visit suggestion. * - * The rule is not implemented here. It lives in `suggest-window.ts` and is - * resolved server-side, so this surface, the lab review and the manual lab form - * cannot answer the same question differently. What this component owns is only - * how the three verdicts LOOK: + * The rule and the rendering both live elsewhere — `suggest-window.ts` on the + * server and `EncounterSuggestionField` in the browser — so this file is only + * the wiring: which date to anchor on, and where the chosen id goes. * - * one candidate → pre-selected, visibly, with one undo; - * two or more → a picker with NOTHING pre-selected; - * none → nothing at all. No empty picker, no "create a visit?" nudge. + * The anchor is `reportDate ?? documentDate`. `reportDate` is what the model + * transcribed off the letter itself and is never inferred, so it is the truer + * of the two; `documentDate` is the person's own filing date and is the + * fallback. * - * It never blocks anything, it is never behind an AI call — the match is a - * date-window query over the person's own rows and resolves on an account with - * no provider configured — and it renders nothing once the document already - * names a visit: an offer to file something already filed is noise. - * - * The row is a content sentence, not meta, so it is `text-foreground`. + * It renders nothing once the document already names a visit that this session + * did not choose: an offer to file something already filed is noise. */ -import { CalendarClock, Check, Undo2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { encounterKindText } from "@/components/encounters/encounter-labels"; -import { useEncounterSuggestion } from "@/hooks/use-encounters"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import type { EncounterKind } from "@/generated/prisma/client"; -import type { EncounterSuggestion } from "@/lib/encounters/suggest-window"; +import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; export function DocumentEncounterSuggestion({ anchor, alreadyLinked, disabled, - onPick, - pickedId, - onUndo, + value, + onChange, }: { - /** `reportDate ?? documentDate`, ISO-8601, or null when the document has none. */ anchor: string | null; - /** The document already names a visit — there is nothing to offer. */ alreadyLinked: boolean; disabled?: boolean; - onPick: (encounterId: string) => void; - /** The visit this surface has just filed against, for the undo affordance. */ - pickedId: string | null; - onUndo: () => void; + value: string | null; + onChange: (encounterId: string | null) => void; }) { - const { t } = useTranslations(); - const format = useFormatters(); - const suggestion = useEncounterSuggestion( - anchor, - !alreadyLinked && !disabled, - ); - - if (alreadyLinked || disabled) return null; - const result = suggestion.data; - if (!result || result.kind === "none") return null; - - const label = (candidate: EncounterSuggestion) => - `${ - candidate.practitionerName ?? - encounterKindText(t, candidate.kind as EncounterKind) - } · ${format.date(candidate.occurredAt)}`; - - // ── One candidate: pre-selected, and undoable in one tap ──────────────── - if (result.kind === "one") { - const candidate = result.encounter; - const applied = pickedId === candidate.id; - return ( -
-

- - {t("documents.detail.visitSuggestion")} -

-
- - {applied ? ( - - ) : null} -
-
- ); - } - - // ── Two or more: a picker with nothing pre-selected ───────────────────── + if (alreadyLinked) return null; return ( -
-

- - {t("documents.detail.visitSuggestionChoose")} -

-
- {result.encounters.map((candidate) => { - const applied = pickedId === candidate.id; - return ( - - ); - })} -
-
+ ); } diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index a7da7ed91..ef056ff28 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -67,7 +67,7 @@ function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) { defaultOptions: { queries: { retry: false } }, }); queryClient.setQueryData( - queryKeys.encounterList(null, null, undefined), + queryKeys.encounterList(null, null, undefined, undefined), list, ); return renderToStaticMarkup( diff --git a/src/components/encounters/encounter-suggestion-field.tsx b/src/components/encounters/encounter-suggestion-field.tsx new file mode 100644 index 000000000..90fed3350 --- /dev/null +++ b/src/components/encounters/encounter-suggestion-field.tsx @@ -0,0 +1,130 @@ +"use client"; + +/** + * "Gehört zu Termin …" — the one rendering of the suggestion, for all three + * moments that ask it. + * + * The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and + * is resolved server-side, so a document arriving in the vault, a lab panel + * committed from an extraction and a panel typed by hand cannot answer the same + * question differently. What this component owns is only how the three verdicts + * look, and it owns that once: + * + * one candidate → pre-selected, visibly, with one undo; + * two or more → a picker with NOTHING pre-selected; + * none → nothing at all. No empty picker, no "create a visit?" nudge. + * + * The middle branch is the point. Pre-selecting the first of two is the failure + * that teaches a person to stop reading suggestions, and the component cannot + * do it: the "many" verdict carries no pre-selection and this file never picks + * one. + * + * It never blocks a save — every caller treats the value as optional — and it + * is never behind an AI call: the match is a date-window query over the + * person's own rows and resolves on an account with no provider configured. + * + * Controlled. The caller owns where the chosen id goes: straight into a PATCH + * on a document, or into the body of a lab write that has not happened yet. + */ +import { CalendarClock, Check, Undo2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useEncounterSuggestion } from "@/hooks/use-encounters"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { EncounterKind } from "@/generated/prisma/client"; +import type { EncounterSuggestion } from "@/lib/encounters/suggest-window"; +import { encounterKindText } from "./encounter-labels"; + +export function EncounterSuggestionField({ + anchor, + value, + onChange, + disabled, + slot = "encounter-suggestion", +}: { + /** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */ + anchor: string | null; + /** The visit currently chosen here, or null. */ + value: string | null; + onChange: (encounterId: string | null) => void; + disabled?: boolean; + /** `data-slot` prefix, so a caller's tests can address its own instance. */ + slot?: string; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const suggestion = useEncounterSuggestion(anchor, !disabled); + + if (disabled) return null; + const result = suggestion.data; + if (!result || result.kind === "none") return null; + + const label = (candidate: EncounterSuggestion) => + `${ + candidate.practitionerName ?? + encounterKindText(t, candidate.kind as EncounterKind) + } · ${format.date(candidate.occurredAt)}`; + + const candidates = + result.kind === "one" ? [result.encounter] : result.encounters; + + return ( +
+ {/* A content sentence, not meta (§3): it is the offer itself. */} +

+ + {t( + result.kind === "one" + ? "encounters.suggestion.single" + : "encounters.suggestion.choose", + )} +

+
+ {candidates.map((candidate) => { + const chosen = value === candidate.id; + return ( + + ); + })} + {value !== null ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/illness/episode-visits-card.tsx b/src/components/illness/episode-visits-card.tsx new file mode 100644 index 000000000..c52fb539f --- /dev/null +++ b/src/components/illness/episode-visits-card.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * "Termine dazu" — the visits filed against one condition episode. + * + * **From the episode side only, and that is a decision rather than an + * omission.** An episode spans weeks and a visit is a point, so matching from + * the VISIT side would produce false positives at a rate that teaches a person + * to ignore every suggestion in the product. Here the person already has the + * condition in front of them and is the one who knows; the card offers an + * action instead of a guess. + * + * Modelled on the episode's documents card, which does exactly this for the + * vault. Renders nothing when the illness module is off — a gated surface never + * leaks, and an episode page is unreachable in that state anyway. + */ +import { CalendarClock, Plus } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { EncounterSheet } from "@/components/encounters/encounter-sheet"; +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ListRow } from "@/components/ui/list-row"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { EncounterKind } from "@/generated/prisma/client"; + +export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) { + const { t } = useTranslations(); + const format = useFormatters(); + const { user } = useAuth(); + const { canManage } = useRecordCapabilities(); + const [sheetOpen, setSheetOpen] = useState(false); + const [session, setSession] = useState(0); + + const moduleEnabled = user?.modules?.illness === true; + // The filter runs in SQL: the two arms of the response are separately + // bounded, so narrowing here would page wrongly on a long history. + const list = useEncounters(moduleEnabled, { episodeId }); + + if (!moduleEnabled) return null; + + if (list.isError) { + return ( + void list.refetch()} + /> + ); + } + + const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])]; + + return ( + <> + + + 0 ? ( + + {visits.length} + + ) : undefined + } + /> + + {list.isPending ? ( +
+ {Array.from({ length: 2 }, (_, index) => ( + + ))} +
+ ) : visits.length === 0 ? ( + // A quiet one-line absence, not a teaching empty state: the person + // came here about the condition, not to be taught about visits. +

+ {t("encounters.episodeCard.emptyLine")} +

+ ) : ( +
    + {visits.map((visit) => ( +
  • + + + + + {visit.practitioner?.name ?? + encounterKindText(t, visit.kind as EncounterKind)} + + + {format.date(visit.occurredAt)} + + + +
  • + ))} +
+ )} + + {canManage ? ( +
+ +
+ ) : null} +
+
+ + {canManage ? ( + + ) : null} + + ); +} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index c272917cf..89938bbda 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -11,6 +11,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useState } from "react"; +import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card"; import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { nothing when the documents module is off for this account. */} {episode ? : null} + {/* Condition ⇄ visits linking, condition side ONLY. A visit never + suggests an episode: an episode spans weeks and a visit is a point, + so the reverse match would be wrong often enough to train the person + to ignore every suggestion in the product. */} + {episode ? : null} + {episode ? ( <> (null); + // The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion + // asks the server in ISO-8601, so it is converted once here rather than in + // the shared field, which must not know about this form's input format. + const takenAtAnchor = useMemo(() => { + const parsed = new Date(takenAt); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + }, [takenAt]); const [note, setNote] = useState(""); // The window the paper report printed for THIS reading. Optional and folded // away by default: most entries are typed against a catalog marker and never @@ -224,6 +240,7 @@ export function LabForm({ : {}), takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), + ...(visitId ? { encounterId: visitId } : {}), }); toast.success( t("labs.form.savedToast"), @@ -449,6 +466,15 @@ export function LabForm({ + {/* Anchored on the draw date, not on now: backdating a reading moves + the offer with it. */} + + {resultType === "numeric" && selected && referenceText ? (

{t("labs.referenceLabel")} {referenceText} {selected.unit} diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 29eee6ce6..e64c33577 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ApiError } from "@/lib/api/api-fetch"; +import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; import { useTranslations } from "@/lib/i18n/context"; import { OcrRowEditor } from "./ocr-row-editor"; @@ -128,6 +129,12 @@ export function OcrReviewDialog({ // filed into the Documents vault and cross-linked to the committed labs. Text // mode keeps the image on-device, so nothing is retained there. const [pickedFile, setPickedFile] = useState(null); + /** + * The visit this panel came out of, if the review step offered one and the + * person took the offer. Never blocks the save and never pre-fills anything + * else: the panel commits with it unset exactly as before. + */ + const [visitId, setVisitId] = useState(null); // Text mode OCR's the image in the browser then POSTs the text; vision mode // uploads the image. Both resolve with the same proposed-rows DTO. @@ -143,10 +150,24 @@ export function OcrReviewDialog({ [rows], ); + /** + * The draw date the whole panel hangs off — the earliest `takenAt` the scan + * read. A panel is one draw; asking the suggestion about each row separately + * would ask the same question a dozen times and could answer it a dozen ways. + */ + const panelAnchor = useMemo(() => { + const dates = rows + .map((row) => row.takenAt) + .filter((value): value is string => Boolean(value)) + .sort(); + return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null; + }, [rows]); + function reset() { setStage("pick"); setRows([]); setPickedFile(null); + setVisitId(null); extract.reset(); commit.reset(); } @@ -183,7 +204,7 @@ export function OcrReviewDialog({ return; } commit.mutate( - { rows: payload, file: pickedFile }, + { rows: payload, file: pickedFile, encounterId: visitId }, { onSuccess: (result) => { // The commit re-checks each confirmed row against what is already @@ -307,6 +328,16 @@ export function OcrReviewDialog({

{t("labs.ocr.foundCount", { count: rows.length })}

+ + {/* Anchored on the draw date the scan read, so the offer is about + the panel rather than about today. */} + + {rows.map((row) => ( { export function useOcrCommit() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ rows, file }: OcrCommitInput) => { + mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => { const documentId = file ? await fileScanToVault(file).catch(() => undefined) : undefined; return apiPost("/api/labs/ocr/commit", { rows, ...(documentId ? { documentId } : {}), + ...(encounterId ? { encounterId } : {}), }); }, onSuccess: (_result, { file }) => { diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index bc09c1e4f..5f65c474e 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -20,7 +20,14 @@ import { useRouter } from "next/navigation"; import { CalendarClock, CheckCircle2, Plus } from "lucide-react"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context"; +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { useEncounters } from "@/hooks/use-encounters"; +import { + useFormatters, + useTranslations, + useDisplayTimezone, +} from "@/lib/i18n/context"; +import type { EncounterKind } from "@/generated/prisma/client"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -38,6 +45,15 @@ import { const SUMMARY_LIMIT = 3; +/** + * How far ahead an appointment has to be before this card stops mentioning it. + * + * A dashboard summary answers "what is coming", and two weeks is the horizon a + * person plans in. Further out and the line is noise that pushes the checkups + * it sits under off the card. + */ +const APPOINTMENT_HORIZON_DAYS = 14; + function resolveLabel( reminder: MeasurementReminder, t: (key: string) => string, @@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() { // Issue #490 — the same profile zone the checkups page derives its due // phrase in, so the two screens still agree when the device sits elsewhere. const displayTz = useDisplayTimezone(); + const format = useFormatters(); const router = useRouter(); // The same answer `/checkups` gives the same action. Satisfying a reminder // is not one of the verbs a delegation admits at any level @@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() { const showLoading = !reminders && !isError; const { satisfy } = useMeasurementReminderMutations(); const [now] = useState(() => Date.now()); + + /** + * The next appointment, read from the VISIT list and never from the reminder + * table. + * + * An ENCOUNTER-origin reminder is excluded from every preventive-care read, + * which is correct — an appointment is not a checkup, and relaxing that + * filter here would put one in the due list. But a notification about + * something invisible in the app is worse than no notification, so the + * appointment appears where a person looks, sourced from the record it + * actually lives in. A diff in this file that touches `origin` is the wrong + * fix. + */ + const visits = useEncounters(); + const nextAppointment = (visits.data?.upcoming ?? []).find( + (visit) => + visit.status === "PLANNED" && + new Date(visit.occurredAt).getTime() - now <= + APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000, + ); const [capturing, setCapturing] = useState(null); const [captureFooterEl, setCaptureFooterEl] = useState( null, @@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() { })} )} + + {/* Visually distinct from a due checkup: a quiet line under the block + rather than a fourth row with an action, because there is nothing + to do about an appointment except keep it. */} + {nextAppointment ? ( + + + + {t("encounters.dashboard.nextVisit", { + what: + nextAppointment.practitioner?.name ?? + encounterKindText(t, nextAppointment.kind as EncounterKind), + when: format.date(nextAppointment.occurredAt), + })} + + + ) : null} ( null, ); + /** + * The due checkup a visit is being filed against, if any. + * + * The visit sheet does the rest: saving it as DONE satisfies this reminder + * through the server's forward-only, idempotent path, so filing a visit AND + * ticking the box is one satisfaction rather than an error. + */ + const [visitForReminder, setVisitForReminder] = useState(null); + const [visitSession, setVisitSession] = useState(0); + const openVisitFor = (reminder: MeasurementReminder) => { + setVisitForReminder(reminder.id); + setVisitSession((n) => n + 1); + }; const [form, setForm] = useState(EMPTY_FORM); // The value-capture sheet: holds the reminder whose measurement we are // entering. Non-null ⇒ the MeasurementForm sheet is open for that row. @@ -738,6 +753,7 @@ export function VorsorgeSection({ now={now} view={prefs.view} onPrimaryAction={() => onPrimaryAction(reminder)} + onFileVisit={() => openVisitFor(reminder)} onRemove={() => remove.mutate(reminder.id)} onEdit={() => openEdit(reminder)} busy={ @@ -749,6 +765,18 @@ export function VorsorgeSection({ )} )} + { + if (!open) setVisitForReminder(null); + }} + encounter={null} + reminderId={visitForReminder} + // The reminder list repaints from the encounter write's own + // invalidation; nothing extra to do here. + onSaved={() => setVisitForReminder(null)} + /> ); } @@ -779,6 +807,7 @@ function VorsorgeCard({ now, view, onPrimaryAction, + onFileVisit, onRemove, onEdit, busy, @@ -787,6 +816,8 @@ function VorsorgeCard({ now: number; view: "cards" | "list"; onPrimaryAction: () => void; + /** Open the visit sheet with this checkup pre-filled, closing it on save. */ + onFileVisit: () => void; onRemove: () => void; onEdit: () => void; busy: boolean; @@ -912,6 +943,30 @@ function VorsorgeCard({ // "do it now" wash; a button that changes colour by schedule state reads as // a different control). Due-ness is communicated by the discreet coloured // next-due text above. + /** + * "Termin eintragen" — the second way to close a checkup. + * + * Beside "erledigt" rather than instead of it, because the two answer + * different questions: one says the thing happened, the other says WHERE it + * happened and files the record of it. Saving the visit as DONE satisfies + * this reminder through the same forward-only, idempotent primitive the + * checkbox uses, so a person who does both gets one satisfaction and no + * error. + */ + const fileVisitButton = canManage ? ( + + ) : null; + const primaryButton = canManage ? ( + ) : null} {headerActions} @@ -1129,7 +1202,10 @@ function VorsorgeCard({ {/* Bottom-pinned single primary action — green when due (MOD-06). */} {primaryButton ? ( -
{primaryButton}
+
+ {primaryButton} + {fileVisitButton} +
) : null} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index cd3618eec..7b475bb01 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record = { ecg_new_recording: "dashboard.heroItems.ecg_new_recording", tension_window: "dashboard.heroItems.tension_window", same_time_baseline: "dashboard.heroItems.same_time_baseline", + upcoming_visit: "dashboard.heroItems.upcoming_visit", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index 7a777ec5f..a46a7c5b3 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -15,6 +15,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { encounterDependentKeys, invalidateKeys, @@ -44,17 +45,29 @@ const BASE = "/api/encounters"; export function useEncounters( enabled = true, - window?: { from?: string; to?: string; status?: string }, + window?: { + from?: string; + to?: string; + status?: string; + /** Only visits filed against this condition episode. */ + episodeId?: string; + }, ) { const from = window?.from ?? null; const to = window?.to ?? null; return useQuery({ - queryKey: queryKeys.encounterList(from, to, window?.status), + queryKey: queryKeys.encounterList( + from, + to, + window?.status, + window?.episodeId, + ), 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); + if (window?.episodeId) sp.set("episodeId", window.episodeId); const qs = sp.toString(); return apiGet(qs ? `${BASE}?${qs}` : BASE); }, @@ -91,7 +104,24 @@ export function useEncounterSuggestion(anchor: string | null, enabled = true) { export function useEncounterMutations() { const qc = useQueryClient(); - const invalidate = () => invalidateKeys(qc, encounterDependentKeys); + /** + * A visit write reaches further than the visit list. + * + * Filing one against a due checkup satisfies that reminder, which moves its + * next-due date on the preventive-care list, on the dashboard card and on the + * Today rail — three surfaces that read the reminder table and would + * otherwise keep showing it due until their own poll. Booking one moves the + * same reads the other way: a new appointment is a row the digest surfaces. + * + * So the fan-out is the visit roots PLUS the reminder reads, through the same + * helper the Vorsorge surfaces use, rather than a second sweep that could + * drift from it. + */ + const invalidate = () => + Promise.all([ + invalidateKeys(qc, encounterDependentKeys), + invalidateReminderReads(qc), + ]); const create = useMutation({ mutationKey: queryKeys.encounterCreate(), diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 421935e26..2756a6dec 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/db", () => ({ dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) }, + encounter: { findFirst: vi.fn().mockResolvedValue(null) }, }, })); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 61696103d..e51448a84 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -126,6 +126,21 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * A booked visit falling today or tomorrow. + * + * Read off the VISIT table, never off the reminder engine: an appointment's + * one-shot reminder is excluded from every preventive-care read, and this is + * how the appointment reaches the rail without relaxing that exclusion. The + * kind rides as the enum constant so the reader's own bundle names it. + */ +export interface DailyDigestUpcomingVisit { + id: string; + kind: string; + occurredAt: string; + practitionerName: string | null; +} + /** * S11 — a confident elevated-at-rest ("tension") window for the day, already * detected server-side (`loadIntradayPulse`) under its full confidence gate. @@ -251,6 +266,8 @@ export interface DailyDigestInput { morningRefreshedToday: boolean; syncIssues: DailyDigestSyncIssue[]; preventiveDue: DailyDigestPreventiveDue[]; + /** The booked visit falling today or tomorrow, or null. Honest-absent. */ + upcomingVisit?: DailyDigestUpcomingVisit | null; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; /** @@ -504,6 +521,44 @@ function buildPreventiveCareItem( }; } +/** + * The booked visit falling today or tomorrow, as one rail line. + * + * No module gate: a visit is core, like the checkups page it lives on. No + * action beyond opening the page either — there is nothing to do about an + * appointment except keep it, and a "mark done" here would duplicate the + * visit's own status field with a second place to set it. + */ +function buildUpcomingVisitItem( + visit: DailyDigestUpcomingVisit | null, + now: Date, + t: Translate, +): PriorityItem | null { + if (!visit) return null; + const at = new Date(visit.occurredAt); + if (Number.isNaN(at.getTime())) return null; + // Today vs tomorrow is decided on the SAME instants the read window used, so + // a visit admitted by the query can never be described as neither. + const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000); + const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`); + return { + kind: "upcoming_visit", + title: t("daily.item.upcomingVisit.title"), + body: + hoursAway <= 24 + ? t("daily.item.upcomingVisit.bodyToday", { what }) + : t("daily.item.upcomingVisit.bodyTomorrow", { what }), + status: "info", + actions: [ + { + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href: "/checkups", + }, + ], + }; +} + /** * S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable * state was reached TODAY (the IO seam already applied the reached-once gate). @@ -880,6 +935,16 @@ export function buildDailyDigest( if (ecg) worthALook.push(ecg); const preventive = buildPreventiveCareItem(input.preventiveDue, t); if (preventive) worthALook.push(preventive); + // Beside the checkups rather than among them: the two are neighbours on the + // rail because a person reads "what am I meant to arrange" and "where am I + // meant to be" in one glance, and they stay separate items because one is + // overdue and the other is simply scheduled. + const visit = buildUpcomingVisitItem( + input.upcomingVisit ?? null, + input.now, + t, + ); + if (visit) worthALook.push(visit); // S11 — the calm, informational tension marker sits last: it is context, not // an action that expires, so a time-sensitive dose / sync / check-in wins the // bounded rail ahead of it. diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index bb90bd9dd..5182159d9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -32,6 +32,7 @@ import { type DailyDigestCoachPlan, type DailyDigestEcg, type DailyDigestPreventiveDue, + type DailyDigestUpcomingVisit, type DailyDigestScore, type DailyDigestSameTime, type DailyDigestSyncIssue, @@ -63,6 +64,16 @@ import { /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; +/** + * How far ahead the rail mentions a booked appointment: today or tomorrow. + * + * The rail is a "what about today" surface, so anything further out is not a + * thing to act on this morning. Two days rather than one because an evening + * reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it + * from them. + */ +const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; + /** Defensive cap on how many overdue reminders we read for the rail summary. */ const PREVENTIVE_DUE_READ_LIMIT = 20; @@ -355,6 +366,7 @@ export async function loadDailyDigest( planRows, ecgRow, arrivalRows, + nextVisitRow, ] = await Promise.all([ readDashboardSnapshotCached(user), resolveModuleMap(user.id), @@ -428,6 +440,27 @@ export async function loadDailyDigest( generatedAt: true, }, }), + // The next PLANNED visit inside the two-day horizon. Read straight off the + // visit table, never off the reminder engine — see the mapping below for + // why the exclusion above must stay untouched. + prisma.encounter.findFirst({ + where: { + userId: user.id, + deletedAt: null, + status: "PLANNED", + occurredAt: { + gt: now, + lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS), + }, + }, + orderBy: { occurredAt: "asc" }, + select: { + id: true, + kind: true, + occurredAt: true, + practitioner: { select: { name: true } }, + }, + }), ]); const score: DailyDigestScore | null = snapshot.healthScore @@ -463,6 +496,29 @@ export async function loadDailyDigest( label: row.label, })); + /** + * The appointment falling today or tomorrow, read from the VISIT table. + * + * Not from the reminder table. An ENCOUNTER-origin reminder is excluded from + * every preventive-care read, which is right — an appointment is not a + * checkup — but a notification about something the app never shows is worse + * than none. So the appointment reaches the rail from the record it lives + * in, and the exclusion above stays exactly as it is. A diff here that + * touches `origin` is the wrong fix. + * + * `occurredAt > now` deliberately, not `>= todayStart`: an appointment that + * has already happened today is not something to be reminded about, and the + * person is more likely to be at it than looking at this. + */ + const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow + ? { + id: nextVisitRow.id, + kind: nextVisitRow.kind, + occurredAt: nextVisitRow.occurredAt.toISOString(), + practitionerName: nextVisitRow.practitioner?.name ?? null, + } + : null; + // S10 — the freshest ECG recording (device verdict + recordedAt only). The // builder decides "new" and gates on the `insights` module. An ECG row only // ever carries an AFib-screening verdict; the shared enum's other members @@ -563,6 +619,7 @@ export async function loadDailyDigest( morningRefreshedToday, syncIssues, preventiveDue, + upcomingVisit, coachPlans, milestone, tensionWindow, diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts index d29582a5c..7b9bc9957 100644 --- a/src/lib/daily/priority-item.ts +++ b/src/lib/daily/priority-item.ts @@ -35,6 +35,7 @@ export const PRIORITY_ITEM_KINDS = [ "ecg_new_recording", "tension_window", "same_time_baseline", + "upcoming_visit", ] as const; export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number]; @@ -52,7 +53,7 @@ export function isPriorityItemKind(value: unknown): value is PriorityItemKind { * action; the user can only * acknowledge them, so they're the only kinds the Today rail lets you * dismiss. The remaining kinds (`dose_window`, `sync_issue`, - * `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their + * `preventive_care`, `coach_checkin`, `upcoming_visit`) are ACTIONABLE — they clear on their * own once the user acts (logs the dose, reconnects the integration, …) and * are never dismissible; offering a dismiss on them would just let a user * silence a still-open action item. diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index d98bd748e..70600950a 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -26,14 +26,6 @@ import { const DEFAULT_TIMEZONE = "Europe/Berlin"; -export async function resolveTimezone(userId: string): Promise { - const row = await prisma.user.findUnique({ - where: { id: userId }, - select: { timezone: true }, - }); - return row?.timezone || DEFAULT_TIMEZONE; -} - /** * The record owner's zone AND language, read together. * diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 13b916da7..2e6313277 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -54,7 +54,7 @@ encounterUpdateSchema.meta({ encounterListQuerySchema.meta({ id: "ListEncountersQuery", description: - "Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).", + "Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, `episodeId` (only visits filed against that condition episode — the condition side of the link, and the only direction it is offered in), and `limit` (1–200, default 100).", }); encounterSuggestQuerySchema.meta({ diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 849518f32..63f351f62 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -26,7 +26,7 @@ import { createLabResultSchema.meta({ id: "CreateLabResultRequest", description: - 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted.', + 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted. The optional `encounterId` files the reading against a visit the caller owns — always optional, and a link that could not be made never fails the reading.', }); updateLabResultSchema.meta({ diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 90baa89d3..526d2569f 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -22,7 +22,7 @@ import { dataEnvelope, stdResponses } from "./shared"; ocrCommitSchema.meta({ id: "OcrCommitRequest", description: - "The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value).", + "The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.", }); const capabilityResponse = z diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index 61bfd6d2f..be23db66a 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -31,8 +31,20 @@ export const encounterKeys = { * year at a time, and two windows are two different answers rather than one * answer that overwrites the other. */ - encounterList: (from: string | null, to: string | null, status?: string) => - ["encounters", "list", from, to, status ?? null] as const, + encounterList: ( + from: string | null, + to: string | null, + status?: string, + episodeId?: string, + ) => + [ + "encounters", + "list", + from, + to, + status ?? null, + episodeId ?? null, + ] as const, encounter: (id: string) => ["encounters", "detail", id] as const, /** * The "which visit does this belong to" verdict for one anchor date. The diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index ce6a1d54c..009e11165 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -138,6 +138,15 @@ export const encounterListQuerySchema = z to: z.iso.datetime({ offset: true }).optional(), status: encounterStatusEnum.optional(), practitionerId: id.optional(), + /** + * Only visits filed against this condition episode. + * + * The condition side of the link, and the only direction it is offered in: + * an episode spans weeks and a visit is a point, so matching from the visit + * side would produce false positives at a rate that trains a person to + * ignore every suggestion in the product. + */ + episodeId: id.optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }) .strict() diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index e79843f8b..e06a70443 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -209,6 +209,17 @@ export const ocrCommitSchema = z.object({ * is verified server-side before any link is written. */ documentId: z.string().trim().min(1).max(64).optional(), + /** + * The visit this panel came out of, when the review step offered one. + * + * Optional, always. A panel commits with no visit named and the commit + * succeeds unchanged — the link is the by-the-way half of the flow, never + * a condition of the write. Ownership is verified server-side, and one link + * is written PER RESULT ROW rather than one for the panel: a re-run panel on + * a different day links to its own visit, which is why the join is m:n and + * not a scalar column on the result. + */ + encounterId: z.string().trim().min(1).max(40).optional(), }); export type OcrCommitInput = z.infer; diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index c3b77b8c0..8a1b7c6bc 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -131,6 +131,12 @@ export const createLabResultSchema = z takenAt: takenAtField, note: optionalNote, source, + /** + * The visit this reading came out of, when the form offered one. Optional + * always: a reading saves with no visit named, and the link never gates + * the write. + */ + encounterId: z.string().trim().min(1).max(40).optional(), }) // Either a catalog link OR a free-text analyte must be present. .refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {