mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'fix/v14311-visits' into release/v1.43.11
This commit is contained in:
commit
8d16799479
53 files changed
+1837
-118
No files matched your search
@@ -161,6 +161,37 @@ describe("<PriorityCard>", () => {
|
||||
expect(html).toContain("View check-ups");
|
||||
});
|
||||
|
||||
// An appointment keeps the check-up label on the wire for the shipped iOS
|
||||
// app; the web names what the link opens. The check-up item above is the
|
||||
// negative control.
|
||||
it("names an appointment's action for the visit it opens", () => {
|
||||
const action = (href: string) => ({
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href,
|
||||
});
|
||||
const one = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "upcoming_visit",
|
||||
actions: [action("/checkups?visit=v1")],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(one).toContain("View appointment");
|
||||
expect(one).not.toContain("View check-ups");
|
||||
expect(one).toContain('href="/checkups?visit=v1"');
|
||||
const several = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "upcoming_visit",
|
||||
actions: [action("/checkups?view=visits")],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(several).toContain("View appointments");
|
||||
});
|
||||
|
||||
describe("dismiss affordance — observational kinds only", () => {
|
||||
const DISMISSIBLE: PriorityItemKind[] = [
|
||||
"milestone",
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { VISITS_PATH } from "@/lib/encounters/visit-links";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -205,7 +206,7 @@ export function PriorityCard({
|
||||
<ActionLink
|
||||
key={action.intent}
|
||||
href={action.href}
|
||||
label={t(action.labelKey)}
|
||||
label={t(actionLabelKey(item, action))}
|
||||
/>
|
||||
) : onAction ? (
|
||||
<ActionButton
|
||||
@@ -225,6 +226,24 @@ export function PriorityCard({
|
||||
}
|
||||
|
||||
/** A navigation action. Always offered — reading is never delegated away. */
|
||||
/**
|
||||
* The label key a web card shows for an action.
|
||||
*
|
||||
* The server's `labelKey`, except on an appointment: that item still carries
|
||||
* the check-up item's label on the wire ("View check-ups"), because the
|
||||
* shipped iOS app resolves the label and routes the intent it already knows.
|
||||
* Its `href` already opens the visit, so the web names what it opens.
|
||||
*/
|
||||
export function actionLabelKey(
|
||||
item: Pick<PriorityItem, "kind">,
|
||||
action: Pick<PriorityItemAction, "labelKey" | "href">,
|
||||
): string {
|
||||
if (item.kind !== "upcoming_visit" || !action.href) return action.labelKey;
|
||||
return action.href === VISITS_PATH
|
||||
? "daily.action.viewAppointments"
|
||||
: "daily.action.viewAppointment";
|
||||
}
|
||||
|
||||
function ActionLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -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, or the
|
||||
* visit itself on an upcoming_visit item), so
|
||||
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
|
||||
* by construction; S2 invents no new backend action.
|
||||
*
|
||||
|
||||
@@ -17,10 +17,17 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { preparationText } from "@/components/encounters/preparation-text";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterPreparationDTO } from "@/lib/encounters/preparation";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
DateKey,
|
||||
@@ -46,14 +53,20 @@ import { useTodayKey } from "./use-today-key";
|
||||
/**
|
||||
* "Since the last visit": the preparation the next planned appointment
|
||||
* otherwise lacks. Dose changes, episodes that began, new results, extreme
|
||||
* readings and gaps between the last visit and today, each dated, each date
|
||||
* opening its day. Compiled from the record without any judgement; the
|
||||
* wording is the day view's own.
|
||||
* readings and gaps between the start of the window and today, each dated,
|
||||
* each date opening its day. Compiled from the record without any judgement;
|
||||
* the wording is the day view's own.
|
||||
*
|
||||
* One read, `GET /api/day/notable` for the window from the last visit to
|
||||
* today: the server lists every context change in it (a dose change always,
|
||||
* however quiet the day around it) and every notable observation. Nothing is
|
||||
* recomputed here; this only spells the rows.
|
||||
* The window starts where the server says (`EncounterDTO.preparation`): the
|
||||
* last visit with the same practitioner, else the same specialty, else the
|
||||
* same kind, else the last 90 days — never simply the last visit of any kind,
|
||||
* which handed a dermatology appointment a week of an internist's data. The
|
||||
* card's title says which, so the start date explains itself.
|
||||
*
|
||||
* One read, `GET /api/day/notable` for that window up to today: the server
|
||||
* lists every context change in it (a dose change always, however quiet the
|
||||
* day around it) and every notable observation. Nothing is recomputed here;
|
||||
* this only spells the rows.
|
||||
*
|
||||
* The component owns its card and draws nothing until it has a row to show:
|
||||
* not while the window loads, not when it holds nothing worth listing. A card
|
||||
@@ -132,12 +145,12 @@ export function preparationRows(
|
||||
|
||||
export function SinceLastVisit({
|
||||
next,
|
||||
last,
|
||||
preparation,
|
||||
}: {
|
||||
/** The next planned visit, the one this prepares. */
|
||||
next: EncounterDTO;
|
||||
/** The last visit that took place. */
|
||||
last: EncounterDTO;
|
||||
/** The window before it, as the server resolved it. */
|
||||
preparation: EncounterPreparationDTO;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -147,16 +160,17 @@ export function SinceLastVisit({
|
||||
const notableText = useNotableText();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const lastDay = dateKeyOfInstant(last.occurredAt, timeZone);
|
||||
// From the last visit's own day (a dose changed at the visit belongs to
|
||||
// what the next one should know) to today. The server caps the span.
|
||||
// From the server's start day (the matched visit's own day: a dose changed
|
||||
// at that visit belongs to what the next one should know) to today. The
|
||||
// notable read caps the span.
|
||||
const fromDay: DateKey = preparation.from;
|
||||
const window = useQuery({
|
||||
queryKey: queryKeys.dayNotable(lastDay, today),
|
||||
queryKey: queryKeys.dayNotable(fromDay, today),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ from: lastDay, to: today });
|
||||
const params = new URLSearchParams({ from: fromDay, to: today });
|
||||
return apiGet<DayNotableWindowResponse>(`/api/day/notable?${params}`);
|
||||
},
|
||||
enabled: lastDay <= today,
|
||||
enabled: fromDay <= today,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -194,7 +208,15 @@ export function SinceLastVisit({
|
||||
})
|
||||
: [];
|
||||
|
||||
const lastLabel = fmt.dateShortSmartCalendar(lastDay);
|
||||
const fromLabel = fmt.dateShortSmartCalendar(fromDay);
|
||||
// Where the window starts and why, in one spelling shared with the report
|
||||
// panel's offer.
|
||||
const heading = preparationText(t, fmt.dateShortSmartCalendar, preparation);
|
||||
const reason = heading.reason ? (
|
||||
<CardDescription className="text-xs" data-slot="since-last-visit-reason">
|
||||
{heading.reason}
|
||||
</CardDescription>
|
||||
) : null;
|
||||
|
||||
// A failed read keeps the card and its title, so the page does not jump
|
||||
// and the reader still sees what could not be loaded.
|
||||
@@ -206,8 +228,9 @@ export function SinceLastVisit({
|
||||
size="sm"
|
||||
icon={Stethoscope}
|
||||
titleAs="h3"
|
||||
title={t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
title={heading.title}
|
||||
/>
|
||||
{reason}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QueryErrorRow
|
||||
@@ -236,13 +259,14 @@ export function SinceLastVisit({
|
||||
icon={Stethoscope}
|
||||
id={`since-last-visit-${next.id}`}
|
||||
titleAs="h3"
|
||||
title={t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
title={heading.title}
|
||||
right={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{reason}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<section
|
||||
@@ -297,11 +321,11 @@ export function SinceLastVisit({
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
{t("day.sinceLastVisit.report", { date: lastLabel })}
|
||||
{t("day.sinceLastVisit.report", { date: fromLabel })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -50,6 +50,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
@@ -109,7 +110,7 @@ export async function fetchEncounterPage(
|
||||
|
||||
/** Where a visit opens: its sheet on the checkups page. */
|
||||
function visitHref(encounterId: string): string {
|
||||
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
|
||||
return visitPath(encounterId);
|
||||
}
|
||||
|
||||
/** Where a dose opens: its sheet on the vaccinations page. */
|
||||
|
||||
@@ -47,7 +47,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url";
|
||||
import { todayKeyInZone } from "@/components/day/day-url";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { DayNotableWindowResponse } from "@/lib/day/contract";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -174,13 +174,32 @@ describe("<VisitsSection>", () => {
|
||||
* to wrap it in a card, so whenever it had nothing to list (still loading, a
|
||||
* quiet window, a failed read) an empty bordered card sat under the visit.
|
||||
* It now draws its own card, and only once it has a row.
|
||||
*
|
||||
* The window is the server's (`preparation` on the booked visit). The section
|
||||
* used to start it at the last visit of any kind it found in the past list;
|
||||
* the fixtures carry such a visit, newer than the window's start, and the
|
||||
* read must not move to it.
|
||||
*/
|
||||
describe("<VisitsSection> since the last visit", () => {
|
||||
const sameDoctor = {
|
||||
id: "derm-before",
|
||||
occurredAt: "2026-03-02T09:00:00.000Z",
|
||||
kind: "SPECIALIST" as const,
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
};
|
||||
const next = visit({
|
||||
id: "next",
|
||||
status: "PLANNED",
|
||||
occurredAt: "2026-09-20T09:00:00.000Z",
|
||||
preparation: {
|
||||
from: "2026-03-02",
|
||||
basis: "practitioner",
|
||||
since: sameDoctor,
|
||||
},
|
||||
});
|
||||
const unprepared = visit({ ...next, preparation: null });
|
||||
// A newer visit elsewhere: what the old client rule anchored on.
|
||||
const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" });
|
||||
|
||||
/** Cards painted inside the upcoming list. */
|
||||
@@ -194,12 +213,8 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
function seedWindow(window: DayNotableWindowResponse | null) {
|
||||
return (queryClient: QueryClient) => {
|
||||
if (!window) return;
|
||||
const tz = DEFAULT_TIMEZONE;
|
||||
queryClient.setQueryData(
|
||||
queryKeys.dayNotable(
|
||||
dateKeyOfInstant(last.occurredAt, tz),
|
||||
todayKeyInZone(tz),
|
||||
),
|
||||
queryKeys.dayNotable("2026-03-02", todayKeyInZone(DEFAULT_TIMEZONE)),
|
||||
window,
|
||||
);
|
||||
};
|
||||
@@ -219,10 +234,10 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
});
|
||||
|
||||
it("draws no card while the window is still loading", () => {
|
||||
const withLast = render({ upcoming: [next], past: [last] });
|
||||
const withoutLast = render({ upcoming: [next], past: [] });
|
||||
expect(withLast).not.toContain('data-slot="since-last-visit"');
|
||||
expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast));
|
||||
const loading = render({ upcoming: [next], past: [last] });
|
||||
const without = render({ upcoming: [unprepared], past: [last] });
|
||||
expect(loading).not.toContain('data-slot="since-last-visit"');
|
||||
expect(upcomingCards(loading)).toBe(upcomingCards(without));
|
||||
});
|
||||
|
||||
it("draws no card when the window holds nothing worth listing", () => {
|
||||
@@ -230,17 +245,16 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
{ upcoming: [next], past: [last] },
|
||||
seedWindow(emptyWindow),
|
||||
);
|
||||
const baseline = render({ upcoming: [next], past: [] });
|
||||
const baseline = render({ upcoming: [unprepared], past: [last] });
|
||||
expect(html).not.toContain('data-slot="since-last-visit"');
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
|
||||
});
|
||||
|
||||
function seedFailure(error: Error) {
|
||||
return (queryClient: QueryClient) => {
|
||||
const tz = DEFAULT_TIMEZONE;
|
||||
const queryKey = queryKeys.dayNotable(
|
||||
dateKeyOfInstant(last.occurredAt, tz),
|
||||
todayKeyInZone(tz),
|
||||
"2026-03-02",
|
||||
todayKeyInZone(DEFAULT_TIMEZONE),
|
||||
);
|
||||
// The state the section sees once the real read has given up: a mount
|
||||
// does not retry it.
|
||||
@@ -257,14 +271,14 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
{ upcoming: [next], past: [last] },
|
||||
seedFailure(new ApiError("window read failed", 500)),
|
||||
);
|
||||
const baseline = render({ upcoming: [next], past: [] });
|
||||
const baseline = render({ upcoming: [unprepared], past: [last] });
|
||||
expect(html).toContain('data-slot="since-last-visit-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain('data-slot="since-last-visit"');
|
||||
// The failed window keeps its own card and title, so the page does not
|
||||
// jump; the visit cards beside it stay as they were.
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
|
||||
expect(html).toContain("Since the last visit on");
|
||||
expect(html).toContain("Since your last visit with Skin practice on");
|
||||
});
|
||||
|
||||
// Visits need the profile section, the window reads measurements, and a
|
||||
@@ -296,10 +310,15 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
],
|
||||
} as unknown as DayNotableWindowResponse),
|
||||
);
|
||||
const baseline = render({ upcoming: [next], past: [] });
|
||||
const baseline = render({ upcoming: [unprepared], past: [last] });
|
||||
expect(html).toContain('data-slot="since-last-visit"');
|
||||
expect(html).toContain('data-slot="since-last-visit-row"');
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
|
||||
// The title says where the window starts and why; the report starts
|
||||
// there too, not at the newer visit elsewhere.
|
||||
expect(html).toContain("Since your last visit with Skin practice on");
|
||||
expect(html).toContain("reportFrom=2026-03-02&");
|
||||
expect(html).not.toContain('data-slot="since-last-visit-reason"');
|
||||
// The card heads like every compact card: TileHeader at the small size,
|
||||
// a real heading under the section's own, at the compact density.
|
||||
const card = html.slice(
|
||||
@@ -314,4 +333,37 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says why on the default window, where the title alone cannot", () => {
|
||||
const html = render(
|
||||
{
|
||||
upcoming: [
|
||||
visit({
|
||||
...next,
|
||||
preparation: { from: "2026-03-02", basis: "default", since: null },
|
||||
}),
|
||||
],
|
||||
past: [last],
|
||||
},
|
||||
seedWindow({
|
||||
...emptyWindow,
|
||||
changes: [
|
||||
{
|
||||
date: "2026-08-03",
|
||||
kind: "doseChange",
|
||||
section: "medications",
|
||||
id: "d1",
|
||||
title: "Example 5 mg",
|
||||
count: 1,
|
||||
href: null,
|
||||
},
|
||||
],
|
||||
} as unknown as DayNotableWindowResponse),
|
||||
);
|
||||
expect(html).toContain("The last 90 days");
|
||||
expect(html).toContain('data-slot="since-last-visit-reason"');
|
||||
expect(html).toContain(
|
||||
"No earlier visit with the same practice, specialty or kind.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The window before a booked visit, in words: the line that says where the
|
||||
* visit preparation and the offered doctor report start, and why there.
|
||||
*
|
||||
* Spells only what the server resolved (`EncounterDTO.preparation`); nothing
|
||||
* here chooses a date. One spelling for both surfaces that show it, so the
|
||||
* visits tab and the report panel cannot give two reasons for one window.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import {
|
||||
PREPARATION_DEFAULT_DAYS,
|
||||
type EncounterPreparationDTO,
|
||||
} from "@/lib/encounters/preparation";
|
||||
|
||||
import { encounterKindText } from "./encounter-labels";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export interface PreparationText {
|
||||
/** Where the window starts: "Since your last visit with … on …". */
|
||||
title: string;
|
||||
/** Why, when the title alone does not say: only on the default window. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export function preparationText(
|
||||
t: Translate,
|
||||
formatDay: (day: string) => string,
|
||||
preparation: EncounterPreparationDTO,
|
||||
): PreparationText {
|
||||
const date = formatDay(preparation.from);
|
||||
const since = preparation.since;
|
||||
switch (preparation.basis) {
|
||||
case "practitioner":
|
||||
return {
|
||||
title: t("encounters.preparation.practitioner", {
|
||||
name: since?.practitionerName ?? "",
|
||||
date,
|
||||
}),
|
||||
reason: null,
|
||||
};
|
||||
case "specialty":
|
||||
return {
|
||||
title: t("encounters.preparation.specialty", {
|
||||
specialty: since?.specialty ?? "",
|
||||
date,
|
||||
}),
|
||||
reason: null,
|
||||
};
|
||||
case "kind":
|
||||
return {
|
||||
title: t("encounters.preparation.kind", {
|
||||
kind: since ? encounterKindText(t, since.kind as EncounterKind) : "",
|
||||
date,
|
||||
}),
|
||||
reason: null,
|
||||
};
|
||||
case "default":
|
||||
return {
|
||||
title: t("encounters.preparation.default", {
|
||||
days: PREPARATION_DEFAULT_DAYS,
|
||||
}),
|
||||
reason: t("encounters.preparation.defaultReason"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
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");
|
||||
// The next booked visit carries the window before it, resolved by the
|
||||
// server (`EncounterDTO.preparation`): which earlier visit it starts at and
|
||||
// why. Never picked here from the past list, which would be the last visit
|
||||
// of any kind — the rule the server replaced.
|
||||
const prepared = upcoming.find((encounter) => encounter.preparation);
|
||||
|
||||
const addButton = canAddVisit ? (
|
||||
<Button
|
||||
@@ -135,18 +138,21 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter, i) => (
|
||||
{upcoming.map((encounter) => (
|
||||
<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} />
|
||||
{/* v1.42 — the next booked appointment carries what
|
||||
happened in the window before it, each date opening its
|
||||
day. The component draws its own card, and none when it
|
||||
has nothing to list. */}
|
||||
{encounter.id === prepared?.id && prepared.preparation ? (
|
||||
<SinceLastVisit
|
||||
next={encounter}
|
||||
preparation={prepared.preparation}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -64,6 +64,7 @@ import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
type RelatedKind = RelatedLink["kind"];
|
||||
@@ -311,7 +312,7 @@ function VisitPicker({ selected, onChange, anchor, known }: PickerProps) {
|
||||
label: encounterKindText(t, visit.kind as EncounterKind),
|
||||
meta: visit.practitioner?.name ?? null,
|
||||
occurredAt: visit.occurredAt,
|
||||
href: `/checkups?visit=${encodeURIComponent(visit.id)}`,
|
||||
href: visitPath(visit.id),
|
||||
})),
|
||||
anchor,
|
||||
{
|
||||
|
||||
+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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
import { ReportVisitOffer, visitReportWindow } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -67,7 +67,7 @@ function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
<ReportVisitOffer onUseVisitWindow={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -108,4 +108,57 @@ describe("<ReportVisitOffer>", () => {
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The issue behind this: an internist a week ago, a dermatologist next. The
|
||||
// start used to be whatever the panel held, typically the last visit of any
|
||||
// kind; it is now the server's window before this visit, and says why.
|
||||
it("names where the report would start, and why there", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "derm",
|
||||
preparation: {
|
||||
from: "2026-03-02",
|
||||
basis: "specialty",
|
||||
since: {
|
||||
id: "old-derm",
|
||||
occurredAt: "2026-03-02T09:00:00.000Z",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Former skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain('data-slot="report-visit-offer-window"');
|
||||
expect(html).toContain("Since your last Dermatology visit on");
|
||||
expect(html).toContain("Use this window");
|
||||
});
|
||||
|
||||
it("says why on the default window", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "first",
|
||||
preparation: { from: "2026-07-23", basis: "default", since: null },
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("The last 90 days");
|
||||
expect(html).toContain(
|
||||
"No earlier visit with the same practice, specialty or kind.",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the server's start and the appointment's day on a press", () => {
|
||||
const at = new Date(Date.now() + 5 * DAY_MS);
|
||||
const window = visitReportWindow(
|
||||
visit({
|
||||
id: "derm",
|
||||
occurredAt: at.toISOString(),
|
||||
preparation: { from: "2026-03-02", basis: "default", since: null },
|
||||
}),
|
||||
);
|
||||
expect(window.from).toBe("2026-03-02");
|
||||
expect(window.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
// Without a window from the server, only the end moves.
|
||||
expect(visitReportWindow(visit({ id: "plain" })).from).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,20 @@
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
* Pressing it sets both ends of the custom range: the end on the appointment's
|
||||
* day, the start where the server put the window before that visit
|
||||
* (`EncounterDTO.preparation`: the last visit with the same practitioner, else
|
||||
* the same specialty, else the same kind, else the last 90 days). The offer
|
||||
* says which, so the start date explains itself, and both dates stay editable.
|
||||
* Nothing about the stored selection changes.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { preparationText } from "@/components/encounters/preparation-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
|
||||
@@ -40,11 +45,27 @@ function localDay(iso: string): string {
|
||||
return userDayKey(new Date(iso), detectBrowserTimezone());
|
||||
}
|
||||
|
||||
/** The report window an appointment offers, as `yyyy-MM-dd` days. */
|
||||
export interface VisitReportWindow {
|
||||
/** The server's start of the window before the visit; null if it has none. */
|
||||
from: string | null;
|
||||
/** The appointment's own day. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** What pressing the offer for `visit` applies. */
|
||||
export function visitReportWindow(visit: Encounter): VisitReportWindow {
|
||||
return {
|
||||
from: visit.preparation?.from ?? null,
|
||||
to: localDay(visit.occurredAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
onUseVisitWindow,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
/** Sets the custom window. Called only on an explicit press. */
|
||||
onUseVisitWindow: (window: VisitReportWindow) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
@@ -68,6 +89,9 @@ export function ReportVisitOffer({
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
const windowText = next.preparation
|
||||
? preparationText(t, format.dateShortSmartCalendar, next.preparation)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,24 +102,38 @@ export function ReportVisitOffer({
|
||||
a phone the sentence takes the full row and the two buttons wrap
|
||||
below it; with a zero basis it was squeezed into a one-word column
|
||||
beside them. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 basis-full items-center gap-2 text-sm sm:basis-0">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<div className="text-foreground flex min-w-0 flex-1 basis-full items-start gap-2 text-sm sm:basis-0">
|
||||
<CalendarClock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</p>
|
||||
{/* Where the report would start, and why there: meta, so muted. */}
|
||||
{windowText ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="report-visit-offer-window"
|
||||
>
|
||||
{windowText.title}
|
||||
</p>
|
||||
) : null}
|
||||
{windowText?.reason ? (
|
||||
<p className="text-muted-foreground text-xs">{windowText.reason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto min-h-11 sm:ml-0 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
onClick={() => onUseVisitWindow(visitReportWindow(next))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
{t("settings.healthRecord.visitOfferApplyWindow")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -322,11 +322,12 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
sets both its dates, which stay editable. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
onUseVisitWindow={({ from, to }) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
if (from) setStartDate(from);
|
||||
setEndDate(to);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user