mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge the medication adherence end-to-end journey
This commit is contained in:
commit
89ec49cd42
10 files changed
+954
-8
No files matched your search
@@ -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<T> {
|
||||
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<T>(
|
||||
page: Page,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<ApiResult<T>> {
|
||||
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<string> {
|
||||
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<MedicationAgeStamps> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<CompliancePayload>(
|
||||
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<CompliancePayload>(
|
||||
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<CompliancePayload>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
* 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<void> {
|
||||
// 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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("<MedicationComplianceChart>", () => {
|
||||
const html = render(<MedicationComplianceChart />);
|
||||
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(<MedicationComplianceChart />);
|
||||
expect(html).toContain('data-latest-rate="25"');
|
||||
} finally {
|
||||
queryState.data = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
data-slot="medication-compliance-chart"
|
||||
data-latest-rate={latestRate ?? undefined}
|
||||
>
|
||||
{/* v1.4.19 A2 — header layout split into title row + controls
|
||||
row on small screens. Pre-fix the trend chip + 4 range tabs +
|
||||
|
||||
@@ -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(/>[^<]+</g, "><")
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-2.5">
|
||||
// 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".
|
||||
<div
|
||||
className="space-y-2.5"
|
||||
data-slot="medication-card-compliance"
|
||||
data-rate-short={shortRounded}
|
||||
data-rate-long={longRounded}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
data-slot="medication-intake-take"
|
||||
className="min-h-11 flex-1"
|
||||
onClick={() => onRecordIntake(false)}
|
||||
disabled={!!intakeLoading}
|
||||
@@ -49,6 +55,7 @@ export function MedicationIntakeActions({
|
||||
{t("medications.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
data-slot="medication-intake-skip"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => onRecordIntake(true)}
|
||||
|
||||
@@ -155,6 +155,19 @@ const DEFAULT_PAGE_SIZE = 25;
|
||||
*/
|
||||
const STATUS_FILTER = "completed";
|
||||
|
||||
/**
|
||||
* The row's outcome as a stable token, carried on both layouts' rows as
|
||||
* `data-status`. The visible outcome is a translated badge that renders at a
|
||||
* different size on each layout, so it is not a thing an end-to-end journey
|
||||
* can address; the branch itself is one line and already computed twice.
|
||||
* "unknown" cannot occur while `STATUS_FILTER` holds — it is what a malformed
|
||||
* row would say rather than quietly reading as taken.
|
||||
*/
|
||||
function rowStatus(isTaken: boolean, isSkipped: boolean): string {
|
||||
if (isTaken) return "taken";
|
||||
return isSkipped ? "skipped" : "unknown";
|
||||
}
|
||||
|
||||
export function IntakeHistoryListV2({
|
||||
medicationId,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
@@ -346,7 +359,11 @@ export function IntakeHistoryListV2({
|
||||
const isSelected =
|
||||
selection?.selected.has(event.id) ?? false;
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
<TableRow
|
||||
key={event.id}
|
||||
data-slot="intake-history-row"
|
||||
data-status={rowStatus(isTaken, isSkipped)}
|
||||
>
|
||||
{showSelection && (
|
||||
<TableCell className="w-8">
|
||||
<Checkbox
|
||||
@@ -441,6 +458,8 @@ export function IntakeHistoryListV2({
|
||||
return (
|
||||
<ListRow
|
||||
key={event.id}
|
||||
data-slot="intake-history-row"
|
||||
data-status={rowStatus(isTaken, isSkipped)}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user