Files
HealthLog/src/app/checkups/page-client.tsx
T
Marc-André Bombeck 0dcafeeb70 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.
2026-10-21 12:22:36 +02:00

148 lines
6.3 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
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";
/**
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
* feature surface for "wann muss ich was wo machen". Auth-gated; the
* section component owns the list + create flow.
*
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
* measurements): a pull invalidates the measurement-reminders read so the
* list + server-computed next-due repaint.
*
* The page now holds two things, split by a segmented control rather than by a
* second nav entry: the preventive-care list, and the visits. They answer the
* same question from two directions — what do I still have to arrange, and what
* did I actually attend — and the page's own sentence already covers both. A
* visit carries no module gate for the same reason this page never had one.
*
* v1.39.1 — a third tab, the procedure and surgery history. It is the visits
* list read one way (the visits of kind PROCEDURE, searchable by body site),
* so it sits beside the visits rather than behind a nav entry of its own, and
* its read hangs off the same `["encounters"]` root the pull already evicts.
*
* v1.39.2 — a fourth tab, the body-site view: procedures and conditions by
* where on the body they are, with what is linked to them. It starts from the
* visits (a procedure is one), so it sits here rather than under the illness
* journal, and its read hangs off the same `["encounters"]` root.
*
* The pull invalidates both reads. Refreshing one and leaving the other stale
* is how a page ends up showing a visit that closed a checkup still listed as
* due.
*/
export default function CheckupsPageClient() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const queryClient = useQueryClient();
const { t } = useTranslations();
// A link to one visit (`?visit=<id>`, from a document's link chip) lands on
// the visits tab, where the section opens it.
const searchParams = useSearchParams();
const linkedVisit = searchParams?.get("visit");
// A link to one body site (`?site=<text>&side=<LEFT|RIGHT|BOTH>`, from a
// condition's page) lands on the body-site tab with that site picked.
const linkedSite = searchParams?.get("site")?.trim() || null;
const linkedSideParam = searchParams?.get("side");
const linkedSide: BodySiteSide =
linkedSideParam === "LEFT" ||
linkedSideParam === "RIGHT" ||
linkedSideParam === "BOTH"
? linkedSideParam
: null;
// 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. 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(
() =>
invalidateKeys(queryClient, [
queryKeys.measurementReminders(),
queryKeys.encounters(),
]),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refresh });
if (!mounted || isLoading) {
return <PageAuthGate />;
}
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
{t("checkups.tabVorsorge")}
</TabsTrigger>
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
{t("checkups.tabVisits")}
</TabsTrigger>
<TabsTrigger value="procedures" data-testid="checkups-tab-procedures">
{t("checkups.tabProcedures")}
</TabsTrigger>
<TabsTrigger value="body-sites" data-testid="checkups-tab-body-sites">
{t("checkups.tabBodySites")}
</TabsTrigger>
</TabsList>
<TabsContent value="vorsorge">
<VorsorgeSection enabled={isAuthenticated} variant="page" />
</TabsContent>
<TabsContent value="visits">
{/* Mounted only on the active tab, so the visits read does not fire
for a person who never opens it. */}
<VisitsSection enabled={isAuthenticated} />
</TabsContent>
<TabsContent value="procedures">
<ProceduresSection enabled={isAuthenticated} />
</TabsContent>
<TabsContent value="body-sites">
<BodySitesSection
enabled={isAuthenticated}
initialSite={linkedSite}
initialSide={linkedSide}
/>
</TabsContent>
</Tabs>
</div>
);
}