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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:38:31 +02:00
1 parent 939a732aa6
commit 5b47aae8b1
3 files changed
+371 -320

No files matched your search

@@ -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 `<MedicationCard>` and the `<Glp1MedicationCard>` 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<MedicationWindowStatus, null>;
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 (
<Card
className={cn(
"h-full",
active ? "" : "opacity-60",
inTakeWindow && "ring-success/40 bg-success/5 ring-1",
)}
>
<MedicationCardHeader
name={name}
dose={dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={href}
linkLabel={linkLabel}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* 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 ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</p>
) : windowStatus ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
/>
) : null}
{/* Next / last intake — the two decisive lines, each shown once. */}
<MedicationNextLastSlot next={nextLine} last={lastLine} />
{/* 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 ? (
<MedicationComplianceBars
rate7={compliance.rate7}
rate30={compliance.rate30}
streak={compliance.streak}
shortDays={compliance.shortDays}
longDays={compliance.longDays}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — calm, rate-decoupled. */}
{active && currentCycle && (
<MedicationCycleStatus cycle={currentCycle} />
)}
{/* Quick actions — bottom-pinned so the action rows align across a
grid row regardless of how much content sits above. */}
{active && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={onRecordIntake}
/>
</div>
)}
</CardContent>
{children}
</Card>
);
}
@@ -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 && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</>
) : 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 (
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* 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 && (
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{/* Injection state — next + last, rendered through the SAME shared
<MedicationNextLastSlot> 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. */}
<MedicationNextLastSlot
next={
next && currentWindowStatus.status !== "in_window" ? (
<>
{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 && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</>
) : 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 ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* 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 && (
<MedicationCycleStatus cycle={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 && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
</CardContent>
<MedicationCardBody
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
active={medication.active}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
stateBadges={stateBadges}
headerActions={headerActions}
windowStatus={
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={lastLine}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
@@ -523,18 +440,6 @@ export function Glp1MedicationCard({
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
</MedicationCardBody>
);
}
/**
* 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("");
}
+93 -162
View File
@@ -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 && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {s.dose}
</span>
)}
</>
);
})()
: null;
return (
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{/* Next / last intake — rendered through the shared
<MedicationNextLastSlot> 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. */}
<MedicationNextLastSlot
next={
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 && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {s.dose}
</span>
)}
</>
);
})()
: 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 ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* 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 && (
<MedicationCycleStatus cycle={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 && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
</CardContent>
<MedicationCardBody
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
active={medication.active}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
stateBadges={stateBadges}
headerActions={headerActions}
windowStatus={
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={
medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null
}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
@@ -454,6 +385,6 @@ export function MedicationCard({
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
</MedicationCardBody>
);
}