From c47122ccfed386cd0e92789493016035f5f12eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:08:09 +0200 Subject: [PATCH] feat(encounters): the lab moment, the condition side, and closing a checkup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lab panel files itself against the visit it came from, and never has to. The OCR review and the manual form both offer the suggestion through the one shared field, anchored on the draw date rather than on now, so backdating a reading moves the offer with it. The commit writes one link PER RESULT ROW, which is why the join is m:n: a marker re-run on a different day belongs to its own visit. Best-effort by design — the readings are the write that matters and must not be lost to a link that could not be made. The condition side gets a Visits about this card with an action that pre-links the episode. From the episode side only, and that is a decision: an episode spans weeks and a visit is a point, so matching the other way would be wrong often enough to teach a person to ignore every suggestion in the product. A due checkup gains a second action beside erledigt. Filing the visit as done satisfies the checkup through the same forward-only, idempotent primitive the checkbox uses, so doing both is one satisfaction and no error. The two actions answer different questions — one says it happened, the other says where. An appointment now appears where a person looks, and NEITHER surface relaxes the reminder-table exclusion that keeps it out of the preventive-care lists. Both read the visit table: a quiet line under the dashboard checkup block for anything inside two weeks, and one rail item for today or tomorrow. A diff in either that touches origin is the wrong fix. A visit write now also refreshes the reminder reads. Closing a checkup moves its next-due date on three surfaces that would otherwise keep showing it due until their own poll. OpenAPI regenerated in the same commit. --- docs/api/openapi.yaml | 21 ++- messages/de.json | 29 +++- messages/en.json | 29 +++- messages/es.json | 29 +++- messages/fr.json | 29 +++- messages/it.json | 29 +++- messages/pl.json | 29 +++- src/app/api/encounters/route.ts | 6 + src/app/api/labs/ocr/commit/route.ts | 24 ++- src/app/api/labs/route.ts | 17 ++ src/components/daily/priority-card.tsx | 2 + .../documents/document-detail-sheet.tsx | 5 +- .../document-encounter-suggestion.tsx | 150 +++-------------- .../__tests__/visits-section.test.tsx | 2 +- .../encounters/encounter-suggestion-field.tsx | 130 +++++++++++++++ .../illness/episode-visits-card.tsx | 151 ++++++++++++++++++ .../illness/illness-episode-detail.tsx | 7 + src/components/labs/lab-form.tsx | 28 +++- src/components/labs/ocr-review-dialog.tsx | 33 +++- src/components/labs/use-ocr-extract.ts | 9 +- .../vorsorge-dashboard-card.tsx | 60 ++++++- .../vorsorge-section.tsx | 78 ++++++++- .../settings/dashboard-layout-section.tsx | 1 + src/hooks/use-encounters.ts | 36 ++++- src/lib/daily/__tests__/load-digest.test.ts | 1 + src/lib/daily/digest.ts | 65 ++++++++ src/lib/daily/load-digest.ts | 57 +++++++ src/lib/daily/priority-item.ts | 3 +- src/lib/encounters/service.ts | 8 - src/lib/openapi/routes/encounters.ts | 2 +- src/lib/openapi/routes/labs.ts | 2 +- src/lib/openapi/routes/ocr.ts | 2 +- src/lib/query-keys/encounters.ts | 16 +- src/lib/validations/encounters.ts | 9 ++ src/lib/validations/labs-ocr.ts | 11 ++ src/lib/validations/labs.ts | 6 + 36 files changed, 924 insertions(+), 192 deletions(-) create mode 100644 src/components/encounters/encounter-suggestion-field.tsx create mode 100644 src/components/illness/episode-visits-card.tsx 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, {