diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index 809f7120e..c9358f1d1 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -466,7 +466,7 @@ test.describe("medication adherence journey", () => { // The 90-day ledger beside it is a view of the SCHEDULE and stops at now, // so a dose skipped ahead of its window is not in it. The intake history // is the view of what was recorded, and both gestures belong there. - await page.goto(`/medications/${medicationId}?tab=verlauf`); + await page.goto(`/medications/${medicationId}?tab=history`); await page .locator('[data-slot="medication-verlauf-view-toggle-all"]') .click(); diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts index ea8b68a90..97d583b91 100644 --- a/e2e/notification-dispatch-journey.spec.ts +++ b/e2e/notification-dispatch-journey.spec.ts @@ -661,7 +661,7 @@ test.describe("notification preferences drive the dispatch decision", () => { await page.goto(`/medications/${medicationId}`, { waitUntil: "domcontentloaded", }); - await page.locator('[data-slot="medication-tab-zeitplan"]').click(); + await page.locator('[data-slot="medication-tab-schedule"]').click(); await expect( page.locator('[data-slot="notifications-client-managed-chip"]'), ).toBeVisible({ timeout: 30_000 }); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 7579b4981..3021a04a6 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93880, + "total": 93824, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -13,7 +13,7 @@ "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1087, "components/measurements/measurement-form.tsx": 815, - "components/medications/detail/medication-detail-tabs.tsx": 1157, + "components/medications/detail/medication-detail-tabs.tsx": 1129, "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a6c30e3f9..837812e0f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 339, - "total": 110080, + "total": 110064, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -147,10 +147,10 @@ "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352, "components/measurements/measurement-form.tsx :: MeasurementForm": 496, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, - "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767, + "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 760, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, - "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, + "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 396, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403, "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208, diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 948509a8d..fbb2eba00 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -21,7 +21,7 @@ export default function IntakeHistoryRedirectPage({ const router = useRouter(); useEffect(() => { - router.replace(`/medications/${id}?tab=verlauf`); + router.replace(`/medications/${id}?tab=history`); }, [id, router]); return ( diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index e197120d3..f35ecf09b 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -435,7 +435,7 @@ export default function MedicationsPageClient() { function openHistory(med: Medication) { seedDetail(med); - router.push(`/medications/${med.id}?tab=verlauf`); + router.push(`/medications/${med.id}?tab=history`); } function closeDialog() { diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx index e4b9685aa..175308929 100644 --- a/src/components/day/__tests__/day-view.test.tsx +++ b/src/components/day/__tests__/day-view.test.tsx @@ -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: [ diff --git a/src/components/insights/medication-efficacy-summary.tsx b/src/components/insights/medication-efficacy-summary.tsx index 773bbccc7..cac643d01 100644 --- a/src/components/insights/medication-efficacy-summary.tsx +++ b/src/components/insights/medication-efficacy-summary.tsx @@ -114,7 +114,7 @@ export function MedicationEfficacySummary({ {t("medications.efficacy.insights.link")} diff --git a/src/components/medications/__tests__/detail-page-cascade.test.tsx b/src/components/medications/__tests__/detail-page-cascade.test.tsx index 2f1cd6df9..7dc8f59c6 100644 --- a/src/components/medications/__tests__/detail-page-cascade.test.tsx +++ b/src/components/medications/__tests__/detail-page-cascade.test.tsx @@ -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'); }); }); diff --git a/src/components/medications/__tests__/medication-tab-slugs.test.ts b/src/components/medications/__tests__/medication-tab-slugs.test.ts new file mode 100644 index 000000000..219d803f6 --- /dev/null +++ b/src/components/medications/__tests__/medication-tab-slugs.test.ts @@ -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]+$/); + }); +}); diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 12f6c37de..8eecf84bc 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -6,12 +6,12 @@ * Promotes the detail page from a read-only history view to the * canonical home for ONE medication. The modal `` * 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=`), 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 = 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 = 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 = 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 = { - 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 = 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( () => 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])} ))} @@ -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. */} - + 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 && ( - + + {/* 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({ {/* BESTAND — inventory readout + register / correct flows. */} - + +
+ {/* 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. */} - + = { + 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 = { + 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"; +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 91502183c..f85b491e7 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -174,7 +174,7 @@ 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; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index d3033e08b..2a790b525 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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; /** diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 7e0f6b33e..674632eaf 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -310,7 +310,7 @@ describe("runMedicationLowStockTick", () => { thresholdDays: 7, unitsRemaining: 5, // Deep link lands on the detail page's Bestand (supply) tab. - url: "/medications/med-1?tab=bestand", + url: "/medications/med-1?tab=supply", }), }), ); diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 16a8b2571..3d8f28435 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -621,7 +621,7 @@ export async function runMedicationLowStockTick( // the service-worker payload's click target. Lands on // the detail page's Bestand (supply) tab, the surface // the alert is about. - url: `/medications/${med.id}?tab=bestand`, + url: `/medications/${med.id}?tab=supply`, }, }); if (!outcome.dispatched) { diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 6f1cfb0ce..03c595a0e 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -1031,7 +1031,7 @@ const IOS_METADATA_ALLOWLIST = new Set([ "phase", "date", // v1.16.11 — MEDICATION_LOW_STOCK deep link + context: the in-app - // path the push should open (`/medications/{id}?tab=bestand`) plus + // path the push should open (`/medications/{id}?tab=supply`) plus // the runway / threshold figures the iOS handler can surface. "url", "runwayDays",