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] 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 } },