Merge the medication adherence end-to-end journey

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:20:01 +02:00
commit 89ec49cd42
10 files changed
+954 -8

No files matched your search

@@ -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"
>