diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts new file mode 100644 index 000000000..1db17d453 --- /dev/null +++ b/e2e/medication-compliance-journey.spec.ts @@ -0,0 +1,598 @@ +/** + * Medication adherence, proven end to end. + * + * The percentage on a medication card is the number this product is for, and + * until now nothing in the browser suite walked the path that produces it: the + * wizard specs stub `/api/medications` and stop at the POST body, and the + * compliance engine's own tests are unit tests over pure functions. So the one + * thing nobody checked was the join — that a tap on "Genommen" reaches the + * engine, and that the engine's answer reaches every surface that shows it. + * + * Three flows: + * + * 1. Journey — a twice-daily medication created through the real wizard, one + * dose taken and one skipped from the card, and the resulting figure read + * back on the card, in the intake history and on the dashboard tile. + * 2. Refusal — a dose recorded against a schedule that does not exist is + * refused, and the rate is exactly what it was before. + * 3. Cadence — a medication due on two weekdays does not count the other + * five as missed. Its daily twin, given the identical doses, does. + * + * Every assertion addresses a `data-slot` / `data-*` attribute rather than + * viewport text: the copy is i18n-driven, the wizard runs in German and the + * card's own labels collapse to icons at narrow widths. + * + * The flows mutate the one seeded account and read counts back off it, so — + * like `visits.spec.ts` and `vaccinations.spec.ts` — this file runs in a + * single project (see `playwright.config.ts`) and serial, and clears the + * cabinet before each test so a rate counts only the doses the test wrote. + * + * Why the medications are aged (`ageMedication`): compliance is reconstructed + * from the medication's creation stamp forward, so a medication created a + * second ago has expected no dose yet and reads the empty-set 100 %. A number + * that cannot move is a number this spec cannot check, so each flow puts a few + * days of unlogged history behind its medication first — which is also the + * only state in which "missed" means anything. + */ +import type { Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { ageMedication, resetMedications } from "./setup/medication-fixture"; +import { expect, test } from "./setup/test"; +import { + clickNext, + clickSave, + expectStep, + fillStep1Name, + fillStep3Dose, + openCreateWizard, + pickCadenceRow, + pickTreatmentRow, +} from "./medications-wizard-helpers"; + +/** One `{ data, error }` envelope as the browser sees it. */ +interface ApiResult { + status: number; + data: T | null; +} + +/** The slice of `GET /api/medications/{id}/compliance` these flows read. */ +interface CompliancePayload { + compliance7: { + totalExpected: number; + taken: number; + skipped: number; + missed: number; + rate: number; + }; + complianceDisplay: { + shortDays: number; + short: { rate: number; taken: number; expected: number; missed: number }; + }; +} + +/** + * Call the API the way the app does: from the page's own `fetch`, on the + * page's origin, with the session cookie the browser already holds. A + * request issued from the test process would carry a different jar and would + * not exercise the same-origin path every client write takes. + */ +async function api( + page: Page, + method: "GET" | "POST" | "PUT", + path: string, + body?: unknown, +): Promise> { + return page.evaluate( + async ({ method, path, body }) => { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + // Read the body as text first: a refusal is allowed to answer with no + // body at all, and a bare `res.json()` would then throw a parse error + // over the status code the assertion is actually about. + const raw = await res.text(); + const envelope = raw ? (JSON.parse(raw) as { data: unknown }) : null; + return { status: res.status, data: (envelope?.data ?? null) as never }; + }, + { method, path, body }, + ); +} + +/** One schedule entry as `POST /api/medications` accepts it. */ +interface SeedSchedule { + windowStart: string; + windowEnd: string; + timesOfDay: string[]; + rrule: string; +} + +/** Create a medication through the page's own fetch. Returns its id. */ +async function seedMedication( + page: Page, + input: { + name: string; + dose: string; + startsOn: string; + schedule: SeedSchedule; + }, +): Promise { + const created = await api<{ id: string }>(page, "POST", "/api/medications", { + name: input.name, + dose: input.dose, + startsOn: input.startsOn, + schedules: [input.schedule], + }); + expect(created.status, `create ${input.name}`).toBe(201); + const id = created.data?.id; + expect(id, `created ${input.name} carries an id`).toBeTruthy(); + return id as string; +} + +/** + * The write `ageMedication` uses to flush the server's memoised compliance + * cells after it has edited the row underneath them: an ordinary update that + * re-sends the dose the medication already has. + */ +function cacheFlusher(page: Page, medicationId: string, dose: string) { + return async () => { + const saved = await api<{ id: string }>( + page, + "PUT", + `/api/medications/${medicationId}`, + { dose }, + ); + expect(saved.status, "cache-flushing update").toBe(200); + }; +} + +/** The compliance block on a medication card, as the card renders it. */ +function complianceBlock(page: Page, medicationId: string) { + return page + .locator(`[data-medication-id="${medicationId}"]`) + .locator('[data-slot="medication-card-compliance"]'); +} + +/** + * Read the card's short-window adherence percentage. + * + * The rate's presence is the gate: the skeleton and the quiet error fallback + * reserve the same footprint as the loaded bars and carry no rates, so an + * attribute that is there is a figure the card really painted. + * + * The reload is for one specific state. The batched compliance route allows + * thirty reads a minute per account, one list visit spends several, and a + * refused read paints the error fallback rather than the bars — so a repeated + * local run, or CI retrying the whole serial group inside the same minute, can + * meet a card with no figure on it for reasons that have nothing to do with + * adherence. Re-reading rides that out without hiding anything: if the figure + * never arrives, this still fails. + */ +async function readCardRate(page: Page, medicationId: string): Promise { + const block = complianceBlock(page, medicationId); + const painted = async (): Promise => + (await block.count()) === 0 + ? null + : block.first().getAttribute("data-rate-short"); + + await expect + .poll( + async () => { + const first = await painted(); + if (first !== null) return first; + await page.reload(); + return painted(); + }, + { + timeout: 70_000, + intervals: [500, 1_000, 2_000, 5_000, 10_000], + message: "the card never painted an adherence figure", + }, + ) + .toMatch(/^\d+$/); + + return Number(await painted()); +} + +/** The dashboard tile's most recent day, as the tile itself computed it. */ +async function readTileLatestRate(page: Page): Promise { + const tile = page.locator('[data-slot="medication-compliance-chart"]'); + await expect(tile).toBeVisible({ timeout: 15_000 }); + await expect(tile).toHaveAttribute("data-latest-rate", /^\d+$/, { + timeout: 15_000, + }); + return Number(await tile.getAttribute("data-latest-rate")); +} + +/** + * Open a surface the seeding calls can run from. + * + * Deliberately NOT the medications list: every visit to it spends one call of + * the batched compliance route's per-actor minute allowance, and a spec that + * spends that allowance on its own setup has less of it left for the reads it + * actually asserts on — and none left at all for a CI retry, which re-runs the + * whole serial group inside the same minute. + */ +async function openSeedingSurface(page: Page): Promise { + await page.goto("/profile"); + // The shell's own main region, not `main` — the profile page nests a second + // one inside it and a bare tag selector is ambiguous there. + await expect(page.locator("#main-content")).toBeVisible({ timeout: 15_000 }); +} + +/** + * The two dose times the journey's medication runs on: now, and twelve hours + * from now. + * + * They are derived from the clock rather than picked from the wizard's + * suggestion chips, and that is the whole reason the flow is stable. The card + * offers exactly one dose — the open window, else the next one due — so with + * fixed times there is always an hour of the day at which the dose it offers + * belongs to yesterday or to tomorrow, and the day the write lands on decides + * what the dashboard tile reads. A slot anchored at "now" is inside its own + * on-time window by construction, so the take is always today's, whenever the + * suite runs. Its twin twelve hours away is the second dose of the day. + */ +async function clockAnchoredDoseTimes( + page: Page, +): Promise<{ open: string; other: string }> { + return page.evaluate(() => { + const pad = (n: number) => String(n).padStart(2, "0"); + const now = new Date(); + return { + open: `${pad(now.getHours())}:${pad(now.getMinutes())}`, + other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`, + }; + }); +} + +/** Add one `HH:mm` to the wizard's times-of-day chips. */ +async function addDoseTime(page: Page, time: string): Promise { + const chips = page.locator('[data-slot="times-of-day-chips"]'); + const draft = chips.getByTestId("times-of-day-draft-input"); + await draft.fill(time); + await draft.press("Enter"); + await chips.locator('[data-slot="times-of-day-add"]').click(); + await expect( + chips.locator(`[data-slot="times-of-day-chip"][data-time="${time}"]`), + ).toBeVisible(); +} + +/** + * Walk the create wizard for a twice-daily medication and return its id. + * Reuses the cadence specs' step helpers; unlike those specs it leaves + * `/api/medications` unstubbed, so the medication is really written. + */ +async function createTwiceDailyMedication( + page: Page, + name: string, +): Promise<{ id: string; times: { open: string; other: string } }> { + await openCreateWizard(page); + const times = await clockAnchoredDoseTimes(page); + + await fillStep1Name(page, { name }); + await clickNext(page); + + await expectStep(page, 2); + await pickTreatmentRow(page, "other"); + await clickNext(page); + + await expectStep(page, 3); + await fillStep3Dose(page, { amount: "500" }); + await clickNext(page); + + await expectStep(page, 4); + await clickNext(page); + + await expectStep(page, 5, 8); + await pickCadenceRow(page, "daily"); + // Picking a daily cadence compresses the path from eight steps to seven. + await expectStep(page, 5, 7); + await clickNext(page); + + // Times of day — the step arrives with 08:00 already on it. Add the two the + // journey needs, then drop 08:00 unless it is one of them, so the plan is + // exactly twice daily. + await expectStep(page, 6, 7); + const chips = page.locator('[data-slot="times-of-day-chips"]'); + await addDoseTime(page, times.open); + await addDoseTime(page, times.other); + if (times.open !== "08:00" && times.other !== "08:00") { + await chips + .locator('[data-slot="times-of-day-chip"][data-time="08:00"]') + .locator('[data-slot="times-of-day-chip-remove"]') + .click(); + } + await expect(chips.locator('[data-slot="times-of-day-chip"]')).toHaveCount(2); + await clickNext(page); + + await expectStep(page, 7, 7); + await clickSave(page); + + await page.waitForURL(/\/medications\/[^/?]+$/, { timeout: 15_000 }); + const id = new URL(page.url()).pathname.split("/").pop(); + expect(id, "the create flow lands on the new medication").toBeTruthy(); + return { id: id as string, times }; +} + +test.beforeEach(async () => { + // Own every row the rates count. The dashboard tile sums the expected doses + // of every active medication on the account, so a leftover from a previous + // run would sit in its denominator. + await resetMedications(); +}); + +// Serial and single-account: the reset above would race a sibling worker, and +// two tests clearing the same cabinet would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +test.describe("medication adherence journey", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({ + page, + }) => { + // The wizard is a seven-step dialog with per-step validation gating Next; + // on a loaded runner that settle earns the tripled timeout. + test.slow(); + + const { id: medicationId } = await createTwiceDailyMedication( + page, + "E2E Adhärenz", + ); + // Four days of plan behind it, none of it logged: the engine expects two + // doses a day and nobody took any, so the starting rate is a real 0 %. + await ageMedication( + medicationId, + 4, + cacheFlusher(page, medicationId, "500 mg"), + ); + + // --- before ------------------------------------------------------------ + await page.goto("/"); + expect(await readTileLatestRate(page)).toBe(0); + + await page.goto("/medications"); + expect(await readCardRate(page, medicationId)).toBe(0); + + // --- the two gestures -------------------------------------------------- + const card = page.locator(`[data-medication-id="${medicationId}"]`); + const intakePath = `/api/medications/${medicationId}/intake`; + + const takeWritten = page.waitForResponse( + (res) => + new URL(res.url()).pathname === intakePath && + res.request().method() === "POST", + ); + await card.locator('[data-slot="medication-intake-take"]').click(); + expect((await takeWritten).status(), "the take is written").toBe(201); + + // The skip must land on the OTHER dose, so it has to wait for the card to + // re-read its schedule: the card binds each write to the slot it is + // currently showing, and a skip fired against a stale render would flip + // the dose just taken instead of recording a second one. The rate leaving + // zero is the card's own confirmation that the refetch landed. + await expect + .poll(() => readCardRate(page, medicationId), { timeout: 30_000 }) + .toBeGreaterThan(0); + const skipWritten = page.waitForResponse( + (res) => + new URL(res.url()).pathname === intakePath && + res.request().method() === "POST", + ); + await card.locator('[data-slot="medication-intake-skip"]').click(); + expect((await skipWritten).status(), "the skip is written").toBe(201); + + // --- the card agrees with the engine ----------------------------------- + const payload = await api( + page, + "GET", + `/api/medications/${medicationId}/compliance`, + ); + expect(payload.status).toBe(200); + const compliance = payload.data as CompliancePayload; + + // One dose taken, one deliberately skipped, and the four days of plan + // nobody logged still counting against the rate. + expect(compliance.compliance7.taken).toBe(1); + expect(compliance.compliance7.missed).toBeGreaterThanOrEqual(6); + expect(compliance.complianceDisplay.shortDays).toBe(7); + expect(compliance.complianceDisplay.short.taken).toBe(1); + + // A skip is a decision, not a failure: it never enters the denominator. + // So the rate has moved off zero and is nowhere near a hundred — one dose + // taken against at least six missed. + const rateAfter = await readCardRate(page, medicationId); + expect(rateAfter).toBe(compliance.complianceDisplay.short.rate); + expect(rateAfter).toBeGreaterThan(0); + expect(rateAfter).toBeLessThanOrEqual(20); + + // --- the intake history shows both events, each as what it was ---------- + // The 90-day ledger beside it is a view of the SCHEDULE and stops at now, + // so a dose skipped ahead of its window is not in it. The intake history + // is the view of what was recorded, and both gestures belong there. + await page.goto(`/medications/${medicationId}?tab=verlauf`); + await page + .locator('[data-slot="medication-verlauf-view-toggle-all"]') + .click(); + const history = page.locator('[data-slot="intake-history-row"]'); + await expect(history).toHaveCount(2, { timeout: 15_000 }); + await expect( + page.locator('[data-slot="intake-history-row"][data-status="taken"]'), + ).toHaveCount(1); + await expect( + page.locator('[data-slot="intake-history-row"][data-status="skipped"]'), + ).toHaveCount(1); + + // --- and so does the dashboard tile ------------------------------------ + // Today expected two doses and one was taken. The tile and the card read + // the same event through different engines; this is the assertion that + // they cannot drift apart silently. + await page.goto("/"); + expect(await readTileLatestRate(page)).toBe(50); + }); + + test("a dose recorded against a schedule that does not exist is refused and moves nothing", async ({ + page, + }) => { + // Room for the reload budget in `readCardRate` (see its note). + test.slow(); + await openSeedingSurface(page); + + const today = await page.evaluate(() => { + const now = new Date(); + const iso = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + now.setDate(now.getDate() - 4); + return iso(now); + }); + const medicationId = await seedMedication(page, { + name: "E2E Ablehnung", + dose: "10 mg", + startsOn: today, + schedule: { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00", "22:00"], + rrule: "FREQ=DAILY", + }, + }); + await ageMedication( + medicationId, + 4, + cacheFlusher(page, medicationId, "10 mg"), + ); + + await page.goto("/medications"); + const before = await readCardRate(page, medicationId); + + // A medication id nothing on this account owns: the same body the card + // sends, addressed to a schedule that is not there. + const refused = await api( + page, + "POST", + "/api/medications/med_e2e_does_not_exist/intake", + { skipped: false }, + ); + expect(refused.status, "an unknown schedule is refused").toBe(404); + expect(refused.data).toBeNull(); + + // Nothing was written, so nothing moved — including on the engine's own + // read, which the refused call had no opportunity to invalidate. + const payload = await api( + page, + "GET", + `/api/medications/${medicationId}/compliance`, + ); + expect(payload.status).toBe(200); + expect((payload.data as CompliancePayload).compliance7.taken).toBe(0); + + await page.goto("/medications"); + expect(await readCardRate(page, medicationId)).toBe(before); + }); + + test("a two-weekday plan does not count the other five days as missed", async ({ + page, + }) => { + // Room for the reload budget in `readCardRate` (see its note). + test.slow(); + await openSeedingSurface(page); + + // Both dose days are behind us and neither is today, so the plan's whole + // realised history is closed and the verdicts do not depend on the hour + // the suite runs at. The 08:00 dose eight days back falls on the same + // weekday as yesterday's, which is what keeps the longer window's answer + // the same as the short one's. + const plan = await page.evaluate(() => { + const TOKENS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + const at8 = (daysAgo: number) => { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + d.setHours(8, 0, 0, 0); + return d; + }; + const iso = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const doseDays = [at8(8), at8(2), at8(1)]; + return { + byday: [TOKENS[at8(2).getDay()], TOKENS[at8(1).getDay()]].join(","), + startsOn: iso(at8(8)), + slots: doseDays.map((d) => d.toISOString()), + }; + }); + + const weekdayId = await seedMedication(page, { + name: "E2E Wochentage", + dose: "5 mg", + startsOn: plan.startsOn, + schedule: { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: `FREQ=WEEKLY;BYDAY=${plan.byday}`, + }, + }); + // The control: the same doses on the same days, on a plan that expects one + // every day. Without it "100 %" proves nothing — an engine that counted no + // days at all would also read 100 %. + const dailyId = await seedMedication(page, { + name: "E2E Täglich", + dose: "5 mg", + startsOn: plan.startsOn, + schedule: { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + }, + }); + + for (const id of [weekdayId, dailyId]) { + await ageMedication(id, 8, cacheFlusher(page, id, "5 mg")); + for (const slot of plan.slots) { + const written = await api( + page, + "POST", + `/api/medications/${id}/intake`, + { + skipped: false, + scheduledFor: slot, + takenAt: slot, + }, + ); + expect(written.status, "a backdated dose is written").toBe(201); + } + } + + const read = async (id: string) => { + const res = await api( + page, + "GET", + `/api/medications/${id}/compliance`, + ); + expect(res.status).toBe(200); + return (res.data as CompliancePayload).compliance7; + }; + const weekday = await read(weekdayId); + const daily = await read(dailyId); + + // Two dose days in the trailing week, both taken, none missed. If the five + // non-dose weekdays reached the denominator this would read seven. + expect(weekday.totalExpected).toBe(2); + expect(weekday.taken).toBe(2); + expect(weekday.missed).toBe(0); + expect(weekday.rate).toBe(100); + + // The daily twin, given exactly the same doses, is missing the rest. + expect(daily.totalExpected).toBeGreaterThanOrEqual(6); + expect(daily.missed).toBeGreaterThanOrEqual(4); + expect(daily.rate).toBeLessThan(100); + + // And the two cards say the same thing side by side. + await page.goto("/medications"); + expect(await readCardRate(page, weekdayId)).toBe(100); + expect(await readCardRate(page, dailyId)).toBeLessThan(100); + }); +}); diff --git a/e2e/setup/medication-fixture.ts b/e2e/setup/medication-fixture.ts new file mode 100644 index 000000000..101c60e09 --- /dev/null +++ b/e2e/setup/medication-fixture.ts @@ -0,0 +1,96 @@ +/** + * Account hygiene and row-ageing for the medication-adherence journey. + * + * `medication-compliance-journey.spec.ts` asserts RATES — a percentage over + * the seeded account's own doses — and the dashboard tile sums the expected + * doses of every active medication the account holds. Both are counts, so the + * spec has to own the rows it counts against: a medication left by an earlier + * run or a Playwright retry would sit in the denominator of every verdict. + * `resetMedications` is the delete-then-count guarantee, keyed by the seeded + * user's id; every other e2e spec that touches `/api/medications` stubs the + * route, so nothing else has live rows here to lose. + * + * The ageing is the other half. Compliance is reconstructed over + * `[max(medication.createdAt, now − window), now]` — a medication created a + * second ago has expected no dose yet, so its rate is the empty-set 100 % and + * no take can move it. A journey that wants to watch a number change needs a + * medication that has been in the cabinet for a few days, and the only honest + * way to have one inside a test is to age the row. + */ +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +function pool(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[medication-fixture] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +async function getUserId(db: pg.Pool): Promise { + const res = await db.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[medication-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Drop the seeded e2e user's medications. Schedules, schedule revisions, + * pause eras and intake events all cascade off `medications`, so the one + * delete clears the whole cabinet. Safe to call from every test's + * `beforeEach`. + */ +export async function resetMedications(): Promise { + const db = pool(); + try { + const userId = await getUserId(db); + await db.query("DELETE FROM medications WHERE user_id = $1", [userId]); + } finally { + await db.end(); + } +} + +/** + * Move a medication's creation stamp (and its course start) `days` into the + * past, so the compliance engine expands the schedule over those days and the + * doses nobody logged read as missed. + * + * The row is edited directly, which no cache invalidation can see: the + * per-medication compliance payload and the dashboard's daily buckets are + * both memoised server-side for 15 minutes, and the detail page the create + * flow lands on has already warmed them. The caller therefore passes a + * writer — one ordinary API write against the medication — and this helper + * uses it to flush those cells through the app's own invalidation path. + */ +export async function ageMedication( + medicationId: string, + days: number, + flushCaches: () => Promise, +): Promise { + const db = pool(); + try { + const userId = await getUserId(db); + const res = await db.query( + `UPDATE medications + SET created_at = now() - make_interval(days => $1), + starts_on = (CURRENT_DATE - make_interval(days => $1))::date + WHERE id = $2 AND user_id = $3`, + [days, medicationId, userId], + ); + if (res.rowCount !== 1) { + throw new Error( + `[medication-fixture] expected to age exactly one medication, updated ${res.rowCount}`, + ); + } + } finally { + await db.end(); + } + await flushCaches(); +} diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..5989cc8b0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,6 +152,14 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The adherence journey walks the wizard, records doses on the one + // shared account and then reads RATES back off it — and the dashboard + // tile sums the expected doses of every active medication the account + // holds. Two projects writing that cabinet in parallel would each sit + // in the other's denominator, so the percentages would be answers to a + // question neither test asked. It proves flows through stable + // data-slots, not a mobile layout, so it runs in one project. + "medication-compliance-journey.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 57ec29259..866759999 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -393,10 +393,19 @@ export function MedicationComplianceChart({ // explicit user opt-in — silence would feel like a bug). const hasData = chartData.length > 0; + // The tile's adherence number exists only as bar geometry, so nothing on + // this surface can be read back except by measuring pixels. The most + // recent day's rate — the one figure a reader takes off the tile — rides + // the root as a plain data attribute so the browser journey can assert the + // dashboard and the medication card agree on the same dose. Presentation + // is untouched; the value is the one `chartData` already carries. + const latestRate = hasData ? chartData[chartData.length - 1].rate : null; + return (
{/* v1.4.19 A2 — header layout split into title row + controls row on small screens. Pre-fix the trend chip + 4 range tabs + diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 50598c3c3..7b7b54d70 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -609,7 +609,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => { ); // Strip text nodes; the remaining tag/class skeleton must be identical // between a 0% and a 100% card (transform style varies with the value - // by design, so it is normalised too). + // by design, so it is normalised too — as are the two rate attributes the + // block publishes, which are the value and not the geometry). const skeleton = (html: string) => html .replace(/>[^<]+<") @@ -617,6 +618,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => { .replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"') .replace(/data-state="[^"]*"/g, 'data-state="S"') .replace(/data-value="[^"]*"/g, 'data-value="V"') + .replace(/data-rate-short="[^"]*"/g, 'data-rate-short="R"') + .replace(/data-rate-long="[^"]*"/g, 'data-rate-long="R"') .replace(/data-max="[^"]*"/g, 'data-max="M"'); expect(skeleton(a)).toBe(skeleton(b)); }); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 1227288e8..634c44148 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -107,8 +107,10 @@ export function MedicationComplianceBars({ // v1.12.2 — route the rate through the locale number formatter and round // so a non-integer rate (e.g. 33.333) never leaks raw into the caption, // matching how every other percentage renders. - const shortPct = fmt.number(Math.round(rate7)); - const longPct = fmt.number(Math.round(rate30)); + const shortRounded = Math.round(rate7); + const longRounded = Math.round(rate30); + const shortPct = fmt.number(shortRounded); + const longPct = fmt.number(longRounded); // v1.16.6 — both rows carry an identical, FIXED geometry: the label line // is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`, @@ -117,7 +119,21 @@ export function MedicationComplianceBars({ // is colour / text only — never geometry; the two bars on a card (and // across a grid row) always align. return ( -
+ // Both rates ride the block as plain data attributes. The percentages + // themselves are locale-formatted text inside a fixed-height caption, and + // the bar beside them is a shadcn `Progress` that keeps its value for the + // indicator transform and never forwards it to the primitive — so the one + // number this card exists to show has, until now, been readable only by + // parsing a translated string. The attributes carry the ROUNDED value the + // caption renders, so a reader and an assertion see the same figure. + // Presentation is untouched; the loading and error variants below stay + // free of them, which is what makes their presence mean "loaded". +
diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx index c25cbda1a..1403fa8ad 100644 --- a/src/components/medications/card-parts/medication-intake-actions.tsx +++ b/src/components/medications/card-parts/medication-intake-actions.tsx @@ -22,6 +22,11 @@ interface MedicationIntakeActionsProps { * * Extracted from the generic and GLP-1 cards so the action row is shared * structurally rather than kept byte-equivalent by hand. + * + * Both buttons carry a `data-slot`. They are the only controls on the card + * that write, and their labels are i18n copy that also collapses to an icon + * on the narrow card — an e2e journey addressing them by text would be + * asserting the translation, not the control. */ export function MedicationIntakeActions({ intakeLoading, @@ -37,6 +42,7 @@ export function MedicationIntakeActions({ return (