Merge branch 'fix/v1475-cleanup' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:51:42 +01:00
commit d5dd05b5ac
164 files changed
+1109 -15288

No files matched your search

+1 -1
View File
@@ -21,4 +21,4 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
## Conventions
Kebab-case filenames; React component exports stay PascalCase regardless. (A handful of PascalCase-filename outliers under `medications/` + `onboarding/` are pre-existing drift, noted in [`../../CLAUDE.md`](../../CLAUDE.md).) Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
Kebab-case filenames; React component exports stay PascalCase regardless. Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
@@ -320,20 +320,16 @@ describe("marking a dose", () => {
describe("the medication card menu", () => {
const node = (
<MedicationCardMenu
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={() => {}}
/>
<MedicationCardMenu onEdit={() => {}} onOpenHistory={() => {}} />
);
it("renders its trigger in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain("<button");
});
it("keeps a trigger for a delegate who may write", () => {
// One item survives for them — noting a side effect — so the menu stays.
expect(render(WRITABLE, node)).toContain("<button");
it("disappears for a delegate who may write but not manage", () => {
// Editing and the history editor are owner work; nothing survives.
expect(render(WRITABLE, node)).toBe("");
});
it("disappears entirely for a read-only delegate", () => {
@@ -1,53 +0,0 @@
/**
* v1.16.8 — the in-header "adjust targets" link is gone from the chart
* card. The affordance duplicated the page-level target-adjust control
* in the insights sub-page header (`<TargetAdjustButton>`); the chart
* stays a read surface. Pinned at the source level (same convention as
* the auth-shell hoist contract) so the link doesn't quietly return on
* a future header rework.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
const WEIGHT_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/weight/page.tsx",
);
const BP_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/blood-pressure/page.tsx",
);
const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
describe("v1.16.8 — chart-card target-adjust link removed", () => {
it("ships no targetSettingsHref prop or header link in the chart", () => {
// The component and every part it is built from (header included).
const source = [
readSource("src/components/charts/health-chart.tsx", "HealthChart"),
...readSourceFolder("src/components/charts/health-chart", {
floor: 9,
}).map(([, text]) => text),
].join("\n");
expect(source).not.toContain("targetSettingsHref");
expect(source).not.toContain("chart-target-settings-link");
expect(source).not.toContain("charts.adjustTargets");
});
it("weight and blood-pressure pages no longer pass the link href", () => {
for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) {
const source = readFileSync(path, "utf8");
expect(source).not.toContain("targetSettingsHref");
}
});
it("the orphaned locale key is gone", () => {
const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as {
charts: Record<string, unknown>;
};
expect(bundle.charts.adjustTargets).toBeUndefined();
});
});
@@ -38,7 +38,7 @@ const DAY: DayResponse = {
until: "2026-03-31",
dayIndex: 64,
dayCount: 151,
href: "/medications/m1?tab=verlauf",
href: "/medications/m1?tab=history",
},
],
values: [
+1 -1
View File
@@ -321,7 +321,7 @@ export function SinceLastVisit({
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
href={`/settings/health-record?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveKey } from "@/lib/i18n/resolve-key";
/**
* The illness screens name the vital that drives a recovery gap and label the
* day picker of the log sheet through these keys. They were once copied from
* English into five catalogs, so a German reader saw "Weight", "Pulse",
* "Recovery" and "Day" among translated neighbours. The English leak guard
* skips single words, so it cannot see this; the pin below can.
*/
const MESSAGES_DIR = join(__dirname, "../../../../messages");
function catalog(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
const KEYS = [
"illness.vital.WEIGHT",
"illness.vital.PULSE",
"illness.vital.RECOVERY_SCORE",
"illness.sheet.day",
] as const;
const english = catalog("en");
describe("illness labels", () => {
for (const locale of [
"de",
"fr",
"es",
"it",
"pl",
"nl",
"pt-BR",
"ko",
"ru",
]) {
it(`are translated in ${locale}`, () => {
const messages = catalog(locale);
for (const key of KEYS) {
const value = resolveKey(messages, key);
expect(value, `${locale} ${key}`).toBeTruthy();
expect(value, `${locale} ${key} is still English`).not.toBe(
resolveKey(english, key),
);
}
});
}
});
@@ -47,7 +47,10 @@ vi.mock("@/components/symptoms/symptoms-section", () => ({
SymptomsSection: () => null,
}));
vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null }));
vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null }));
vi.mock("../log-day-sheet", () => ({
LogDaySheet: () => null,
useLastLoggableDay: (today: string) => today,
}));
vi.mock("@/components/day/day-link", () => ({
DAY_LINK_SLOT: "{date}",
DayLinkStated: ({ children }: { children: React.ReactNode }) => children,
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { lastLoggableDay } from "../log-day-sheet";
/**
* "Log day" on an illness opens on the day it suggests and bounds the picker
* there. For a recovered episode that used to be today, so one tap on Save
* filed a sick day days after the recovery, and the day view then showed it
* with no illness running beside it.
*/
describe("lastLoggableDay", () => {
it("is today while the episode runs", () => {
expect(lastLoggableDay("2026-11-12", null, "Europe/Berlin")).toBe(
"2026-11-12",
);
});
it("is the recovery day once the episode ended before today", () => {
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-09T08:00:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-09");
});
it("stays today when the episode ended today", () => {
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-12T08:00:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-12");
});
it("reads the recovery day in the profile zone", () => {
// 23:30 UTC is already the next day in Berlin.
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-09T23:30:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-10");
});
});
@@ -30,7 +30,7 @@ import { canReadBodySites } from "@/hooks/use-body-sites";
import { IllnessCorrelationCard } from "./illness-correlation-card";
import { IllnessDayTimeline } from "./illness-day-timeline";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import { EpisodeMenu } from "./episode-menu";
import { useIllnessEpisode, useResolveEpisode } from "./use-illness";
@@ -66,6 +66,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const [logOpen, setLogOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const today = todayLocal();
const logLastDay = useLastLoggableDay(today, episode?.resolvedAt);
if (isError) {
return (
@@ -226,7 +227,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
open={logOpen}
onOpenChange={setLogOpen}
episodeId={episode.id}
date={today}
date={logLastDay}
onsetDate={episode.onsetAt.slice(0, 10)}
/>
<NewEpisodeSheet
+3 -2
View File
@@ -42,7 +42,7 @@ import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import { EpisodeMenu } from "./episode-menu";
import { IllnessInsightsCard } from "./illness-insights-card";
@@ -384,6 +384,7 @@ export function IllnessView() {
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
const logLastDay = useLastLoggableDay(today, logEpisode?.resolvedAt);
/**
* Group the flat list into active / resolved, nesting flares and recurring
@@ -532,7 +533,7 @@ export function IllnessView() {
if (!open) setLogEpisodeId(null);
}}
episodeId={logEpisodeId}
date={today}
date={logLastDay}
onsetDate={logEpisode?.onsetAt.slice(0, 10)}
/>
) : null}
+36 -4
View File
@@ -24,7 +24,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import { userDayKey } from "@/lib/tz/format";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -35,7 +40,11 @@ interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
episodeId: string;
/** The day to seed the picker with (usually today). */
/**
* The last day of the episode (`YYYY-MM-DD`): the day the picker opens on
* and the latest it offers. Today while the episode runs, its recovery day
* once it ended (`useLastLoggableDay`).
*/
date: string;
/**
* Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so
@@ -45,6 +54,29 @@ interface LogDaySheetProps {
onsetDate?: string;
}
/**
* The last day a log can belong to: today, or the recovery day when the
* episode ended before today. The recovery day is read in the profile zone,
* the zone the server reads it in.
*/
export function lastLoggableDay(
today: string,
resolvedAt: string | null | undefined,
timeZone: string,
): string {
if (!resolvedAt) return today;
const resolvedDay = userDayKey(new Date(resolvedAt), timeZone);
return resolvedDay < today ? resolvedDay : today;
}
/** `lastLoggableDay` in the viewer's profile zone. */
export function useLastLoggableDay(
today: string,
resolvedAt: string | null | undefined,
): string {
return lastLoggableDay(today, resolvedAt, useDisplayTimezone());
}
/** Mini toggle chip — the shared selection vocabulary. */
function Chip({
active,
@@ -147,8 +179,8 @@ export function LogDaySheet({
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
const feverUnit = unitFor("BODY_TEMPERATURE");
// The day being logged — seeded from `date` (today), but the picker lets the
// user backdate within [onset, today] for the retrospective journal.
// The day being logged — seeded from `date` (the episode's last day), and
// the picker lets the user backdate within [onset, date] for the journal.
const [selectedDate, setSelectedDate] = useState(date);
// Re-seed the picker each time the sheet opens (the open transition).
const [pickerWasOpen, setPickerWasOpen] = useState(false);
@@ -114,7 +114,7 @@ export function MedicationEfficacySummary({
</div>
<InlineAction asChild className="shrink-0">
<Link
href={`/medications/${med.id}?tab=wirkung`}
href={`/medications/${med.id}?tab=effect`}
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
@@ -1,85 +0,0 @@
import { describe, it, expect } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const COMPONENT_DIR = join(__dirname, "..");
const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
/**
* The two modules the mood insights surface is rendered from. The breakdown
* cluster lives behind `next/dynamic` in its own file; both are the surface.
*/
const INSIGHTS_SURFACE_FILES = [
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
join(COMPONENT_DIR, "mood-insights-breakdowns.tsx"),
];
/**
* v1.8.6 — the notes timeline display is removed from the mood insights
* surface (note capture on the form stays). These structural guards keep
* the surface from regressing back to a notes feed and keep long notes
* truncated in the entries table.
*/
describe("mood notes timeline removal", () => {
it("deletes the notes-timeline component file", () => {
expect(existsSync(join(COMPONENT_DIR, "mood-notes-timeline.tsx"))).toBe(
false,
);
});
it("no longer renders the notes timeline from the insights sections", () => {
// Both halves of the surface: the eager module that paints the calendar
// and the assessment, and the deferred module that carries every
// breakdown. A guard reading only one of them would go quiet the moment
// the other grew a notes feed.
for (const file of INSIGHTS_SURFACE_FILES) {
const src = readFileSync(file, "utf8");
expect(src).not.toContain("MoodNotesTimeline");
expect(src).not.toContain("notesTimeline");
expect(src).not.toContain("notesTimelineTitle");
}
});
it("mounts the narrative feed in the insights sections", () => {
// v1.12.7 — the narrative one-liners now ride the merged "What stands out"
// card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
// The structural guard tracks the mount point on the sections surface.
//
// v1.38 — that mount point moved into `mood-insights-breakdowns.tsx` when
// the below-the-fold cluster went behind `next/dynamic`. Read as the union
// of the two files, so the guard asserts the card is mounted SOMEWHERE on
// the surface rather than in one particular file — which is what it was
// always about.
const src = INSIGHTS_SURFACE_FILES.map((f) => readFileSync(f, "utf8")).join(
"\n",
);
expect(src).toContain("MoodWhatStandsOut");
expect(src).toContain("MoodInTargetTile");
});
it("renders the narrative feed inside the merged what-stands-out card", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-what-stands-out.tsx"),
"utf8",
);
expect(src).toContain("MoodNarrativeFeed");
expect(src).toContain("MoodDiscoveredRelations");
});
});
describe("mood entries table — long-note truncation", () => {
const src = readFileSync(MOOD_LIST, "utf8");
it("caps the note column width to prevent horizontal scroll", () => {
expect(src).toContain("max-w-[18rem]");
});
it("clamps the note by default and exposes the full text via the row toggle", () => {
// v1.16.8 — the desktop hover tooltip is replaced by an expandable
// toggle (works on touch, preserves line breaks via pre-wrap). The
// default presentation stays clamped so the table keeps its density.
expect(src).toContain("line-clamp-2");
expect(src).toContain("mood-note-toggle");
expect(src).toContain("whitespace-pre-wrap");
expect(src).not.toContain("TooltipContent");
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
/**
* A checkup that is switched off, or that the Coach created, says so in the
* card view. The list view used to drop both badges, so a disabled checkup
* looked active there; which view a person sees is a remembered preference,
* so the same checkup read differently depending on a toggle.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}));
import { VorsorgeCard } from "../checkups/checkup-card";
const reminder = {
id: "coach-off",
label: "Check blood pressure",
measurementType: null,
intervalDays: 30,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "COACH",
nextDueAt: null,
notifyHour: 9,
location: null,
lastSatisfiedAt: null,
enabled: false,
createdAt: "2030-01-01T00:00:00.000Z",
updatedAt: "2030-01-01T00:00:00.000Z",
} as MeasurementReminder;
function render(view: "cards" | "list"): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const noop = () => {};
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<VorsorgeCard
reminder={reminder}
now={Date.parse("2030-02-01T12:00:00.000Z")}
view={view}
onPrimaryAction={noop}
onFileVisit={noop}
onRemove={noop}
onEdit={noop}
onSkip={noop}
onSnooze={noop}
busy={false}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("checkup state badges", () => {
it.each(["cards", "list"] as const)(
"show the Coach and switched-off badges in the %s view",
(view) => {
const html = render(view);
expect(html).toContain("Check blood pressure");
expect(html).toContain(">Coach<");
expect(html).toContain(">Off<");
},
);
});
@@ -28,6 +28,7 @@ const LOCALES = readdirSync(MESSAGES_DIR)
const REQUIRED_KEYS = [
// Newly added Vorsorge reminder type labels.
"measurementReminders.types.WAIST_CIRCUMFERENCE",
"measurementReminders.types.BLOOD_KETONES",
"measurementReminders.types.WHO5_SCORE",
"measurementReminders.types.SCI_SCORE",
// Pre-existing screening labels (regression floor).
@@ -244,6 +244,30 @@ function NextLastSlot({
);
}
/**
* The Coach-origin and switched-off badges, shared by the card and the list
* view; `undefined` when neither applies, so no empty row is drawn.
*/
function checkupStateBadges(
reminder: MeasurementReminder,
t: (key: string) => string,
): React.ReactNode {
const isCoach = reminder.origin === "COACH";
if (!isCoach && reminder.enabled) return undefined;
return (
<>
{isCoach && (
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</>
);
}
/** The dense list variant of a checkup. */
function CheckupListRow({
reminder,
@@ -251,6 +275,7 @@ function CheckupListRow({
categoryLabel,
cadenceText,
meta,
stateBadges,
isLinked,
isScreening,
canManageMeasurements,
@@ -265,6 +290,8 @@ function CheckupListRow({
categoryLabel: string;
cadenceText: string | null;
meta: React.ReactNode;
/** The same Coach and disabled badges the card view shows. */
stateBadges: React.ReactNode;
isLinked: boolean;
isScreening: boolean;
canManageMeasurements: boolean;
@@ -283,6 +310,7 @@ function CheckupListRow({
<span className="truncate font-medium">
{resolveReminderLabel(reminder, t)}
</span>
{stateBadges}
</div>
{/* Cadence rides on the metric line, styled like the
metric text (no shaded pill). */}
@@ -537,7 +565,6 @@ export function VorsorgeCard({
: reminder.rrule
? t("measurementReminders.cadence.custom")
: "";
const isCoach = reminder.origin === "COACH";
const isLinked = reminder.measurementType != null;
// A screening reminder's action starts the check-in, never numeric entry.
const isScreening = isScreeningReminderType(reminder.measurementType);
@@ -558,20 +585,7 @@ export function VorsorgeCard({
? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}`
: undefined;
// Provenance and disabled badges get a row only when one applies.
const hasStateBadges = isCoach || !reminder.enabled;
const stateBadges = hasStateBadges ? (
<>
{isCoach && (
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</>
) : undefined;
const stateBadges = checkupStateBadges(reminder, t);
const headerActions = canManageMeasurements ? (
<CheckupMenu
@@ -643,6 +657,7 @@ export function VorsorgeCard({
categoryLabel={categoryLabel}
cadenceText={cadenceText}
meta={skipSnoozeMeta}
stateBadges={stateBadges}
isLinked={isLinked}
isScreening={isScreening}
canManageMeasurements={canManageMeasurements}
@@ -16,6 +16,7 @@ export const TYPE_GROUPS = [
"BLOOD_PRESSURE_SYS",
"PULSE",
"BLOOD_GLUCOSE",
"BLOOD_KETONES",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
],
@@ -201,7 +201,7 @@ export function VorsorgeSection({
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/vorsorge"
href="/settings/layout/checkups"
aria-label={t("measurementReminders.customize")}
title={t("measurementReminders.customize")}
>
@@ -111,8 +111,6 @@ describe("medication detail compliance applicability", () => {
});
it("keeps PRN compliance disabled at the query boundary", () => {
expect(source).toContain(
'enabled: activeTab === "uebersicht" && !asNeeded',
);
expect(source).toContain('enabled: activeTab === "overview" && !asNeeded');
});
});
@@ -465,49 +465,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Low stock");
});
it("folds the side-effect quick-log into the same overflow kebab", () => {
// v1.7.2 — the side-effect quick-log folds into the SAME overflow
// menu as edit / history / advanced, so the GLP-1 header carries one
// kebab whether or not the hand-off is wired (parity with the generic
// card). SSR doesn't open Radix Portal content, so we assert the
// single trigger surfaces and smoke-check the handler payload.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const handler = vi.fn();
const html = render(
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={handler}
/>,
client,
);
// Exactly one kebab trigger with the localised aria-label.
const triggers = html.match(/aria-label="More options"/g) ?? [];
expect(triggers).toHaveLength(1);
// The side-effect button must no longer ride alongside
// Eingenommen / Übersprungen in the primary actions row.
expect(html).not.toMatch(
/class="[^"]*flex[^"]*gap-2[^"]*"[^>]*>[\s\S]*?Log side effect/,
);
// Invoke the supplied handler the way the menu item's onClick
// would — pins the prefill payload contract for MoodEntry.
handler(med7p5);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ name: "Mounjaro", treatmentClass: "GLP1" }),
);
});
it("still renders the single kebab when onLogSideEffect is not supplied", () => {
// v1.7.2 — the kebab is unconditional now (it always carries edit
// / history / advanced); the side-effect item is the only opt-in
// part. Mounjaro and Ramipril therefore share the same one-kebab
// header shape regardless of the hand-off prop.
it("renders the single overflow kebab", () => {
// The kebab carries edit and history, so Mounjaro and Ramipril share
// the same one-kebab header shape.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
@@ -190,9 +190,8 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
}
});
it("Mounjaro keeps the single kebab even when onLogSideEffect is supplied (GLP-1-only)", () => {
// The side-effect quick-log folds into the SAME overflow menu, so the
// GLP-1 header shape stays byte-symmetric with the generic card: one
it("Mounjaro carries the same single kebab as the generic card", () => {
// The GLP-1 header shape stays byte-symmetric with the generic card: one
// kebab trigger, no extra control.
const client = makeClient();
seedCompliance(client, mounjaro.id);
@@ -203,7 +202,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={() => {}}
/>,
client,
);
@@ -294,7 +292,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={() => {}}
/>,
client,
);
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
resolveTabSlug,
TAB_SLUGS,
} from "@/components/medications/detail/tab-slugs";
/**
* The medication tabs carried German slugs in every language
* (`?tab=verlauf`). They are English now, and every old link (a bookmark,
* a report, a low-stock notification sent before the change) still lands on
* the tab it named.
*/
describe("medication tab slugs", () => {
const all = [...TAB_SLUGS];
it("opens a tab by its own slug", () => {
expect(resolveTabSlug("history", all)).toBe("history");
expect(resolveTabSlug("supply", all)).toBe("supply");
});
it.each([
["uebersicht", "overview"],
["wirkung", "effect"],
["zeitplan", "schedule"],
["bestand", "supply"],
["verlauf", "history"],
["injektion", "injection"],
["erweitert", "advanced"],
["erinnerung", "schedule"],
["api", "advanced"],
] as const)("sends the old ?tab=%s to %s", (legacy, tab) => {
expect(resolveTabSlug(legacy, all)).toBe(tab);
});
it("falls back to the overview for a missing, unknown or unavailable tab", () => {
expect(resolveTabSlug(null, all)).toBe("overview");
expect(resolveTabSlug("constructor", all)).toBe("overview");
expect(resolveTabSlug("nope", all)).toBe("overview");
expect(
resolveTabSlug(
"injektion",
all.filter((tab) => tab !== "injection"),
),
).toBe("overview");
});
it("keeps every slug URL-safe ASCII", () => {
for (const slug of TAB_SLUGS) expect(slug).toMatch(/^[a-z]+$/);
});
});
@@ -6,12 +6,12 @@
* Promotes the detail page from a read-only history view to the
* canonical home for ONE medication. The modal `<AdvancedSettingsSheet>`
* is dissolved into the Erweitert tab (no more five-card stack in a
* sheet) and the separate `/history` route folds into the Verlauf tab
* (the route now redirects to `?tab=verlauf`).
* sheet) and the separate `/history` route folds into the history tab
* (the route now redirects to `?tab=history`).
*
* Tab state lives in the URL (`?tab=<slug>`), so a tab is deep-linkable,
* survives reload, and is reachable from the medications-list card kebab
* (History→verlauf). The kebab's Edit arrives as `?edit=1`, which opens
* (History). The kebab's Edit arrives as `?edit=1`, which opens
* the wizard in edit mode on top of the landing tab. Slugs are ASCII /
* locale-independent; the visible labels are i18n.
*
@@ -20,7 +20,7 @@
* switch + the reminder grace), and Übersicht grew from a lone
* compliance bar into the status landing surface (next due dose,
* reminder state, supply runway — each with a jump link into its owner
* tab). Stale `?tab=erinnerung` deep-links land on Zeitplan.
* tab). Stale `?tab=erinnerung` deep-links land on the schedule tab.
*
* Only the active tab body mounts (Radix `Tabs` default) so inactive tabs
* do not fetch. The hero stays above the tabs as the always-visible
@@ -106,6 +106,12 @@ import { MedicationRelatedSection } from "@/components/medications/detail/medica
import type { RelatedLink } from "@/lib/links/related";
import { InlineAction } from "@/components/ui/inline-action";
import { EmptyState } from "@/components/ui/empty-state";
import {
resolveTabSlug,
TAB_LABEL_KEYS,
TAB_SLUGS,
type TabSlug,
} from "@/components/medications/detail/tab-slugs";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
@@ -202,20 +208,6 @@ export interface MedicationDetailSnapshot {
related?: RelatedLink[];
}
// Slugs are ASCII / locale-independent so the URL stays stable across
// locales; the visible labels resolve through i18n. The list drives both
// the tab strip render and the `?tab=` round-trip validation.
const TAB_SLUGS = [
"uebersicht",
"wirkung",
"zeitplan",
"bestand",
"verlauf",
"injektion",
"erweitert",
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
/**
* Tabs whose entire content is owner-only work: the schedule editor and its
* plan history, the supply ledger, the external-token row, and the advanced
@@ -225,10 +217,10 @@ type TabSlug = (typeof TAB_SLUGS)[number];
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
// Schedule times are a MANAGE route, but the tab also carries the reminder
// and notification settings, which resolve the caller.
"zeitplan",
"schedule",
// Phase config, export and the per-medication tokens (a credential
// surface, never delegated) resolve the caller.
"erweitert",
"advanced",
]);
/**
@@ -237,20 +229,7 @@ const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
* is one; the three above are not, because each mixes in a route that
* resolves the caller and would 403 under a switch.
*/
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["bestand"]);
/**
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
* that now owns the reminder controls instead of falling back to the
* landing tab.
*/
const LEGACY_TAB_SLUGS: Record<string, TabSlug> = {
erinnerung: "zeitplan",
// The API tab folded back into Erweitert in v1.42: two tabs for the
// rarely-opened owner settings made a strip that could not fit a phone.
api: "erweitert",
};
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["supply"]);
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
@@ -364,26 +343,26 @@ export function MedicationDetailTabs({
// Only the active tab body mounts, but the tab REGISTRY must agree with
// whatever `?tab=` carries — Injektion is absent for non-injectable
// medications, so a stale `?tab=injektion` deep-link falls back to the
// medications, so a stale `?tab=injection` deep-link falls back to the
// landing tab instead of rendering an empty surface.
// v1.16.11 — an as-needed medication has no schedule and never
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
// stale `?tab=schedule` deep-link falls back to the landing tab.
// v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
// supply ledger, the external-token row and the advanced surface that holds
// the wizard, the import and the destructive zone. None of it is delegated,
// so inside somebody else's record they leave the registry entirely rather
// than opening onto controls the server refuses — which also means a stale
// `?tab=erweitert` deep link lands on the landing tab, the same way an
// `?tab=advanced` deep link lands on the landing tab, the same way an
// inapplicable tab already does.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded) &&
(slug !== "wirkung" || hasEfficacyTarget) &&
(slug !== "injection" || isInjectable) &&
(slug !== "schedule" || !asNeeded) &&
(slug !== "effect" || hasEfficacyTarget) &&
(!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) &&
(canManageMedications || !MANAGE_TABS.has(slug)),
),
@@ -396,14 +375,7 @@ export function MedicationDetailTabs({
],
);
const requestedRaw = searchParams?.get("tab");
const requested = (
requestedRaw && requestedRaw in LEGACY_TAB_SLUGS
? LEGACY_TAB_SLUGS[requestedRaw]
: requestedRaw
) as TabSlug | null;
const activeTab: TabSlug =
requested && availableTabs.includes(requested) ? requested : "uebersicht";
const activeTab = resolveTabSlug(searchParams?.get("tab"), availableTabs);
useEffect(() => {
if (shouldOpenEditFromUrl) {
@@ -438,7 +410,7 @@ export function MedicationDetailTabs({
},
// v1.16.11 — no compliance read for an as-needed medication (the
// section is hidden; there is no rate to fetch).
enabled: activeTab === "uebersicht" && !asNeeded,
enabled: activeTab === "overview" && !asNeeded,
staleTime: 30_000,
});
@@ -468,7 +440,7 @@ export function MedicationDetailTabs({
return null;
}
},
enabled: activeTab === "uebersicht",
enabled: activeTab === "overview",
staleTime: 30_000,
});
@@ -549,7 +521,7 @@ export function MedicationDetailTabs({
value={slug}
data-slot={`medication-tab-${slug}`}
>
{t(`medications.detail.tabs.${slug}`)}
{t(TAB_LABEL_KEYS[slug])}
</TabsTrigger>
))}
</TabsList>
@@ -558,7 +530,7 @@ export function MedicationDetailTabs({
reminder state and supply runway (each with a jump link into
its owner tab), then the compliance readout. All values come
from data the page already holds — no new endpoints. */}
<TabsContent value="uebersicht" className="space-y-6 pt-2">
<TabsContent value="overview" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-uebersicht-status-heading"
title={t("medications.detail.uebersicht.statusTitle")}
@@ -583,7 +555,7 @@ export function MedicationDetailTabs({
? t("medications.detail.uebersicht.jumpToVerlauf")
: t("medications.detail.uebersicht.jumpToZeitplan")
}
onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
onJump={() => onTabChange(asNeeded ? "history" : "schedule")}
dataSlot="uebersicht-next-dose"
/>
{/* v1.16.11 — as-needed never reminds; the row would only
@@ -597,7 +569,7 @@ export function MedicationDetailTabs({
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
onJump={() => onTabChange("schedule")}
dataSlot="uebersicht-reminder"
/>
)}
@@ -639,7 +611,7 @@ export function MedicationDetailTabs({
</>
}
jumpLabel={t("medications.detail.uebersicht.jumpToBestand")}
onJump={() => onTabChange("bestand")}
onJump={() => onTabChange("supply")}
dataSlot="uebersicht-supply"
/>
)}
@@ -692,10 +664,10 @@ export function MedicationDetailTabs({
with a before/after comparison + adherence lane. Strictly
descriptive; hidden for one-shot + no-target medications. */}
{hasEfficacyTarget && (
<TabsContent value="wirkung" className="space-y-6 pt-2">
<TabsContent value="effect" className="space-y-6 pt-2">
<EfficacyTab
medicationId={id}
active={activeTab === "wirkung"}
active={activeTab === "effect"}
isGlp1={isGlp1}
medicationName={medication.name}
medicationDose={medication.dose}
@@ -708,7 +680,7 @@ export function MedicationDetailTabs({
notifications switch + the reminder grace, folded in from the
dissolved Erinnerung tab). Cadence-kind stays structural (the
hero's "Vollständig bearbeiten"). */}
<TabsContent value="zeitplan" className="space-y-6 pt-2">
<TabsContent value="schedule" className="space-y-6 pt-2">
{/* v1.39.1 (#1033) — the intake-tracking switch leads the tab:
it decides whether the times below are a dose plan or a
record. */}
@@ -827,7 +799,7 @@ export function MedicationDetailTabs({
</TabsContent>
{/* BESTAND — inventory readout + register / correct flows. */}
<TabsContent value="bestand" className="space-y-4 pt-2">
<TabsContent value="supply" className="space-y-4 pt-2">
<InventorySection
medicationId={id}
dosesPerUnit={medication.dosesPerUnit}
@@ -841,7 +813,7 @@ export function MedicationDetailTabs({
optimistic recompute, edit/add (incl. the late-take "diesem
Slot zuordnen?" nudge). CSV import lives under Erweitert →
Daten (the DataPortabilityRow), not in this header. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<TabsContent value="history" className="space-y-6 pt-2">
<div className="flex justify-end">
<Segmented
mode="toggle"
@@ -887,7 +859,7 @@ export function MedicationDetailTabs({
side-effect log; the body-map + adherence detail land next
wave. */}
{isInjectable && (
<TabsContent value="injektion" className="space-y-6 pt-2">
<TabsContent value="injection" className="space-y-6 pt-2">
{/* Dose-escalation plan: completed + planned titration steps
with a "you are here" marker. Renders for every injectable
route (not GLP-1-only) since titration is a property of the
@@ -916,7 +888,7 @@ export function MedicationDetailTabs({
{/* ERWEITERT — Lifecycle → Daten → external API → Gefahrenzone.
The API tab folded back in here (v1.42); its group stays
collapsed like the other two, and the danger zone stays last. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<TabsContent value="advanced" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
@@ -0,0 +1,58 @@
/**
* The medication detail page's tab slugs, and what an old `?tab=` value maps
* to. Slugs are ASCII and the same in every locale so a link stays stable;
* the visible labels resolve through i18n.
*/
export const TAB_SLUGS = [
"overview",
"effect",
"schedule",
"supply",
"history",
"injection",
"advanced",
] as const;
export type TabSlug = (typeof TAB_SLUGS)[number];
/**
* Slugs an old link may still carry. The tabs used to have German slugs in
* every language; links in old reports, notifications and bookmarks keep
* landing on the same tab. The Erinnerung tab folded into the schedule tab,
* and the API tab into the advanced one.
*/
export const LEGACY_TAB_SLUGS: Record<string, TabSlug> = {
uebersicht: "overview",
wirkung: "effect",
zeitplan: "schedule",
bestand: "supply",
verlauf: "history",
injektion: "injection",
erweitert: "advanced",
erinnerung: "schedule",
api: "advanced",
};
/** The tab labels; the message keys predate the English slugs. */
export const TAB_LABEL_KEYS: Record<TabSlug, string> = {
overview: "medications.detail.tabs.uebersicht",
effect: "medications.detail.tabs.wirkung",
schedule: "medications.detail.tabs.zeitplan",
supply: "medications.detail.tabs.bestand",
history: "medications.detail.tabs.verlauf",
injection: "medications.detail.tabs.injektion",
advanced: "medications.detail.tabs.erweitert",
};
/**
* The tab a `?tab=` value opens: its own slug, or the one an old slug maps
* to, when that tab is available for this medication; the overview
* otherwise.
*/
export function resolveTabSlug(
raw: string | null | undefined,
available: readonly TabSlug[],
): TabSlug {
const slug =
raw && Object.hasOwn(LEGACY_TAB_SLUGS, raw) ? LEGACY_TAB_SLUGS[raw] : raw;
return available.find((tab) => tab === slug) ?? "overview";
}
@@ -174,11 +174,10 @@ interface Glp1MedicationCardProps {
onEdit: (med: Glp1Medication) => void;
/**
* v1.15.18 — navigates to the medication detail page's Verlauf tab
* (`/medications/{id}?tab=verlauf`), mirroring the generic medication
* (`/medications/{id}?tab=history`), mirroring the generic medication
* card. The parent owns the navigation.
*/
onOpenHistory: (med: Glp1Medication) => void;
onLogSideEffect?: (med: Glp1Medication) => void;
/**
* v1.29.x — briefly ringed when this card is the Today digest's "Log
* dose" deep-link target (`/medications?highlight=<id>`).
@@ -215,7 +214,6 @@ export function Glp1MedicationCard({
medication,
onEdit,
onOpenHistory,
onLogSideEffect,
highlighted = false,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
@@ -478,19 +476,11 @@ export function Glp1MedicationCard({
// The four former header icon-buttons (open / edit / history /
// advanced) collapse into a SINGLE overflow menu so the GLP-1 card
// stays symmetric with the generic medication card. The card header
// itself links to the detail page (the former chevron target). The
// optional side-effect quick-log would fold in as a last menu item
// when `onLogSideEffect` is wired, but the medications list page does
// not wire it — side-effect logging lives on the detail page's
// SideEffectsSection — so the menu renders the same items as the
// generic card.
// itself links to the detail page (the former chevron target).
const headerActions = (
<MedicationCardMenu
onEdit={() => onEdit(medication)}
onOpenHistory={() => onOpenHistory(medication)}
onLogSideEffect={
onLogSideEffect ? () => onLogSideEffect(medication) : undefined
}
/>
);
@@ -14,13 +14,8 @@
* target as the name tap); the structural wizard lives under the detail
* page's Erweitert → Lebenszyklus row.
*
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
* "Log side effect" item into the SAME menu, but it is NOT wired on the
* medications list page — both card variants there receive only the
* edit / history / advanced handlers, so the item never renders. Side-
* effect logging lives on the detail page's `<SideEffectsSection>`. When
* the slot is left undefined the two card variants stay byte-symmetric in
* the header (one kebab, identical items).
* Side-effect logging lives on the detail page's `<SideEffectsSection>`, so
* the two card variants carry the same menu (one kebab, identical items).
*
* The trigger is a 44px ghost icon-button (WCAG 2.5.5); every
* `DropdownMenuItem` already carries `min-h-11` from the shadcn
@@ -33,14 +28,13 @@
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
import { History, MoreVertical, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
@@ -48,25 +42,19 @@ import { useTranslations } from "@/lib/i18n/context";
export interface MedicationCardMenuProps {
onEdit: () => void;
onOpenHistory: () => void;
/** GLP-1-only: folds the side-effect quick-log into the same menu. */
onLogSideEffect?: () => void;
}
export function MedicationCardMenu({
onEdit,
onOpenHistory,
onLogSideEffect,
}: MedicationCardMenuProps) {
const { t } = useTranslations();
// v1.36.x — noting a side effect is an admitted delegated write; editing the
// medication and opening its history editor are not. When nothing in the
// menu survives, the trigger goes with it rather than opening onto an empty
// sheet. The card itself is untouched: no wash, no badge, no colour change.
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
if (!canManageMedications && !showSideEffect) return null;
// Editing the medication and opening its history editor are owner work, not
// delegated writes. Without either, the trigger goes too rather than opening
// onto an empty menu. The card itself is untouched: no wash, no badge, no
// colour change.
const { canManageDomain } = useRecordCapabilities();
if (!canManageDomain("medications")) return null;
return (
<DropdownMenu>
@@ -89,27 +77,14 @@ export function MedicationCardMenu({
className="w-56"
data-slot="medication-card-menu"
>
{canManageMedications && (
<>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
</>
)}
{showSideEffect && onLogSideEffect && (
<>
{canManageMedications && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onLogSideEffect}>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -147,7 +147,7 @@ interface MedicationCardProps {
onEdit: (med: Medication) => void;
/**
* v1.15.18 — navigates to the medication detail page's Verlauf tab
* (`/medications/{id}?tab=verlauf`). The parent owns the navigation.
* (`/medications/{id}?tab=history`). The parent owns the navigation.
*/
onOpenHistory: (med: Medication) => void;
/**
@@ -111,7 +111,7 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
it("renders both column headers in German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(html).toContain("Kachel");
expect(html).toContain("Chart");
expect(html).toContain("Diagramm");
});
it("paints both switch slots per widget", () => {
@@ -10,7 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/gesundheitsakte",
usePathname: () => "/settings/health-record",
}));
vi.mock("@tanstack/react-query", () => ({
@@ -43,19 +43,19 @@ describe("<GesundheitsakteSection> — SSR smoke", () => {
// v1.18.6 — the visible heading (with the historic id) comes from
// the shared frame the route wraps the section in.
const html = render(
<SettingsSectionFrame slug="gesundheitsakte">
<SettingsSectionFrame slug="health-record">
<GesundheitsakteSection />
</SettingsSectionFrame>,
);
expect(html).toContain('id="settings-section-gesundheitsakte-title"');
expect(html).toContain('id="settings-section-health-record-title"');
expect(html).toContain('data-testid="health-record-export-panel"');
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.gesundheitsakte.");
expect(html).not.toContain("settings.sections.health-record.");
});
it("German locale resolves without leaking the raw key", () => {
const html = render(<GesundheitsakteSection />, "de");
expect(html).toContain('data-testid="health-record-export-panel"');
expect(html).not.toContain("settings.sections.gesundheitsakte.");
expect(html).not.toContain("settings.sections.health-record.");
});
});
@@ -134,6 +134,19 @@ describe("Settings — GlucoseUnitSelect", () => {
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
});
// The targets panel takes its unit from the targets payload, not from the
// account, so a save that only refreshed `/me` left it on the old unit
// until the server cache turned over (up to ten minutes). Both ends have
// to let go of the old payload.
it("refreshes the targets payload on both ends", () => {
expect(componentSource).toMatch(/queryKey: queryKeys\.insightsTargets\(\)/);
const route = readFileSync(
join(process.cwd(), "src/app/api/auth/me/glucose-unit/route.ts"),
"utf8",
);
expect(route).toMatch(/!== next\) \{\s*invalidateUserProfile\(user\.id\);/);
});
it("is actually mounted on the settings page", () => {
// A control nobody can reach is the same defect as no control.
const accountSection = readFileSync(
@@ -38,7 +38,7 @@ describe("layout-groups registry", () => {
"labs",
"illness",
"documents",
"vorsorge",
"checkups",
]);
// generateStaticParams() shape.
expect(LAYOUT_GROUP_IDS.map((module) => ({ module }))).toEqual([
@@ -48,13 +48,13 @@ describe("layout-groups registry", () => {
{ module: "labs" },
{ module: "illness" },
{ module: "documents" },
{ module: "vorsorge" },
{ module: "checkups" },
]);
});
it("guards unknown segments", () => {
expect(isLayoutGroupId("medications")).toBe(true);
expect(isLayoutGroupId("vorsorge")).toBe(true);
expect(isLayoutGroupId("checkups")).toBe(true);
expect(isLayoutGroupId("nope")).toBe(false);
expect(isLayoutGroupId("")).toBe(false);
});
@@ -68,10 +68,10 @@ describe("layout-groups registry", () => {
expect(gate("mood")).toBe("not-a-group");
expect(gate("labs")).toBe("labs");
expect(gate("illness")).toBe("illness");
// dashboard / insights / vorsorge are never gated.
// dashboard / insights / checkups are never gated.
expect(gate("dashboard")).toBeUndefined();
expect(gate("insights")).toBeUndefined();
expect(gate("vorsorge")).toBeUndefined();
expect(gate("checkups")).toBeUndefined();
});
it("maps every id to a distinct section body and the canonical i18n keys", () => {
@@ -56,7 +56,7 @@ describe("<LayoutSection> hub", () => {
"medications",
"labs",
"illness",
"vorsorge",
"checkups",
]) {
expect(html).toContain(`href="/settings/layout/${id}"`);
}
@@ -66,12 +66,12 @@ describe("<LayoutSection> hub", () => {
const html = render({ medications: false, labs: false });
expect(html).not.toContain('href="/settings/layout/medications"');
expect(html).not.toContain('href="/settings/layout/labs"');
// Other modules unaffected; Vorsorge is never gated.
// Other modules unaffected; checkups are never gated.
expect(html).toContain('href="/settings/layout/illness"');
expect(html).toContain('href="/settings/layout/vorsorge"');
expect(html).toContain('href="/settings/layout/checkups"');
});
it("always lists dashboard, insights, and vorsorge (no module gate)", () => {
it("always lists dashboard, insights, and checkups (no module gate)", () => {
const html = render({
medications: false,
mood: false,
@@ -80,7 +80,7 @@ describe("<LayoutSection> hub", () => {
});
expect(html).toContain('href="/settings/layout/dashboard"');
expect(html).toContain('href="/settings/layout/insights"');
expect(html).toContain('href="/settings/layout/vorsorge"');
expect(html).toContain('href="/settings/layout/checkups"');
// v1.44 — mood is tag management and left Arrangement for Modules.
expect(html).not.toContain('href="/settings/layout/mood"');
});
@@ -28,7 +28,7 @@ const LAYOUT_CHILD_SLUGS = new Set([
"mood",
"labs",
"illness",
"vorsorge",
"checkups",
]);
export function SettingsSectionFrame({
@@ -120,7 +120,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// 301-redirects to `/settings/notifications`.
// v1.18.0 — `modules` ("Was du trackst") sits right after `account` as
// the single front door for enabling/disabling secondary domains.
// v1.18.0 — `gesundheitsakte` (the full health-record export) lifts
// v1.18.0 — `health-record` (the full health-record export) lifts
// out of Export & Import into its own top-level entry, before `export`;
// `coach` gathers the Coach preference cards out of the AI section and
// sits right after `ai`.
@@ -133,7 +133,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// first-class sections, right after `mood` (the three were standalone
// `ModuleSettingsFrame` pages).
// v1.18.7 — `sharing` (clinician share links) sits directly after
// `gesundheitsakte`, before `export`: minting a read-only link to the
// `health-record`, before `export`: minting a read-only link to the
// health record belongs next to the health-record export.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
@@ -164,7 +164,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"coach",
"api",
"mcp",
"gesundheitsakte",
"health-record",
"export",
"advanced",
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
@@ -246,7 +246,7 @@ describe("<SettingsShell>", () => {
api: "connections",
mcp: "connections",
export: "data",
gesundheitsakte: "data",
"health-record": "data",
privacy: "data",
advanced: "data",
about: "system",
@@ -397,16 +397,16 @@ describe("<SettingsShell>", () => {
// reachable. The server-side `/api/export/health-record` route remains
// the hard enforcement of the opt-out.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/gesundheitsakte"',
'href="/settings/health-record"',
);
expect(renderShell({ active: "account", modules: {} })).toContain(
'href="/settings/gesundheitsakte"',
'href="/settings/health-record"',
);
const optedOut = renderShell({
active: "account",
modules: { doctorReport: false },
});
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
expect(optedOut).toContain('href="/settings/health-record"');
});
describe("inside somebody else's record", () => {
@@ -507,7 +507,7 @@ describe("<SettingsShell>", () => {
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; tokens");
// v1.18.0 — the health record is its own top-level entry.
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain('href="/settings/health-record"');
expect(html).toContain("Health record");
// v1.4.16: the consolidated Export section is a top-level
// entry in the sidebar; the link must be present in both locales.
@@ -20,7 +20,7 @@
* "Freigabe" section) folds in here as a labelled "Sharing" group below the
* export panel: a time-boxed read-only link is a sharing face of the SAME
* health record. `/settings/sharing` 301-redirects to
* `/settings/gesundheitsakte#sharing`. The backing model, the
* `/settings/health-record#sharing`. The backing model, the
* `/api/share-links` routes, and the public `/c/[token]` view are unchanged.
*/
@@ -29,9 +29,10 @@ import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
* locale.
*
* Persistence mirrors the unit-system select: a change PATCHes
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
* insight page and the targets panel re-render on the next /me refetch.
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`,
* so the dashboard tiles and the glucose insight page re-render on the next
* /me refetch, and `queryKeys.insightsTargets()`, whose payload states the
* target ranges in the unit it was built with.
*/
export function GlucoseUnitSelect({
isAuthenticated,
@@ -66,9 +67,7 @@ export function GlucoseUnitSelect({
onSuccess: () => {
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
setOptimistic(null);
},
onError: (err) => {
+4 -3
View File
@@ -99,9 +99,10 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
Body: DocumentsSection,
},
{
id: "vorsorge",
titleKey: "settings.sections.layout.vorsorge.title",
descriptionKey: "settings.sections.layout.vorsorge.description",
// The address read `vorsorge` in every language; that one redirects.
id: "checkups",
titleKey: "settings.sections.layout.checkups.title",
descriptionKey: "settings.sections.layout.checkups.description",
Body: VorsorgeSection,
},
];
@@ -41,7 +41,7 @@ export const SETTINGS_ANCHOR_FORWARDS: Readonly<
"/settings/layout/medications": {
"injection-sites": "/settings/modules/medications#injection-sites",
},
"/settings/layout/vorsorge": {
"/settings/layout/checkups": {
"screening-hints": "/settings/anamnesis#screening-hints",
"vorsorge-reminders": "/settings/notifications#vorsorge-reminders",
},
@@ -110,7 +110,7 @@ const LOADERS: Record<
import("@/components/settings/api-section").then((m) => m.ApiSection),
mcp: () =>
import("@/components/settings/mcp-section").then((m) => m.McpSection),
gesundheitsakte: () =>
"health-record": () =>
import("@/components/settings/gesundheitsakte-section").then(
(m) => m.GesundheitsakteSection,
),
@@ -152,7 +152,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
}),
api: dynamic(LOADERS.api, { loading: () => <SectionLoading /> }),
mcp: dynamic(LOADERS.mcp, { loading: () => <SectionLoading /> }),
gesundheitsakte: dynamic(LOADERS.gesundheitsakte, {
"health-record": dynamic(LOADERS["health-record"], {
loading: () => <SectionLoading />,
}),
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
+2 -2
View File
@@ -257,8 +257,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
// Not module-gated: the health record is a flagship export, and its only
// entry point must not vanish behind the `doctorReport` opt-out.
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
slug: "health-record",
titleKey: "settings.sections.health-record.title",
icon: FileHeart,
group: "data",
},
@@ -23,9 +23,8 @@ type UnitPreference = "metric" | "imperial";
* picker rather than in a standalone card (it replaces the retired
* `<UnitPreferenceCard>`). Persistence is unchanged: a change PATCHes
* `/api/auth/me/unit-preference` immediately and invalidates
* `queryKeys.authMe()` + `queryKeys.userUnitPreference()` so every chart
* display transform (km/h vs mph, km vs mi) re-renders on the next /me
* refetch — no manual reload, no contract change.
* `queryKeys.authMe()`, the account payload every chart display transform
* (km/h vs mph, km vs mi) reads, so they re-render on the next /me refetch.
*/
export function UnitPreferenceSelect({
isAuthenticated,
@@ -59,9 +58,6 @@ export function UnitPreferenceSelect({
onSuccess: () => {
toastWrittenOutcome("success", t("settings.dashboard.units.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userUnitPreference(),
});
setOptimistic(null);
},
onError: (err) => {