From 5b47aae8b16ad486db6f77a2243041c62ed25b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 13:38:31 +0200 Subject: [PATCH] share one card body across both medication card variants Extract a single MedicationCardBody that owns the entire card shell, header, status line, next/last slot, compliance bars, cycle line and action row. Both the generic and the GLP-1 card render it and pass only the value content of their next/last lines, so structure, section order, spacing tokens and labels are identical by construction and cannot drift again. This also pins the two cards to the same CardContent spacing (flex h-full flex-col space-y-3.5) and the same mt-auto action wrapper, removing the bottom-spacing mismatch between the two cards. Drive the card's state from the new dose status: highlight the card green when the dose is in its take-window, and show a top status line that escalates from 'overdue' to 'very overdue' as a dose passes its miss cutoff. Upcoming and taken states stay calm. --- .../card-parts/medication-card-body.tsx | 215 +++++++++++++++ .../medications/glp1-medication-card.tsx | 221 +++++---------- .../medications/medication-card.tsx | 255 +++++++----------- 3 files changed, 371 insertions(+), 320 deletions(-) create mode 100644 src/components/medications/card-parts/medication-card-body.tsx diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx new file mode 100644 index 000000000..9d34fc9e1 --- /dev/null +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { type ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; +import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; +import { + MedicationComplianceBars, + MedicationComplianceSkeleton, +} from "@/components/medications/card-parts/medication-compliance-bars"; +import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status"; +import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; +import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CurrentCycle, DoseStatus } from "@/lib/analytics/compliance"; +import type { MedicationWindowStatus } from "@/lib/medications/window-status"; + +/** + * v1.15.9 — the ONE shared medication-card body. + * + * Both the generic `` and the `` render + * THIS component for their entire card shell + body, so structure, vertical + * rhythm, spacing tokens, section order and labels are byte-identical by + * construction — they cannot drift. The variants own only the data and the + * VALUE content of the next/last lines (a daily med shows a clock time, a + * weekly GLP-1 shows the liked "Samstag 13.7. (in 7 Tagen)" relative-day + * phrasing); everything structural lives here. + * + * This closes the recurring "the two cards look subtly different" failure. + * The Ramipril-vs-Mounjaro bottom-spacing mismatch in particular is fixed by + * sharing this single `CardContent` (`flex h-full flex-col space-y-3.5`) and + * the single `mt-auto pt-0` action wrapper — there is no per-variant spacing + * left to diverge. + * + * State-driven presentation (like the iOS app): + * - `on_time_window` → the card highlights green (`ring` + tinted bg): the + * actionable "take it now" state stands out at a glance. + * - `overdue` → a calm top status line; `missed` (at / past the miss + * cutoff) escalates to "Stark überfällig" in the destructive tone. + * - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm. + */ +export interface MedicationCardBodyProps { + /** Medication name shown on the header's line 1. */ + name: string; + /** Dose shown beside the name on line 1. */ + dose: string; + /** Localised category label for the header badge. */ + categoryLabel: string; + /** Whether the medication is active (greys out + hides the action row). */ + active: boolean; + /** Detail-page link target for the header region. */ + href: string; + /** Accessible label for the navigating header link. */ + linkLabel: string; + /** State badges (without-notification / paused) for the header. */ + stateBadges: ReactNode; + /** Overflow kebab for the header. */ + headerActions: ReactNode; + + /** Current take-window status pill props, or null when no window is open. */ + windowStatus: { + status: Exclude; + windowStart: string; + windowEnd: string; + } | null; + + /** + * The open dose's server-derived {@link DoseStatus}. Drives the green + * take-window highlight and the overdue / heavily-overdue top line. + */ + doseStatus: DoseStatus; + + /** Upcoming-intake line value, or null when there is nothing to show. */ + nextLine: ReactNode | null; + /** Last-intake line value, or null when the med has never been taken. */ + lastLine: ReactNode | null; + + /** + * The resolved compliance block, or null while the per-card query is in + * flight (the body then renders the constant-height skeleton). + */ + compliance: { + rate7: number; + rate30: number; + streak: number; + shortDays: number; + longDays: number; + } | null; + + /** The open-cycle descriptor, or null when the display block is absent. */ + currentCycle: CurrentCycle | null; + + /** "take" | "skip" while the matching request is in flight, else null. */ + intakeLoading: string | null; + /** Record the displayed dose (the card binds the slot instant). */ + onRecordIntake: (skipped: boolean) => void; + + /** GLP-1 variant mounts its post-dose injection-site dialog here. */ + children?: ReactNode; +} + +export function MedicationCardBody({ + name, + dose, + categoryLabel, + active, + href, + linkLabel, + stateBadges, + headerActions, + windowStatus, + doseStatus, + nextLine, + lastLine, + compliance, + currentCycle, + intakeLoading, + onRecordIntake, + children, +}: MedicationCardBodyProps) { + const { t } = useTranslations(); + + // Green take-window highlight — the actionable "take it now" state, mirrored + // from the iOS card. Suppressed on an inactive med (it carries no actions). + const inTakeWindow = active && doseStatus === "on_time_window"; + + // Overdue escalation line: a dose past its on-time window. `missed` (at / + // past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable + // `overdue` tail reads the calmer "Überfällig". Both use the destructive + // tone so the urgency is unmistakable; suppressed on an inactive med. + const overdueLabel = + active && doseStatus === "missed" + ? t("medications.veryOverdue") + : active && doseStatus === "overdue" + ? t("medications.overdue") + : null; + + return ( + + + + + {/* Top status line. The in-window take-now pill (success) and the + overdue escalation are mutually exclusive — a dose is either still + in its take-window or past it. The overdue line wins when present + so a heavily-overdue dose reads loud at the top of the card. */} + {overdueLabel ? ( +

+

+ ) : windowStatus ? ( + + ) : null} + + {/* Next / last intake — the two decisive lines, each shown once. */} + + + {/* Compliance bars — always two rows; constant-height skeleton holds + the slot while the query is in flight so the grid row stays even. */} + {active && + (compliance ? ( + + ) : ( + + ))} + + {/* Open-cycle status line — calm, rate-decoupled. */} + {active && currentCycle && ( + + )} + + {/* Quick actions — bottom-pinned so the action rows align across a + grid row regardless of how much content sits above. */} + {active && ( +
+ +
+ )} +
+ + {children} +
+ ); +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 8bfdbae2a..bfcd6306c 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -4,23 +4,11 @@ import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Card, CardContent } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; -import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; -import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; -import { - MedicationComplianceBars, - MedicationComplianceSkeleton, -} from "@/components/medications/card-parts/medication-compliance-bars"; -import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status"; -import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; +import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; -import { - MedicationNextLastSlot, - useWeekdayLabel, -} from "@/components/medications/card-parts/medication-next-last-slot"; +import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { invalidateKeys, @@ -285,13 +273,10 @@ export function Glp1MedicationCard({ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; - // v1.15.8 — taken-of-expected counts, shown after each percentage so two - // identical rates stay distinguishable (a weekly GLP-1 reading 100% on - // both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`). - const takenShort = display?.short.taken; - const expectedShort = display?.expectedShort; - const takenLong = display?.long.taken; - const expectedLong = display?.expectedLong; + // v1.15.9 — the open dose's server-derived status drives the green + // take-window highlight + the overdue / heavily-overdue top line, shared + // verbatim with the generic card. Defaults to "upcoming" (calm). + const doseStatus = display?.currentDose.status ?? "upcoming"; // v1.4.37 W4b — symmetric take-now / overdue pill with the generic // card. Sort schedules by `windowStart` so the most-actionable @@ -325,9 +310,12 @@ export function Glp1MedicationCard({ now, }); + // v1.15.9 — the recent-injection list now feeds ONLY the post-dose + // injection-site dialog's rotation history; the card surface no longer + // shows where the last shot landed. The operator: "where I injected + // doesn't interest me." Site TRACKING / logging is unchanged everywhere + // else (the picker, the detail history) — only the card line drops it. const recentInjections = details?.recentIntakes ?? []; - const lastSite = - recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null; function lastInjectionLabel(): string | null { if (!medication.lastTakenAt) return null; @@ -382,129 +370,58 @@ export function Glp1MedicationCard({ const categoryLabel = getMedicationCategoryLabel(medication.category, t); + // The upcoming-injection line value. The card owns this VALUE content — + // the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while + // the structure / labels live in the shared body. The purple dose accent + // is byte-equivalent with the generic card's. + const nextLine = + next && currentWindowStatus.status !== "in_window" ? ( + <> + {nextInjectionLabel()} + {schedule?.dose && ( + + {" "} + — {schedule.dose} + + )} + + ) : null; + + // v1.15.9 — the last-injection line drops the injection-site display; it + // now reads exactly like the generic card's last line (relative-day only). + const lastLine = medication.lastTakenAt + ? t("medications.glp1LastInjection", { label: lastInjectionLabel() ?? "—" }) + : null; + return ( - - - - - {/* Take-now / overdue / very-overdue pill, shared with the - generic medication card. The GLP-1 card historically - omitted this row, which made Mounjaro feel different from - Ramipril on the medications grid even though the underlying - schedule contract is the same shape. */} - {currentWindowStatus.status && ( - - )} - - {/* Injection state — next + last, rendered through the SAME shared - the generic card uses, so the order - (next-then-last), labels ("Next intake" / "Last intake"), colour, - spacing, gating, reserved min-height and the label-left / - value-right two-column layout are identical across the two card - types. v1.15.8 dropped the GLP-1-specific appointment-phrased - label override so both cards name the concept identically; the - relative-day value phrasing ("in N days" / "today") stays the - GLP-1 card's own and is preserved. */} - - {nextInjectionLabel()} - {/* Purple dose accent on the upcoming schedule dose, - byte-equivalent with the generic card. Schedule.dose can - override the medication-level dose during titration. - Hidden below sm: to keep the narrow viewport row tight. */} - {schedule?.dose && ( - - {" "} - — {schedule.dose} - - )} - - ) : null - } - last={ - medication.lastTakenAt - ? lastSite - ? t("medications.glp1LastInjectionWithSite", { - label: lastInjectionLabel() ?? "—", - site: t(`medications.site${siteSuffix(lastSite)}`), - }) - : t("medications.glp1LastInjection", { - label: lastInjectionLabel() ?? "—", - }) - : null - } - /> - - {/* v1.4.28 — the "Bestand" (inventory) surface retired from - the GLP-1 card: both the inline pens-remaining summary line - and the per-pen disclosure are gone. The iOS-consumed - Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays - in the response shape; only the web mounts are gone. */} - - {/* Compliance bars — always two rows, shared with the generic - card so the page grid stays harmonious. A constant-height - skeleton holds the slot while the per-card compliance query is - in flight (or returns null) so the card body keeps a fixed - footprint and the action row pins to the same baseline as its - grid-row sibling. */} - {medication.active && - (compliance ? ( - - ) : ( - - ))} - - {/* Open-cycle status line — the real win for a weekly GLP-1: between - injections the percentage rows are vacuous, so this calm, - rate-decoupled line reports "next dose in N days" / "due today" / - "overdue" (or a neutral "no closed cycles yet") instead of a 0% - that misreads a perfectly on-schedule med. `state: "none"` - collapses to nothing inside the part. */} - {medication.active && display?.currentCycle && ( - - )} - - {/* Primary actions row — shared with the generic medication card. - The GLP-1-specific side-effect quick-log lives in the - header-actions overflow (kebab), not this row, so Mounjaro and - Ramipril share the canonical two-button primary row. The content - above reserves constant-height slots, so the card bodies in a - grid row are equal height and `mt-auto` pins the action row to - the same baseline without opening a void. */} - {medication.active && ( -
- recordIntake(skipped, displayedSlot)} - /> -
- )} -
- + recordIntake(skipped, displayedSlot)} + > {/* v1.8.5 — post-dose injection-site capture (optional, skippable). */} {tracksInjection && ( setSiteIntakeId(null)} /> )} -
+ ); } - -/** - * Map enum value → i18n suffix. ABDOMEN_LEFT → "AbdomenLeft" so we can - * lookup `medications.siteAbdomenLeft` etc. without a switch table. - */ -function siteSuffix(site: InjectionSiteKey): string { - return site - .toLowerCase() - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 157231c45..737620689 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -3,23 +3,11 @@ import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Card, CardContent } from "@/components/ui/card"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; -import { cn } from "@/lib/utils"; -import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; -import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; -import { - MedicationComplianceBars, - MedicationComplianceSkeleton, -} from "@/components/medications/card-parts/medication-compliance-bars"; -import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status"; -import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; -import { - MedicationNextLastSlot, - useWeekdayLabel, -} from "@/components/medications/card-parts/medication-next-last-slot"; +import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body"; +import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; @@ -85,10 +73,6 @@ interface Medication { interface ComplianceData { compliance7: { - totalExpected: number; - taken: number; - skipped: number; - missed: number; rate: number; streak: number; }; @@ -98,9 +82,10 @@ interface ComplianceData { /** * v1.8.6 — the two compliance windows scaled to the dosing cadence. The * card always shows two percentage rows; `shortDays` / `longDays` name the - * windows and `short` / `long` carry their rates. Additive — older mocks - * omit it, in which case the card falls back to the static 7-/30-day - * `compliance7` / `compliance30` fields. + * windows and `short` / `long` carry their rates. v1.15.9 also carries the + * open dose's `currentDose.status` for the green / overdue presentation. + * Additive — older mocks omit it, in which case the card falls back to the + * static 7-/30-day `compliance7` / `compliance30` fields and a calm state. */ complianceDisplay?: ComplianceDisplay; } @@ -211,14 +196,10 @@ export function MedicationCard({ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; - // v1.15.8 — taken-of-expected counts, shown after each percentage so two - // identical rates stay distinguishable. Falls back to the static 7-day - // `taken` when the cadence-scaled display block is absent. - const takenShort = display?.short.taken ?? compliance?.compliance7?.taken; - const expectedShort = - display?.expectedShort ?? compliance?.compliance7?.totalExpected; - const takenLong = display?.long.taken; - const expectedLong = display?.expectedLong; + // v1.15.9 — the open dose's server-derived status drives the card's green + // take-window highlight + the overdue / heavily-overdue top line. Defaults + // to "upcoming" (calm) when the display block is absent (older mocks). + const doseStatus = display?.currentDose.status ?? "upcoming"; const categoryLabel = getMedicationCategoryLabel(medication.category, t); const sortedSchedules = [...medication.schedules].sort( (a, b) => @@ -307,139 +288,89 @@ export function MedicationCard({ /> ); + // The upcoming-intake line value — a day label + window range + optional + // dose accent. The card owns this VALUE content (a daily med reads as a + // clock-time window); the structure / labels live in the shared body. + const nextLine = + nextSchedule && currentWindowStatus.status !== "in_window" + ? (() => { + const s = nextSchedule; + + // Format day label relative to today + let dayLabel = ""; + if (nextAt) { + const nextDate = toBerlinDate(new Date(nextAt)); + const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; + const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; + const tomorrow = new Date(nowBerlin); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; + + const diffDays = Math.round( + (nextDate.getTime() - nowBerlin.getTime()) / + (24 * 60 * 60 * 1000), + ); + + if (nextStr === todayStr) { + dayLabel = t("medications.today"); + } else if (nextStr === tomorrowStr) { + dayLabel = t("medications.tomorrow"); + } else if (diffDays <= 5) { + dayLabel = weekdayLabel(nextDate.getDay()); + } else { + dayLabel = fmt.dateWithWeekday(nextDate); + } + } + + return ( + <> + {dayLabel && `${dayLabel}, `} + {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} + {s.label && ( + ({s.label}) + )} + {s.dose && ( + + {" "} + — {s.dose} + + )} + + ); + })() + : null; + return ( - - - - - {/* Status, last & next intake info */} - {currentWindowStatus.status && ( - - )} - - {/* Next / last intake — rendered through the shared - so the generic and GLP-1 cards keep an - identical slot (order, colour, spacing, gating, reserved - min-height). The card owns only the *content* of each line; the - wrapper + "Next / Last intake" labels live in the shared part. */} - { - const s = nextSchedule; - - // Format day label relative to today - let dayLabel = ""; - if (nextAt) { - const nextDate = toBerlinDate(new Date(nextAt)); - const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; - const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; - const tomorrow = new Date(nowBerlin); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; - - const diffDays = Math.round( - (nextDate.getTime() - nowBerlin.getTime()) / - (24 * 60 * 60 * 1000), - ); - - if (nextStr === todayStr) { - dayLabel = t("medications.today"); - } else if (nextStr === tomorrowStr) { - dayLabel = t("medications.tomorrow"); - } else if (diffDays <= 5) { - dayLabel = weekdayLabel(nextDate.getDay()); - } else { - dayLabel = fmt.dateWithWeekday(nextDate); - } - } - - return ( - <> - {dayLabel && `${dayLabel}, `} - {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} - {s.label && ( - ({s.label}) - )} - {s.dose && ( - - {" "} - — {s.dose} - - )} - - ); - })() - : null - } - last={ - medication.lastTakenAt - ? formatLastTakenAt(medication.lastTakenAt) - : null - } - /> - - {/* Compliance bars — always two rows. The server scales the two - windows to the dosing cadence (7 / 30 days for dense meds, - stepping up to 90 / 365 for sparse ones); the labels follow the - chosen windows. A constant-height skeleton holds the slot while - the per-card compliance query is in flight (or returns null) so - the card body keeps a fixed footprint and the action row pins to - the same baseline as its grid-row sibling. */} - {medication.active && - (compliance ? ( - - ) : ( - - ))} - - {/* Open-cycle status line — calm, rate-decoupled. A sparse weekly / - rolling med between doses reports "next dose in N days" / "due - today" / "overdue" (or a neutral "no closed cycles yet") instead of - leaning on a percentage that reads as a scary 0% for a med that is - actually on schedule. Rendered only when the server supplies the - descriptor; `state: "none"` collapses to nothing inside the part. */} - {medication.active && display?.currentCycle && ( - - )} - - {/* Quick actions — primary buttons of the medication card. The - content above reserves constant-height slots, so the card bodies - in a grid row are equal height and `mt-auto` pins the action row - to the same baseline without opening the void that an unequal- - height pin produced before. */} - {medication.active && ( -
- recordIntake(skipped, displayedSlot)} - /> -
- )} -
- + recordIntake(skipped, displayedSlot)} + > {/* v1.8.5 — post-dose injection-site capture (optional, skippable). */} {tracksInjection && ( setSiteIntakeId(null)} /> )} -
+ ); }