From fb6e494f64c2f283f611e6d4279c100a4a6abe51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 13:34:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(medications):=20calmer=20detail=20page=20?= =?UTF-8?q?=E2=80=94=20one=20card=20language,=20six=20tabs,=20a=20status?= =?UTF-8?q?=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every detail section now sits on the card surface with the standard padding instead of a bare border on the page background, the schedule tab loses its duplicated summary header, and the reminder tab folds into the schedule tab next to the times it gates (old deep links redirect). The overview tab becomes a real landing surface — next due dose, reminder state and supply runway with jump links — instead of a lone compliance bar. History rows calm down: settled rows carry a single row menu, actionable rows exactly one visible take button plus the menu, and the quick-mark toast gains undo. The card menu drops the advanced-settings entry and its edit action opens the editor it promises. Schedule-tab controls share one height system, and the injection charts load on demand so the chart library leaves the initial bundle. --- src/app/medications/page.tsx | 22 +- .../medications/SideEffectsSection.tsx | 1 - .../medications/__tests__/card-parts.test.tsx | 2 - .../__tests__/medication-card-glp1.test.tsx | 31 +- .../medication-card-next-due.test.tsx | 3 - .../medication-card-symmetry.test.tsx | 17 - .../__tests__/supply-runway.test.ts | 108 ++++++ .../detail/MedicationDetailTabs.tsx | 309 +++++++++++++---- .../medications/detail/supply-runway.ts | 69 ++++ .../medications/dose-history-ledger.tsx | 319 +++++++++++++----- .../medications/glp1-medication-card.tsx | 10 +- .../medications/medication-card-menu.tsx | 15 +- .../medications/medication-card.tsx | 8 - .../medications/medication-detail-section.tsx | 34 +- .../medications/medication-detail-summary.tsx | 2 +- .../scheduling/ScheduleTimesEditor.tsx | 20 +- .../scheduling/TimesOfDayChips.tsx | 13 +- .../medications/sections/settings-section.tsx | 103 +++++- .../medications/wizard/steps/Step3Dose.tsx | 8 +- 19 files changed, 797 insertions(+), 297 deletions(-) create mode 100644 src/components/medications/__tests__/supply-runway.test.ts create mode 100644 src/components/medications/detail/supply-runway.ts 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}