fix(medications): make the card identical across every type

Route both the generic and GLP-1 cards through the shared next/last
slot, ending the divergence that made two meds of different types read
differently in the same grid row:

- the GLP-1 card showed last-then-next; both now show next-then-last;
- its next line painted text-foreground/85; both now use the muted token;
- its inner spacing was space-y-1; both now use space-y-3.5;
- it always rendered the next line and printed a literal em-dash when
  there was no next injection; both now gate the line on an actual next
  and drop the placeholder.

Make CardContent a flex column and pin the action row with mt-auto.
Combined with the reserved next/last slot and the constant-height
compliance skeleton, every card body is now equal height, so the pin
aligns the take/skip buttons across a grid row without opening the void
that an unequal-height pin produced before.

Fold the GLP-1 injection-site rotation nudge into the upcoming-injection
line instead of a separate bordered block, so the GLP-1 card keeps the
same row inventory and height as the generic card.

Repoint both cards' dose accent onto text-dose-accent, drop the GLP-1
compliance query's active-only enabled guard so both cards make the same
calls, and share the weekday-label helper.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 07:07:48 +02:00
1 parent 0255dbad7d
commit b3e8f43ab8
2 files changed
+187 -175

No files matched your search

@@ -10,8 +10,15 @@ import { MedicationCardHeader } from "@/components/medications/MedicationCardHea
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 } from "@/components/medications/card-parts/medication-compliance-bars";
import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import {
MedicationNextLastSlot,
useWeekdayLabel,
} from "@/components/medications/card-parts/medication-next-last-slot";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -143,16 +150,6 @@ interface Glp1MedicationCardProps {
onLogSideEffect?: (med: Glp1Medication) => void;
}
const DAY_KEYS = [
"medications.daysSun",
"medications.daysMon",
"medications.daysTue",
"medications.daysWed",
"medications.daysThu",
"medications.daysFri",
"medications.daysSat",
] as const;
function diffDays(target: Date, from: Date): number {
const ms =
Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) -
@@ -188,6 +185,7 @@ export function Glp1MedicationCard({
const queryClient = useQueryClient();
const { t } = useTranslations();
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
@@ -206,7 +204,6 @@ export function Glp1MedicationCard({
return json.data as ComplianceData;
},
staleTime: 30 * 1000,
enabled: medication.active,
});
// v1.4.37 W4b — same reminder-thresholds source as the generic
@@ -352,11 +349,11 @@ export function Glp1MedicationCard({
return formatDateTime(medication.lastTakenAt);
}
function nextInjectionLabel(): string {
if (!next) return "—";
function nextInjectionLabel(): string | null {
if (!next) return null;
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
const dayName = t(DAY_KEYS[next.date.getDay()]);
const dayName = weekdayLabel(next.date.getDay());
const dateShort = fmt.dateShort(next.date);
return t("medications.glp1NextInjectionDays", {
label: `${dayName}, ${dateShort}`,
@@ -406,7 +403,7 @@ export function Glp1MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<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
@@ -420,60 +417,62 @@ export function Glp1MedicationCard({
/>
)}
{/* Injection state — last + next. The reserved min-height keeps a
card whose last-injection line is absent the same vertical
footprint as one that renders both lines, matching the generic
medication card's reserved last/next slot, so the dose rows line
up across the grid. */}
<div className="min-h-[2.75rem] space-y-1 text-sm">
{medication.lastTakenAt && (
<p className="text-muted-foreground">
{lastSite
{/* Injection state — next + last, rendered through the SAME shared
<MedicationNextLastSlot> the generic card uses, so the order
(next-then-last), colour, spacing, gating and reserved
min-height are identical across the two card types. The GLP-1
phrasing carries its own "Next / Last injection" wording (and
the rotation nudge), so it opts out of the part's bold label
prefix and supplies the full line content. */}
<MedicationNextLastSlot
labelled={false}
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>
)}
{/* Rotation nudge — folded into the upcoming-injection line
instead of a separate bordered block, so the GLP-1 card
keeps the same row inventory (and height) as the generic
card. Only when we have a last + recommended site that
differs from it. */}
{lastSite &&
recommendedNextSite &&
recommendedNextSite !== lastSite && (
<span className="hidden sm:inline">
{" · "}
{t("medications.glp1RotationSuggested", {
site: t(
`medications.site${siteSuffix(recommendedNextSite)}`,
),
})}
</span>
)}
</>
) : null
}
last={
medication.lastTakenAt
? lastSite
? t("medications.glp1LastInjectionWithSite", {
label: lastInjectionLabel() ?? "—",
site: t(`medications.site${siteSuffix(lastSite)}`),
})
: t("medications.glp1LastInjection", {
label: lastInjectionLabel() ?? "—",
})}
</p>
)}
<p className="text-foreground/85">
{nextInjectionLabel()}
{/* v1.4.37 W4b — purple dose accent on the upcoming
schedule dose, byte-equivalent with the generic
medication card. Schedule.dose can override the
medication-level dose during titration, so we surface
it here when set. Hidden below sm: to keep the narrow
viewport row tight, matching the generic card. */}
{schedule?.dose && (
<span className="hidden font-medium text-purple-400 sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</p>
</div>
{/* Rotation hint — only when we have a last + recommended site
different from it. The picker on the dashboard tile owns
mode-switching; the card just nudges. */}
{lastSite &&
recommendedNextSite &&
recommendedNextSite !== lastSite && (
<div className="border-border/60 bg-muted/40 rounded-md border px-3 py-2 text-xs">
<p className="text-muted-foreground">
{t("medications.glp1RotationLast", {
site: t(`medications.site${siteSuffix(lastSite)}`),
})}
</p>
<p className="text-foreground/90 font-medium">
{t("medications.glp1RotationSuggested", {
site: t(`medications.site${siteSuffix(recommendedNextSite)}`),
})}
</p>
</div>
)}
})
: null
}
/>
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
the GLP-1 card: both the inline pens-remaining summary line
@@ -482,31 +481,38 @@ export function Glp1MedicationCard({
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. The server scales the
two windows to the dosing cadence; most weekly GLP-1 injections
land on the 30-/90-day rung where the bars stay meaningful. */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
)}
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}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* 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
row sits directly after the content (the card still fills the
grid cell via h-full, but the actions are not pushed to the
bottom — that left a void between the content and the buttons). */}
{/* 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 && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>
+94 -88
View File
@@ -9,8 +9,15 @@ import { MedicationCardHeader } from "@/components/medications/MedicationCardHea
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 } from "@/components/medications/card-parts/medication-compliance-bars";
import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import {
MedicationNextLastSlot,
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";
@@ -121,6 +128,7 @@ export function MedicationCard({
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
// event id returned by the take POST so the confirm handler can PATCH
@@ -311,7 +319,7 @@ export function MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<MedicationStatusPill
@@ -321,102 +329,100 @@ export function MedicationCard({
/>
)}
{/* Next / last intake — wrapped in a fixed min-height slot so a
card missing its last-dose line keeps the same vertical
footprint as a sibling that renders both lines, so the dose rows
line up across the 2-col grid. */}
<div className="min-h-[2.75rem] space-y-3.5">
{nextSchedule &&
currentWindowStatus.status !== "in_window" &&
(() => {
const s = nextSchedule;
{/* 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()}`;
// 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),
);
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) {
const weekdayLabels = [
t("medications.weekdaySunday"),
t("medications.weekdayMonday"),
t("medications.weekdayTuesday"),
t("medications.weekdayWednesday"),
t("medications.weekdayThursday"),
t("medications.weekdayFriday"),
t("medications.weekdaySaturday"),
];
dayLabel = weekdayLabels[nextDate.getDay()];
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
}
}
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 (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
{t("medications.nextIntake")}
</span>{" "}
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="hidden font-medium text-purple-400 sm:inline">
{" "}
— {s.dose}
</span>
)}
</p>
);
})()}
{medication.lastTakenAt && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
{formatLastTakenAt(medication.lastTakenAt)}
</p>
)}
</div>
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. */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
)}
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}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* Quick actions — primary buttons of the medication card. They sit
directly after the content (the card still fills the grid cell via
h-full, but the actions are not pushed to the bottom — that left a
void between the content and the buttons on shorter cards). */}
{/* 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 && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>