fix(medications): split the card next/last dose lines into label-left, value-right rows

The next-intake and last-intake lines on the medication cards ran the
label and the time together on one run of text, reading as crammed. Lay
each line out as a two-column row — bold label pinned left, value flush
right — so the two rows read as distinct, aligned columns. The label
truncates first on a narrow viewport; the time never truncates.

The GLP-1 card routes through the same shared slot and now overrides only
the left-column label with its appointment-phrased wording, so the value
column carries the time and the optional rotation nudge. Its value strings
drop the now-redundant leading label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 15:34:44 +02:00
1 parent c59290ddc6
commit 83cf4eff2e
10 files changed
+114 -75

No files matched your search

@@ -320,13 +320,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The with-site copy reads "Last: <label> · <site>"; both halves
// must show up in the SSR string.
// The last-line renders the "Last:" label in its own left column and
// the value (label · site) flush right; both halves show in the SSR.
expect(html).toContain("Last:");
expect(html).toContain("Abdomen, lower left");
// The next-injection helper produces one of three strings — today,
// tomorrow, or "in N days". All three contain the localised "Next:"
// prefix from `glp1NextInjection*` keys.
// The next-line carries the "Next:" label in its left column,
// independent of which of the three value variants (today / tomorrow /
// "in N days") the helper produced.
expect(html).toMatch(/Next:/);
});
@@ -448,11 +448,19 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
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.
// Both lines present (next + last). Each line is a two-column
// label-left / value-right row (`flex … justify-between`) so the
// label and the time read as distinct columns rather than crammed
// onto one run of text. Two such rows 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);
const slot = html.slice(slotStart, slotStart + 1200);
const rows =
slot.match(
/<div class="text-muted-foreground flex items-baseline justify-between gap-3">/g,
) ?? [];
expect(rows.length).toBe(2);
// The value is pinned flush-right in its own column.
expect(slot).toContain('<span class="text-right">');
}
});
@@ -21,10 +21,17 @@ import { useTranslations } from "@/lib/i18n/context";
* 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.
* Each card still builds the value *content* itself (the generic card emits a
* day-label + window-range; the GLP-1 card emits a weekly-cadence string),
* because the two cadences read differently — but the wrapper, order, colour,
* label/value layout, and gating are now guaranteed identical. The GLP-1 card
* overrides the left-column label with its appointment-phrased wording.
*
* Layout: when `labelled`, each line is a `flex justify-between` row with the
* bold label pinned left and the value flush right (`text-right`), so the
* label and the time read as two distinct columns rather than crammed onto
* one run of text. The label truncates first on a narrow viewport; the value
* (the time / cadence) never truncates.
*/
interface MedicationNextLastSlotProps {
/**
@@ -36,45 +43,56 @@ interface MedicationNextLastSlotProps {
/** 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.
* When true the part renders each line as a two-column row: a bold label
* pinned left and the value flush right. The generic card uses the default
* oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them
* with its appointment-phrased labels (see `nextLabel` / `lastLabel`).
* When false the line renders as a single full-width run of text.
*/
labelled?: boolean;
/**
* Override for the left-column label on the next-line. Defaults to the
* oral `medications.nextIntake` string. The GLP-1 card passes its own
* "next appointment" wording so the value column carries only the time.
*/
nextLabel?: React.ReactNode;
/** Override for the left-column label on the last-line. */
lastLabel?: React.ReactNode;
}
export function MedicationNextLastSlot({
next,
last,
labelled = true,
nextLabel,
lastLabel,
}: 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>
)}
{next &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{nextLabel ?? t("medications.nextIntake")}
</span>
<span className="text-right">{next}</span>
</div>
) : (
<p className="text-muted-foreground">{next}</p>
))}
{last &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{lastLabel ?? t("medications.lastIntake")}
</span>
<span className="text-right">{last}</span>
</div>
) : (
<p className="text-muted-foreground">{last}</p>
))}
</div>
);
}
@@ -410,13 +410,14 @@ export function Glp1MedicationCard({
{/* 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. */}
(next-then-last), colour, spacing, gating, reserved min-height
and the label-left / value-right two-column layout are identical
across the two card types. The GLP-1 card overrides only the
left-column label with its appointment-phrased wording; the value
column carries the time (plus the optional rotation nudge). */}
<MedicationNextLastSlot
labelled={false}
nextLabel={t("medications.glp1NextLabel")}
lastLabel={t("medications.glp1LastLabel")}
next={
next && currentWindowStatus.status !== "in_window" ? (
<>