feat(encounters): the lab moment, the condition side, and closing a checkup

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:08:09 +02:00
1 parent be9c79974f
commit c47122ccfe
36 files changed
+924 -192

No files matched your search

+19 -2
View File
@@ -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 `<kind>:...`. 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."
+23 -6
View File
@@ -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",
+23 -6
View File
@@ -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",
+23 -6
View File
@@ -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",
+23 -6
View File
@@ -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",
+23 -6
View File
@@ -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",
+23 -6
View File
@@ -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",
+6
View File
@@ -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: {
+23 -1
View File
@@ -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
+17
View File
@@ -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);
+2
View File
@@ -20,6 +20,7 @@
import Link from "next/link";
import {
Activity,
CalendarCheck,
Award,
CalendarClock,
Footprints,
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
ecg_new_recording: HeartPulse,
tension_window: Activity,
same_time_baseline: Footprints,
upcoming_visit: CalendarCheck,
};
/**
@@ -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
@@ -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 (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="one"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestion")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-apply"
onClick={() => onPick(candidate.id)}
>
<Check
className={cn("size-4", applied ? "opacity-100" : "opacity-0")}
aria-hidden
/>
{label(candidate)}
</Button>
{applied ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
onClick={onUndo}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
// ── Two or more: a picker with nothing pre-selected ─────────────────────
if (alreadyLinked) return null;
return (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="many"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestionChoose")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{result.encounters.map((candidate) => {
const applied = pickedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-option"
onClick={() => (applied ? onUndo() : onPick(candidate.id))}
>
{label(candidate)}
</Button>
);
})}
</div>
</div>
<EncounterSuggestionField
anchor={anchor}
value={value}
onChange={onChange}
disabled={disabled}
slot="document-encounter-suggestion"
/>
);
}
@@ -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(
@@ -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 (
<div
className="space-y-1.5"
data-slot={slot}
data-verdict={result.kind}
// The count is on the element so a test can prove the rule reached the
// surface rather than inferring it from how many buttons rendered.
data-candidates={candidates.length}
>
{/* A content sentence, not meta (§3): it is the offer itself. */}
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "encounters.suggestion.single"
: "encounters.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = value === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
data-slot={`${slot}-option`}
onClick={() => onChange(chosen ? null : candidate.id)}
>
{result.kind === "one" ? (
<Check
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
aria-hidden
/>
) : null}
{label(candidate)}
</Button>
);
})}
{value !== null ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
data-slot={`${slot}-undo`}
onClick={() => onChange(null)}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
@@ -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 (
<QueryErrorCard
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
);
}
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
return (
<>
<Card data-slot="episode-visits-card">
<CardContent className="space-y-4">
<TileHeader
icon={CalendarClock}
title={t("encounters.episodeCard.title")}
right={
visits.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{visits.length}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-lg" />
))}
</div>
) : 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.
<p className="text-muted-foreground text-sm">
{t("encounters.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{visits.map((visit) => (
<li key={visit.id}>
<ListRow asChild>
<Link
href="/checkups"
data-encounter-id={visit.id}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(visit.occurredAt)}
</span>
</Link>
</ListRow>
</li>
))}
</ul>
)}
{canManage ? (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-slot="episode-visits-add"
onClick={() => {
setSheetOpen(true);
setSession((n) => n + 1);
}}
>
<Plus className="size-4" aria-hidden />
{t("encounters.episodeCard.add")}
</Button>
</div>
) : null}
</CardContent>
</Card>
{canManage ? (
<EncounterSheet
key={session}
open={sheetOpen}
onOpenChange={setSheetOpen}
encounter={null}
// Pre-linked, so the person never has to find this condition again
// in a picker they already navigated away from.
episodeId={episodeId}
/>
) : null}
</>
);
}
@@ -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 ? <EpisodeDocumentsCard episodeId={episode.id} /> : 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 ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
+27 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useRef, useState } from "react";
import { useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -118,6 +119,21 @@ export function LabForm({
const [value, setValue] = useState("");
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
/**
* The visit this reading came out of, when the suggestion offered one.
*
* Deliberately NOT cleared by the keep-open reset below: a real lab report
* shares one draw date and one visit across every analyte, so the next row
* off the same report keeps both rather than asking again.
*/
const [visitId, setVisitId] = useState<string | null>(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({
</div>
</div>
{/* Anchored on the draw date, not on now: backdating a reading moves
the offer with it. */}
<EncounterSuggestionField
anchor={takenAtAnchor}
value={visitId}
onChange={setVisitId}
slot="lab-form-encounter-suggestion"
/>
{resultType === "numeric" && selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
+32 -1
View File
@@ -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<File | null>(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<string | null>(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({
<p className="text-muted-foreground text-sm">
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
{/* Anchored on the draw date the scan read, so the offer is about
the panel rather than about today. */}
<EncounterSuggestionField
anchor={panelAnchor}
value={visitId}
onChange={setVisitId}
slot="labs-ocr-encounter-suggestion"
/>
{rows.map((row) => (
<OcrRowEditor
key={row.key}
+8 -1
View File
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
* image stays on-device.
*/
file?: File | null;
/**
* The visit this panel came out of, when the review step offered one and the
* person took the offer. Optional always — a commit with none named succeeds
* unchanged.
*/
encounterId?: string | null;
}
/**
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
export function useOcrCommit() {
const queryClient = useQueryClient();
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
mutationFn: async ({ rows, file }: OcrCommitInput) => {
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
const documentId = file
? await fileScanToVault(file).catch(() => undefined)
: undefined;
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
rows,
...(documentId ? { documentId } : {}),
...(encounterId ? { encounterId } : {}),
});
},
onSuccess: (_result, { file }) => {
@@ -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<MeasurementReminder | null>(null);
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
null,
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
})}
</ul>
)}
{/* 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 ? (
<Link
href="/checkups"
data-slot="vorsorge-dashboard-next-visit"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">
{t("encounters.dashboard.nextVisit", {
what:
nextAppointment.practitioner?.name ??
encounterKindText(t, nextAppointment.kind as EncounterKind),
when: format.date(nextAppointment.occurredAt),
})}
</span>
</Link>
) : null}
</CardContent>
<ResponsiveSheet
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
import {
Activity,
CalendarClock,
CalendarPlus,
CheckCircle2,
Plus,
RefreshCw,
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
import { cn } from "@/lib/utils";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AlertDialog,
@@ -238,6 +240,19 @@ export function VorsorgeSection({
const [editing, setEditing] = useState<MeasurementReminder | "new" | 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<string | null>(null);
const [visitSession, setVisitSession] = useState(0);
const openVisitFor = (reminder: MeasurementReminder) => {
setVisitForReminder(reminder.id);
setVisitSession((n) => n + 1);
};
const [form, setForm] = useState<FormState>(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({
)}
</ul>
)}
<EncounterSheet
key={visitSession}
open={visitForReminder !== null}
onOpenChange={(open) => {
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)}
/>
</section>
);
}
@@ -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 ? (
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
data-slot="vorsorge-file-visit"
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" />
{t("measurementReminders.fileVisit")}
</Button>
) : null;
const primaryButton = canManage ? (
<Button
type="button"
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
: t("measurementReminders.markDone")}
</Button>
) : null}
{/* Icon-only in the dense list, labelled in the cards branch —
the same action either way, never one branch offering what
the other withholds. */}
{canManage ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-file-visit"
aria-label={t("measurementReminders.fileVisit")}
title={t("measurementReminders.fileVisit")}
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" aria-hidden />
</Button>
) : null}
{headerActions}
</div>
</CardContent>
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
{primaryButton ? (
<div className="mt-auto pt-0">{primaryButton}</div>
<div className="mt-auto space-y-1.5 pt-0">
{primaryButton}
{fileVisitButton}
</div>
) : null}
</CardContent>
</Card>
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
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 }) {
+33 -3
View File
@@ -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<EncounterList>(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(),
@@ -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) },
},
}));
+65
View File
@@ -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.
+57
View File
@@ -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,
+2 -1
View File
@@ -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.
-8
View File
@@ -26,14 +26,6 @@ import {
const DEFAULT_TIMEZONE = "Europe/Berlin";
export async function resolveTimezone(userId: string): Promise<string> {
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.
*
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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
+14 -2
View File
@@ -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
+9
View File
@@ -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()
+11
View File
@@ -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<typeof ocrCommitSchema>;
+6
View File
@@ -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, {