fix(responsive): align medication-row and side-effects at narrow viewports

Two narrow-viewport regressions surface on 320 px phones. The medication-list row's category badge and state badges share line 2 via flex flex-wrap; any state badge pushes the row to three lines and breaks the FB-G1 two-line contract for ~20 % of configured drugs. State badges now ride their own row below the category badge so the canonical row stays two lines. The side-effects timeline pinned its date column at 88 px; the longest short-date variant fits inside 56 px with slack, so the column narrows and the category + entry + severity left slot recovers 32 px of wrap headroom.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 02:36:49 +02:00
1 parent f91b4732ea
commit 786fbde6a5
4 files changed
+151 -3

No files matched your search

@@ -48,12 +48,27 @@ export function MedicationCardHeader({
<CardTitle className="text-lg">
{name} {dose}
</CardTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
{/* D-H6 — state badges (without-notification, paused-since,
inactive) used to share line 2 with the category badge
via `flex flex-wrap`. At 320 px any state badge pushed
the row to three lines and broke the FB-G1 "two-line,
no exceptions" contract for ~20 % of configured drugs.
State badges now ride their own row below the category
badge so the canonical row stays two lines on narrow
viewports. */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
{stateBadges}
</div>
{stateBadges ? (
<div
data-slot="medication-card-header-state-badges"
className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
{stateBadges}
</div>
) : null}
</div>
{actions ? (
<div className="flex items-center gap-0.5">{actions}</div>
@@ -303,7 +303,14 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</Badge>
</div>
<p
className="text-muted-foreground w-[5.5rem] shrink-0 text-right text-xs tabular-nums"
// D-H7 — narrowed from `w-[5.5rem]` (88 px) to
// `w-14` (56 px). The longest short-date variant
// ("15. Mai") fits inside 56 px with slack;
// recovering 32 px relieves the left slot's
// wrap rhythm at 320 px so the category badge +
// entry label + severity chip stop pushing the
// free-text notes off-screen.
className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums"
data-slot="side-effect-row-date"
>
{fmt.dateShort(new Date(row.occurredAt))}
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
/**
* v1.4.28 — narrow-viewport contract for the shared medication-list
* row primitive (D-H6).
*
* FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the
* category badge on line 2. State badges (without-notification, paused,
* inactive) used to share line 2 via `flex flex-wrap`; on a 320 px
* viewport that pushed the row to three lines for ~20 % of configured
* drugs. State badges now ride their own row below the category badge
* so the canonical row stays two lines on narrow viewports.
*
* The tests assert the structural seams, not the visual measurement
* (no DOM measurement layer in SSR). The data-slot on the state-badges
* row plus the absence of state badges inside the category row are the
* load-bearing contracts.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(<Card>{node}</Card>);
}
describe("<MedicationCardHeader>", () => {
it("paints `{name} {dose}` on the title row", () => {
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
/>,
);
expect(html).toContain("Ramipril 5 mg");
expect(html).toContain("Blood Pressure");
});
it("does not paint the state-badges row when no badges are supplied", () => {
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
/>,
);
// Pin the absence so a future refactor can't paint an empty
// wrapper that eats vertical space.
expect(html).not.toContain(
'data-slot="medication-card-header-state-badges"',
);
});
it("breaks state badges onto their own row below the category badge (D-H6)", () => {
// The narrow-viewport contract: state badges live on their own
// row so the canonical row stays two lines at 320 px even when
// a drug carries one of the state badges. Pinning the seam.
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
stateBadges={
<>
<Badge variant="secondary" className="text-xs">
Without notification
</Badge>
<Badge variant="secondary" className="text-xs">
Paused since 12.05
</Badge>
</>
}
/>,
);
expect(html).toContain(
'data-slot="medication-card-header-state-badges"',
);
// Both badges land inside the state row, not adjacent to the
// category badge. Verifying via document order: the state-badges
// wrapper must paint AFTER the category badge.
const categoryIdx = html.indexOf("Blood Pressure");
const stateRowIdx = html.indexOf(
'data-slot="medication-card-header-state-badges"',
);
expect(categoryIdx).toBeGreaterThan(-1);
expect(stateRowIdx).toBeGreaterThan(categoryIdx);
});
});
@@ -194,6 +194,41 @@ describe("<SideEffectsSection> — timeline rows", () => {
expect(html).toContain("Very severe");
});
it("narrows the date column to w-14 at narrow viewports (D-H7)", () => {
// The earlier `w-[5.5rem]` (88 px) date column overspec pushed
// the category badge + entry label + severity chip's left slot
// into a wrap-prone shape at 320 px. The narrowed `w-14` (56 px)
// fits the longest short-date variant ("15. Mai") with slack
// and recovers 32 px for the free-text notes.
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "se-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
]);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
// Class attribute order in SSR depends on JSX prop order; match
// either order so the assertion stays stable across React minor
// versions.
const dateCell = html.match(
/<p[^>]*data-slot="side-effect-row-date"[^>]*>|<p[^>]*class="[^"]*w-14[^"]*"[^>]*data-slot="side-effect-row-date"/,
);
expect(dateCell).not.toBeNull();
// Source-of-truth check via the rendered HTML — pin both the
// adoption (`w-14`) and the absence (`w-[5.5rem]`).
expect(html).toContain('data-slot="side-effect-row-date"');
const tagWithSlot = html.match(
/<p[^>]*data-slot="side-effect-row-date"[^>]*>/,
);
expect(tagWithSlot?.[0]).toContain("w-14");
expect(tagWithSlot?.[0]).not.toContain("w-[5.5rem]");
});
it("renders German entry and category labels when locale=de", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [