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

+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
"glp1LastInjection": "Letzter Termin: {label}",
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
"glp1NextInjectionToday": "Nächster Termin: heute",
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
"glp1NextLabel": "Nächster Termin:",
"glp1LastLabel": "Letzter Termin:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (in {days} Tagen)",
"glp1NextInjectionToday": "heute",
"glp1NextInjectionTomorrow": "morgen",
"glp1RotationLast": "Letzte Stelle: {site}",
"glp1RotationSuggested": "Empfohlen: {site}",
"glp1LogSideEffect": "Nebenwirkung loggen",
+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
"glp1NextInjectionToday": "Next: today",
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1NextLabel": "Next:",
"glp1LastLabel": "Last:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (in {days} days)",
"glp1NextInjectionToday": "today",
"glp1NextInjectionTomorrow": "tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1LogSideEffect": "Log side effect",
+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Dosis por pluma / vial",
"glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.",
"glp1LastInjection": "Última cita: {label}",
"glp1LastInjectionWithSite": "Última cita: {label} · {site}",
"glp1NextInjectionDays": "Siguiente: {label} (en {days} días)",
"glp1NextInjectionToday": "Próxima cita: hoy",
"glp1NextInjectionTomorrow": "Próxima cita: mañana",
"glp1NextLabel": "Próxima cita:",
"glp1LastLabel": "Última cita:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (en {days} días)",
"glp1NextInjectionToday": "hoy",
"glp1NextInjectionTomorrow": "mañana",
"glp1RotationLast": "Último sitio: {site}",
"glp1RotationSuggested": "Sugerido: {site}",
"glp1LogSideEffect": "Registrar efecto secundario",
+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Doses par stylo / flacon",
"glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.",
"glp1LastInjection": "Dernier rendez-vous : {label}",
"glp1LastInjectionWithSite": "Dernier rendez-vous : {label} · {site}",
"glp1NextInjectionDays": "Suivant : {label} (dans {days} jours)",
"glp1NextInjectionToday": "Prochain rendez-vous : aujourd’hui",
"glp1NextInjectionTomorrow": "Prochain rendez-vous : demain",
"glp1NextLabel": "Prochain rendez-vous :",
"glp1LastLabel": "Dernier rendez-vous :",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (dans {days} jours)",
"glp1NextInjectionToday": "aujourd’hui",
"glp1NextInjectionTomorrow": "demain",
"glp1RotationLast": "Dernier site : {site}",
"glp1RotationSuggested": "Conseillé : {site}",
"glp1LogSideEffect": "Consigner un effet secondaire",
+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Dosi per penna / fiala",
"glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.",
"glp1LastInjection": "Ultimo appuntamento: {label}",
"glp1LastInjectionWithSite": "Ultimo appuntamento: {label} · {site}",
"glp1NextInjectionDays": "Prossimo: {label} (tra {days} giorni)",
"glp1NextInjectionToday": "Prossimo appuntamento: oggi",
"glp1NextInjectionTomorrow": "Prossimo appuntamento: domani",
"glp1NextLabel": "Prossimo appuntamento:",
"glp1LastLabel": "Ultimo appuntamento:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (tra {days} giorni)",
"glp1NextInjectionToday": "oggi",
"glp1NextInjectionTomorrow": "domani",
"glp1RotationLast": "Ultima sede: {site}",
"glp1RotationSuggested": "Suggerita: {site}",
"glp1LogSideEffect": "Registra effetto collaterale",
+7 -5
View File
@@ -702,11 +702,13 @@
"glp1DosesPerUnit": "Dawki na pen / fiolkę",
"glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.",
"glp1LastInjection": "Ostatni termin: {label}",
"glp1LastInjectionWithSite": "Ostatni termin: {label} · {site}",
"glp1NextInjectionDays": "Następnie: {label} (za {days} dni)",
"glp1NextInjectionToday": "Następny termin: dziś",
"glp1NextInjectionTomorrow": "Następny termin: jutro",
"glp1NextLabel": "Następny termin:",
"glp1LastLabel": "Ostatni termin:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (za {days} dni)",
"glp1NextInjectionToday": "dziś",
"glp1NextInjectionTomorrow": "jutro",
"glp1RotationLast": "Ostatnie miejsce: {site}",
"glp1RotationSuggested": "Sugerowane: {site}",
"glp1LogSideEffect": "Zapisz działanie niepożądane",
@@ -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" ? (
<>