From 617beb40dc8c35cb7e840b1be8bd503cb196d43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:46:13 +0200 Subject: [PATCH] test(e2e): cover the medication-adherence journey end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The percentage on a medication card is the number this product is for, and 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. The join was the part nobody checked. Three flows against the seeded account, serial and in one project because they read counts back off a cabinet they own: - a twice-daily medication created through the real wizard, one dose taken and one skipped from the card, and the figure read back on the card, in the intake history and on the dashboard tile; - a dose recorded against a schedule that does not exist, refused, with the rate exactly what it was before; - a plan due on two weekdays whose other five days stay out of the denominator, with a daily twin given the identical doses as the control — without it, "100 %" would also be what an engine that counted no days at all would say. The dose times are derived from the clock rather than picked from the wizard's suggestions: the card offers one dose, so with fixed times there is always an hour at which that dose belongs to yesterday or tomorrow, and the day the write lands on decides what the tile reads. Each medication is aged a few days as well — compliance is reconstructed from the creation stamp forward, so a medication created a second ago reads the empty-set 100 % and no take can move it. Four surfaces gain data attributes so the journey addresses controls and figures rather than translated copy: the card's take / skip buttons, the card's two adherence rates, the intake-history row status, and the dashboard tile's most recent day, which existed only as bar geometry. Presentation is unchanged; the compliance block's fixed-geometry guard normalises the two new rate attributes alongside the ones it already did. --- e2e/medication-compliance-journey.spec.ts | 598 ++++++++++++++++++ e2e/setup/medication-fixture.ts | 96 +++ playwright.config.ts | 8 + .../charts/medication-compliance-chart.tsx | 9 + .../medications/__tests__/card-parts.test.tsx | 5 +- .../card-parts/medication-compliance-bars.tsx | 22 +- .../card-parts/medication-intake-actions.tsx | 7 + .../medications/intake-history-list-v2.tsx | 21 +- 8 files changed, 761 insertions(+), 5 deletions(-) create mode 100644 e2e/medication-compliance-journey.spec.ts create mode 100644 e2e/setup/medication-fixture.ts 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 (