mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
78bacb0d86
commit
0dcafeeb70
29 files changed
+591
-23
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11997,6 +11997,8 @@
|
||||
"logDose": "복용 기록",
|
||||
"reconnect": "다시 연결",
|
||||
"viewCheckups": "건강검진 보기",
|
||||
"viewAppointment": "예약 보기",
|
||||
"viewAppointments": "예약 목록 보기",
|
||||
"checkinKeep": "계속하기",
|
||||
"checkinAdjust": "조정",
|
||||
"checkinLetGo": "놓아주기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ść",
|
||||
|
||||
@@ -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<string, { count: number; why: string }> = {
|
||||
"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<string, number> = {};
|
||||
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=<id>` 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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Which tab a link to `/checkups` opens.
|
||||
*
|
||||
* Visits are the page's second tab. `?visit=<id>` 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<typeof import("@tanstack/react-query")>()),
|
||||
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: () => <div data-section="vorsorge" />,
|
||||
}));
|
||||
vi.mock("@/components/encounters/visits-section", () => ({
|
||||
VisitsSection: () => <div data-section="visits" />,
|
||||
}));
|
||||
vi.mock("@/components/encounters/procedures-section", () => ({
|
||||
ProceduresSection: () => <div data-section="procedures" />,
|
||||
}));
|
||||
vi.mock("@/components/body-sites/body-sites-section", () => ({
|
||||
BodySitesSection: () => <div data-section="body-sites" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import CheckupsPageClient from "../page-client";
|
||||
|
||||
function openTab(query: string): string | null {
|
||||
search = new URLSearchParams(query);
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CheckupsPageClient />
|
||||
</I18nProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>(
|
||||
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(
|
||||
() =>
|
||||
|
||||
@@ -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 `<Link>` to its existing destination
|
||||
* by construction; S2 invents no new backend action.
|
||||
*
|
||||
|
||||
@@ -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 }) {
|
||||
<li key={visit.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href="/checkups"
|
||||
href={visitPath(visit.id)}
|
||||
data-encounter-id={visit.id}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
|
||||
+72
@@ -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(`<a[^>]*data-slot="${slot}"[^>]*>`));
|
||||
return tag?.[0].match(/href="([^"]*)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe("<VorsorgeDashboardCard> — the next appointment", () => {
|
||||
it("opens the visit itself", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<VorsorgeDashboardCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<Link
|
||||
href="/checkups"
|
||||
href={visitPath(nextAppointment.id)}
|
||||
data-slot="vorsorge-dashboard-next-visit"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
|
||||
@@ -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 }) {
|
||||
<PageHeader
|
||||
title={t("practitioners.title")}
|
||||
description={t("practitioners.description")}
|
||||
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
|
||||
backLink={{ href: VISITS_PATH, label: t("encounters.sectionTitle") }}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" }) }),
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user