diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e0146c5..c31386416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e5c3c41c7..dcfed2711 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 419ce0580..b071e945e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/globals.css b/src/app/globals.css index cc0895e5b..a10abb382 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 6263f478c..2c1c6bc95 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -359,11 +359,15 @@ describe(" — 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(" — 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"); }); diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index d6d2627fe..0dfcee20e 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -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 + // . 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( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ); + const mounjaroHtml = render( + {}} + 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

in the slot. + const slotStart = html.indexOf("min-h-[2.75rem]"); + const slot = html.slice(slotStart, slotStart + 800); + const lines = slot.match(/

/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( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + 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 }); }); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index cd613b19d..4d6de452a 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -77,3 +77,38 @@ export function MedicationComplianceBars({ ); } + +/** + * 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 ( +

+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/medications/card-parts/medication-next-last-slot.tsx b/src/components/medications/card-parts/medication-next-last-slot.tsx new file mode 100644 index 000000000..f2f6e6ea2 --- /dev/null +++ b/src/components/medications/card-parts/medication-next-last-slot.tsx @@ -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 ( +
+ {next && ( +

+ {labelled && ( + <> + {t("medications.nextIntake")}{" "} + + )} + {next} +

+ )} + {last && ( +

+ {labelled && ( + <> + {t("medications.lastIntake")}{" "} + + )} + {last} +

+ )} +
+ ); +} + +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]); +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 876e5fe03..a687d037c 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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(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")} /> - + {/* 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. */} -
- {medication.lastTakenAt && ( -

- {lastSite + {/* Injection state — next + last, rendered through the SAME shared + 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. */} + + {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} + + )} + {/* 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 && ( + + {" · "} + {t("medications.glp1RotationSuggested", { + site: t( + `medications.site${siteSuffix(recommendedNextSite)}`, + ), + })} + + )} + + ) : null + } + last={ + medication.lastTakenAt + ? lastSite ? t("medications.glp1LastInjectionWithSite", { label: lastInjectionLabel() ?? "—", site: t(`medications.site${siteSuffix(lastSite)}`), }) : t("medications.glp1LastInjection", { label: lastInjectionLabel() ?? "—", - })} -

- )} -

- {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 && ( - - {" "} - — {schedule.dose} - - )} -

-
- - {/* 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 && ( -
-

- {t("medications.glp1RotationLast", { - site: t(`medications.site${siteSuffix(lastSite)}`), - })} -

-

- {t("medications.glp1RotationSuggested", { - site: t(`medications.site${siteSuffix(recommendedNextSite)}`), - })} -

-
- )} + }) + : 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 && ( - - )} + 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 ? ( + + ) : ( + + ))} - {/* 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 && ( - +
+ +
)}
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 407cb6442..a3f66f186 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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(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")} /> - + {/* Status, last & next intake info */} {currentWindowStatus.status && ( )} - {/* 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. */} -
- {nextSchedule && - currentWindowStatus.status !== "in_window" && - (() => { - const s = nextSchedule; + {/* 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()}`; + // 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 ( -

- - {t("medications.nextIntake")} - {" "} - {dayLabel && `${dayLabel}, `} - {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} - {s.label && ( - ({s.label}) - )} - {s.dose && ( - - {" "} - — {s.dose} - - )} -

- ); - })()} - - {medication.lastTakenAt && ( -

- {t("medications.lastIntake")}{" "} - {formatLastTakenAt(medication.lastTakenAt)} -

- )} -
+ 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. */} - {medication.active && compliance && ( - - )} + 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 ? ( + + ) : ( + + ))} - {/* 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 && ( - +
+ +
)}