Merge pull request #247 from MBombeck/release/v1.10.1

v1.10.1 — consistent medication cards
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-03 07:22:47 +02:00
commit e6ade6b03d
10 files changed
+561 -187

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [1.10.1] — 2026-06-03 — Consistent medication cards
### Fixed
- **Every medication card looks the same now, whatever the type.** Oral, injection, GLP-1, as-needed, cyclic and one-time medications previously drew their "next / last intake" line, dose accent, spacing and compliance bars slightly differently, so cards in the same row could sit at different heights with the action buttons misaligned. The cards now share one layout: the same intake line, a reserved-height compliance area, and the take/skip buttons pinned to the bottom — so a row of mixed medications lines up cleanly. The dose accent also meets contrast on the light theme.
## [1.10.0.2] — 2026-06-03 — Disable the intra-day retention drain
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.10.0.2
version: 1.10.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.10.0.2",
"version": "1.10.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+27
View File
@@ -109,6 +109,18 @@
--color-status-pill-in: var(--status-pill-in);
--color-status-pill-near: var(--status-pill-near);
--color-status-pill-out: var(--status-pill-out);
/*
* Dose-accent token. The medication cards stamp the upcoming schedule
* dose in purple. They previously hard-coded Tailwind stock
* `text-purple-400` (`oklch(0.714 0.203 305.504)`), which reads ~3.1:1 on
* the white Alucard card and fails WCAG AA (4.5:1) for body text. This
* theme-aware token keeps the exact dark-mode purple and swaps to a darker
* AA-safe purple on the light card — mirroring how the semantic feedback
* tokens (`--success` / `--warning` / `--info`) land each tone above AA in
* both themes.
*/
--color-dose-accent: var(--dose-accent);
}
/*
@@ -189,6 +201,13 @@
--status-pill-near: #ffb86c;
--status-pill-out: #ff8a8a;
/*
* Dose accent in dark mode — the exact Tailwind `text-purple-400`
* (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card
* stays pixel-identical. AA on the dark card at ~6.8:1.
*/
--dose-accent: oklch(0.714 0.203 305.504);
color-scheme: dark;
}
@@ -245,6 +264,14 @@
--status-pill-near: #a34d14;
--status-pill-out: #b32d1e;
/*
* Dose accent in light mode (Alucard). The bright `text-purple-400` reads
* ~3.1:1 on the white card and fails AA; this darker purple — the same
* family as the light-mode `--primary` (#644ac9) — clears 4.5:1 on both
* the white card and the muted panel while keeping the purple identity.
*/
--dose-accent: #6f42c1;
color-scheme: light;
}
@@ -359,11 +359,15 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
it("renders the injection-site rotation marker (last + recommended)", () => {
// The rotation hint surfaces only when `lastSite` and the
// recommender disagree. We feed a left-cluster history → the
// recommender lands on a right-side or thigh/arm site, so the
// hint card renders.
it("renders the injection-site rotation nudge folded into the upcoming line", () => {
// The rotation nudge surfaces only when `lastSite` and the recommender
// disagree. We feed a left-cluster history → the recommender lands on a
// right-side or thigh/arm site. The nudge is no longer a separate
// bordered block (which made the GLP-1 card taller than the generic card
// in a grid row); it is folded into the upcoming-injection line as a
// "· Recommended next: …" suffix, so the card keeps the same row
// inventory as the generic card. The last site stays visible on the
// last-injection line.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -386,9 +390,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
expect(html).toContain("Last site:");
// The recommended-next nudge renders in the upcoming line.
expect(html).toContain("Recommended next:");
// Last site is the most-recent intake site.
// The standalone bordered rotation block is gone (height-neutral fold).
expect(html).not.toContain("bg-muted/40 rounded-md border");
// Last site stays visible on the last-injection line.
expect(html).toContain("Abdomen, lower left");
});
@@ -328,8 +328,12 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
label: null,
daysOfWeek: null,
};
// A future next-due so the GLP-1 card's now-gated next-injection line
// renders (it no longer prints a "—" placeholder when there is no next).
const nextDueAt = new Date("2026-06-05T01:00:00Z").toISOString();
const ramiprilFuture = {
...ramipril,
nextDueAt,
schedules: [
{
id: "s-ramipril-future",
@@ -340,6 +344,7 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
};
const mounjaroFuture: Glp1Medication = {
...mounjaro,
nextDueAt,
schedules: [
{
id: "s-mounjaro-future",
@@ -373,8 +378,185 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
client,
);
// Both surfaces carry the purple token on the upcoming dose.
expect(ramiprilHtml).toContain("text-purple-400");
expect(mounjaroHtml).toContain("text-purple-400");
// Both surfaces carry the dose-accent token on the upcoming dose. The
// token is theme-aware (`--dose-accent`): pixel-identical to the former
// Tailwind `text-purple-400` in dark mode, AA-safe on the white Alucard
// card in light mode. The hard-coded `text-purple-400` is gone.
expect(ramiprilHtml).toContain("text-dose-accent");
expect(mounjaroHtml).toContain("text-dose-accent");
expect(ramiprilHtml).not.toContain("text-purple-400");
expect(mounjaroHtml).not.toContain("text-purple-400");
});
it("the next/last slot is structurally identical (same wrapper, order, colour, spacing) on both cards", () => {
// The two cards now route the middle slot through the shared
// <MedicationNextLastSlot>. The wrapper, line order (next then last),
// colour token and spacing must be byte-identical across types — the
// historical divergence (reversed order, `text-foreground/85` vs
// `text-muted-foreground`, `space-y-1` vs `space-y-3.5`) is gone.
const pastWindow = {
windowStart: "01:00",
windowEnd: "02:00",
label: null,
daysOfWeek: null,
};
// Render with a last-intake set + an upcoming (non-active) window so
// BOTH the next and last lines are present, exercising the full slot.
const yesterday = new Date("2026-06-01T09:00:00Z").toISOString();
const ramiprilBoth = {
...ramipril,
lastTakenAt: yesterday,
nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(),
schedules: [{ id: "s-r-both", ...pastWindow, dose: "5 mg" }],
};
const mounjaroBoth: Glp1Medication = {
...mounjaro,
lastTakenAt: yesterday,
nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(),
schedules: [{ id: "s-m-both", ...pastWindow, dose: "7.5 mg" }],
};
const client = makeClient();
seedCompliance(client, ramiprilBoth.id);
seedCompliance(client, mounjaroBoth.id);
seedGlp1Details(client, mounjaroBoth.id);
const ramiprilHtml = render(
<MedicationCard
medication={ramiprilBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard
medication={mounjaroBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
for (const html of [ramiprilHtml, mounjaroHtml]) {
// Identical reserved-height wrapper with identical spacing.
expect(html).toContain('class="min-h-[2.75rem] space-y-3.5 text-sm"');
// The slot's lines paint with the muted token on both cards — the
// brighter `text-foreground/85` GLP-1 next-line is gone.
expect(html).not.toContain("text-foreground/85");
// No literal em-dash placeholder for an absent next-injection.
expect(html).not.toContain(">—<");
// Both lines present (next + last), so two muted <p> in the slot.
const slotStart = html.indexOf("min-h-[2.75rem]");
const slot = html.slice(slotStart, slotStart + 800);
const lines = slot.match(/<p class="text-muted-foreground">/g) ?? [];
expect(lines.length).toBe(2);
}
});
it("both cards keep a constant-height card body and bottom-pin the action row across types", () => {
// The body is a flex column (`flex h-full flex-col`) and the action row
// carries `mt-auto`, so unequal content can't misalign the take/skip
// buttons across a grid row. A compliance skeleton reserves the bars'
// footprint while the query is null. Exercise the generic card across
// schedule-less types (PRN / one-shot) plus a scheduled oral and GLP-1.
const base = {
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
};
const oral = {
...base,
id: "m-oral",
name: "Metformin",
dose: "500 mg",
category: "DIABETES",
schedules: [{ id: "so", ...allDayWindow, dose: "500 mg" }],
};
const prn = {
...base,
id: "m-prn",
name: "Ibuprofen",
dose: "400 mg",
category: "OTHER",
// PRN: no schedule, no next-due → shortest possible body.
schedules: [],
};
const oneShot = {
...base,
id: "m-oneshot",
name: "Vaccine",
dose: "1 dose",
category: "OTHER",
schedules: [],
};
const glp1: Glp1Medication = {
...mounjaro,
id: "m-glp1",
};
const client = makeClient();
// Seed compliance for some but NOT others, so the skeleton path
// (compliance null) is exercised alongside the loaded path.
seedCompliance(client, oral.id);
seedGlp1Details(client, glp1.id);
// prn + oneShot intentionally left without compliance → skeleton.
const htmls = [
render(
<MedicationCard
medication={oral}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<MedicationCard
medication={prn}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<MedicationCard
medication={oneShot}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<Glp1MedicationCard
medication={glp1}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
];
for (const html of htmls) {
// Card body is a bottom-pinning flex column on every type.
expect(html).toContain("flex h-full flex-col space-y-3.5");
// Action row carries the bottom-pin.
expect(html).toContain("mt-auto");
// Reserved next/last slot is present on every type (constant height).
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
}
// The two cards left without compliance render the skeleton so their
// body keeps the bars' footprint rather than collapsing ~5rem shorter.
expect(htmls[1]).toContain("aria-hidden"); // prn skeleton
expect(htmls[2]).toContain("aria-hidden"); // one-shot skeleton
});
});
@@ -77,3 +77,38 @@ export function MedicationComplianceBars({
</div>
);
}
/**
* Constant-height placeholder for the compliance block, shown while the
* per-card `/compliance` query is in flight or returns null. Without it a
* card whose compliance has not yet resolved is ~5rem shorter than a sibling
* whose compliance loaded, so the two cards in a 2-col grid row jump to
* unequal heights (transiently on first paint, persistently for any med
* whose endpoint returns null). The skeleton mirrors the two-bar layout's
* footprint so the card body keeps a constant inventory and the action row
* pins to the same baseline across the row.
*
* The streak-flame row height is intentionally NOT reserved — the flame is
* gated on `streak > 0` on the loaded card too, so reserving it would make
* every streak-less card taller than its own loaded self.
*/
export function MedicationComplianceSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
<div className="bg-muted h-2 rounded" />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
<div className="bg-muted h-2 rounded" />
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { useTranslations } from "@/lib/i18n/context";
/**
* Shared "next / last intake" middle slot for the medication cards.
*
* Extracted from the generic and GLP-1 cards so the slot is structurally
* identical rather than hand-synced. Before extraction the two cards
* diverged on every axis the eye reads in a 2-col grid row:
* - order: generic showed next-then-last; GLP-1 showed last-then-next;
* - colour: generic next-line was `text-muted-foreground`; GLP-1 was the
* brighter `text-foreground/85`;
* - spacing: generic `space-y-3.5`; GLP-1 `space-y-1`;
* - gating: generic only rendered the next-line when a schedule was due
* and the window was not currently open; GLP-1 always rendered it and
* printed a literal "—" placeholder.
*
* This part fixes the order (next then last), the colour
* (`text-muted-foreground` on both lines), the spacing (`space-y-3.5`), and
* the gating (the caller only passes `next` when it should render, and the
* placeholder branch is gone). The reserved `min-h-[2.75rem]` keeps a card
* whose last-dose line is absent the same vertical footprint as a sibling
* that renders both lines, so the dose rows line up across the grid.
*
* Each card still builds the line *content* itself (the generic card emits a
* day-label + window-range; the GLP-1 card emits a weekly-cadence prose
* string), because the two cadences read differently — but the wrapper,
* order, colour, and gating are now guaranteed identical.
*/
interface MedicationNextLastSlotProps {
/**
* The upcoming-intake line content, or null when there is nothing to show
* (no schedule, the window is currently open, a one-shot already taken,
* etc.). The caller owns the gating; this part never prints a placeholder.
*/
next: React.ReactNode | null;
/** The last-intake line content, or null when the med has never been taken. */
last: React.ReactNode | null;
/**
* When true (the generic card) the part prepends the bold
* "Next intake" / "Last intake" labels. The GLP-1 card passes its own
* site-aware prose (whose i18n strings already read "Next injection …" /
* "Last injection …"), so it opts out and supplies the full line content.
* Either way the wrapper, order, colour, spacing, gating and reserved
* min-height are identical.
*/
labelled?: boolean;
}
export function MedicationNextLastSlot({
next,
last,
labelled = true,
}: MedicationNextLastSlotProps) {
const { t } = useTranslations();
return (
<div className="min-h-[2.75rem] space-y-3.5 text-sm">
{next && (
<p className="text-muted-foreground">
{labelled && (
<>
<span className="font-medium">{t("medications.nextIntake")}</span>{" "}
</>
)}
{next}
</p>
)}
{last && (
<p className="text-muted-foreground">
{labelled && (
<>
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
</>
)}
{last}
</p>
)}
</div>
);
}
const WEEKDAY_KEYS = [
"medications.daysSun",
"medications.daysMon",
"medications.daysTue",
"medications.daysWed",
"medications.daysThu",
"medications.daysFri",
"medications.daysSat",
] as const;
/**
* Single weekday-label helper shared by both medication cards. Before this
* the generic card used the `medications.weekday{Monday…}` (full-name) key
* family while the GLP-1 card used `medications.days{Mon…}` (abbreviated),
* so the same weekday rendered "Monday" on one card and "Mon" on the other
* in the same grid. Both now resolve to the abbreviated form, which keeps
* the slot tight and consistent across types.
*
* @returns a function mapping a JS `Date.getDay()` value (0 = Sunday …
* 6 = Saturday) to its localised abbreviated weekday name.
*/
export function useWeekdayLabel(): (dayIndex: number) => string {
const { t } = useTranslations();
return (dayIndex: number) => t(WEEKDAY_KEYS[dayIndex]);
}
@@ -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>