Files
HealthLog/src/components/encounters/visits-section.tsx
T
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00

188 lines
7.0 KiB
TypeScript

"use client";
/**
* "Termine" — the visits half of the checkups page.
*
* Two lists rather than one, and the split is the server's: upcoming reads
* soonest-first, past reads newest-first, and the API resolves both so the
* ordering lives in one place. A client that split and re-sorted one array
* would be the second place it lives.
*
* No new nav entry and no new route. `/checkups` already owns exactly the right
* sentence — when do I have to do what, where — and is deliberately not module
* gated, which is also why a visit carries no module key of its own.
*/
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
import Link from "next/link";
import { Fragment, useState } from "react";
import { SinceLastVisit } from "@/components/day/since-last-visit";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
/** One visit by id, for a link to a visit older than the list reads. */
function fetchVisit(id: string): Promise<Encounter | null> {
return apiGet<Encounter>(`/api/encounters/${encodeURIComponent(id)}`).catch(
() => null,
);
}
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// `POST /api/encounters` answers at WRITE under the profile section; the
// edit and the delete behind the row are both MANAGE. Only the add was
// asked, so every row stayed tappable into a sheet whose Save and Delete
// the server refuses.
const canAddVisit = canWriteDomain("profile");
const canManageVisits = canManageDomain("profile");
const list = useEncounters(enabled);
// `null` closed, `"new"` create, a row edits it.
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
const [session, setSession] = useState(0);
const openSheet = (target: Encounter | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const upcoming = list.data?.upcoming ?? [];
const past = list.data?.past ?? [];
// `?visit=<id>` — a document's link chip names the visit it is filed
// against. Scroll to it, and open it for someone who may edit it.
useDeepLinkedRecord({
param: "visit",
records: list.data ? [...upcoming, ...past] : undefined,
anchorAttribute: "data-encounter-id",
open: canManageVisits ? openSheet : undefined,
// The list reads the newest hundred of each arm; a search hit can name an
// older visit, which is read on its own.
resolve: fetchVisit,
});
const empty = upcoming.length === 0 && past.length === 0;
// The last visit that took place: the start of the visit preparation.
const lastDone = past.find((encounter) => encounter.status === "DONE");
const addButton = canAddVisit ? (
<Button
type="button"
className="min-h-11"
data-slot="visits-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("encounters.add")}
</Button>
) : null;
return (
<section aria-labelledby="visits-section-title" className="space-y-4">
<PageHeader
titleId="visits-section-title"
title={t("encounters.sectionTitle")}
description={t("encounters.sectionDescription")}
actions={
<>
<Button asChild variant="outline" className="min-h-11">
<Link href="/checkups/practitioners">
<ContactRound className="size-4" aria-hidden />
{t("practitioners.linkLabel")}
</Link>
</Button>
{/* The empty state carries the add action while there is no
visit. */}
{!list.isPending && !list.isError && empty ? null : addButton}
</>
}
/>
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : empty ? (
<EmptyState
icon={<CalendarClock className="size-6" aria-hidden />}
title={t("encounters.emptyTitle")}
description={t("encounters.emptyDescription")}
action={addButton ?? undefined}
/>
) : (
<div className="space-y-6">
{upcoming.length > 0 ? (
<div className="space-y-2" data-slot="visits-upcoming">
<SectionHeading
icon={CalendarClock}
title={t("encounters.upcomingHeading")}
/>
{upcoming.map((encounter, i) => (
<Fragment key={encounter.id}>
<VisitCard
encounter={encounter}
onOpen={canManageVisits ? openSheet : undefined}
/>
{/* v1.42 — the next appointment carries what happened
since the last one, each date opening its day. The
component draws its own card, and none when it has
nothing to list. */}
{i === 0 && lastDone ? (
<SinceLastVisit next={encounter} last={lastDone} />
) : null}
</Fragment>
))}
</div>
) : null}
{past.length > 0 ? (
<div className="space-y-2" data-slot="visits-past">
<SectionHeading
icon={History}
title={t("encounters.pastHeading")}
/>
{past.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
) : null}
</div>
)}
{/* `key` changes on every open, so the sheet remounts with a fresh draft
instead of copying props into state from an effect. It stays mounted
while closing, which is what keeps the exit animation. */}
<EncounterSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
encounter={editing === "new" ? null : editing}
/>
</section>
);
}