diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 0d0d203f7..bd4bd0fbf 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -171,14 +171,15 @@ export default function MedicationsPage() { queryClient.setQueryData(queryKeys.medicationDetail(med.id), med); } - // v1.15.18 — the card kebab actions now navigate to the full-page - // tabbed detail surface on the right tab rather than opening a modal - // editor / sheet. Edit lands on Zeitplan (the everyday schedule view + - // the hero's "Vollständig bearbeiten" jump into the wizard), History on - // Verlauf, Advanced on Erweitert (the dissolved advanced-settings). + // v1.15.20 — the kebab's Edit opens the medication wizard directly: + // it navigates to the detail page with `?edit=1`, which the tabbed + // shell reads synchronously to mount the wizard in edit mode (the same + // `editOpen` path the hero's "Vollständig bearbeiten" uses). History + // still lands on the Verlauf tab; the former Advanced item is retired — + // the Erweitert tab stays reachable through the card body. function openEdit(med: Medication) { seedDetail(med); - router.push(`/medications/${med.id}?tab=zeitplan`); + router.push(`/medications/${med.id}?edit=1`); } function openHistory(med: Medication) { @@ -186,11 +187,6 @@ export default function MedicationsPage() { router.push(`/medications/${med.id}?tab=verlauf`); } - function openAdvanced(med: Medication) { - seedDetail(med); - router.push(`/medications/${med.id}?tab=erweitert`); - } - function closeDialog() { setDialogOpen(false); } @@ -327,7 +323,6 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} - onOpenAdvanced={openAdvanced} /> ) : ( ), )} @@ -357,7 +351,6 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} - onOpenAdvanced={openAdvanced} /> ) : ( ), )} diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx index 7a9bfa811..bcf14ff3a 100644 --- a/src/components/medications/SideEffectsSection.tsx +++ b/src/components/medications/SideEffectsSection.tsx @@ -262,7 +262,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { titleId="side-effects-heading" title={t("medications.sideEffects.section")} headerExtras={headerExtras} - bodyPaddingY="py-2.5" > {isLoading && (
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 07f351996..044ab318b 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -349,7 +349,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => { medication={ramipril} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -358,7 +357,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index d07c69d14..fb5517007 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -194,7 +194,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -208,26 +207,24 @@ describe(" — GLP-1 variant rendering", () => { expect(html).not.toMatch(/lucide-syringe/i); }); - it("collapses edit / history / advanced into a single overflow kebab + navigable header", () => { - // v1.7.2 W3 — the four former header icon-buttons collapse into one + it("collapses edit / history into a single overflow kebab + navigable header", () => { + // v1.7.2 W3 — the former header icon-buttons collapse into one // kebab; the card header links to the detail page. The menu items - // (edit / history / advanced) render inside the portalled dropdown - // content, so SSR markup carries only the kebab trigger + the header - // link, not the individual action buttons. + // (edit / history) render inside the portalled dropdown content, so + // SSR markup carries only the kebab trigger + the header link, not + // the individual action buttons. v1.15.20 retires the Advanced item. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, {}); const onEdit = vi.fn(); const onOpenHistory = vi.fn(); - const onOpenAdvanced = vi.fn(); const html = render( , client, ); @@ -240,13 +237,13 @@ describe(" — GLP-1 variant rendering", () => { // Smoke-check the handler contract — SSR can't fire DOM events, so // invoke the handlers the way each menu item's onClick would and pin - // the medication-object payload the parent routes / sheets against. + // the medication-object payload the parent routes against. onOpenHistory(med7p5); - onOpenAdvanced(med7p5); + onEdit(med7p5); expect(onOpenHistory).toHaveBeenCalledWith( expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }), ); - expect(onOpenAdvanced).toHaveBeenCalledWith( + expect(onEdit).toHaveBeenCalledWith( expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }), ); }); @@ -264,7 +261,6 @@ describe(" — GLP-1 variant rendering", () => { medication={defaultMed} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -288,7 +284,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -315,7 +310,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -364,7 +358,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -397,7 +390,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -428,7 +420,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -454,7 +445,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -479,7 +469,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} onLogSideEffect={handler} />, client, @@ -516,7 +505,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -540,7 +528,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -565,7 +552,6 @@ describe(" — GLP-1 variant rendering", () => { medication={paused} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -598,7 +584,6 @@ describe(" — GLP-1 variant rendering", () => { medication={med7p5} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, "de", diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index d37d5ae2b..d6b5cbd96 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -101,7 +101,6 @@ describe(" — next-due reads from server nextDueAt", () => { medication={rollingMed} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -151,7 +150,6 @@ describe(" — next-due reads from server nextDueAt", () => { medication={med} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -206,7 +204,6 @@ describe(" — next-due reads from server nextDueAt", () => { medication={med} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index d7040d59d..12c6af0b4 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -153,7 +153,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramipril} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -162,7 +161,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -197,7 +195,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} onLogSideEffect={() => {}} />, client, @@ -219,7 +216,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramipril} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -228,7 +224,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -252,7 +247,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramipril} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -261,7 +255,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -285,7 +278,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramipril} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -294,7 +286,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaro} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} onLogSideEffect={() => {}} />, client, @@ -364,7 +355,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramiprilFuture} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -373,7 +363,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaroFuture} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -426,7 +415,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={ramiprilBoth} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -435,7 +423,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={mounjaroBoth} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ); @@ -520,7 +507,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={oral} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ), @@ -529,7 +515,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={prn} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ), @@ -538,7 +523,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={oneShot} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ), @@ -547,7 +531,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { medication={glp1} onEdit={() => {}} onOpenHistory={() => {}} - onOpenAdvanced={() => {}} />, client, ), diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts new file mode 100644 index 000000000..bb08eb09b --- /dev/null +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -0,0 +1,108 @@ +/** + * v1.15.20 — supply-runway estimate behind the detail Übersicht's + * "lasts about N more days" line. Pure helpers, no render needed. + */ + +import { describe, it, expect } from "vitest"; + +import { + estimateDailyDoseCount, + estimateRunwayDays, + type RunwaySchedule, +} from "@/components/medications/detail/supply-runway"; + +function schedule(partial: Partial): RunwaySchedule { + return { + windowStart: "08:00", + daysOfWeek: null, + ...partial, + }; +} + +describe("estimateDailyDoseCount", () => { + it("counts a plain daily schedule as its times-of-day per day", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00", "20:00"] }), + ]), + ).toBe(2); + }); + + it("falls back to one dose per day when timesOfDay is absent", () => { + expect(estimateDailyDoseCount([schedule({})])).toBe(1); + }); + + it("scales a rolling interval down (one dose every N days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }), + ]), + ).toBeCloseTo(1 / 7); + }); + + it("scales weekday picks against the 7-day week", () => { + // Mon/Wed/Fri → 3 doses per 7 days. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], daysOfWeek: "1,3,5" }), + ]), + ).toBeCloseTo(3 / 7); + }); + + it("honours the encoded interval-weeks cadence", () => { + // Every 2nd week on one weekday → 1 dose per 14 days. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], daysOfWeek: "i2;1" }), + ]), + ).toBeCloseTo(1 / 14); + }); + + it("approximates a monthly RRULE at one dose per 30 days", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }), + ]), + ).toBeCloseTo(1 / 30); + }); + + it("sums across multiple schedules", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"] }), + schedule({ timesOfDay: ["20:00"] }), + ]), + ).toBe(2); + }); +}); + +describe("estimateRunwayDays", () => { + it("divides the remaining doses by the daily consumption", () => { + expect( + estimateRunwayDays(14, [schedule({ timesOfDay: ["08:00", "20:00"] })]), + ).toBe(7); + }); + + it("floors to whole days", () => { + expect(estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })])).toBe( + 2, + ); + }); + + it("returns null when no supply remains", () => { + expect(estimateRunwayDays(0, [schedule({})])).toBeNull(); + }); + + it("returns null when no schedule consumes doses", () => { + expect(estimateRunwayDays(10, [])).toBeNull(); + }); + + it("covers the weekly injection case (rolling 7-day pen)", () => { + // 4 doses left on a once-a-week injection → ~28 days. + expect( + estimateRunwayDays(4, [ + schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }), + ]), + ).toBe(28); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index aed58d3e7..886b52945 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -11,24 +11,35 @@ * * Tab state lives in the URL (`?tab=`), so a tab is deep-linkable, * survives reload, and is reachable from the medications-list card kebab - * (Edit→zeitplan, History→verlauf, Advanced→erweitert). Slugs are ASCII / + * (History→verlauf). 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. * + * v1.15.20 — six tabs: the former Erinnerung tab dissolved into Zeitplan + * (a "Erinnerung" card under the times editor owns the notifications + * 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. + * * 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 * read-only summary plus the "Vollständig bearbeiten" jump into the * creation/edit wizard (the structural-edit tool — name / class / * cadence kind / schedules). Everyday levers (reminders, supply, history) - * live inline in the tabs. - * - * Verlauf renders the existing editable intake history and Zeitplan the - * read-only cadence summary for now — the dose-history ledger and the - * per-dose window editor land in the next wave. Injektion is gated to - * injectable routes of administration. + * live inline in the tabs. Injektion is gated to injectable routes of + * administration. */ -import { useCallback, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import Link from "next/link"; +import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowLeft, Pencil } from "lucide-react"; @@ -56,19 +67,37 @@ import { } from "@/components/medications/sections/destructive-zone-section"; import { SettingsGroup } from "@/components/medications/settings-group"; import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet"; -import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; -import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; import { parseScheduleRecurrence, } from "@/lib/medication-schedule"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; import type { ComplianceDisplay } from "@/lib/analytics/compliance"; +// v1.15.20 — Recharts stays out of the detail page's initial bundle: +// both chart bodies load through `next/dynamic` (client-only) with the +// shared chart skeleton standing in while the chunk streams. +const DrugLevelChart = dynamic( + () => + import("@/components/medications/DrugLevelChart").then((mod) => ({ + default: mod.DrugLevelChart, + })), + { ssr: false, loading: () => }, +); +const DoseStrengthCurve = dynamic( + () => + import("@/components/medications/dose-strength-curve").then((mod) => ({ + default: mod.DoseStrengthCurve, + })), + { ssr: false, loading: () => }, +); + interface ScheduleSnapshot { id: string; windowStart: string; @@ -106,6 +135,12 @@ export interface MedicationDetailSnapshot { oneShot?: boolean; atcCode?: string | null; rxNormCode?: string | null; + /** + * v1.15.20 — server-computed next due instant (the detail GET carries + * it alongside the row). The Übersicht status surface renders it + * directly; no client-side recurrence walking. + */ + nextDueAt?: string | null; schedules: ScheduleSnapshot[]; } @@ -115,7 +150,6 @@ export interface MedicationDetailSnapshot { const TAB_SLUGS = [ "uebersicht", "zeitplan", - "erinnerung", "bestand", "verlauf", "injektion", @@ -123,6 +157,16 @@ const TAB_SLUGS = [ ] as const; type TabSlug = (typeof TAB_SLUGS)[number]; +/** + * 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", +}; + function snapshotToWizardPayload( med: MedicationDetailSnapshot, ): MedicationPayload { @@ -161,10 +205,17 @@ export function MedicationDetailTabs({ medication: MedicationDetailSnapshot; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const router = useRouter(); const searchParams = useSearchParams(); - const [editOpen, setEditOpen] = useState(false); + // v1.15.20 — the medications-list kebab's "Bearbeiten" arrives as + // `?edit=1` and opens the wizard straight away (the same editOpen the + // hero button drives). Read synchronously so the wizard mounts open on + // the first render; the effect below strips the param so a close + + // reload stays closed. + const shouldOpenEditFromUrl = searchParams?.get("edit") === "1"; + const [editOpen, setEditOpen] = useState(shouldOpenEditFromUrl); const [importOpen, setImportOpen] = useState(false); const [phaseSheetOpen, setPhaseSheetOpen] = useState(false); @@ -188,10 +239,30 @@ export function MedicationDetailTabs({ [isInjectable], ); - const requested = searchParams?.get("tab") as TabSlug | null; + 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"; + useEffect(() => { + if (shouldOpenEditFromUrl) { + // Drop `?edit=1` (keep any `tab` param) so a refresh after closing + // the wizard does not keep reopening it. + const params = new URLSearchParams( + Array.from(searchParams?.entries() ?? []), + ); + params.delete("edit"); + const qs = params.toString(); + router.replace(`/medications/${id}${qs ? `?${qs}` : ""}`, { + scroll: false, + }); + } + }, [shouldOpenEditFromUrl, id, router, searchParams]); + const onTabChange = useCallback( (next: string) => { const params = new URLSearchParams( @@ -230,6 +301,38 @@ export function MedicationDetailTabs({ const shortDays = display?.shortDays ?? 7; const longDays = display?.longDays ?? 30; + // v1.15.20 — supply readout for the Übersicht status surface. Same + // query key the Bestand tab uses, so the two tabs share one cache slot + // and the row only renders when the user has actually registered + // supply items. + const { data: inventory } = useQuery({ + queryKey: queryKeys.medicationInventory(id), + queryFn: async () => { + const res = await fetch(`/api/medications/${id}/inventory`); + if (!res.ok) return null; + return (await res.json()).data as { + items: Array<{ + state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + dosesTotal: number; + dosesRemaining: number; + }>; + } | null; + }, + enabled: activeTab === "uebersicht", + staleTime: 30_000, + }); + + const inventoryItems = Array.isArray(inventory?.items) + ? inventory.items + : []; + const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP"); + const dosesRemaining = liveItems.reduce( + (sum, i) => sum + i.dosesRemaining, + 0, + ); + const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0); + const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules); + return (
- } > - - {importOpen && ( - setImportOpen(false)} - /> - )} {/* INJEKTION — injectable routes only. Current GLP-1 charts + @@ -545,6 +689,45 @@ export function MedicationDetailTabs({ ); } +/** + * One Übersicht status row: label + value on the left, a quiet jump link + * into the owning tab on the right. The link is a button (the tab switch + * is shallow URL state, not a navigation) styled as an inline text link. + */ +function StatusRow({ + label, + value, + jumpLabel, + onJump, + dataSlot, +}: { + label: string; + value: ReactNode; + jumpLabel: string; + onJump: () => void; + dataSlot: string; +}) { + return ( +
  • +
    +

    {label}

    +

    {value}

    +
    + +
  • + ); +} + /** * The danger zone's purge gate needs `intakeCount`. Fetch it lazily here * (the Erweitert tab pays for it; the other tabs never do) rather than diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts new file mode 100644 index 000000000..0c31761f1 --- /dev/null +++ b/src/components/medications/detail/supply-runway.ts @@ -0,0 +1,69 @@ +/** + * v1.15.20 — supply-runway estimate for the medication detail Übersicht. + * + * Pure helpers that turn the schedule snapshot the detail GET already + * carries into an approximate doses-per-day figure, so the Übersicht tab + * can render "lasts about N more days" from the inventory remaining-doses + * count without a new endpoint. The estimate is deliberately coarse + * (calendar months ≈ 30 days) — the copy says "about" and the Bestand tab + * stays the authoritative per-item readout. + */ + +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; + +/** The schedule fields the runway estimate reads. */ +export interface RunwaySchedule { + windowStart: string; + daysOfWeek: string | null; + timesOfDay?: string[]; + rrule?: string | null; + rollingIntervalDays?: number | null; +} + +/** + * Approximate doses per day across every schedule: times-of-day count, + * scaled down by the cadence (rolling interval, weekly day picks + + * interval weeks, monthly/yearly RRULEs). + */ +export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { + let perDay = 0; + for (const s of schedules) { + const times = + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; + if ( + typeof s.rollingIntervalDays === "number" && + s.rollingIntervalDays >= 1 + ) { + perDay += times / s.rollingIntervalDays; + continue; + } + const rrule = s.rrule ?? ""; + if (/FREQ=MONTHLY/.test(rrule)) { + perDay += times / 30; + continue; + } + if (/FREQ=YEARLY/.test(rrule)) { + perDay += times / 365; + continue; + } + const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); + const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; + const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; + perDay += (times * daysPerWeek) / (7 * weeks); + } + return perDay; +} + +/** + * Whole days the remaining supply covers, or `null` when no estimate is + * possible (no supply, no consuming schedule). + */ +export function estimateRunwayDays( + dosesRemaining: number, + schedules: RunwaySchedule[], +): number | null { + if (dosesRemaining <= 0) return null; + const perDay = estimateDailyDoseCount(schedules); + if (perDay <= 0) return null; + return Math.floor(dosesRemaining / perDay); +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 138ebe900..c0de3fc59 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -10,13 +10,16 @@ * day first, chronological inside the day. * * Three interactions land here: - * - a pending / missed slot exposes Genommen / Übersprungen ON the row - * (~2 taps, not buried), which mark the dose with INSTANT feedback — the + * - an actionable slot (upcoming, or missed today) exposes ONE visible + * [Genommen] button that marks the dose with INSTANT feedback — the * cached ledger + the headline % are mutated optimistically (the engines * are pure, so the client mirrors their accounting) before the server - * write fires and the authoritative refetch reconciles; - * - any row with an intake can be edited (time / skipped) or deleted via the - * shared `` + delete confirm; + * write fires and the authoritative refetch reconciles. The success + * toast carries an Undo. Übersprungen and the backfill of older missed + * slots live in the row's overflow kebab; + * - settled rows (taken / skipped / a past missed day) carry ONLY the + * kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen — so the + * ledger reads as a calm record, not a button wall; * - a header "+ Eintrag" opens an add dialog scoped to this medication * (incl. backdated / off-schedule); an off-window take surfaces the * "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via @@ -35,13 +38,23 @@ import { Check, CircleCheck, Loader2, + MoreVertical, + Pencil, Plus, SkipForward, + Trash2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, @@ -60,6 +73,7 @@ import { queryKeys, } from "@/lib/query-keys"; import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; +import { runUndoIntake } from "@/components/medications/use-medication-intake"; import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog"; import { @@ -177,11 +191,26 @@ export function DoseHistoryLedger({ [data, timeZone], ); + // Today's local day key — a missed slot from TODAY is still in active + // play (visible Genommen button); older missed days settle into the + // row kebab so the history reads calm. + const todayKey = useMemo( + () => + new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()), + [timeZone], + ); + /** * Genommen / Übersprungen on a pending / missed slot. The cached ledger is * mutated optimistically (row status + the headline % flip in this paint), * then the server write fires and the dependent-key invalidation reconciles. - * A failed write rolls the optimistic snapshot back + toasts (mirrors the + * A failed write rolls the optimistic snapshot back + toasts; a successful + * one carries the same Undo action the medication cards offer (mirrors the * C1/C2 pattern in `use-medication-intake.ts`). */ const markSlot = useCallback( @@ -220,6 +249,15 @@ export function DoseHistoryLedger({ ); return; } + // The POST returns the created event; its id drives the Undo + // affordance (the same soft-delete route the cards use). + let eventId: string | undefined; + try { + const json = await res.json(); + eventId = json?.data?.id as string | undefined; + } catch { + /* dose recorded; the body is best-effort for the id */ + } toast.success( t( action === "skipped" @@ -227,6 +265,20 @@ export function DoseHistoryLedger({ : "medications.intakeToastTaken", { name: medicationName }, ), + eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => + void runUndoIntake({ + medication: { id: medicationId, name: medicationName }, + eventId, + t, + queryClient, + }), + }, + } + : undefined, ); await invalidateKeys(queryClient, [ ...medicationDependentKeys, @@ -244,6 +296,49 @@ export function DoseHistoryLedger({ [marking, medicationId, medicationName, queryClient, queryKey, t], ); + /** + * Flip an existing intake between Übersprungen and Genommen from the row + * kebab. Same `PUT intake/[eventId]` contract the edit dialog uses; a + * skipped→taken flip anchors `takenAt` on the slot instant (a past slot) + * rather than "now". + */ + const toggleIntakeSkipped = useCallback( + async (intake: { + id: string; + skipped: boolean; + scheduledFor: string | null; + }) => { + try { + const body = intake.skipped + ? { + skipped: false, + takenAt: intake.scheduledFor ?? new Date().toISOString(), + } + : { skipped: true, takenAt: null }; + const res = await fetch( + `/api/medications/${medicationId}/intake/${intake.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.intake.edit.failed")); + return; + } + await invalidateKeys(queryClient, [ + ...medicationDependentKeys, + queryKey, + ]); + toast.success(t("medications.detail.intake.edit.savedToast")); + } catch { + toast.error(t("medications.detail.intake.edit.failed")); + } + }, + [medicationId, queryClient, queryKey, t], + ); + async function confirmRowDelete() { if (!pendingDeleteId) return; const id = pendingDeleteId; @@ -330,7 +425,9 @@ export function DoseHistoryLedger({ key={`${row.kind}:${row.at}:${row.intake?.id ?? i}`} row={row} marking={marking} + isToday={group.dayKey === todayKey} onMark={markSlot} + onToggleSkipped={toggleIntakeSkipped} onEdit={(ev) => setEditingEvent(ev)} onDelete={(id) => setPendingDeleteId(id)} /> @@ -367,20 +464,34 @@ export function DoseHistoryLedger({ } /** - * One ledger row: time + status pill + (when an intake is attributed) its real - * take time, plus the inline actions. A pending/missed slot offers Genommen / - * Übersprungen; a row with an intake offers Bearbeiten / Löschen. + * One ledger row: time + status (colour + glyph + text) + (when an intake is + * attributed) its real take time, plus the row actions. + * + * Action shape (v1.15.20): an actionable slot that is still in play + * (upcoming, or missed TODAY) carries exactly ONE visible [Genommen] button — + * label visible on every viewport — plus the overflow kebab (Übersprungen). + * Every settled row (taken / skipped / a past missed day / ad-hoc) carries + * ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen — + * and, for a past missed slot, the Genommen / Übersprungen backfill marks. */ function LedgerRowItem({ row, marking, + isToday, onMark, + onToggleSkipped, onEdit, onDelete, }: { row: LedgerRow; marking: string | null; + isToday: boolean; onMark: (row: LedgerRow, action: "taken" | "skipped") => void; + onToggleSkipped: (intake: { + id: string; + skipped: boolean; + scheduledFor: string | null; + }) => void; onEdit: (event: { id: string; takenAt: string | null; @@ -393,6 +504,10 @@ function LedgerRowItem({ const fmt = useFormatters(); const actionable = isSlotActionable(row); + // A missed slot from an earlier day stays markable, but only through the + // kebab — the visible button is reserved for doses still in play. + const showTakenButton = + actionable && (row.status === "upcoming" || isToday); const intake = row.intake; const takeBusy = marking === `${row.at}:taken`; const skipBusy = marking === `${row.at}:skipped`; @@ -445,81 +560,125 @@ function LedgerRowItem({
    - {actionable && ( - <> + {showTakenButton && ( + + )} + + + + + {actionable && !showTakenButton && ( + onMark(row, "taken")} + data-slot="ledger-menu-taken" + > + + )} + {intake?.id && ( + <> + + onEdit({ + id: intake.id as string, + takenAt: intake.takenAt, + skipped: intake.skipped, + scheduledFor: intake.scheduledFor, + }) + } + data-slot="ledger-edit" + > + + {(row.status === "skipped" || + row.status === "taken_on_time" || + row.status === "taken_late") && ( + + onToggleSkipped({ + id: intake.id as string, + skipped: intake.skipped, + scheduledFor: intake.scheduledFor, + }) + } + data-slot="ledger-toggle-skipped" + > + {intake.skipped ? ( + + )} + + onDelete(intake.id as string)} + className="text-destructive focus:text-destructive" + data-slot="ledger-delete" + > + + + )} + +
    ); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index a9fdc9433..f151d14dd 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -30,7 +30,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance"; /** * v1.4.25 W4d — GLP-1 medication card variant. * - * Marc directive 2026-05-14: NO chart on the medication card. The card + * Maintainer directive 2026-05-14: NO chart on the medication card. The card * stays text-rich (drug name + current dose, last/next injection, * injection-site rotation hint, side-effect quick-log). v1.4.28 * retired the inline dose-history disclosure and the inventory @@ -129,12 +129,6 @@ interface Glp1MedicationCardProps { * card. The parent owns the navigation. */ onOpenHistory: (med: Glp1Medication) => void; - /** - * v1.15.18 — navigates to the medication detail page's Erweitert tab - * (`/medications/{id}?tab=erweitert`), the home of the dissolved - * advanced settings. - */ - onOpenAdvanced: (med: Glp1Medication) => void; onLogSideEffect?: (med: Glp1Medication) => void; } @@ -167,7 +161,6 @@ export function Glp1MedicationCard({ medication, onEdit, onOpenHistory, - onOpenAdvanced, onLogSideEffect, }: Glp1MedicationCardProps) { const queryClient = useQueryClient(); @@ -364,7 +357,6 @@ export function Glp1MedicationCard({ onEdit(medication)} onOpenHistory={() => onOpenHistory(medication)} - onOpenAdvanced={() => onOpenAdvanced(medication)} onLogSideEffect={ onLogSideEffect ? () => onLogSideEffect(medication) : undefined } diff --git a/src/components/medications/medication-card-menu.tsx b/src/components/medications/medication-card-menu.tsx index cec65b51f..86bb31b3d 100644 --- a/src/components/medications/medication-card-menu.tsx +++ b/src/components/medications/medication-card-menu.tsx @@ -7,8 +7,11 @@ * collapsed their former four header icon-buttons (open / edit / history * / advanced) into a single overflow kebab so the card header carries one * affordance instead of a cluster. The card body itself navigates to the - * detail page (the former chevron target); this menu owns the edit / - * history / advanced actions. + * detail page (the former chevron target); this menu owns the edit and + * history actions. v1.15.20 retires the "Advanced settings" item — the + * detail page's Erweitert tab is reachable through the card body, and + * Edit now opens the medication wizard directly instead of landing on + * the Zeitplan tab. * * 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 @@ -28,7 +31,7 @@ * navigation. */ -import { History, MoreVertical, Pencil, SlidersHorizontal, Stethoscope } from "lucide-react"; +import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -43,7 +46,6 @@ import { useTranslations } from "@/lib/i18n/context"; export interface MedicationCardMenuProps { onEdit: () => void; onOpenHistory: () => void; - onOpenAdvanced: () => void; /** GLP-1-only: folds the side-effect quick-log into the same menu. */ onLogSideEffect?: () => void; } @@ -51,7 +53,6 @@ export interface MedicationCardMenuProps { export function MedicationCardMenu({ onEdit, onOpenHistory, - onOpenAdvanced, onLogSideEffect, }: MedicationCardMenuProps) { const { t } = useTranslations(); @@ -85,10 +86,6 @@ export function MedicationCardMenu({ {t("medications.detail.header.historyLabel")} - - - {t("medications.detail.header.advancedLabel")} - {onLogSideEffect && ( <> diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 9ee34e52b..861ea278a 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -98,19 +98,12 @@ interface MedicationCardProps { * (`/medications/{id}?tab=verlauf`). The parent owns the navigation. */ onOpenHistory: (med: Medication) => void; - /** - * v1.15.18 — navigates to the medication detail page's Erweitert tab - * (`/medications/{id}?tab=erweitert`), the home of the dissolved - * advanced settings. - */ - onOpenAdvanced: (med: Medication) => void; } export function MedicationCard({ medication, onEdit, onOpenHistory, - onOpenAdvanced, }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); @@ -288,7 +281,6 @@ export function MedicationCard({ onEdit(medication)} onOpenHistory={() => onOpenHistory(medication)} - onOpenAdvanced={() => onOpenAdvanced(medication)} /> ); diff --git a/src/components/medications/medication-detail-section.tsx b/src/components/medications/medication-detail-section.tsx index a6157e53c..86ee544b2 100644 --- a/src/components/medications/medication-detail-section.tsx +++ b/src/components/medications/medication-detail-section.tsx @@ -9,19 +9,18 @@ import { type ReactNode } from "react"; * divider + body padding — and drifted on padding, border opacity, and * aria wiring. * - * v1.4.28 FB-F3 / F4 — heading scale collapsed to one shape across the - * `/medications/[id]` surface so the page reads as one consistent - * document. Title classes now match the settings symmetry sweep: - * `text-base font-semibold leading-6 tracking-tight`. Body band keeps - * its `text-xs` density; one heading scale (`text-base font-semibold`), - * one body scale (`text-sm`), one micro scale (`text-xs`) on the page. + * v1.15.20 — lifted to the card language the rest of the detail page + * speaks (`SettingsGroup`, the dashboard cards): `bg-card` surface, + * `rounded-xl`, `p-4 md:p-6` padding. The body band reads at `text-sm` + * (the standard body scale) instead of the former `text-xs` container, + * so every tab renders the same surface vocabulary. * * Contract: - * - `border-border/60 rounded-md border` chrome - * - `px-3 py-2.5` header row with + * - `bg-card rounded-xl border p-4 md:p-6` chrome + * - header row with * `text-foreground text-base font-semibold leading-6 tracking-tight` - * title - * - `border-border/60 border-t px-3 py-3 text-xs` body band + * title, separated from the body by a `border-border/60` rule + * - `pt-3 text-sm` body band * - `aria-labelledby` wired to a stable per-section id derived from `titleId` * * Consumers pass: @@ -35,9 +34,6 @@ import { type ReactNode } from "react"; * - `dataSlot` — optional `data-slot` attribute on the outer section * so cross-section selectors (Playwright, snapshot tests) stay * stable. - * - `bodyPaddingY` — escape hatch for sections that need a different - * vertical padding (the side-effects body runs at py-2.5 because - * it owns an inline timeline with its own gap rhythm). */ export interface MedicationDetailSectionProps { titleId: string; @@ -45,7 +41,6 @@ export interface MedicationDetailSectionProps { headerExtras?: ReactNode; children: ReactNode; dataSlot?: string; - bodyPaddingY?: "py-2.5" | "py-3"; } export function MedicationDetailSection({ @@ -54,15 +49,14 @@ export function MedicationDetailSection({ headerExtras, children, dataSlot, - bodyPaddingY = "py-3", }: MedicationDetailSectionProps) { return (
    -
    +

    {headerExtras}

    -
    - {children} -
    +
    {children}
    ); } diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index e5eae9751..2cae2fdb6 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -87,7 +87,7 @@ export function MedicationDetailSummary({ return (
    -

    {name}

    +

    {name}

    {dose}

    void; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -238,19 +235,6 @@ export function ScheduleTimesEditor({ {t("medications.detail.zeitplan.graceEcho", { minutes: grace, })} - {onRequestReminderTab ? ( - <> - {" · "} - - - ) : null}

    ) : null}
    diff --git a/src/components/medications/scheduling/TimesOfDayChips.tsx b/src/components/medications/scheduling/TimesOfDayChips.tsx index b166b6293..d2f200ce1 100644 --- a/src/components/medications/scheduling/TimesOfDayChips.tsx +++ b/src/components/medications/scheduling/TimesOfDayChips.tsx @@ -159,7 +159,7 @@ export function TimesOfDayChips({ if (!TIME_RE.test(v)) return; onChange([v]); }} - className="h-11 w-32" + className="h-11 w-32 sm:h-9" aria-label={t(`${i18nPrefix}.label`)} />
    @@ -195,7 +195,10 @@ export function TimesOfDayChips({ data-active={isOn ? "true" : "false"} onClick={() => onPresetToggle(preset)} className={[ - "focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50", + // 44 px tap floor on mobile, 36 px on sm+ — the same + // height system the dose-window inputs + save button use + // so the Zeitplan tab renders one control height. + "focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 sm:h-9", isOn ? "bg-primary text-primary-foreground border-primary" : "border-border bg-background text-foreground hover:bg-muted", @@ -227,7 +230,7 @@ export function TimesOfDayChips({ key={time} data-slot="times-of-day-chip" data-time={time} - className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium" + className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium sm:h-9" > {time}