fix(medications): restore equal-height cards and aligned dose rows

The v1.8.6 compliance rework left the last/next-dose and compliance rows in
a plain vertical stack with independently conditional rows. A card missing
its last-dose line (never taken) or its streak flame (zero streak) rendered
shorter, so the dose rows and action buttons drifted out of alignment across
the two-column grid.

Pin the layout on both card variants identically: the Card fills the
stretched grid cell with h-full, the CardContent becomes a flex column, the
next/last-dose pair sits in a reserved min-height slot so a missing line
keeps its footprint, and the intake-actions block is pushed to the bottom
with mt-auto for a shared action baseline. Generic and GLP-1 cards now align
row-for-row regardless of which optional rows render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a7e541eeb5123abb63514f56d12b93b0023ce417)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:23:16 +02:00
1 parent 5f8e191e2b
commit 4a0ad1fc64
2 files changed
+99 -76

No files matched your search

@@ -5,6 +5,7 @@ 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";
@@ -394,7 +395,7 @@ export function Glp1MedicationCard({
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
@@ -405,7 +406,7 @@ export function Glp1MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<CardContent className="flex flex-1 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
@@ -419,8 +420,12 @@ export function Glp1MedicationCard({
/>
)}
{/* Injection state — last + next */}
<div className="space-y-1 text-sm">
{/* 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, so the compliance bars
and action row below align row-for-row across the grid — matching
the generic medication card's reserved last/next slot. */}
<div className="min-h-[2.75rem] space-y-1 text-sm">
{medication.lastTakenAt && (
<p className="text-muted-foreground">
{lastSite
@@ -493,12 +498,17 @@ export function Glp1MedicationCard({
{/* 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. */}
and Ramipril share the canonical two-button primary row.
Pinned to the bottom with `mt-auto` so the action baseline is
shared across every card in the grid row, identical to the
generic medication card. */}
{medication.active && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-3.5">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>
+80 -67
View File
@@ -4,6 +4,7 @@ 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 { 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";
@@ -299,7 +300,7 @@ export function MedicationCard({
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
@@ -310,7 +311,7 @@ export function MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<CardContent className="flex flex-1 flex-col space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<MedicationStatusPill
@@ -320,72 +321,79 @@ export function MedicationCard({
/>
)}
{nextSchedule &&
currentWindowStatus.status !== "in_window" &&
(() => {
const s = nextSchedule;
{/* 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. Without the
reserved height the compliance bars and action row below would
start at a different Y card-to-card across the 2-col grid. */}
<div className="min-h-[2.75rem] space-y-3.5">
{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) {
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);
}
}
}
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>
);
})()}
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>
)}
{medication.lastTakenAt && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
{formatLastTakenAt(medication.lastTakenAt)}
</p>
)}
</div>
{/* Compliance bars — always two rows. The server scales the two
windows to the dosing cadence (7 / 30 days for dense meds,
@@ -401,12 +409,17 @@ export function MedicationCard({
/>
)}
{/* Quick actions — primary buttons of the medication card. */}
{/* Quick actions — primary buttons of the medication card.
Pinned to the bottom with `mt-auto` so the action baseline is
shared across every card in the grid row regardless of how many
optional rows above it rendered. */}
{medication.active && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-3.5">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>