diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts new file mode 100644 index 000000000..bf6926db7 --- /dev/null +++ b/e2e/medication-compliance-journey.spec.ts @@ -0,0 +1,670 @@ +/** + * 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 — an intake against a medication this account does not own, and + * one against a slot its schedule does not have, are both 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 and the wizard runs in German, so + * addressing a control by its label would assert the translation. + * + * The flows write rows and read counts back off them, so they run on their own + * account (`E2E_MEDICATION`) — the rate a card shows is a percentage over one + * cabinet, and a sibling spec mounting `/medications` in the other worker + * would otherwise be spending the same per-actor read allowance and sitting in + * the same dashboard denominator. The file still runs serial and in a single + * project (see `playwright.config.ts`), because its three tests share that one + * cabinet with each other. + * + * 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 { MEDICATION_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ageMedication, + resetMedications, + type MedicationAgeStamps, +} 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" | "DELETE", + 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 two stamps `ageMedication` writes, computed in the browser's clock. + * + * Deliberately not in SQL: `CURRENT_DATE` resolves in the database session's + * zone, the plan dates every flow sends are computed here, and between + * midnight and 02:00 Berlin those are different calendar days. + */ +async function ageStamps( + page: Page, + days: number, +): Promise { + return page.evaluate((days) => { + const pad = (n: number) => String(n).padStart(2, "0"); + const at = new Date(); + at.setDate(at.getDate() - days); + return { + createdAt: at.toISOString(), + startsOn: `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`, + }; + }, days); +} + +/** + * 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"]') + .first(); +} + +/** + * 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. Twenty seconds + * is the list's own load-plus-fetch budget and nothing more — the account is + * this spec's own, so a card with no figure on it is a defect rather than a + * neighbour's spent read allowance. + */ +async function readCardRate(page: Page, medicationId: string): Promise { + const block = complianceBlock(page, medicationId); + await expect( + block, + "the card never painted an adherence figure", + ).toHaveAttribute("data-rate-short", /^\d+$/, { timeout: 20_000 }); + return Number(await block.getAttribute("data-rate-short")); +} + +/** 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 reset and the seeding calls can run from. + * + * Both go out as the page's own `fetch`, so the page has to be on the app's + * origin first. Deliberately NOT the medications list: the reset that follows + * deletes rows, and a list mount would first fetch compliance for every one of + * them. + */ +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. + * + * The Enter press is the commit, not a flourish: the field's overlay input + * holds what is being typed and only hands it to the form on Enter or on blur + * (`src/components/ui/time-field.tsx:175-184`). Until then the draft is empty, + * and the add button is `disabled` on exactly that + * (`times-of-day-chips.tsx:267`) — so a fill followed straight by a click + * waits out the actionability timeout on a button that never enables. + */ +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 }; +} + +// Serial: the three tests share one cabinet, and two of them clearing it at +// once would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +test.describe("medication adherence journey", () => { + test.use({ storageState: MEDICATION_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + // 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. The removal goes out as the + // app's own DELETE so the server's memoised medication cells go with it. + await openSeedingSurface(page); + await resetMedications(async (id) => { + const removed = await api(page, "DELETE", `/api/medications/${id}`); + expect(removed.status, "clearing a leftover medication").toBe(200); + }); + }); + + 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(); + + // The tile reads THE LATEST day, and a day boundary crossed mid-flow moves + // that day out from under both tile assertions: a fresh day has two doses + // scheduled, none taken, and answers 0 where 50 is asserted. The flow is a + // couple of minutes long, so declining to start in the last ten of the day + // is enough. The clock is the browser's, which the config pins to the zone + // the compliance engine falls back to. + const minutesLeftInDay = await page.evaluate( + () => 24 * 60 - (new Date().getHours() * 60 + new Date().getMinutes()), + ); + test.skip( + minutesLeftInDay < 10, + "starts inside the last ten minutes of the day — the dashboard tile's latest day would change under the flow", + ); + + 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, + await ageStamps(page, 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( + () => + complianceBlock(page, medicationId).getAttribute("data-rate-short"), + { + timeout: 30_000, + message: "the card never re-read its schedule after the take", + }, + ) + .toMatch(/^[1-9]\d*$/); + 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 the dashboard tile, which answers the skip differently --------- + // Today expected two doses; one was taken and one deliberately skipped. + // + // The two surfaces do not agree about that skip, and this pins the + // difference rather than claiming it away. The tile's denominator is the + // day's expected slots, which a skip does not leave, and its numerator + // counts only rows carrying a `takenAt` + // (`src/lib/analytics/schedule-anchored-compliance.ts:141-164`), so it + // reads 50. The card's engine — `src/lib/analytics/compliance.ts`, the + // documented single source of the percentage — drops a skip from the + // denominator outright (`src/lib/analytics/compliance/ledger.ts:43-47`), + // which is what the counts asserted above already show. The number below + // is the one the server's daily buckets really produce for today; the + // card's is the authoritative answer about adherence. + await page.goto("/"); + expect(await readTileLatestRate(page)).toBe(50); + }); + + test("an intake against an unowned medication, and one against a slot the schedule does not have, are both refused", async ({ + page, + }) => { + // Two full list loads either side of the refusals, plus a seed and an age; + // on a loaded runner that settle earns the tripled timeout. + test.slow(); + + const stamps = await ageStamps(page, 4); + // An instant the schedule below does not have a slot for. Computed here, + // before the first navigation, so the whole test body reads as one setup + // step followed by assertions on it. + const unscheduled = await page.evaluate(() => { + const at = new Date(); + at.setHours(3, 17, 0, 0); + return at.toISOString(); + }); + const medicationId = await seedMedication(page, { + name: "E2E Ablehnung", + dose: "10 mg", + startsOn: stamps.startsOn, + schedule: { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00", "22:00"], + rrule: "FREQ=DAILY", + }, + }); + await ageMedication( + medicationId, + stamps, + cacheFlusher(page, medicationId, "10 mg"), + ); + + await page.goto("/medications"); + const before = await readCardRate(page, medicationId); + + // A medication id nothing on this account owns. The privacy gate answers + // before the body is even read, so the 404 is about ownership and says + // nothing about the medication existing elsewhere. + const unowned = await api( + page, + "POST", + "/api/medications/med_e2e_does_not_exist/intake", + { skipped: false }, + ); + expect(unowned.status, "an unowned medication is refused").toBe(404); + + // And the schedule's own boundary: this medication doses at 08:00 and + // 22:00, so 03:17 is not one of its slots and the pin is refused rather + // than silently recorded as an ad-hoc dose. + const offSlot = await api( + page, + "POST", + `/api/medications/${medicationId}/intake`, + { + skipped: false, + forceSlotInstant: unscheduled, + }, + ); + expect(offSlot.status, "an instant that is not a slot is refused").toBe( + 422, + ); + + // Nothing was written, so nothing moved — including on the engine's own + // read, which neither refused call had an 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, + }) => { + // Two medications seeded, aged and dosed three times apiece before the + // first assertion; on a loaded runner that earns the tripled timeout. + test.slow(); + + // 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", + }, + }); + + const stamps = await ageStamps(page, 8); + for (const id of [weekdayId, dailyId]) { + await ageMedication(id, stamps, 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/global-setup.ts b/e2e/setup/global-setup.ts index 9ee0f42ec..904250778 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -93,6 +93,25 @@ export const E2E_GUARDIAN = { role: "USER", } as const; +/** + * The medication-adherence journey's own account. + * + * The batched compliance read is capped at thirty calls a minute per ACTOR, + * and the journey spends that allowance twice over: it writes doses and then + * reads the resulting rates back off the same cabinet. On the shared account + * the allowance is also being spent by whichever sibling happens to be + * mounting `/medications` in the other worker, and the cabinet itself is + * shared with them — so the journey's `beforeEach` clear would take rows + * somebody else seeded with it. One account of its own makes the rate-limit + * bucket, the cabinet and the dashboard tile's denominator private at once. + */ +export const E2E_MEDICATION = { + email: "e2e-medication@healthlog.test", + username: "e2e-medication", + password: "Hj6!Tw2pV9sQ4nDx", + role: "USER", +} as const; + /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", @@ -209,6 +228,12 @@ export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScopeCapabilities.json", ); +/** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */ +export const MEDICATION_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateMedication.json", +); + /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as @@ -408,6 +433,32 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_GUARDIAN.username], ); + // The adherence journey's account. Modules are pinned rather than left to + // the defaults: the dashboard's medication tile is one of the surfaces the + // journey reads, and it only paints while the module is on. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_MEDICATION.username, + E2E_MEDICATION.email, + await hashPassword(E2E_MEDICATION.password), + now, + JSON.stringify({ medications: true }), + ], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -647,7 +698,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TEN times now + // The login bucket, for the same reason. This setup signs in TWELVE times now // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), and the ceiling is five attempts per IP per // quarter-hour — so two local runs in a row would otherwise end with a 429 @@ -727,7 +778,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in ten times, so clearing once before the batch is no longer + * signs in twelve times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -753,6 +804,10 @@ export default async function globalSetup(config: FullConfig): Promise { // for what happens when the journey switches the shared row instead. await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH); + // The adherence journey's jar. Its own account, so nothing it writes and + // nothing it clears is visible to the shared one. + await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/setup/medication-fixture.ts b/e2e/setup/medication-fixture.ts new file mode 100644 index 000000000..86c85e3a0 --- /dev/null +++ b/e2e/setup/medication-fixture.ts @@ -0,0 +1,130 @@ +/** + * Account hygiene and row-ageing for the medication-adherence journey. + * + * `medication-compliance-journey.spec.ts` asserts RATES — a percentage over + * one account's own doses — and the dashboard tile sums the expected doses of + * every active medication that 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. Everything + * here is keyed on `E2E_MEDICATION`, the journey's own account, so neither the + * clearing nor the ageing can be felt by a spec running in the other worker. + * + * 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_MEDICATION } 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_MEDICATION.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[medication-fixture] medication account not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Clear the journey account's cabinet — through the app, not around it. + * + * The rows are enumerated in SQL because the list route serves a memoised + * shape and a row that cache has not caught up with would hide from it. Every + * removal then goes out as the caller's own `DELETE /api/medications/{id}`, + * which is the only path that evicts the three 15-minute per-user cells + * medication state lives in (`caches.medications`, + * `caches.medicationCompliance`, `caches.medicationsIntake`). A raw + * `DELETE FROM medications` reaches none of them, and the next read would + * answer out of a cache still naming rows that no longer exist — which is + * exactly the read this helper exists to make trustworthy. Schedules, schedule + * revisions, pause eras and intake events cascade off the row either way. + * + * Safe to call from every test's `beforeEach`. + */ +export async function resetMedications( + deleteMedication: (id: string) => Promise, +): Promise { + const db = pool(); + let ids: string[]; + try { + const userId = await getUserId(db); + const res = await db.query<{ id: string }>( + "SELECT id FROM medications WHERE user_id = $1", + [userId], + ); + ids = res.rows.map((row) => row.id); + } finally { + await db.end(); + } + for (const id of ids) await deleteMedication(id); +} + +/** The two stamps {@link ageMedication} writes onto the row. */ +export interface MedicationAgeStamps { + /** The instant the row should claim as its creation, as an ISO string. */ + createdAt: string; + /** The `YYYY-MM-DD` the course should claim as its start. */ + startsOn: string; +} + +/** + * Move a medication's creation stamp and its course start into the past, so + * the compliance engine expands the schedule over those days and the doses + * nobody logged read as missed. + * + * Both stamps come from the caller rather than from the database clock, and + * that is deliberate: `CURRENT_DATE` resolves in the database session's zone, + * which on CI is the Postgres container's UTC, while the plan dates the spec + * sends are computed in the browser's pinned `Europe/Berlin`. Between midnight + * and 02:00 Berlin the two name different calendar days, and the row would + * quietly start one day earlier than its caller asked for. `starts_on` is + * OVERWRITTEN, not adjusted — whatever the create call sent is replaced by + * what is passed here. + * + * 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, + stamps: MedicationAgeStamps, + flushCaches: () => Promise, +): Promise { + const db = pool(); + try { + const userId = await getUserId(db); + const res = await db.query( + `UPDATE medications + SET created_at = $1::timestamptz, + starts_on = $2::date + WHERE id = $3 AND user_id = $4`, + [stamps.createdAt, stamps.startsOn, 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 4bd5961d6..a8d751009 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -160,6 +160,15 @@ export default defineConfig({ // other one mid-navigation. It proves a flow through stable // data-slots, not a mobile layout, so it runs in one project. "glucose-journey.spec.ts", + // The adherence journey walks the wizard, records doses on its own + // account and then reads RATES back off it — and the dashboard tile + // sums the expected doses of every active medication that account + // holds. The account keeps siblings out; two PROJECTS running this + // same file in parallel would still write the one cabinet and 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", // The Apple Health import journey uploads an archive into the one diff --git a/src/components/charts/__tests__/medication-compliance-chart.test.tsx b/src/components/charts/__tests__/medication-compliance-chart.test.tsx index fd9287925..7aae3683f 100644 --- a/src/components/charts/__tests__/medication-compliance-chart.test.tsx +++ b/src/components/charts/__tests__/medication-compliance-chart.test.tsx @@ -15,8 +15,16 @@ import { * aggregation helper produces the correct rates. */ +/** + * The daily points the mocked query serves. Mutable so one test can hand the + * chart a loaded window; every other test leaves it empty. + */ +const queryState = vi.hoisted(() => ({ + data: [] as { date: string; scheduled: number; taken: number }[], +})); + vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: [], isLoading: false }), + useQuery: () => ({ data: queryState.data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), getQueryData: () => undefined, @@ -195,4 +203,24 @@ describe("", () => { const html = render(); expect(html).not.toContain('data-slot="medication-trend-chip"'); }); + + /** + * The tile's adherence number exists only as bar geometry, so the root's + * `data-latest-rate` is the one way to read it back — and the browser suite + * was its only reader, which put a production attribute's shape three + * minutes and a whole server away from the gate that should own it. Pinned + * here: the value is the LAST day's rate, as an integer. + */ + it("carries the last day's rate on the root as data-latest-rate", () => { + queryState.data = [ + { date: "2026-05-01", scheduled: 2, taken: 2 }, // 100 % + { date: "2026-05-02", scheduled: 4, taken: 1 }, // 25 % + ]; + try { + const html = render(); + expect(html).toContain('data-latest-rate="25"'); + } finally { + queryState.data = []; + } + }); }); 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..296922b7a 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 translated copy — 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 (