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=<id>`), 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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:22:36 +02:00
1 parent 78bacb0d86
commit 0dcafeeb70
29 files changed
+591 -23

No files matched your search

+53
View File
@@ -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", () => {
@@ -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", () => {
+14 -4
View File
@@ -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)),
},
],
};
+3 -1
View File
@@ -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)),
};
}
@@ -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();
});
});
@@ -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();
});
});
+24 -2
View File
@@ -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);
}
+39 -4
View File
@@ -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;
}
@@ -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<DispatchFn>(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 () => {
+5
View File
@@ -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 } : {}),
},
});
@@ -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" }) }),
+4 -1
View File
@@ -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"),
});
}
+2 -1
View File
@@ -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 } },