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 && ( - +
+ +
)}