fix(medications): restore the two-row compliance display, scaled to cadence

v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose
adherence timeline on sparse-cadence medications. The timeline breaks the
card layout and reads as restless, so every medication is back to two
percentage rows.

The two windows are no longer hardcoded — they scale with the dosing
cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and
returns the densest rung whose both windows clear MIN_STABLE_DOSES realised
expected doses, so a daily med stays on 7/30, a weekly med steps up to
30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When
even the top rung can't clear the floor (a brand-new or very rare med) the
widest rung is returned so the card still shows two honest rows. The row
labels follow the chosen windows via a parametrised complianceWindow string.

The compliance percentage math is unchanged: each row is the existing
cadence-aware calculateCompliance over the selected window. compliance7 /
compliance30 stay on the wire verbatim for iOS and the Health Score.

- compliance.ts: replace the timeline display with selectComplianceWindows +
  the window ladder; buildComplianceDisplay now returns the two scaled rows
- remove the dose-adherence-timeline component and its now-dead strings
- medication-compliance-bars: parametrise the row labels on the day-counts
- switch the standard and GLP-1 cards back to the always-two-row bars
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:44:13 +02:00
1 parent 6c44d4a2c1
commit e5b873833a
15 files changed
+376 -627

No files matched your search

@@ -247,13 +247,12 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
});
});
// v1.8.5 — the additive `complianceDisplay` block. The server decides the
// render mode from expected-dose density: a daily med keeps the percentage
// bars (`"percent"`), a sparse rolling/long-interval med swaps to the
// per-dose timeline (`"timeline"`). The existing `compliance7` /
// `compliance30` fields stay untouched.
// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the
// server scales the two windows to the dosing cadence. A daily med keeps
// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung.
// The existing `compliance7` / `compliance30` fields stay untouched.
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
it("daily med → mode: percent", async () => {
it("daily med → 7 / 30-day windows", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
@@ -277,24 +276,32 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
shortDays: number;
longDays: number;
expectedLong: number;
minStableDoses: number;
short: { rate: number; streak: number };
long: { rate: number };
};
expect(display.mode).toBe("percent");
expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses);
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses);
expect(display.short).toBeDefined();
expect(display.long).toBeDefined();
// No timeline field — the display is two rows only.
expect(display).not.toHaveProperty("mode");
// The legacy fields stay on the wire.
expect(data.compliance7).toBeDefined();
expect(data.compliance30).toBeDefined();
});
it("35-day-interval med → mode: timeline", async () => {
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
{
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
endsOn: null,
oneShot: false,
schedules: [
@@ -314,8 +321,6 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
],
} as never,
);
// One past intake re-anchors the rolling cadence ~25 days ago, so the
// next slot lands beyond the 30-day window → <4 expected → timeline.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
@@ -326,11 +331,12 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
minStableDoses: number;
shortDays: number;
longDays: number;
};
expect(display.mode).toBe("timeline");
expect(display.expected30).toBeLessThan(display.minStableDoses);
// A 35-day cadence can't clear four expected doses in a 30-day window, so
// the ladder steps the short window beyond 7 days and the long beyond 30.
expect(display.shortDays).toBeGreaterThan(7);
expect(display.longDays).toBeGreaterThan(30);
});
});
@@ -142,12 +142,12 @@ export const GET = apiHandler(
{ medicationContext },
);
// v1.8.5 — the additive display block. `mode` is server-decided from
// expected-dose density: dense cadences keep the 7-/30-day percentage
// bars (`"percent"`), sparse cadences (weekly, bi-/tri-weekly, rolling
// 35-day injections) swap to the per-dose uptime strip (`"timeline"`).
// `compliance7` / `compliance30` above are untouched — iOS + the Health
// Score read them verbatim.
// v1.8.6 — the two-row compliance display. The card always shows two
// percentage rows; the server scales the two windows to the dosing
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
// up) and computes each row's rate over the chosen span. `compliance7`
// / `compliance30` above are untouched — iOS + the Health Score read
// them verbatim.
const complianceDisplay = buildComplianceDisplay(
mapped,
medication.schedules,
@@ -289,7 +289,8 @@ export const GET = apiHandler(
meta: {
compliance7: compliance7.rate,
compliance30: compliance30.rate,
displayMode: complianceDisplay.mode,
complianceShortDays: complianceDisplay.shortDays,
complianceLongDays: complianceDisplay.longDays,
},
});