From 78bacb0d8605ae5f81c6173e894b6943ea7556e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:22:22 +0200 Subject: [PATCH 1/5] refactor(visits): build every link to a visit in one place Seven surfaces spelled `/checkups?visit=` by hand, some encoding the id and some not. They now share `visitPath()` from `src/lib/encounters/visit-links.ts`, the place the next fix extends. No behaviour change: a cuid encodes to itself. --- .../documents/document-record-links.tsx | 3 ++- src/components/links/related-records.tsx | 3 ++- src/lib/day/records.ts | 3 ++- src/lib/day/visit-preparation.ts | 3 ++- .../encounters/__tests__/visit-links.test.ts | 10 ++++++++++ src/lib/encounters/visit-links.ts | 17 +++++++++++++++++ src/lib/links/related.ts | 3 ++- src/lib/mcp/record-search.ts | 3 ++- src/lib/timeline/lanes.ts | 3 ++- 9 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 src/lib/encounters/__tests__/visit-links.test.ts create mode 100644 src/lib/encounters/visit-links.ts diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 93b4c4509..c3cbd6827 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -50,6 +50,7 @@ import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; +import { visitPath } from "@/lib/encounters/visit-links"; type Translate = ReturnType["t"]; @@ -109,7 +110,7 @@ export async function fetchEncounterPage( /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { - return `/checkups?visit=${encodeURIComponent(encounterId)}`; + return visitPath(encounterId); } /** Where a dose opens: its sheet on the vaccinations page. */ diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index aadcfd923..85ff071f4 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -64,6 +64,7 @@ import type { IllnessEpisodeDTO } from "@/lib/illness/dto"; import type { RelatedLink } from "@/lib/links/related"; import type { EncounterKind } from "@/generated/prisma/client"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { visitPath } from "@/lib/encounters/visit-links"; type Translate = ReturnType["t"]; type RelatedKind = RelatedLink["kind"]; @@ -311,7 +312,7 @@ function VisitPicker({ selected, onChange, anchor, known }: PickerProps) { label: encounterKindText(t, visit.kind as EncounterKind), meta: visit.practitioner?.name ?? null, occurredAt: visit.occurredAt, - href: `/checkups?visit=${encodeURIComponent(visit.id)}`, + href: visitPath(visit.id), })), anchor, { diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index d4794e2a8..b3581cac9 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -41,6 +41,7 @@ import { resolveCycleDay } from "@/lib/cycle/verdict"; import { effectiveMoodTz } from "@/lib/mood/date-key"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { visitPath } from "@/lib/encounters/visit-links"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -630,7 +631,7 @@ async function visitsPart(frame: DayFrame): Promise { meta: row.status === "PLANNED" ? "PLANNED" : row.kind, note: openText(row.outcomeEncrypted, "visit outcome"), docs: docs.get(row.id) ?? [], - href: `/checkups?visit=${row.id}`, + href: visitPath(row.id), }), ), }; diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index b4f6f67e2..7040dc585 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -26,6 +26,7 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { visitPath } from "@/lib/encounters/visit-links"; /** The window when the record holds no visit the caller may see. */ export const FALLBACK_WINDOW_DAYS = 90; @@ -275,7 +276,7 @@ async function procedureChanges(w: Window): Promise { kind: "procedure", id: row.id, title: openText(row.reasonEncrypted, "procedure reason") ?? "", - href: `/checkups?visit=${row.id}`, + href: visitPath(row.id), }), ); } diff --git a/src/lib/encounters/__tests__/visit-links.test.ts b/src/lib/encounters/__tests__/visit-links.test.ts new file mode 100644 index 000000000..7ff94fee9 --- /dev/null +++ b/src/lib/encounters/__tests__/visit-links.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { visitPath } from "../visit-links"; + +describe("visit links", () => { + it("opens one visit on the visits tab, never the bare checkups page", () => { + expect(visitPath("v1")).toBe("/checkups?visit=v1"); + expect(visitPath("a b&c")).toBe("/checkups?visit=a%20b%26c"); + }); +}); diff --git a/src/lib/encounters/visit-links.ts b/src/lib/encounters/visit-links.ts new file mode 100644 index 000000000..dd228a1fa --- /dev/null +++ b/src/lib/encounters/visit-links.ts @@ -0,0 +1,17 @@ +/** + * Where a visit lives in the app, spelled once. + * + * Visits have no page of their own: they are the second tab of `/checkups`, + * beside the preventive-care list that opens by default. The checkups page + * opens the visit a `visit` parameter names on the visits tab + * (`src/hooks/use-deep-linked-record.ts`). + * + * Every link to one visit is built here. + * + * Pure and client-safe: no server imports. + */ + +/** One visit: the visits tab, scrolled to the visit and opened. */ +export function visitPath(id: string): string { + return `/checkups?visit=${encodeURIComponent(id)}`; +} diff --git a/src/lib/links/related.ts b/src/lib/links/related.ts index 24bd01927..c1fc30bc1 100644 --- a/src/lib/links/related.ts +++ b/src/lib/links/related.ts @@ -34,6 +34,7 @@ import type { RELATED_KINDS, RELATED_VIA, } from "@/lib/validations/relation-links"; +import { visitPath } from "@/lib/encounters/visit-links"; export type RelatedKind = (typeof RELATED_KINDS)[number]; export type RelatedVia = (typeof RELATED_VIA)[number]; @@ -191,7 +192,7 @@ async function hydrate( row.practitioner?.name ?? row.kind, date: iso(row.occurredAt), - href: `/checkups?visit=${encodeURIComponent(row.id)}`, + href: visitPath(row.id), }); } return out; diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 6edfa7102..34aafb724 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -73,6 +73,7 @@ import { queryWords, type SearchCandidate, } from "@/lib/search/match"; +import { visitPath } from "@/lib/encounters/visit-links"; // ── Bounds ─────────────────────────────────────────────────────────── @@ -227,7 +228,7 @@ export function recordUrl( const rid = encodeURIComponent(id); switch (kind) { case "visit": - return `${origin}/checkups?visit=${rid}`; + return `${origin}${visitPath(id)}`; case "condition": return `${origin}/illness/${rid}`; case "document": diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index f35f91489..73fdb5789 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -34,6 +34,7 @@ import { surfaceModule } from "@/lib/modules/surface"; import type { ShareDomain } from "@/lib/sharing/scope"; import { dateOnlyKey } from "@/lib/tz/date-only"; import { userDayKey } from "@/lib/tz/format"; +import { visitPath } from "@/lib/encounters/visit-links"; /** * The sharing domain each lane reads, or `null` for a lane only the record's @@ -423,7 +424,7 @@ async function visitsLane(frame: LaneFrame): Promise { r.practitioner?.name ?? "", sub: r.kind, - href: `/checkups?visit=${r.id}`, + href: visitPath(r.id), }), ), standing: [], From 0dcafeeb70ad8f17fbd2d4d14613cb3e37c8745f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:22:36 +0200 Subject: [PATCH 2/5] fix(visits): open an upcoming appointment where it lives Visits are the second tab of `/checkups`, and the bare path opens the first one, the preventive-care list, which never shows an appointment. Every surface that listed an upcoming appointment linked it there: the dashboard's next-appointment line, the Today rail item, the Today overview fact and the appointment's push notification. A tap on the appointment landed on the list it is not on. Each now opens the visit itself (`/checkups?visit=`), or the visits tab (`/checkups?view=visits`) for a line naming several appointments. The checkups page reads `view` to pick a tab. The rail item stops borrowing the check-up item's action: it carries its own label ("View appointment") and intent `visit.view`. The appointment push names its visit through the producer's `metadata.url` override; a check-up's push keeps the checkups list. The same mis-route sat on three links that lead to visits without being upcoming: a condition's visit rows, the address book's back link and the onboarding and timeline prompts to add a visit. `visit-link-guard.test.ts` keeps the visit paths in the builder and counts every bare `/checkups` per file, each with the reason it names the checkups list. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/ko.json | 2 + messages/nl.json | 2 + messages/pl.json | 2 + src/__tests__/visit-link-guard.test.ts | 169 ++++++++++++++++++ .../__tests__/page-client-view.test.tsx | 75 ++++++++ src/app/checkups/page-client.tsx | 19 +- src/components/daily/today-hero.tsx | 3 +- .../illness/episode-visits-card.tsx | 3 +- ...orsorge-dashboard-card-next-visit.test.tsx | 72 ++++++++ .../vorsorge-dashboard-card.tsx | 7 +- .../practitioners/practitioner-list.tsx | 3 +- src/lib/daily/__tests__/digest.test.ts | 53 ++++++ .../daily/__tests__/today-overview.test.ts | 4 + src/lib/daily/digest.ts | 18 +- src/lib/daily/today-overview.ts | 4 +- .../__tests__/appointment-reminder.test.ts | 29 +++ .../encounters/__tests__/visit-links.test.ts | 25 ++- src/lib/encounters/appointment-reminder.ts | 26 ++- src/lib/encounters/visit-links.ts | 43 ++++- .../__tests__/measurement-reminder.test.ts | 28 +++ src/lib/jobs/measurement-reminder.ts | 5 + .../onboarding/__tests__/checklist.test.ts | 4 +- src/lib/onboarding/checklist.ts | 5 +- src/lib/timeline/readiness.ts | 3 +- 29 files changed, 591 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/visit-link-guard.test.ts create mode 100644 src/app/checkups/__tests__/page-client-view.test.tsx create mode 100644 src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx diff --git a/messages/de.json b/messages/de.json index 9a2c80392..b5519dc87 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11997,6 +11997,8 @@ "logDose": "Dosis erfassen", "reconnect": "Neu verbinden", "viewCheckups": "Vorsorge ansehen", + "viewAppointment": "Termin ansehen", + "viewAppointments": "Termine ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", "checkinLetGo": "Loslassen", diff --git a/messages/en.json b/messages/en.json index 8d6ab2cd4..83af58f77 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11997,6 +11997,8 @@ "logDose": "Log dose", "reconnect": "Reconnect", "viewCheckups": "View check-ups", + "viewAppointment": "View appointment", + "viewAppointments": "View appointments", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", "checkinLetGo": "Let it go", diff --git a/messages/es.json b/messages/es.json index f86b42a52..40801b3e7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11997,6 +11997,8 @@ "logDose": "Registrar dosis", "reconnect": "Reconectar", "viewCheckups": "Ver revisiones", + "viewAppointment": "Ver cita", + "viewAppointments": "Ver citas", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", "checkinLetGo": "Dejarlo ir", diff --git a/messages/fr.json b/messages/fr.json index 3b3a92f2f..843183ae9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11997,6 +11997,8 @@ "logDose": "Enregistrer la dose", "reconnect": "Reconnecter", "viewCheckups": "Voir les examens", + "viewAppointment": "Voir le rendez-vous", + "viewAppointments": "Voir les rendez-vous", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", "checkinLetGo": "Laisser tomber", diff --git a/messages/it.json b/messages/it.json index 85e7d2cf8..b157dc025 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11997,6 +11997,8 @@ "logDose": "Registra dose", "reconnect": "Riconnetti", "viewCheckups": "Vedi controlli", + "viewAppointment": "Vedi appuntamento", + "viewAppointments": "Vedi appuntamenti", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", "checkinLetGo": "Lascia andare", diff --git a/messages/ko.json b/messages/ko.json index cc848901c..486967c65 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11997,6 +11997,8 @@ "logDose": "복용 기록", "reconnect": "다시 연결", "viewCheckups": "건강검진 보기", + "viewAppointment": "예약 보기", + "viewAppointments": "예약 목록 보기", "checkinKeep": "계속하기", "checkinAdjust": "조정", "checkinLetGo": "놓아주기", diff --git a/messages/nl.json b/messages/nl.json index 745094620..75249dbd8 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -11997,6 +11997,8 @@ "logDose": "Dosis registreren", "reconnect": "Opnieuw verbinden", "viewCheckups": "Controles bekijken", + "viewAppointment": "Afspraak bekijken", + "viewAppointments": "Afspraken bekijken", "checkinKeep": "Aanhouden", "checkinAdjust": "Aanpassen", "checkinLetGo": "Loslaten", diff --git a/messages/pl.json b/messages/pl.json index d87da90db..e74a5abc5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11997,6 +11997,8 @@ "logDose": "Zapisz dawkę", "reconnect": "Połącz ponownie", "viewCheckups": "Zobacz badania", + "viewAppointment": "Zobacz wizytę", + "viewAppointments": "Zobacz wizyty", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", "checkinLetGo": "Odpuść", diff --git a/src/__tests__/visit-link-guard.test.ts b/src/__tests__/visit-link-guard.test.ts new file mode 100644 index 000000000..bf1c20e51 --- /dev/null +++ b/src/__tests__/visit-link-guard.test.ts @@ -0,0 +1,169 @@ +/** + * A link to a visit is built by `src/lib/encounters/visit-links.ts`, and a + * bare `/checkups` is a link to the checkups, never to a visit. + * + * Visits are the second tab of `/checkups`. The bare path opens the first + * one, the preventive-care list, which never lists an appointment. Four + * surfaces showed an upcoming appointment and linked it to the bare path — + * the dashboard's next-appointment line, the Today rail item, the Today + * overview fact and the appointment's push — so a tap on the appointment + * landed on the list it is not on. Each had spelled the path by hand. + * + * Two rules, both over code lines (comments are skipped): + * + * 1. The visit and tab query forms (`/checkups?visit=`, `/checkups?view=`) + * appear only in the builder. + * 2. The bare `/checkups` literal appears only where the target IS the + * checkups list, counted per file. A new occurrence fails the run until + * someone decides whether it names a checkup (add it here, with the + * reason) or a visit (use the builder). + * + * Not matched, stated rather than hidden: a path assembled from parts + * (`"/check" + "ups"`), and a `/*` inside a string on the same line as the + * literal, which the comment skipper reads as a comment. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); +const BUILDER = "lib/encounters/visit-links.ts"; + +/** + * Where a bare `/checkups` is right, and how often: each names the + * preventive-care list itself, or lists routes rather than linking one. + */ +const BARE_CHECKUPS: Record = { + "components/layout/nav-model.ts": { count: 1, why: "the page's nav entry" }, + "components/measurement-reminders/vorsorge-dashboard-card.tsx": { + count: 1, + why: "the checkups card's empty state opens the checkups", + }, + "components/illness/illness-episode-detail.tsx": { + count: 2, + why: "the body-site view (`?site=`); the bare fallback never renders", + }, + "lib/navigation/shared-record.ts": { + count: 2, + why: "route lists for domain visibility, not links", + }, + "lib/daily/digest.ts": { count: 1, why: "the due check-ups rail item" }, + "lib/day/records.ts": { count: 1, why: "a check-up satisfied that day" }, + "lib/onboarding/tour-state.ts": { count: 1, why: "the checkups tour step" }, + "lib/notifications/tap-target.ts": { + count: 1, + why: "a checkup push; an appointment's names its visit as override", + }, +}; + +/** The code on each line, with line and block comments removed. */ +function codeLines(source: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const raw of source.split("\n")) { + let line = raw; + let code = ""; + while (line.length > 0) { + if (inBlock) { + const end = line.indexOf("*/"); + if (end === -1) { + line = ""; + break; + } + inBlock = false; + line = line.slice(end + 2); + continue; + } + const start = line.indexOf("/*"); + const lineComment = line.search(/(^|[^:])\/\//); + if (lineComment !== -1 && (start === -1 || lineComment < start)) { + code += line.slice( + 0, + lineComment + (line[lineComment] === "/" ? 0 : 1), + ); + line = ""; + break; + } + if (start === -1) { + code += line; + line = ""; + break; + } + code += line.slice(0, start); + inBlock = true; + line = line.slice(start + 2); + } + out.push(code); + } + return out; +} + +const QUERY_FORM = /\/checkups\?(visit|view)=/; +// The bare page, or the page with a query the builder does not own. +const BARE = /["'`]\/checkups(?:["'`]|\?(?!visit=|view=))/g; + +function scan(files: { path: string; source: string }[]) { + const queryForms: string[] = []; + const bare: Record = {}; + for (const { path, source } of files) { + for (const line of codeLines(source)) { + if (QUERY_FORM.test(line)) queryForms.push(path); + const hits = line.match(BARE)?.length ?? 0; + if (hits > 0) bare[path] = (bare[path] ?? 0) + hits; + } + } + return { queryForms: [...new Set(queryForms)].sort(), bare }; +} + +describe("links to a visit go through the visit-links builder", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter((p) => !p.includes("__tests__/") && !p.startsWith("generated/")) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the tree", () => { + expect(files.length).toBeGreaterThan(2000); + }); + + it("spells the visit and tab paths only in the builder", () => { + // The builder itself must match, or the matcher proves nothing. + expect( + scan(files).queryForms, + "Build a link to a visit with visitPath() / visitsPath() / VISITS_PATH from src/lib/encounters/visit-links.ts.", + ).toEqual([BUILDER]); + }); + + it("links the bare checkups page only where the target is the checkups", () => { + const { bare } = scan(files); + delete bare[BUILDER]; + const expected = Object.fromEntries( + Object.entries(BARE_CHECKUPS).map(([path, { count }]) => [path, count]), + ); + expect( + bare, + "A new bare `/checkups`: if it leads to a visit, use the visit-links builder; if it really is the checkups list, add it to BARE_CHECKUPS with the reason.", + ).toEqual(expected); + }); + + it("skips comments and keeps code", () => { + const { queryForms, bare } = scan([ + { + path: "a.tsx", + source: [ + "/**", + " * `/checkups?visit=` in a docblock", + " */", + '// "/checkups" in a line comment', + '{/* a JSX comment naming "/checkups"', + ' over two lines "/checkups" */}', + 'const url = "https://example.test/x"; const bad = "/checkups";', + ].join("\n"), + }, + { path: "b.ts", source: "const p = `/checkups?visit=${id}`;" }, + { path: "c.ts", source: "const q = `/checkups?${params}`;" }, + ]); + expect(queryForms).toEqual(["b.ts"]); + expect(bare).toEqual({ "a.tsx": 1, "c.ts": 1 }); + }); +}); diff --git a/src/app/checkups/__tests__/page-client-view.test.tsx b/src/app/checkups/__tests__/page-client-view.test.tsx new file mode 100644 index 000000000..7e65a4a93 --- /dev/null +++ b/src/app/checkups/__tests__/page-client-view.test.tsx @@ -0,0 +1,75 @@ +/** + * Which tab a link to `/checkups` opens. + * + * Visits are the page's second tab. `?visit=` opens one, `?view=visits` + * the list, and the bare path keeps opening the checkups, which is what the + * nav entry and the checkup surfaces mean by it. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let search = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/checkups", + useSearchParams: () => search, +})); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({}), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-pull-to-refresh", () => ({ + usePullToRefresh: () => ({}), +})); +vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({ + PullToRefreshIndicator: () => null, +})); +vi.mock("@/components/measurement-reminders/vorsorge-section", () => ({ + VorsorgeSection: () =>
, +})); +vi.mock("@/components/encounters/visits-section", () => ({ + VisitsSection: () =>
, +})); +vi.mock("@/components/encounters/procedures-section", () => ({ + ProceduresSection: () =>
, +})); +vi.mock("@/components/body-sites/body-sites-section", () => ({ + BodySitesSection: () =>
, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import CheckupsPageClient from "../page-client"; + +function openTab(query: string): string | null { + search = new URLSearchParams(query); + const html = renderToStaticMarkup( + + + , + ); + return html.match(/data-section="([^"]+)"/)?.[1] ?? null; +} + +describe("/checkups — the tab a link opens", () => { + it("opens the checkups on the bare path", () => { + expect(openTab("")).toBe("vorsorge"); + }); + + it("opens the visits for a link to one visit", () => { + expect(openTab("visit=v1")).toBe("visits"); + }); + + it("opens the tab `view` names", () => { + expect(openTab("view=visits")).toBe("visits"); + expect(openTab("view=procedures")).toBe("procedures"); + }); + + it("ignores a `view` it does not know", () => { + expect(openTab("view=elsewhere")).toBe("vorsorge"); + }); +}); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index e97672d7c..b4bc35a05 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -15,6 +15,7 @@ import { VisitsSection } from "@/components/encounters/visits-section"; import { ProceduresSection } from "@/components/encounters/procedures-section"; import { BodySitesSection } from "@/components/body-sites/body-sites-section"; import type { BodySiteSide } from "@/hooks/use-body-sites"; +import { parseCheckupsView } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -66,17 +67,29 @@ export default function CheckupsPageClient() { linkedSideParam === "BOTH" ? linkedSideParam : null; - const [view, setView] = useState( - linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", + // A link to one tab (`?view=visits`, from a line naming several + // appointments). Built by `src/lib/encounters/visit-links.ts`, never by hand. + const linkedView = parseCheckupsView(searchParams?.get("view")); + const [view, setView] = useState( + linkedVisit + ? "visits" + : linkedSite + ? "body-sites" + : (linkedView ?? "vorsorge"), ); // A link to a visit can arrive while the page is already open (the search // opens one from wherever the person is): it moves to the visits tab then - // too, rather than only on the first render. + // too, rather than only on the first render. A link to a tab does the same. const [observedVisit, setObservedVisit] = useState(linkedVisit); if (linkedVisit !== observedVisit) { setObservedVisit(linkedVisit); if (linkedVisit) setView("visits"); } + const [observedView, setObservedView] = useState(linkedView); + if (linkedView !== observedView) { + setObservedView(linkedView); + if (linkedView && !linkedVisit) setView(linkedView); + } const refresh = useCallback( () => diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index d9650202d..f5649a39e 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -30,7 +30,8 @@ * stands in its place — not an alarming empty card. * * Every `PriorityItem` action carries an `href` (dose.log → /medications, - * sync.reconnect → /settings/integrations, checkup.view → /checkups), so + * sync.reconnect → /settings/integrations, checkup.view → /checkups, + * visit.view → the visit on the checkups page's visits tab), so * `PriorityCard` wires each tap as a `` to its existing destination * by construction; S2 invents no new backend action. * diff --git a/src/components/illness/episode-visits-card.tsx b/src/components/illness/episode-visits-card.tsx index ad50bd99b..49e9a7760 100644 --- a/src/components/illness/episode-visits-card.tsx +++ b/src/components/illness/episode-visits-card.tsx @@ -29,6 +29,7 @@ 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 { visitPath } from "@/lib/encounters/visit-links"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { EncounterKind } from "@/generated/prisma/client"; @@ -95,7 +96,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
  • diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx new file mode 100644 index 000000000..bddb52399 --- /dev/null +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx @@ -0,0 +1,72 @@ +/** + * The dashboard's next-appointment line opens the appointment, on the visits + * tab, and not the checkups list the card otherwise summarises. + * + * The line linked the bare `/checkups`, which opens the preventive-care tab — + * the one list an appointment is never on. The negative control is the card's + * own empty state, whose link is to the checkups and stays there. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const visit = { + id: "visit-42", + status: "PLANNED", + kind: "SPECIALIST", + // Inside the card's two-week horizon whenever the suite runs. + occurredAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + practitioner: { name: "Practice Example" }, +}; + +vi.mock("@/hooks/use-measurement-reminders", () => ({ + useMeasurementReminders: () => ({ + data: [], + isError: false, + refetch: vi.fn(), + }), + useMeasurementReminderMutations: () => ({ + satisfy: { mutate: vi.fn(), isPending: false }, + }), +})); + +vi.mock("@/hooks/use-encounters", () => ({ + useEncounters: () => ({ data: { upcoming: [visit], past: [] } }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canManage: true, + canWriteDomain: () => true, + canManageDomain: () => true, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { VorsorgeDashboardCard } from "../vorsorge-dashboard-card"; + +function linkHref(html: string, slot: string): string | null { + const tag = html.match(new RegExp(`]*data-slot="${slot}"[^>]*>`)); + return tag?.[0].match(/href="([^"]*)"/)?.[1] ?? null; +} + +describe(" — the next appointment", () => { + it("opens the visit itself", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Practice Example"); + expect(linkHref(html, "vorsorge-dashboard-next-visit")).toBe( + "/checkups?visit=visit-42", + ); + // The empty state names the checkups and still opens them. + expect(html).toContain('href="/checkups"'); + }); +}); diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index 7fbe3287a..ff724b5d5 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -22,6 +22,7 @@ import { CalendarClock, CheckCircle2, Plus } from "lucide-react"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useEncounters } from "@/hooks/use-encounters"; +import { visitPath } from "@/lib/encounters/visit-links"; import { useFormatters, useTranslations, @@ -240,10 +241,12 @@ 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. */} + to do about an appointment except keep it. It opens the visit + itself: a bare `/checkups` lands on the checkups tab, which is + exactly where the appointment is not. */} {nextAppointment ? ( diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 43f9ec817..b305ed15a 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -39,6 +39,7 @@ import { usePractitioners, type Practitioner, } from "@/hooks/use-practitioners"; +import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { PractitionerSheet } from "./practitioner-sheet"; @@ -107,7 +108,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index dc956aa01..01ce5ce76 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1506,6 +1506,59 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place .map((i) => i.body), ).toEqual(["Dr. Weiss today", "Dentist tomorrow"]); }); + + // The tap opens where the appointment lives. The item used to borrow the + // check-up item's action whole (label, intent and the bare `/checkups`), + // which opens the preventive-care tab an appointment is never on. + it("opens the visit itself, with its own label and intent", () => { + const d = buildDailyDigest(input({ upcomingVisits: [visit()] }), t); + const item = d.worthALook.find((i) => i.kind === "upcoming_visit"); + expect(item?.actions).toEqual([ + { + labelKey: "daily.action.viewAppointment", + intent: "visit.view", + href: "/checkups?visit=v1", + }, + ]); + }); + + it("opens the visits list for a day with several appointments", () => { + const d = buildDailyDigest( + input({ + upcomingVisits: [ + visit(), + visit({ id: "v2", practitionerName: "Dentist" }), + ], + }), + t, + ); + const item = d.worthALook.find((i) => i.kind === "upcoming_visit"); + expect(item?.actions).toEqual([ + { + labelKey: "daily.action.viewAppointments", + intent: "visit.view", + href: "/checkups?view=visits", + }, + ]); + }); + + it("leaves the due check-ups item on the checkups list", () => { + const d = buildDailyDigest( + input({ + preventiveDue: [{ label: "Skin check", staysDue: true }], + upcomingVisits: [visit()], + }), + t, + ); + const care = d.worthALook.find((i) => i.kind === "preventive_care"); + expect(care?.actions).toEqual([ + { + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href: "/checkups", + }, + ]); + }); }); describe("buildDailyDigest — Today overview", () => { diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index a50c476e5..31420c0e2 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -577,6 +577,8 @@ describe("appointments", () => { t, ); expect(o.today[0].value).toBe("Today 16:15, GP"); + // The visit itself, never the bare checkups page. + expect(o.today[0].href).toBe("/checkups?visit=a"); }); it("says nothing about the day after tomorrow", () => { @@ -600,6 +602,8 @@ describe("appointments", () => { t, ); expect(o.today[0].value).toBe("Today 09:00, GP +1"); + // A line naming two appointments opens the list both are on. + expect(o.today[0].href).toBe("/checkups?view=visits"); }); it("does not repeat a visit the rail already shows", () => { diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e3e2f1392..9f7222a57 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -24,6 +24,7 @@ import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import type { ArrivalKind } from "@/lib/arrivals/types"; import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; +import { visitsPath } from "@/lib/encounters/visit-links"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; @@ -675,9 +676,15 @@ function buildPreventiveCareItem( * appointments alone. * * 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 + * action beyond opening the visit 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. + * + * The action opens where the appointment lives: the visit itself, or the + * visits list for a day with several. It is `visit.view` and not the checkup + * item's `checkup.view`: the two kinds used to share that intent, its label + * and its bare `/checkups`, which sent every tap on an appointment to the + * preventive-care list the appointment is not on. */ function buildUpcomingVisitItems( visits: readonly DailyDigestUpcomingVisit[], @@ -706,9 +713,12 @@ function buildUpcomingVisitItems( status: "info", actions: [ { - labelKey: "daily.action.viewCheckups", - intent: "checkup.view", - href: "/checkups", + labelKey: + group.length === 1 + ? "daily.action.viewAppointment" + : "daily.action.viewAppointments", + intent: "visit.view", + href: visitsPath(group.map((visit) => visit.id)), }, ], }; diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index 8aa5b25cc..12389299f 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -28,6 +28,7 @@ import { isFromToday } from "@/lib/insights/measurement-freshness"; import type { ModuleKey } from "@/lib/modules/registry"; import type { PriorityItem } from "@/lib/daily/priority-item"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { visitsPath } from "@/lib/encounters/visit-links"; type Translate = ( key: string, @@ -681,7 +682,8 @@ function appointmentFact( : "daily.todayFact.appointment.tomorrow", { time: visit.timeLabel, what }, ), - href: "/checkups", + // The visit itself, or the visits list when the line counts several. + href: visitsPath(sameDay.map((v) => v.id)), }; } diff --git a/src/lib/encounters/__tests__/appointment-reminder.test.ts b/src/lib/encounters/__tests__/appointment-reminder.test.ts index 47eada90e..d99ffe57a 100644 --- a/src/lib/encounters/__tests__/appointment-reminder.test.ts +++ b/src/lib/encounters/__tests__/appointment-reminder.test.ts @@ -19,6 +19,7 @@ import { appointmentNextDueAfterFiring, appointmentNotifyHour, appointmentReminderAddress, + appointmentReminderVisitPath, } from "@/lib/encounters/appointment-reminder"; const TZ = "Europe/Berlin"; @@ -116,3 +117,31 @@ describe("the address an appointment reminder names", () => { ).toBeNull(); }); }); + +describe("where an appointment's push opens", () => { + const appointment = (visitUserId: string) => ({ + userId: "u1", + origin: "ENCOUNTER", + encounters: [{ id: "visit-1", userId: visitUserId }], + }); + + it("opens the visit, on the visits tab", () => { + expect(appointmentReminderVisitPath(appointment("u1"))).toBe( + "/checkups?visit=visit-1", + ); + }); + + it("counter-check: a checkup keeps the event's default, the checkups list", () => { + expect( + appointmentReminderVisitPath({ + ...appointment("u1"), + origin: "VORSORGE", + }), + ).toBeNull(); + expect( + appointmentReminderVisitPath({ ...appointment("u1"), encounters: [] }), + ).toBeNull(); + // Another account's visit is never a tap target. + expect(appointmentReminderVisitPath(appointment("u2"))).toBeNull(); + }); +}); diff --git a/src/lib/encounters/__tests__/visit-links.test.ts b/src/lib/encounters/__tests__/visit-links.test.ts index 7ff94fee9..85df50439 100644 --- a/src/lib/encounters/__tests__/visit-links.test.ts +++ b/src/lib/encounters/__tests__/visit-links.test.ts @@ -1,10 +1,33 @@ import { describe, expect, it } from "vitest"; -import { visitPath } from "../visit-links"; +import { + CHECKUPS_VIEWS, + VISITS_PATH, + parseCheckupsView, + visitPath, + visitsPath, +} from "../visit-links"; describe("visit links", () => { it("opens one visit on the visits tab, never the bare checkups page", () => { expect(visitPath("v1")).toBe("/checkups?visit=v1"); expect(visitPath("a b&c")).toBe("/checkups?visit=a%20b%26c"); }); + + it("opens the visit itself for one, the visits list for several", () => { + expect(visitsPath(["v1"])).toBe("/checkups?visit=v1"); + expect(visitsPath(["v1", "v2"])).toBe(VISITS_PATH); + expect(visitsPath([])).toBe(VISITS_PATH); + expect(VISITS_PATH).toBe("/checkups?view=visits"); + }); + + it("reads exactly the checkups page's tabs from `view`", () => { + for (const view of CHECKUPS_VIEWS) { + expect(parseCheckupsView(view)).toBe(view); + } + expect(parseCheckupsView("visit")).toBeNull(); + expect(parseCheckupsView("")).toBeNull(); + expect(parseCheckupsView(null)).toBeNull(); + expect(parseCheckupsView(undefined)).toBeNull(); + }); }); diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts index 0cae8c541..7c77748a6 100644 --- a/src/lib/encounters/appointment-reminder.ts +++ b/src/lib/encounters/appointment-reminder.ts @@ -39,6 +39,7 @@ * edit of the address reaches a nudge that is already booked. */ import type { Prisma } from "@/generated/prisma/client"; +import { visitPath } from "@/lib/encounters/visit-links"; import { readPractitionerContact } from "@/lib/practitioners/dto"; import { computeReminderNextDueAt, @@ -188,13 +189,15 @@ export function appointmentNextDueAfterFiring( } /** - * What the reminder tick reads beside an ENCOUNTER row to name the practice - * address: the practitioner of the live visit that owns it. + * What the reminder tick reads beside an ENCOUNTER row: the live visit that + * owns it, for the push's tap target ({@link appointmentReminderVisitPath}), + * and its practitioner, to name the practice address. */ export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = { encounters: { where: { deletedAt: null }, select: { + id: true, userId: true, practitioner: { select: { @@ -245,3 +248,22 @@ export function appointmentReminderAddress(reminder: { "location", ); } + +/** + * Where an appointment's push opens: the visit itself. + * + * The nudge rides the checkup event type, whose default target is the bare + * `/checkups` — the preventive-care tab, which never lists an appointment. A + * checkup's push keeps that default (null here); an appointment's names its + * visit, through the producer override `resolveTapPath` already honours. + */ +export function appointmentReminderVisitPath(reminder: { + userId: string; + origin: string; + encounters?: ReadonlyArray<{ id: string; userId: string }>; +}): string | null { + if (reminder.origin !== "ENCOUNTER") return null; + const visit = reminder.encounters?.[0]; + if (!visit || visit.userId !== reminder.userId) return null; + return visitPath(visit.id); +} diff --git a/src/lib/encounters/visit-links.ts b/src/lib/encounters/visit-links.ts index dd228a1fa..fc7bf497d 100644 --- a/src/lib/encounters/visit-links.ts +++ b/src/lib/encounters/visit-links.ts @@ -2,16 +2,51 @@ * Where a visit lives in the app, spelled once. * * Visits have no page of their own: they are the second tab of `/checkups`, - * beside the preventive-care list that opens by default. The checkups page - * opens the visit a `visit` parameter names on the visits tab - * (`src/hooks/use-deep-linked-record.ts`). + * beside the preventive-care list that opens by default. A bare `/checkups` + * therefore lands on the checkups, which is the wrong half of the page for an + * appointment, and every surface that hand-spelled that path for a visit (the + * dashboard's next-appointment line, the Today rail, the Today overview, the + * appointment's push) sent the person to the list the appointment is not on. * - * Every link to one visit is built here. + * Every link to one visit or to the visits list is built here. The checkups + * page reads both parameters: `visit` opens that visit on the visits tab + * (`src/hooks/use-deep-linked-record.ts`), `view` picks a tab. + * `visit-link-guard.test.ts` refuses a hand-spelled copy elsewhere. * * Pure and client-safe: no server imports. */ +/** The tabs of the checkups page, as the `view` parameter names them. */ +export const CHECKUPS_VIEWS = [ + "vorsorge", + "visits", + "procedures", + "body-sites", +] as const; + +export type CheckupsView = (typeof CHECKUPS_VIEWS)[number]; + +/** The tab a `view` parameter names, or null for anything else. */ +export function parseCheckupsView( + value: string | null | undefined, +): CheckupsView | null { + return (CHECKUPS_VIEWS as readonly string[]).includes(value ?? "") + ? (value as CheckupsView) + : null; +} + +/** The visits list: the checkups page opened on its visits tab. */ +export const VISITS_PATH = "/checkups?view=visits"; + /** One visit: the visits tab, scrolled to the visit and opened. */ export function visitPath(id: string): string { return `/checkups?visit=${encodeURIComponent(id)}`; } + +/** + * Where a group of visits leads: the visit itself when there is one, the + * visits list when several share one line (a day with two appointments). + */ +export function visitsPath(ids: readonly string[]): string { + return ids.length === 1 ? visitPath(ids[0]) : VISITS_PATH; +} diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 8865f8cef..1592377f7 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -141,6 +141,8 @@ interface FakeReminder { lastSatisfiedAt: Date | null; /** v1.39.2 — the last delivered nudge; absent on most fixtures. */ lastNotifiedAt?: Date | null; + /** The visit an appointment's row belongs to; absent on a checkup. */ + encounters?: Array<{ id: string; userId: string; practitioner: null }>; enabled: boolean; createdAt: Date; user: { @@ -355,6 +357,30 @@ describe("runMeasurementReminderTick", () => { expect(payload.metadata?.reminderId).toBe("appt-1"); }); + it("opens the appointment's visit, not the checkups list", async () => { + // The event's default target is the bare `/checkups`, whose first tab is + // the preventive-care list an appointment is never on. + const { prisma } = makePrisma({ + reminders: [ + reminder({ + origin: "ENCOUNTER", + measurementType: null, + id: "appt-1", + encounters: [{ id: "visit-9", userId: "u1", practitioner: null }], + }), + ], + measurementMatch: null, + labMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch.mock.calls[0][0].metadata?.url).toBe( + "/checkups?visit=visit-9", + ); + }); + it("leaves a checkup nudge actionable", async () => { // The negative control. Without it the assertion above would still pass // against a producer that suppressed every reminder's affordance. @@ -368,6 +394,8 @@ describe("runMeasurementReminderTick", () => { expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined(); + // And keeps the event's default target, the checkups list. + expect(dispatch.mock.calls[0][0].metadata?.url).toBeUndefined(); }); it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => { diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index f3c65986a..87dd5cd22 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -63,6 +63,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { APPOINTMENT_REMINDER_ADDRESS_INCLUDE, appointmentReminderAddress, + appointmentReminderVisitPath, } from "@/lib/encounters/appointment-reminder"; /** @@ -479,6 +480,7 @@ export async function runMeasurementReminderTick( // An appointment's address lives encrypted on its practitioner and is // resolved here; a checkup's is its own free-text column. const address = appointmentReminderAddress(reminder); + const visitUrl = appointmentReminderVisitPath(reminder); const { title, body } = buildMeasurementReminderPayload( await resolveJobLocale(reminder.user.locale), reminder.label, @@ -510,6 +512,9 @@ export async function runMeasurementReminderTick( metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, + // An appointment opens its visit; a checkup keeps the event's + // default, the checkups list. + ...(visitUrl ? { url: visitUrl } : {}), }, }); diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index ab84a7908..0e2ef9dbd 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -225,9 +225,11 @@ describe("the visit row (v1.39 C2)", () => { inputs({ onboarding: onboardingState({}, { visit: "within-a-month" }) }), ); expect(asked.map((i) => i.id)).toContain("visit"); + // The visits tab: the bare page opens on the checkups, where a visit is + // neither listed nor added. expect(asked.find((i) => i.id === "visit")).toMatchObject({ done: false, - href: "/checkups", + href: "/checkups?view=visits", }); const later = buildChecklist( inputs({ onboarding: onboardingState({}, { visit: "later" }) }), diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 9a692da0f..3b5b24f02 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -6,6 +6,8 @@ * status + dismissed-ids set) and renders against the result. */ +import { VISITS_PATH } from "@/lib/encounters/visit-links"; + import { hasEnteredOnboardingFlow, isOnboardingSettled, @@ -227,7 +229,8 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { items.push({ id: "visit", done: inputs.upcomingVisitCount >= 1, - href: "/checkups", + // The visits tab, where a visit is added; the bare page opens checkups. + href: VISITS_PATH, dismissed: inputs.dismissedIds.has("visit"), }); } diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index 72e8a1e16..dcafc0d9a 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -30,6 +30,7 @@ import type { TimelineReadinessResponse, } from "@/lib/day/contract"; import { readLocalDaysWithReadings } from "@/lib/day/daily-stats"; +import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { prisma } from "@/lib/db"; import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; @@ -377,7 +378,7 @@ async function visitsLane(frame: Frame) { { detail: { key: "visits", params: { count: done, procedures } } }, ); } - const visitGap = gap("visitsEmpty", 0, "/checkups"); + const visitGap = gap("visitsEmpty", 0, VISITS_PATH); return planned > 0 ? lane("thin", planned, null, { detail: { key: "visitsPlanned", params: { count: planned } }, From 402cf1a35a840969ad400326f2ddd88055dc71a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:32:12 +0200 Subject: [PATCH 3/5] feat(visits): resolve where the window before a booked visit starts The visit preparation and the doctor report offered for an appointment started at the last visit of any kind. An internist one week and a dermatologist the next gave the dermatologist a one-week window of nothing it is about. The list read now publishes `preparation` on every booked upcoming visit: the first day of the window, the rule that chose it and the earlier visit it is the day of. First match wins over the visits that took place before it: the same practitioner, else a practitioner of the same specialty (free text, compared without case or spacing), else the same kind for routine, preventive and therapy visits, else the last 90 days. One query of past visits per page, independent of the list's own filters. Null on every other row; absent on the detail and on write responses. --- docs/api/openapi.yaml | 73 ++++++ src/app/api/encounters/route.ts | 28 +- .../encounters/__tests__/preparation.test.ts | 177 +++++++++++++ src/lib/encounters/dto.ts | 9 + src/lib/encounters/preparation-read.ts | 61 +++++ src/lib/encounters/preparation.ts | 157 +++++++++++ src/lib/openapi/routes/encounters.ts | 41 +++ .../integration/encounter-preparation.test.ts | 243 ++++++++++++++++++ 8 files changed, 781 insertions(+), 8 deletions(-) create mode 100644 src/lib/encounters/__tests__/preparation.test.ts create mode 100644 src/lib/encounters/preparation-read.ts create mode 100644 src/lib/encounters/preparation.ts create mode 100644 tests/integration/encounter-preparation.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..750938941 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -71911,6 +71911,12 @@ components: $ref: "#/components/schemas/EncounterLinks" skipped: $ref: "#/components/schemas/EncounterSkipReport" + preparation: + description: "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, + upcoming), null on every other row. Absent on the detail and on write responses." + anyOf: + - $ref: "#/components/schemas/EncounterPreparation" + - type: "null" createdAt: type: string updatedAt: @@ -72062,6 +72068,73 @@ components: checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again." + EncounterPreparation: + type: object + properties: + from: + type: string + description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before + today on `default`." + basis: + type: string + enum: + - practitioner + - specialty + - kind + - default + description: "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: + `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner + of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for + ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days)." + since: + anyOf: + - $ref: "#/components/schemas/EncounterPreparationAnchor" + - type: "null" + required: + - from + - basis + - since + additionalProperties: false + description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report + offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology + appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets + the person change the dates; it does not recompute the rule." + EncounterPreparationAnchor: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + - PROCEDURE + practitionerName: + type: + - string + - "null" + specialty: + type: + - string + - "null" + required: + - id + - occurredAt + - kind + - practitionerName + - specialty + additionalProperties: false + description: "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and + specialty as they read now." CreateEncounterEnvelope: type: object properties: diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5686620b2..5c73973e6 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/validations/encounters"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; +import { loadVisitPreparations } from "@/lib/encounters/preparation-read"; import { ENCOUNTER_INCLUDE, applyEncounterLinks, @@ -143,12 +144,17 @@ export const GET = apiHandler(async (request: NextRequest) => { // them makes the first read zero and the second unfile everything on save. // Three grouped queries for the page, not three per row. const visible = await actingDomainVisibility(prisma, grantId); - const links = await loadEncounterLinksForMany( - prisma, - user.id, - [...upcoming, ...past].map((row) => row.id), - visible, - ); + // The window before each booked visit reads the whole history, not this + // page of it: the visit it starts at may sit outside the caller's filter. + const [links, preparations] = await Promise.all([ + loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ), + loadVisitPreparations(prisma, user.id, upcoming, now), + ]); annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, @@ -156,8 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), - past: past.map((row) => toEncounterDTO(row, links.get(row.id))), + upcoming: upcoming.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: preparations.get(row.id) ?? null, + })), + past: past.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: null, + })), }; return apiListSuccess(body, combinedCap(upcomingCap, pastCap)); }); diff --git a/src/lib/encounters/__tests__/preparation.test.ts b/src/lib/encounters/__tests__/preparation.test.ts new file mode 100644 index 000000000..b219ef8b1 --- /dev/null +++ b/src/lib/encounters/__tests__/preparation.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; + +import type { EncounterKind } from "@/generated/prisma/client"; +import { + PREPARATION_DEFAULT_DAYS, + resolvePreparation, + specialtyKey, + type PreparationVisit, +} from "../preparation"; + +const TZ = "Europe/Berlin"; +const TODAY = "2026-10-21"; + +function visit( + id: string, + occurredAt: string, + over: { + kind?: EncounterKind; + practitionerId?: string | null; + name?: string; + specialty?: string | null; + } = {}, +): PreparationVisit { + const practitionerId = + over.practitionerId === undefined ? null : over.practitionerId; + return { + id, + occurredAt: new Date(occurredAt), + kind: over.kind ?? "OTHER", + practitionerId, + practitioner: practitionerId + ? { name: over.name ?? practitionerId, specialty: over.specialty ?? null } + : null, + }; +} + +const internist = (id: string, at: string) => + visit(id, at, { + kind: "SPECIALIST", + practitionerId: "p-int", + name: "Internal practice", + specialty: "Internal medicine", + }); + +// The report from the issue: an internist a week ago, a dermatologist next. +const next = visit("next", "2026-10-23T08:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + name: "Skin practice", + specialty: "Dermatology", +}); + +function resolve(target: PreparationVisit, done: PreparationVisit[]) { + return resolvePreparation({ next: target, done, tz: TZ, today: TODAY }); +} + +describe("the window before a booked visit", () => { + it("starts at the last visit with the same practitioner", () => { + const prep = resolve(next, [ + internist("int", "2026-10-16T09:00:00.000Z"), + visit("derm", "2026-03-02T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + name: "Skin practice", + specialty: "Dermatology", + }), + ]); + expect(prep).toEqual({ + from: "2026-03-02", + basis: "practitioner", + since: { + id: "derm", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST", + practitionerName: "Skin practice", + specialty: "Dermatology", + }, + }); + }); + + it("else at the last visit in the same specialty, compared without case", () => { + const prep = resolve(next, [ + internist("int", "2026-10-16T09:00:00.000Z"), + visit("other-derm", "2025-11-20T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm-old", + name: "Former skin practice", + specialty: " dermatology ", + }), + ]); + expect(prep.basis).toBe("specialty"); + expect(prep.from).toBe("2025-11-20"); + expect(prep.since?.id).toBe("other-derm"); + expect(prep.since?.specialty).toBe("dermatology"); + }); + + it("never falls back to the last visit of any kind", () => { + // The issue: one week of internist data offered for the dermatologist. + const prep = resolve(next, [internist("int", "2026-10-16T09:00:00.000Z")]); + expect(prep).toEqual({ + from: "2026-07-23", + basis: "default", + since: null, + }); + expect(PREPARATION_DEFAULT_DAYS).toBe(90); + }); + + it("matches on kind only for a kind that names one line of care", () => { + const therapy = visit("next-t", "2026-10-25T08:00:00.000Z", { + kind: "THERAPY", + }); + const done = [ + visit("acute", "2026-10-18T09:00:00.000Z", { kind: "ACUTE" }), + visit("t1", "2026-10-11T09:00:00.000Z", { kind: "THERAPY" }), + ]; + const prep = resolve(therapy, done); + expect(prep.basis).toBe("kind"); + expect(prep.from).toBe("2026-10-11"); + + // A specialist visit with no specialty says nothing about which one. + const specialist = visit("next-s", "2026-10-25T08:00:00.000Z", { + kind: "SPECIALIST", + }); + expect( + resolve(specialist, [ + visit("s1", "2026-10-11T09:00:00.000Z", { kind: "SPECIALIST" }), + ]).basis, + ).toBe("default"); + // Neither does "other", the kind nobody picked. + const other = visit("next-o", "2026-10-25T08:00:00.000Z"); + expect( + resolve(other, [visit("o1", "2026-10-11T09:00:00.000Z")]).basis, + ).toBe("default"); + }); + + it("prefers the practitioner over a newer visit in the same specialty", () => { + const prep = resolve(next, [ + visit("derm-b", "2026-09-01T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm-b", + specialty: "Dermatology", + }), + visit("derm-a", "2026-01-15T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + specialty: "Dermatology", + }), + ]); + expect(prep.basis).toBe("practitioner"); + expect(prep.since?.id).toBe("derm-a"); + }); + + it("only counts visits before the booked one", () => { + const later = visit("later", "2026-10-24T09:00:00.000Z", { + practitionerId: "p-derm", + }); + expect(resolve(next, [later]).basis).toBe("default"); + }); + + it("starts on the matched visit's own day in the record's zone", () => { + // 23:30 UTC is the next calendar day in Berlin. + const prep = resolve(next, [ + visit("late", "2026-05-04T23:30:00.000Z", { practitionerId: "p-derm" }), + ]); + expect(prep.from).toBe("2026-05-05"); + }); +}); + +describe("specialty comparison", () => { + it("folds case and spacing, and nothing else", () => { + expect(specialtyKey(" Internal Medicine ")).toBe("internal medicine"); + expect(specialtyKey("")).toBeNull(); + expect(specialtyKey(" ")).toBeNull(); + expect(specialtyKey(null)).toBeNull(); + expect(specialtyKey("Dermatology")).not.toBe(specialtyKey("Skin doctor")); + }); +}); diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index f12c18d0d..117db5849 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -21,6 +21,7 @@ import type { MeasurementReminder, Practitioner, } from "@/generated/prisma/client"; +import type { EncounterPreparationDTO } from "@/lib/encounters/preparation"; import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; import { toPractitionerDTO, @@ -93,6 +94,14 @@ export interface EncounterDTO { * at it again. */ skipped?: RestoreSkipSummary; + /** + * The window before a booked visit: the day the visit preparation and the + * doctor report offered for it start, the rule that chose it, and the + * earlier visit it is the day of (`src/lib/encounters/preparation.ts`). + * + * On the list read. Null on every row but a booked upcoming one. + */ + preparation?: EncounterPreparationDTO | null; createdAt: string; updatedAt: string; } diff --git a/src/lib/encounters/preparation-read.ts b/src/lib/encounters/preparation-read.ts new file mode 100644 index 000000000..9ff3f74ee --- /dev/null +++ b/src/lib/encounters/preparation-read.ts @@ -0,0 +1,61 @@ +/** + * The read behind `EncounterDTO.preparation`: one query of the record's past + * visits for every booked visit on the page, resolved by the rule in + * `preparation.ts`. + */ +import type { EncounterStatus, PrismaClient } from "@/generated/prisma/client"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +import { + resolvePreparation, + type EncounterPreparationDTO, + type PreparationVisit, +} from "./preparation"; + +/** + * How many past visits the rule looks through, newest first. Far more than a + * person files in years; a match older than the cap falls to the next rule, + * which on an account this busy is never the default. + */ +export const PREPARATION_HISTORY_CAP = 500; + +type Booked = PreparationVisit & { status: EncounterStatus }; + +/** The window before each booked visit in `upcoming`, by visit id. */ +export async function loadVisitPreparations( + db: Pick, + recordId: string, + upcoming: readonly Booked[], + now: Date, +): Promise> { + const booked = upcoming.filter((visit) => visit.status === "PLANNED"); + if (booked.length === 0) return new Map(); + const [tz, done] = await Promise.all([ + resolveUserTimezone(recordId), + db.encounter.findMany({ + where: { + userId: recordId, + deletedAt: null, + status: "DONE", + occurredAt: { lte: now }, + }, + select: { + id: true, + occurredAt: true, + kind: true, + practitionerId: true, + practitioner: { select: { name: true, specialty: true } }, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: PREPARATION_HISTORY_CAP, + }), + ]); + const today = userDayKey(now, tz); + return new Map( + booked.map((next) => [ + next.id, + resolvePreparation({ next, done, tz, today }), + ]), + ); +} diff --git a/src/lib/encounters/preparation.ts b/src/lib/encounters/preparation.ts new file mode 100644 index 000000000..d5dfb03a5 --- /dev/null +++ b/src/lib/encounters/preparation.ts @@ -0,0 +1,157 @@ +/** + * Where the window before a booked visit starts, and why. + * + * The visit preparation ("what happened since the last visit") and the doctor + * report offered for an appointment both need a start date. It used to be the + * last visit of any kind: a person seeing an internist on one day and a + * dermatologist a week later was offered a one-week report for the + * dermatologist, covering nothing that visit is about. + * + * The rule, first match wins, over the visits that took place (DONE) before + * the booked one: + * + * 1. practitioner — the last visit with the same practitioner; + * 2. specialty — else the last visit with a practitioner of the same + * specialty (the free-text field, compared without case + * or extra spaces, so "Dermatology" and "dermatology " + * match and "Dermatology" and "Skin doctor" do not); + * 3. kind — else the last visit of the same kind, for the kinds that + * name one line of care (routine, preventive, therapy); + * 4. default — else the last {@link PREPARATION_DEFAULT_DAYS} days. + * + * The other kinds never match by kind alone. A specialist visit says nothing + * about which specialty (that is what rule 2 is for), "other" is the default + * nobody picked, and an acute visit, an emergency, a hospital stay or a + * procedure is an event rather than a recurring line of care, so the last one + * is no better a start than the default. + * + * The window starts on the matched visit's own day: a dose changed at that + * visit belongs to what the next one should know. + * + * Resolved on the server, published on the visit list, never recomputed by a + * client. A person can still change both dates on the report. + * + * Pure and client-safe; the read lives in `preparation-read.ts`. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** The window when no earlier visit matches the booked one. */ +export const PREPARATION_DEFAULT_DAYS = 90; + +/** The kinds a visit is matched on when neither practitioner nor specialty is. */ +export const PREPARATION_KIND_MATCHES = [ + "ROUTINE", + "PREVENTIVE", + "THERAPY", +] as const satisfies readonly EncounterKind[]; + +export const PREPARATION_BASES = [ + "practitioner", + "specialty", + "kind", + "default", +] as const; + +export type PreparationBasis = (typeof PREPARATION_BASES)[number]; + +/** The earlier visit the window starts at. */ +export interface PreparationAnchorDTO { + id: string; + occurredAt: string; + kind: EncounterKind; + practitionerName: string | null; + specialty: string | null; +} + +/** The window before one booked visit, resolved. */ +export interface EncounterPreparationDTO { + /** First day of the window, `YYYY-MM-DD` on the record's own calendar. */ + from: string; + /** Which rule chose `from`. */ + basis: PreparationBasis; + /** The visit `from` is the day of; null on `default`. */ + since: PreparationAnchorDTO | null; +} + +/** The fields the rule reads, off a booked visit or an earlier one. */ +export interface PreparationVisit { + id: string; + occurredAt: Date; + kind: EncounterKind; + practitionerId: string | null; + practitioner: { name: string; specialty: string | null } | null; +} + +/** A specialty as compared: no case, no outer or doubled spaces. */ +export function specialtyKey(value: string | null | undefined): string | null { + const key = value + ?.normalize("NFKC") + .trim() + .replace(/\s+/g, " ") + .toLocaleLowerCase(); + return key ? key : null; +} + +/** + * The window before `next`. + * + * `done` is the record's visits that took place, newest first. Only those + * before `next` are considered; `today` (the record's local day) anchors the + * default window. + */ +export function resolvePreparation(args: { + next: PreparationVisit; + done: readonly PreparationVisit[]; + tz: string; + today: string; +}): EncounterPreparationDTO { + const { next, tz, today } = args; + const earlier = args.done.filter( + (visit) => + visit.id !== next.id && + visit.occurredAt.getTime() < next.occurredAt.getTime(), + ); + const specialty = specialtyKey(next.practitioner?.specialty); + const kindMatches = ( + PREPARATION_KIND_MATCHES as readonly EncounterKind[] + ).includes(next.kind); + + const rules: Array<[PreparationBasis, (visit: PreparationVisit) => boolean]> = + [ + [ + "practitioner", + (visit) => + next.practitionerId !== null && + visit.practitionerId === next.practitionerId, + ], + [ + "specialty", + (visit) => + specialty !== null && + specialtyKey(visit.practitioner?.specialty) === specialty, + ], + ["kind", (visit) => kindMatches && visit.kind === next.kind], + ]; + + for (const [basis, matches] of rules) { + const hit = earlier.find(matches); + if (!hit) continue; + return { + from: userDayKey(hit.occurredAt, tz), + basis, + since: { + id: hit.id, + occurredAt: hit.occurredAt.toISOString(), + kind: hit.kind, + practitionerName: hit.practitioner?.name ?? null, + specialty: hit.practitioner?.specialty?.trim() || null, + }, + }; + } + return { + from: shiftDateKey(today, -PREPARATION_DEFAULT_DAYS), + basis: "default", + since: null, + }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 68136f0b2..d00485b32 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -35,6 +35,7 @@ import { practitionerListQuerySchema, } from "@/lib/validations/practitioners"; import { bodySiteQuerySchema } from "@/lib/validations/body-sites"; +import { PREPARATION_BASES } from "@/lib/encounters/preparation"; import { illnessLifecycleEnum, illnessTypeEnum, @@ -169,6 +170,40 @@ const skipReport = z "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again.", }); +const preparationAnchor = z + .object({ + id: z.string(), + occurredAt: z.string(), + kind: encounterKindEnum, + practitionerName: z.string().nullable(), + specialty: z.string().nullable(), + }) + .meta({ + id: "EncounterPreparationAnchor", + description: + "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and specialty as they read now.", + }); + +const encounterPreparation = z + .object({ + from: z + .string() + .describe( + "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`.", + ), + basis: z + .enum(PREPARATION_BASES) + .describe( + "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days).", + ), + since: preparationAnchor.nullable(), + }) + .meta({ + id: "EncounterPreparation", + description: + "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets the person change the dates; it does not recompute the rule.", + }); + const encounter = z .object({ id: z.string(), @@ -183,6 +218,12 @@ const encounter = z reminderNextDueAt: z.string().nullable(), links: encounterLinks.optional(), skipped: skipReport.optional(), + preparation: encounterPreparation + .nullable() + .optional() + .describe( + "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, upcoming), null on every other row. Absent on the detail and on write responses.", + ), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/tests/integration/encounter-preparation.test.ts b/tests/integration/encounter-preparation.test.ts new file mode 100644 index 000000000..6d9f00023 --- /dev/null +++ b/tests/integration/encounter-preparation.test.ts @@ -0,0 +1,243 @@ +/** + * The window before a booked visit, through the real list route and real + * Postgres. + * + * The unit test pins the rule over hand-built rows. What only the database can + * answer is the read: that it looks at visits that took place (DONE, not + * cancelled, not booked), that it ignores the list's own filters and paging, + * that a soft-deleted visit is not an anchor, and that the practitioner's + * specialty reaches the rule through the join. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { GET as listEncounters } from "@/app/api/encounters/route"; +import type { EncounterListDTO } from "@/lib/encounters/dto"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "prep-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * DAY_MS); +} + +/** The local day of an instant on the owner's calendar. */ +function berlinDay(at: Date): string { + return at.toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" }); +} + +async function seed() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "prep-owner", + email: "prep-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function practitioner(name: string, specialty: string | null) { + return getPrismaClient().practitioner.create({ + data: { userId: OWNER_ID, name, specialty }, + }); +} + +async function encounter(data: { + occurredAt: Date; + status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW"; + kind?: "ROUTINE" | "SPECIALIST" | "THERAPY" | "OTHER"; + practitionerId?: string | null; + deletedAt?: Date | null; +}) { + return getPrismaClient().encounter.create({ + data: { + userId: OWNER_ID, + occurredAt: data.occurredAt, + status: data.status ?? "DONE", + kind: data.kind ?? "OTHER", + practitionerId: data.practitionerId ?? null, + deletedAt: data.deletedAt ?? null, + }, + }); +} + +async function list(query = ""): Promise { + const res = await listEncounters( + new Request(`http://localhost/api/encounters${query}`) as never, + ); + expect(res.status).toBe(200); + return ((await res.json()) as { data: EncounterListDTO }).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seed(); +}); + +describe("GET /api/encounters — the window before a booked visit", () => { + it("starts at the same practitioner's last visit, not the last visit of any kind", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + const internist = await practitioner( + "Internal practice", + "Internal medicine", + ); + const dermVisit = await encounter({ + occurredAt: daysFromNow(-200), + kind: "SPECIALIST", + practitionerId: derm.id, + }); + // The newer visit elsewhere the old rule anchored on. + await encounter({ + occurredAt: daysFromNow(-5), + kind: "SPECIALIST", + practitionerId: internist.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(2), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: derm.id, + }); + + const body = await list(); + const booked = body.upcoming.find((v) => v.id === next.id); + expect(booked?.preparation).toEqual({ + from: berlinDay(dermVisit.occurredAt), + basis: "practitioner", + since: { + id: dermVisit.id, + occurredAt: dermVisit.occurredAt.toISOString(), + kind: "SPECIALIST", + practitionerName: "Skin practice", + specialty: "Dermatology", + }, + }); + // Only a booked visit carries one. + expect(body.past.every((v) => v.preparation === null)).toBe(true); + }); + + it("matches a specialty across practitioners, through the join", async () => { + const oldDerm = await practitioner("Former skin practice", "dermatology "); + const newDerm = await practitioner("Skin practice", "Dermatology"); + const anchor = await encounter({ + occurredAt: daysFromNow(-300), + kind: "SPECIALIST", + practitionerId: oldDerm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(3), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: newDerm.id, + }); + + const booked = (await list()).upcoming.find((v) => v.id === next.id); + expect(booked?.preparation?.basis).toBe("specialty"); + expect(booked?.preparation?.since?.id).toBe(anchor.id); + }); + + it("anchors only on a visit that took place and still exists", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + await encounter({ + occurredAt: daysFromNow(-20), + status: "CANCELLED", + practitionerId: derm.id, + }); + await encounter({ + occurredAt: daysFromNow(-30), + status: "NO_SHOW", + practitionerId: derm.id, + }); + await encounter({ + occurredAt: daysFromNow(-40), + practitionerId: derm.id, + deletedAt: new Date(), + }); + // Another booked visit with the same practice is not one that happened. + await encounter({ + occurredAt: daysFromNow(1), + status: "PLANNED", + practitionerId: derm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(9), + status: "PLANNED", + practitionerId: derm.id, + }); + + const booked = (await list()).upcoming.find((v) => v.id === next.id); + expect(booked?.preparation).toEqual({ + from: shiftDateKey(berlinDay(new Date()), -90), + basis: "default", + since: null, + }); + }); + + it("reads the whole history, whatever the list was filtered to", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + const anchor = await encounter({ + occurredAt: daysFromNow(-400), + practitionerId: derm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(4), + status: "PLANNED", + practitionerId: derm.id, + }); + + const from = daysFromNow(-1).toISOString(); + const body = await list(`?from=${encodeURIComponent(from)}`); + expect(body.past).toEqual([]); + expect( + body.upcoming.find((v) => v.id === next.id)?.preparation?.since?.id, + ).toBe(anchor.id); + }); + + it("leaves a cancelled upcoming visit without a window", async () => { + const cancelled = await encounter({ + occurredAt: daysFromNow(5), + status: "CANCELLED", + }); + const booked = (await list()).upcoming.find((v) => v.id === cancelled.id); + expect(booked?.preparation).toBeNull(); + }); +}); From 99fec791770aadb100de544e57f50f718d40a5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:32:18 +0200 Subject: [PATCH 4/5] fix(visits): start the visit preparation and its report at the matching visit Both surfaces read the start of their window from `preparation` and say why it starts there: "Since your last visit with on ", "Since your last visit on ", "Since your last visit of the same kind () on ", or "The last 90 days" with the reason that no earlier visit matched. The visits tab no longer picks the last visit of any kind from the past list. The report panel's appointment offer now sets both dates, the start from the server and the end on the appointment's day, instead of the end alone beside whatever start the panel held. Both stay editable. --- messages/de.json | 10 ++- messages/en.json | 10 ++- messages/es.json | 10 ++- messages/fr.json | 10 ++- messages/it.json | 10 ++- messages/ko.json | 10 ++- messages/nl.json | 10 ++- messages/pl.json | 10 ++- src/components/day/since-last-visit.tsx | 68 ++++++++++----- .../__tests__/visits-section.test.tsx | 86 +++++++++++++++---- src/components/encounters/preparation-text.ts | 69 +++++++++++++++ src/components/encounters/visits-section.tsx | 24 ++++-- .../__tests__/report-visit-offer.test.tsx | 57 +++++++++++- .../report-selection/report-visit-offer.tsx | 72 ++++++++++++---- .../report-selection/selection-panel.tsx | 7 +- 15 files changed, 377 insertions(+), 86 deletions(-) create mode 100644 src/components/encounters/preparation-text.ts diff --git a/messages/de.json b/messages/de.json index b5519dc87..d5c341731 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", "visitOffer": "Am {when} steht ein Termin an: {what}.", - "visitOfferApply": "Bericht bis dahin" + "visitOfferApplyWindow": "Zeitraum übernehmen" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nichts passt zu diesem Filter", "clearFilter": "Filter zurücksetzen", "loadError": "Die Eingriffe konnten nicht geladen werden." + }, + "preparation": { + "practitioner": "Seit deinem letzten Termin bei {name} am {date}", + "specialty": "Seit deinem letzten Termin ({specialty}) am {date}", + "kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}", + "default": "Die letzten {days} Tage", + "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Schnitt aus {count} Messungen", "meanOfReadingsOther": "Schnitt aus {count} Messungen", "sinceLastVisit": { - "title": "Seit dem letzten Besuch am {date}", "countOne": "1 Eintrag", "countFew": "{count} Einträge", "countOther": "{count} Einträge", diff --git a/messages/en.json b/messages/en.json index 83af58f77..dd7f54457 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "You have reached the export limit for this hour.", "errorModuleDisabled": "The health record export is switched off for this account.", "visitOffer": "An appointment is coming up on {when}: {what}.", - "visitOfferApply": "Report up to then" + "visitOfferApplyWindow": "Use this window" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nothing matches this filter", "clearFilter": "Clear filter", "loadError": "Procedures could not be loaded." + }, + "preparation": { + "practitioner": "Since your last visit with {name} on {date}", + "specialty": "Since your last {specialty} visit on {date}", + "kind": "Since your last visit of the same kind ({kind}) on {date}", + "default": "The last {days} days", + "defaultReason": "No earlier visit with the same practice, specialty or kind." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Average of {count} readings", "meanOfReadingsOther": "Average of {count} readings", "sinceLastVisit": { - "title": "Since the last visit on {date}", "countOne": "1 entry", "countFew": "{count} entries", "countOther": "{count} entries", diff --git a/messages/es.json b/messages/es.json index 40801b3e7..b9b593b9d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", "visitOffer": "El {when} hay una cita: {what}.", - "visitOfferApply": "Informe hasta esa fecha" + "visitOfferApplyWindow": "Usar este periodo" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nada coincide con este filtro", "clearFilter": "Quitar filtro", "loadError": "No se pudieron cargar los procedimientos." + }, + "preparation": { + "practitioner": "Desde tu última visita con {name} el {date}", + "specialty": "Desde tu última visita ({specialty}) el {date}", + "kind": "Desde tu última visita del mismo tipo ({kind}) el {date}", + "default": "Los últimos {days} días", + "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Media de {count} mediciones", "meanOfReadingsOther": "Media de {count} mediciones", "sinceLastVisit": { - "title": "Desde la última visita el {date}", "countOne": "1 entrada", "countFew": "{count} entradas", "countOther": "{count} entradas", diff --git a/messages/fr.json b/messages/fr.json index 843183ae9..d2ba94ff6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", "visitOffer": "Un rendez-vous est prévu le {when} : {what}.", - "visitOfferApply": "Rapport jusque-là" + "visitOfferApplyWindow": "Utiliser cette période" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Rien ne correspond à ce filtre", "clearFilter": "Effacer le filtre", "loadError": "Les interventions n’ont pas pu être chargées." + }, + "preparation": { + "practitioner": "Depuis votre dernière visite chez {name} le {date}", + "specialty": "Depuis votre dernière visite ({specialty}) le {date}", + "kind": "Depuis votre dernière visite du même type ({kind}) le {date}", + "default": "Les {days} derniers jours", + "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Moyenne de {count} mesures", "meanOfReadingsOther": "Moyenne de {count} mesures", "sinceLastVisit": { - "title": "Depuis la dernière visite le {date}", "countOne": "1 entrée", "countFew": "{count} entrées", "countOther": "{count} entrées", diff --git a/messages/it.json b/messages/it.json index b157dc025..a59ab9b1e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", "visitOffer": "Il {when} c'è un appuntamento: {what}.", - "visitOfferApply": "Referto fino a quella data" + "visitOfferApplyWindow": "Usa questo periodo" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nessun risultato per questo filtro", "clearFilter": "Rimuovi filtro", "loadError": "Non è stato possibile caricare gli interventi." + }, + "preparation": { + "practitioner": "Dalla tua ultima visita con {name} del {date}", + "specialty": "Dalla tua ultima visita ({specialty}) del {date}", + "kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}", + "default": "Gli ultimi {days} giorni", + "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Media di {count} misurazioni", "meanOfReadingsOther": "Media di {count} misurazioni", "sinceLastVisit": { - "title": "Dall’ultima visita del {date}", "countOne": "1 voce", "countFew": "{count} voci", "countOther": "{count} voci", diff --git a/messages/ko.json b/messages/ko.json index 486967c65..c9a79810d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "이번 시간의 내보내기 한도에 도달했어요.", "errorModuleDisabled": "이 계정에서는 건강 기록 내보내기가 꺼져 있어요.", "visitOffer": "{when}에 예정된 진료가 있어요: {what}.", - "visitOfferApply": "그날까지로 리포트 만들기" + "visitOfferApplyWindow": "이 기간 사용하기" }, "globalExcludedInjectionSitesLabel": "전체 제외 주사 부위", "globalExcludedInjectionSitesHint": "약이 선호하더라도 용량을 기록할 때 추천하지 않고 선택도 막아요.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "이 필터에 맞는 항목이 없습니다", "clearFilter": "필터 지우기", "loadError": "시술 목록을 불러올 수 없습니다." + }, + "preparation": { + "practitioner": "{date} {name} 마지막 방문 이후", + "specialty": "{date} 마지막 {specialty} 방문 이후", + "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", + "default": "최근 {days}일", + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "측정 {count}회 평균", "meanOfReadingsOther": "측정 {count}회 평균", "sinceLastVisit": { - "title": "{date} 마지막 방문 이후", "countOne": "기록 1개", "countFew": "기록 {count}개", "countOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 75249dbd8..90aea8b4d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Je hebt de exportlimiet voor dit uur bereikt.", "errorModuleDisabled": "De export van het gezondheidsdossier is uitgeschakeld voor dit account.", "visitOffer": "Er staat een afspraak gepland op {when}: {what}.", - "visitOfferApply": "Rapport tot die datum" + "visitOfferApplyWindow": "Deze periode gebruiken" }, "globalExcludedInjectionSitesLabel": "Overal uitgesloten injectieplaatsen", "globalExcludedInjectionSitesHint": "Worden nooit aangeboden en geweigerd bij het vastleggen van een dosis, ook als een medicijn er de voorkeur aan geeft.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Niets komt overeen met dit filter", "clearFilter": "Filter wissen", "loadError": "Ingrepen konden niet worden geladen." + }, + "preparation": { + "practitioner": "Sinds je laatste bezoek bij {name} op {date}", + "specialty": "Sinds je laatste bezoek ({specialty}) op {date}", + "kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}", + "default": "De afgelopen {days} dagen", + "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Gemiddelde van {count} metingen", "meanOfReadingsOther": "Gemiddelde van {count} metingen", "sinceLastVisit": { - "title": "Sinds het laatste bezoek op {date}", "countOne": "1 registratie", "countFew": "{count} registraties", "countOther": "{count} registraties", diff --git a/messages/pl.json b/messages/pl.json index e74a5abc5..1b645eb94 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", "visitOffer": "{when} zaplanowano wizytę: {what}.", - "visitOfferApply": "Raport do tego dnia" + "visitOfferApplyWindow": "Użyj tego okresu" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nic nie pasuje do tego filtra", "clearFilter": "Wyczyść filtr", "loadError": "Nie udało się wczytać zabiegów." + }, + "preparation": { + "practitioner": "Od ostatniej wizyty ({name}) {date}", + "specialty": "Od ostatniej wizyty ({specialty}) {date}", + "kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}", + "default": "Ostatnie {days} dni", + "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Średnia z {count} pomiarów", "meanOfReadingsOther": "Średnia z {count} pomiarów", "sinceLastVisit": { - "title": "Od ostatniej wizyty {date}", "countOne": "1 wpis", "countFew": "{count} wpisy", "countOther": "{count} wpisów", diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 08f0cf5b6..488781784 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -17,10 +17,17 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { preparationText } from "@/components/encounters/preparation-text"; import type { EncounterDTO } from "@/lib/encounters/dto"; +import type { EncounterPreparationDTO } from "@/lib/encounters/preparation"; import { apiGet } from "@/lib/api/api-fetch"; import type { DateKey, @@ -46,14 +53,20 @@ import { useTodayKey } from "./use-today-key"; /** * "Since the last visit": the preparation the next planned appointment * otherwise lacks. Dose changes, episodes that began, new results, extreme - * readings and gaps between the last visit and today, each dated, each date - * opening its day. Compiled from the record without any judgement; the - * wording is the day view's own. + * readings and gaps between the start of the window and today, each dated, + * each date opening its day. Compiled from the record without any judgement; + * the wording is the day view's own. * - * One read, `GET /api/day/notable` for the window from the last visit to - * today: the server lists every context change in it (a dose change always, - * however quiet the day around it) and every notable observation. Nothing is - * recomputed here; this only spells the rows. + * The window starts where the server says (`EncounterDTO.preparation`): the + * last visit with the same practitioner, else the same specialty, else the + * same kind, else the last 90 days — never simply the last visit of any kind, + * which handed a dermatology appointment a week of an internist's data. The + * card's title says which, so the start date explains itself. + * + * One read, `GET /api/day/notable` for that window up to today: the server + * lists every context change in it (a dose change always, however quiet the + * day around it) and every notable observation. Nothing is recomputed here; + * this only spells the rows. * * The component owns its card and draws nothing until it has a row to show: * not while the window loads, not when it holds nothing worth listing. A card @@ -132,12 +145,12 @@ export function preparationRows( export function SinceLastVisit({ next, - last, + preparation, }: { /** The next planned visit, the one this prepares. */ next: EncounterDTO; - /** The last visit that took place. */ - last: EncounterDTO; + /** The window before it, as the server resolved it. */ + preparation: EncounterPreparationDTO; }) { const { t, tCount } = useTranslations(); const fmt = useFormatters(); @@ -147,16 +160,17 @@ export function SinceLastVisit({ const notableText = useNotableText(); const [showAll, setShowAll] = useState(false); - const lastDay = dateKeyOfInstant(last.occurredAt, timeZone); - // From the last visit's own day (a dose changed at the visit belongs to - // what the next one should know) to today. The server caps the span. + // From the server's start day (the matched visit's own day: a dose changed + // at that visit belongs to what the next one should know) to today. The + // notable read caps the span. + const fromDay: DateKey = preparation.from; const window = useQuery({ - queryKey: queryKeys.dayNotable(lastDay, today), + queryKey: queryKeys.dayNotable(fromDay, today), queryFn: () => { - const params = new URLSearchParams({ from: lastDay, to: today }); + const params = new URLSearchParams({ from: fromDay, to: today }); return apiGet(`/api/day/notable?${params}`); }, - enabled: lastDay <= today, + enabled: fromDay <= today, staleTime: 5 * 60_000, }); @@ -194,7 +208,15 @@ export function SinceLastVisit({ }) : []; - const lastLabel = fmt.dateShortSmartCalendar(lastDay); + const fromLabel = fmt.dateShortSmartCalendar(fromDay); + // Where the window starts and why, in one spelling shared with the report + // panel's offer. + const heading = preparationText(t, fmt.dateShortSmartCalendar, preparation); + const reason = heading.reason ? ( + + {heading.reason} + + ) : null; // A failed read keeps the card and its title, so the page does not jump // and the reader still sees what could not be loaded. @@ -206,8 +228,9 @@ export function SinceLastVisit({ size="sm" icon={Stethoscope} titleAs="h3" - title={t("day.sinceLastVisit.title", { date: lastLabel })} + title={heading.title} /> + {reason} {tCount("day.sinceLastVisit.count", rows.length)} } /> + {reason}
    diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 31f95b25f..74531b746 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -47,7 +47,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }; }); -import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import { todayKeyInZone } from "@/components/day/day-url"; import { ApiError } from "@/lib/api/api-fetch"; import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; @@ -174,13 +174,32 @@ describe("", () => { * to wrap it in a card, so whenever it had nothing to list (still loading, a * quiet window, a failed read) an empty bordered card sat under the visit. * It now draws its own card, and only once it has a row. + * + * The window is the server's (`preparation` on the booked visit). The section + * used to start it at the last visit of any kind it found in the past list; + * the fixtures carry such a visit, newer than the window's start, and the + * read must not move to it. */ describe(" since the last visit", () => { + const sameDoctor = { + id: "derm-before", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST" as const, + practitionerName: "Skin practice", + specialty: "Dermatology", + }; const next = visit({ id: "next", status: "PLANNED", occurredAt: "2026-09-20T09:00:00.000Z", + preparation: { + from: "2026-03-02", + basis: "practitioner", + since: sameDoctor, + }, }); + const unprepared = visit({ ...next, preparation: null }); + // A newer visit elsewhere: what the old client rule anchored on. const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" }); /** Cards painted inside the upcoming list. */ @@ -194,12 +213,8 @@ describe(" since the last visit", () => { function seedWindow(window: DayNotableWindowResponse | null) { return (queryClient: QueryClient) => { if (!window) return; - const tz = DEFAULT_TIMEZONE; queryClient.setQueryData( - queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), - ), + queryKeys.dayNotable("2026-03-02", todayKeyInZone(DEFAULT_TIMEZONE)), window, ); }; @@ -219,10 +234,10 @@ describe(" since the last visit", () => { }); it("draws no card while the window is still loading", () => { - const withLast = render({ upcoming: [next], past: [last] }); - const withoutLast = render({ upcoming: [next], past: [] }); - expect(withLast).not.toContain('data-slot="since-last-visit"'); - expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast)); + const loading = render({ upcoming: [next], past: [last] }); + const without = render({ upcoming: [unprepared], past: [last] }); + expect(loading).not.toContain('data-slot="since-last-visit"'); + expect(upcomingCards(loading)).toBe(upcomingCards(without)); }); it("draws no card when the window holds nothing worth listing", () => { @@ -230,17 +245,16 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedWindow(emptyWindow), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).not.toContain('data-slot="since-last-visit"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); function seedFailure(error: Error) { return (queryClient: QueryClient) => { - const tz = DEFAULT_TIMEZONE; const queryKey = queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), + "2026-03-02", + todayKeyInZone(DEFAULT_TIMEZONE), ); // The state the section sees once the real read has given up: a mount // does not retry it. @@ -257,14 +271,14 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedFailure(new ApiError("window read failed", 500)), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); expect(html).not.toContain('data-slot="since-last-visit"'); // The failed window keeps its own card and title, so the page does not // jump; the visit cards beside it stay as they were. expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); - expect(html).toContain("Since the last visit on"); + expect(html).toContain("Since your last visit with Skin practice on"); }); // Visits need the profile section, the window reads measurements, and a @@ -296,10 +310,15 @@ describe(" since the last visit", () => { ], } as unknown as DayNotableWindowResponse), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit"'); expect(html).toContain('data-slot="since-last-visit-row"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + // The title says where the window starts and why; the report starts + // there too, not at the newer visit elsewhere. + expect(html).toContain("Since your last visit with Skin practice on"); + expect(html).toContain("reportFrom=2026-03-02&"); + expect(html).not.toContain('data-slot="since-last-visit-reason"'); // The card heads like every compact card: TileHeader at the small size, // a real heading under the section's own, at the compact density. const card = html.slice( @@ -314,4 +333,37 @@ describe(" since the last visit", () => { /

    { + const html = render( + { + upcoming: [ + visit({ + ...next, + preparation: { from: "2026-03-02", basis: "default", since: null }, + }), + ], + past: [last], + }, + seedWindow({ + ...emptyWindow, + changes: [ + { + date: "2026-08-03", + kind: "doseChange", + section: "medications", + id: "d1", + title: "Example 5 mg", + count: 1, + href: null, + }, + ], + } as unknown as DayNotableWindowResponse), + ); + expect(html).toContain("The last 90 days"); + expect(html).toContain('data-slot="since-last-visit-reason"'); + expect(html).toContain( + "No earlier visit with the same practice, specialty or kind.", + ); + }); }); diff --git a/src/components/encounters/preparation-text.ts b/src/components/encounters/preparation-text.ts new file mode 100644 index 000000000..3d5211228 --- /dev/null +++ b/src/components/encounters/preparation-text.ts @@ -0,0 +1,69 @@ +/** + * The window before a booked visit, in words: the line that says where the + * visit preparation and the offered doctor report start, and why there. + * + * Spells only what the server resolved (`EncounterDTO.preparation`); nothing + * here chooses a date. One spelling for both surfaces that show it, so the + * visits tab and the report panel cannot give two reasons for one window. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { + PREPARATION_DEFAULT_DAYS, + type EncounterPreparationDTO, +} from "@/lib/encounters/preparation"; + +import { encounterKindText } from "./encounter-labels"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +export interface PreparationText { + /** Where the window starts: "Since your last visit with … on …". */ + title: string; + /** Why, when the title alone does not say: only on the default window. */ + reason: string | null; +} + +export function preparationText( + t: Translate, + formatDay: (day: string) => string, + preparation: EncounterPreparationDTO, +): PreparationText { + const date = formatDay(preparation.from); + const since = preparation.since; + switch (preparation.basis) { + case "practitioner": + return { + title: t("encounters.preparation.practitioner", { + name: since?.practitionerName ?? "", + date, + }), + reason: null, + }; + case "specialty": + return { + title: t("encounters.preparation.specialty", { + specialty: since?.specialty ?? "", + date, + }), + reason: null, + }; + case "kind": + return { + title: t("encounters.preparation.kind", { + kind: since ? encounterKindText(t, since.kind as EncounterKind) : "", + date, + }), + reason: null, + }; + case "default": + return { + title: t("encounters.preparation.default", { + days: PREPARATION_DEFAULT_DAYS, + }), + reason: t("encounters.preparation.defaultReason"), + }; + } +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index dde6fea21..015e6c008 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -72,8 +72,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { resolve: fetchVisit, }); const empty = upcoming.length === 0 && past.length === 0; - // The last visit that took place: the start of the visit preparation. - const lastDone = past.find((encounter) => encounter.status === "DONE"); + // The next booked visit carries the window before it, resolved by the + // server (`EncounterDTO.preparation`): which earlier visit it starts at and + // why. Never picked here from the past list, which would be the last visit + // of any kind — the rule the server replaced. + const prepared = upcoming.find((encounter) => encounter.preparation); const addButton = canAddVisit ? (