mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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