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]); +}