- );
-}
diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx
index 7fac27474..051b3e343 100644
--- a/src/components/medications/card-parts/medication-compliance-bars.tsx
+++ b/src/components/medications/card-parts/medication-compliance-bars.tsx
@@ -7,13 +7,28 @@ interface MedicationComplianceBarsProps {
rate7: number;
rate30: number;
streak: number;
+ /**
+ * v1.8.6 — the span of the short row in days. The server scales the two
+ * windows to the dosing cadence (7 / 30 for dense meds, stepping up to
+ * 90 / 365 for sparse ones), so the labels follow the chosen windows
+ * instead of a hardcoded 7 / 30. Defaults to 7 / 30 so older callers and
+ * fixtures keep their prior labels.
+ */
+ shortDays?: number;
+ /** v1.8.6 — the span of the long row in days. */
+ longDays?: number;
}
/**
- * Shared 7-day / 30-day compliance bars plus the day-streak flame for the
+ * Shared two-row compliance bars plus the day-streak flame for the
* medication cards. Extracted from the generic and GLP-1 cards so the bars
* are structurally identical rather than hand-synced.
*
+ * v1.8.6 — the two windows scale with the dosing cadence. A daily med shows
+ * 7-day / 30-day; a weekly med 30-day / 90-day; a rare injection up to a
+ * 365-day long window. The labels are parametrised on the chosen day-counts
+ * so each row names the window it actually covers.
+ *
* The streak flame uses the canonical `text-dracula-orange` warning/streak
* token (globals.css). The generic card historically drifted onto Tailwind
* stock `text-orange-400`; unifying here closes that token gap.
@@ -22,38 +37,31 @@ export function MedicationComplianceBars({
rate7,
rate30,
streak,
+ shortDays = 7,
+ longDays = 30,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
+ const shortLabel = t("medications.complianceWindow", { days: shortDays });
+ const longLabel = t("medications.complianceWindow", { days: longDays });
+
return (
-
- {t("medications.compliance7d")}
-
+ {shortLabel}
{rate7}%
{/* aria-label so the bar has an accessible name. */}
-
+
-
- {t("medications.compliance30d")}
-
+ {longLabel}
{rate30}%
-
+
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index c1a28bd07..98b5117fc 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -10,10 +10,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
-import {
- DoseAdherenceTimeline,
- type DoseAdherenceCell,
-} from "@/components/medications/card-parts/dose-adherence-timeline";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
@@ -104,21 +100,24 @@ export interface Glp1Medication {
}
interface ComplianceDisplay {
- mode: "percent" | "timeline";
- expected7: number;
- expected30: number;
+ shortDays: number;
+ longDays: number;
+ expectedShort: number;
+ expectedLong: number;
minStableDoses: number;
- doseTimeline: DoseAdherenceCell[];
- recentDoseSummary: { taken: number; total: number; doseStreak: number };
+ short: { rate: number; streak: number };
+ long: { rate: number };
}
interface ComplianceData {
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
/**
- * v1.8.5 — server-decided render mode. GLP-1 injections are commonly
- * weekly, so this card flips to the per-dose timeline more often than
- * the generic one. Additive — older mocks omit it.
+ * v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
+ * injections are commonly weekly, so this card's windows step up to the
+ * 30-/90-day rung where the bars stay meaningful. Additive — older mocks
+ * omit it, in which case the card falls back to the static 7-/30-day
+ * fields.
*/
complianceDisplay?: ComplianceDisplay;
}
@@ -309,9 +308,15 @@ export function Glp1MedicationCard({
const schedule = medication.schedules[0] ?? null;
const now = new Date();
const next = nextInjectionFromServer(medication.nextDueAt, now);
- const rate7 = compliance?.compliance7?.rate ?? 0;
- const rate30 = compliance?.compliance30?.rate ?? 0;
- const streak = compliance?.compliance7?.streak ?? 0;
+ // v1.8.6 — the two compliance windows scale with the dosing cadence. When
+ // the server supplies `complianceDisplay` the card reads its cadence-scaled
+ // rows; otherwise it falls back to the static 7-/30-day fields.
+ const display = compliance?.complianceDisplay;
+ const shortDays = display?.shortDays ?? 7;
+ const longDays = display?.longDays ?? 30;
+ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
+ const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
+ const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
// card. Sort schedules by `windowStart` so the most-actionable
@@ -480,24 +485,19 @@ export function Glp1MedicationCard({
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
- {/* Compliance region — shared decision with the generic card.
- Dense cadences keep the bars; sparse cadences (most weekly
- GLP-1 injections) swap to the per-dose uptime strip. Falls back
- to the bars when the older payload omits `complianceDisplay`. */}
- {medication.active &&
- compliance &&
- (compliance.complianceDisplay?.mode === "timeline" ? (
-
- ) : (
-
- ))}
+ {/* Compliance bars — always two rows, shared with the generic
+ card so the page grid stays harmonious. The server scales the
+ two windows to the dosing cadence; most weekly GLP-1 injections
+ land on the 30-/90-day rung where the bars stay meaningful. */}
+ {medication.active && compliance && (
+
+ )}
{/* Primary actions row — shared with the generic medication
card. The GLP-1-specific side-effect quick-log lives in the
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index fbb9247e9..d75735ca1 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -9,10 +9,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
-import {
- DoseAdherenceTimeline,
- type DoseAdherenceCell,
-} from "@/components/medications/card-parts/dose-adherence-timeline";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
@@ -76,12 +72,13 @@ interface Medication {
}
interface ComplianceDisplay {
- mode: "percent" | "timeline";
- expected7: number;
- expected30: number;
+ shortDays: number;
+ longDays: number;
+ expectedShort: number;
+ expectedLong: number;
minStableDoses: number;
- doseTimeline: DoseAdherenceCell[];
- recentDoseSummary: { taken: number; total: number; doseStreak: number };
+ short: { rate: number; streak: number };
+ long: { rate: number };
}
interface ComplianceData {
@@ -97,9 +94,11 @@ interface ComplianceData {
rate: number;
};
/**
- * v1.8.5 — server-decided render mode for the compliance region. Dense
- * cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to
- * the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it.
+ * v1.8.6 — the two compliance windows scaled to the dosing cadence. The
+ * card always shows two percentage rows; `shortDays` / `longDays` name the
+ * windows and `short` / `long` carry their rates. Additive — older mocks
+ * omit it, in which case the card falls back to the static 7-/30-day
+ * `compliance7` / `compliance30` fields.
*/
complianceDisplay?: ComplianceDisplay;
}
@@ -222,9 +221,15 @@ export function MedicationCard({
}
}
- const rate7 = compliance?.compliance7?.rate ?? 0;
- const rate30 = compliance?.compliance30?.rate ?? 0;
- const streak = compliance?.compliance7?.streak ?? 0;
+ // v1.8.6 — the two compliance windows scale with the dosing cadence. When
+ // the server supplies `complianceDisplay` the card reads its cadence-scaled
+ // rows; otherwise it falls back to the static 7-/30-day fields.
+ const display = compliance?.complianceDisplay;
+ const shortDays = display?.shortDays ?? 7;
+ const longDays = display?.longDays ?? 30;
+ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
+ const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
+ const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
@@ -391,24 +396,19 @@ export function MedicationCard({
)}
- {/* Compliance region — the server decides whether the percentage
- bars stay (dense cadences) or the per-dose uptime strip takes
- over (sparse cadences). Falls back to the bars when the older
- payload omits `complianceDisplay`. */}
- {medication.active &&
- compliance &&
- (compliance.complianceDisplay?.mode === "timeline" ? (
-
- ) : (
-
- ))}
+ {/* Compliance bars — always two rows. The server scales the two
+ windows to the dosing cadence (7 / 30 days for dense meds,
+ stepping up to 90 / 365 for sparse ones); the labels follow the
+ chosen windows. */}
+ {medication.active && compliance && (
+
+ )}
{/* Quick actions — primary buttons of the medication card. */}
{medication.active && (
diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts
index 6c9b91459..bb5afd5e3 100644
--- a/src/lib/analytics/__tests__/compliance.test.ts
+++ b/src/lib/analytics/__tests__/compliance.test.ts
@@ -1027,7 +1027,13 @@ describe("classifyIntakeTiming", () => {
// `buildComplianceDisplay` decides whether the card keeps the 7-/30-day
// percentage bars (dense cadences) or swaps to a per-dose timeline
// (sparse cadences). The 30-day window is the density yardstick.
-describe("buildComplianceDisplay — density rule + dose timeline", () => {
+// v1.8.6 — the compliance display is always two percentage rows; only the
+// window day-counts 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, and a rare injection
+// lands on the widest rung. The timeline rendering is gone.
+describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
const NOW = new Date("2025-06-15T12:00:00Z");
beforeEach(() => {
@@ -1054,17 +1060,25 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
);
}
- it("echoes the threshold so a client can re-derive the mode", () => {
+ it("echoes the density floor so a client can re-derive the rung", () => {
const display = buildComplianceDisplay(
[],
- [{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, rrule: "FREQ=DAILY", timesOfDay: ["08:00"] }],
+ [
+ {
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ daysOfWeek: null,
+ rrule: "FREQ=DAILY",
+ timesOfDay: ["08:00"],
+ },
+ ],
ctx(),
{ now: NOW },
);
expect(display.minStableDoses).toBe(MIN_STABLE_DOSES);
});
- it("daily med → percent mode (30-day window clears the 4-dose floor)", () => {
+ it("daily med → 7 / 30-day windows (the densest rung clears the floor)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
@@ -1075,13 +1089,37 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
- expect(display.mode).toBe("percent");
- expect(display.expected30).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
+ expect(display.shortDays).toBe(7);
+ expect(display.longDays).toBe(30);
+ // Both windows hold ≥ 4 expected doses.
+ expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
+ expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
- it("35-day-interval med → timeline mode (sparse: <4 doses in 30 days)", () => {
- // A monthly injection: one dose every 5 weeks. Anchored on a recent
- // start so the engine emits at most one slot inside the 30-day window.
+ it("weekly med → steps up to 30 / 90-day windows", () => {
+ // Weekly Mondays-only: the 7-day window holds at most one Monday (< 4),
+ // so the ladder steps up. The 30-day window holds ~4 Mondays and the
+ // 90-day window ~13 — the [30, 90] rung is the densest that clears the
+ // floor on both rows.
+ const schedules: ComplianceSchedule[] = [
+ {
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ daysOfWeek: null,
+ rrule: "FREQ=WEEKLY;BYDAY=MO",
+ timesOfDay: ["08:00"],
+ },
+ ];
+ const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
+ expect(display.shortDays).toBe(30);
+ expect(display.longDays).toBe(90);
+ expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
+ expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
+ });
+
+ it("35-day rolling injection → widest 90 / 365-day windows", () => {
+ // One dose every five weeks: the 30-day window never holds ≥ 4, so the
+ // ladder climbs to the top rung where a full year holds ~10 doses.
const schedules: ComplianceSchedule[] = [
{
windowStart: "10:00",
@@ -1095,129 +1133,79 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
[],
schedules,
ctx({
- startsOn: new Date("2025-05-01T00:00:00Z"),
- // Rolling cadences re-anchor on the last non-skipped intake.
+ startsOn: new Date("2024-06-01T00:00:00Z"),
lastIntakeAt: new Date("2025-05-20T10:00:00Z"),
}),
{ now: NOW },
);
- expect(display.mode).toBe("timeline");
- expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
+ expect(display.shortDays).toBe(90);
+ expect(display.longDays).toBe(365);
});
- it("weekly med → timeline mode (≥4 needs ~28 days; 30d holds ~4 but <4 when phased)", () => {
- // Weekly Mondays-only: across a 30-day trailing window this yields ~4
- // Mondays. With a start mid-window the realised count drops below 4 →
- // timeline. Pin the realised count rather than asserting a fixed mode
- // independent of phase.
+ it("a brand-new sparse med still returns two rows on the widest rung", () => {
+ // Started two days ago: no rung clears the floor. The selection falls
+ // back to the widest rung so the card still shows two honest rows.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
- rrule: "FREQ=WEEKLY;BYDAY=MO",
+ rollingIntervalDays: 35,
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
- ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
+ ctx({ startsOn: new Date("2025-06-13T00:00:00Z") }),
{ now: NOW },
);
- // 2025-05-26 .. 2025-06-15 → Mondays 5/26, 6/2, 6/9 → 3 expected < 4.
- expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
- expect(display.mode).toBe("timeline");
+ expect(display.shortDays).toBe(90);
+ expect(display.longDays).toBe(365);
});
- it("builds taken / missed / skipped cells from the paired engine timeline", () => {
- // Weekly Mondays, three past Mondays inside the window: one taken on
- // time, one skipped, one missed (no intake). The strip mirrors that.
+ it("the row rates match calculateCompliance over the chosen windows", () => {
+ // Daily med, perfect adherence over the trailing two weeks → 100% both
+ // rows. Pins that the display rate equals the cadence-aware calculator.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
- rrule: "FREQ=WEEKLY;BYDAY=MO",
+ rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
- const events = [
- // 2025-06-09 Monday — taken on time.
- {
- scheduledFor: new Date("2025-06-09T08:00:00Z"),
- takenAt: new Date("2025-06-09T08:10:00Z"),
- skipped: false,
- },
- // 2025-06-02 Monday — skipped.
- {
- scheduledFor: new Date("2025-06-02T08:00:00Z"),
- takenAt: null,
- skipped: true,
- },
- // 2025-05-26 Monday — no event → missed.
- ];
- const display = buildComplianceDisplay(
+ const events = [];
+ for (let d = 1; d <= 30; d++) {
+ const at = new Date(NOW.getTime() - d * DAY_MS);
+ at.setUTCHours(8, 10, 0, 0);
+ events.push({ scheduledFor: at, takenAt: at, skipped: false });
+ }
+ const context = ctx();
+ const display = buildComplianceDisplay(events, schedules, context, {
+ now: NOW,
+ });
+ const short = calculateCompliance(
events,
schedules,
- ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
- { now: NOW },
+ display.shortDays,
+ context.createdAt,
+ { now: NOW, medicationContext: context },
);
- const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
- const statuses = past.map((c) => c.status);
- expect(statuses).toContain("taken");
- expect(statuses).toContain("skipped");
- expect(statuses).toContain("missed");
-
- const takenCell = past.find((c) => c.status === "taken")!;
- expect(takenCell.takenAt).not.toBeNull();
- expect(takenCell.timing).toBe("on_time");
-
- const skippedCell = past.find((c) => c.status === "skipped")!;
- expect(skippedCell.takenAt).toBeNull();
- expect(skippedCell.timing).toBeNull();
-
- // Summary: taken counts in numerator + denominator; skipped excluded
- // from both; missed in denominator only.
- expect(display.recentDoseSummary.taken).toBe(1);
- expect(display.recentDoseSummary.total).toBe(2); // taken + missed
- });
-
- it("dose-streak: counts consecutive taken-or-skipped back from the newest, breaks on a miss", () => {
- // Weekly Mondays: 5/26 missed, 6/2 taken, 6/9 taken → newest-back
- // streak = 2 (stops at the 5/26 miss).
- const schedules: ComplianceSchedule[] = [
- {
- windowStart: "08:00",
- windowEnd: "09:00",
- daysOfWeek: null,
- rrule: "FREQ=WEEKLY;BYDAY=MO",
- timesOfDay: ["08:00"],
- },
- ];
- const events = [
- {
- scheduledFor: new Date("2025-06-09T08:00:00Z"),
- takenAt: new Date("2025-06-09T08:05:00Z"),
- skipped: false,
- },
- {
- scheduledFor: new Date("2025-06-02T08:00:00Z"),
- takenAt: new Date("2025-06-02T08:05:00Z"),
- skipped: false,
- },
- // 5/26 missed.
- ];
- const display = buildComplianceDisplay(
+ const long = calculateCompliance(
events,
schedules,
- ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
- { now: NOW },
+ display.longDays,
+ context.createdAt,
+ { now: NOW, medicationContext: context },
);
- expect(display.recentDoseSummary.doseStreak).toBe(2);
+ expect(display.short.rate).toBe(short.rate);
+ expect(display.long.rate).toBe(long.rate);
+ expect(display.short.streak).toBe(short.streak);
});
- it("caps the past strip at 12 cells for a dense daily med", () => {
+ it("never reports a timeline mode field — the display is two rows only", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
@@ -1228,8 +1216,10 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
- const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
- expect(past.length).toBeLessThanOrEqual(12);
+ expect(display).not.toHaveProperty("mode");
+ expect(display).not.toHaveProperty("doseTimeline");
+ expect(display.short).toBeDefined();
+ expect(display.long).toBeDefined();
});
});
diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts
index 8bcc7a20c..be57e63eb 100644
--- a/src/lib/analytics/compliance.ts
+++ b/src/lib/analytics/compliance.ts
@@ -23,7 +23,6 @@ import {
buildCadenceTimeline,
type CadenceEngineContext,
type IntakeEventLike,
- type PairedDose,
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import {
@@ -33,7 +32,6 @@ import {
type RecurrenceContext,
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
-import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface IntakeEvent {
takenAt: Date | null;
@@ -365,122 +363,142 @@ export function expectedSlotsBetween(
}
/**
- * v1.8.5 — render mode for the per-medication compliance display. A
- * window's percentage is only meaningful once the denominator is large
- * enough that a single miss moves the rate by a small amount; below that
- * the bar swings violently and tells the user nothing. So:
- *
- * - `"percent"`: keep the 7-/30-day percentage bars (dense cadences:
- * daily, multi-daily, weekday meds). The window holds ≥
- * `MIN_STABLE_DOSES` expected doses.
- * - `"timeline"`: swap to an uptime-style per-dose strip (sparse cadences:
- * weekly, bi-/tri-weekly, rolling 35-day injections) where each cell is
- * one expected dose, taken=green / missed=red / skipped=grey.
- */
-export type ComplianceDisplayMode = "percent" | "timeline";
-
-/**
- * v1.8.5 — one cell of the dose-adherence timeline. A projection of the
- * canonical engine's expected-dose slots paired with the recorded intake,
- * oldest → newest. `site` is reserved for injection meds so the strip can
- * dual-encode rotation; it is `null` until the per-dose injection-site
- * write path lands.
- */
-export interface DoseTimelineCell {
- /** ISO instant of the expected dose slot. */
- scheduledFor: string;
- status: "taken" | "missed" | "skipped" | "upcoming";
- /** ISO instant the dose was logged, or null when not taken. */
- takenAt: string | null;
- /** Punctuality bucket for a taken dose; null when not taken. */
- timing: IntakeTimingClass | null;
- /** Injection zone for injection meds; null otherwise / when unrecorded. */
- site: InjectionSiteKey | null;
-}
-
-/**
- * v1.8.5 — the additive compliance-display block returned alongside the
- * existing `compliance7` / `compliance30` fields (which iOS + the Health
- * Score still read verbatim). The server decides `mode` from the density
- * rule so the client stays dumb.
- */
-export interface ComplianceDisplay {
- mode: ComplianceDisplayMode;
- /** Realised expected dose count over the 7-day window. */
- expected7: number;
- /** Realised expected dose count over the 30-day window. */
- expected30: number;
- /** Echo of the density threshold so a client can re-derive the mode. */
- minStableDoses: number;
- /** Uptime strip — last N expected dose slots, oldest → newest. */
- doseTimeline: DoseTimelineCell[];
- /** Compact summary for the "last N doses" line. */
- recentDoseSummary: { taken: number; total: number; doseStreak: number };
-}
-
-/**
- * v1.8.5 — the floor at which a window's percentage is stable. Four
+ * v1.8.6 — the floor at which a window's percentage is stable. Four
* expected doses is the point where a single miss moves the rate by ≤25%
- * rather than ±50–100%. A daily med clears it in 4 days; a weekly med
- * needs ~28 days; a tri-weekly / 35-day-rolling med never clears it in a
- * 30-day window — which is exactly the signal to show the timeline.
+ * rather than ±50–100%. A daily med clears it in a 7-day window; a weekly
+ * med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or
+ * more. The window ladder below steps up until both rows clear this floor.
*/
export const MIN_STABLE_DOSES = 4;
-/** Number of past dose cells the card strip carries. */
-const TIMELINE_PAST_DOSES = 12;
-/** Number of upcoming dose cells appended so the strip shows what's next. */
-const TIMELINE_UPCOMING_DOSES = 2;
+/**
+ * v1.8.6 — the rung ladder for the two compliance windows. Each rung is a
+ * `[short, long]` pair of day-counts. The card always shows two percentage
+ * rows; the only thing that scales with cadence is which rung the windows
+ * sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected
+ * dose frequency drops, both windows step up so each row still spans enough
+ * expected doses to mean something, up to a 12-month long window for very
+ * rare meds.
+ */
+export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray =
+ [
+ [7, 30],
+ [30, 90],
+ [90, 365],
+ ];
-/** A single paired dose, distilled into a timeline cell. */
-function toTimelineCell(dose: PairedDose): DoseTimelineCell {
- const takenAt = dose.match?.takenAt ?? null;
- return {
- scheduledFor: dose.windowStart.toISOString(),
- status: dose.status,
- takenAt: takenAt ? takenAt.toISOString() : null,
- // Punctuality is computed against the slot window the engine already
- // emitted. `windowStart` / `windowEnd` are full instants here; reuse
- // the HH:mm classifier by formatting them back to wall-clock on the
- // slot's own day so an early / late dose reads correctly.
- timing:
- dose.status === "taken" && takenAt
- ? classifyIntakeTiming(
- takenAt,
- hhmmOf(dose.windowStart),
- hhmmOf(dose.windowEnd),
- dose.windowStart,
- )
- : null,
- // Injection-site dual-encoding is reserved for the per-dose write
- // path; the compliance read does not yet carry it.
- site: null,
- };
-}
-
-/** Format a Date's UTC wall-clock as "HH:mm" for the punctuality classifier. */
-function hhmmOf(d: Date): string {
- const h = String(d.getUTCHours()).padStart(2, "0");
- const m = String(d.getUTCMinutes()).padStart(2, "0");
- return `${h}:${m}`;
+/**
+ * v1.8.6 — the day-counts of the two compliance windows a medication's
+ * cadence resolves to, plus the realised expected-dose count each window
+ * holds. `shortDays` / `longDays` drive the row labels; the expected counts
+ * are surfaced so a client can show the denominator or re-derive the rung.
+ */
+export interface ComplianceWindowSelection {
+ shortDays: number;
+ longDays: number;
+ expectedShort: number;
+ expectedLong: number;
}
/**
- * v1.8.5 — compute the additive {@link ComplianceDisplay} block.
+ * v1.8.6 — pick the two compliance windows for a medication from its
+ * dosing cadence.
*
- * The mode is decided purely by density: when the 30-day window holds at
- * least {@link MIN_STABLE_DOSES} expected doses the percentage bars stay
- * (`"percent"`); otherwise the per-dose timeline takes over (`"timeline"`).
- * The 30-day window is the honest denominator — a weekly med clears the
- * floor there, a sparse injection never does.
+ * Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and
+ * returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES}
+ * realised expected doses. A daily med clears `[7, 30]` immediately; a
+ * weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a
+ * 35-day-rolling injection needs the top rung `[90, 365]`. When even the
+ * top rung can't clear the floor (a brand-new prescription, a very rare
+ * med) the top rung is returned anyway so the card still shows two honest
+ * percentage rows over the widest windows available.
*
- * The timeline itself is a projection of {@link buildCadenceTimeline}: the
- * engine already pairs each expected slot to its closest intake, so we
- * take the last {@link TIMELINE_PAST_DOSES} past slots plus a couple of
- * upcoming ones and distil each into a cell. `doseStreak` counts
- * consecutive taken-or-skipped doses back from the newest non-upcoming
- * dose — dose-indexed, unlike the day-indexed `streak` on
- * {@link ComplianceResult} which inflates on off-cadence days.
+ * The expected count routes through {@link expectedSlotsBetween} (the
+ * canonical recurrence engine), so PRN / off-cadence / pre-creation days
+ * never inflate the denominator.
+ */
+export function selectComplianceWindows(
+ schedules: ComplianceSchedule[],
+ ctx: ComplianceMedicationContext,
+ options?: { now?: Date },
+): ComplianceWindowSelection {
+ const now = options?.now ?? new Date();
+ const DAY_MS = 24 * 60 * 60 * 1000;
+
+ const expectedOver = (days: number): number =>
+ expectedSlotsBetween(
+ schedules,
+ new Date(now.getTime() - days * DAY_MS),
+ now,
+ ctx,
+ ).length;
+
+ // Memoise per distinct window so a shared rung boundary (e.g. 30 / 90)
+ // isn't re-walked across rungs.
+ const cache = new Map();
+ const expected = (days: number): number => {
+ const hit = cache.get(days);
+ if (hit !== undefined) return hit;
+ const v = expectedOver(days);
+ cache.set(days, v);
+ return v;
+ };
+
+ for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
+ const expectedShort = expected(shortDays);
+ const expectedLong = expected(longDays);
+ if (
+ expectedShort >= MIN_STABLE_DOSES &&
+ expectedLong >= MIN_STABLE_DOSES
+ ) {
+ return { shortDays, longDays, expectedShort, expectedLong };
+ }
+ }
+
+ // No rung cleared the floor — fall back to the widest rung so both rows
+ // still render over the most data the cadence affords.
+ const [shortDays, longDays] =
+ COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1];
+ return {
+ shortDays,
+ longDays,
+ expectedShort: expected(shortDays),
+ expectedLong: expected(longDays),
+ };
+}
+
+/**
+ * v1.8.6 — the compliance-display block returned alongside the existing
+ * `compliance7` / `compliance30` fields (which iOS + the Health Score read
+ * verbatim). The card always renders two percentage rows; the server picks
+ * the two windows from the medication's cadence and computes each row's
+ * rate over the chosen span. A dense med keeps `7` / `30`; a sparse med
+ * steps both windows up so each row covers enough expected doses to be
+ * meaningful.
+ */
+export interface ComplianceDisplay {
+ shortDays: number;
+ longDays: number;
+ /** Realised expected dose count over the short window. */
+ expectedShort: number;
+ /** Realised expected dose count over the long window. */
+ expectedLong: number;
+ /** Echo of the density floor so a client can re-derive the rung. */
+ minStableDoses: number;
+ /** Compliance percentage + day-streak over the short window. */
+ short: { rate: number; streak: number };
+ /** Compliance percentage over the long window. */
+ long: { rate: number };
+}
+
+/**
+ * v1.8.6 — compute the two-row {@link ComplianceDisplay} block.
+ *
+ * The card always shows two percentage rows. {@link selectComplianceWindows}
+ * decides which windows they span from the medication's cadence, then each
+ * row's rate is the cadence-aware {@link calculateCompliance} over that
+ * window. The short row also carries the day-streak. The compliance math is
+ * unchanged from the legacy 7-/30-day path — only the window day-counts move.
*/
export function buildComplianceDisplay(
events: IntakeEvent[],
@@ -489,108 +507,26 @@ export function buildComplianceDisplay(
options?: { now?: Date },
): ComplianceDisplay {
const now = options?.now ?? new Date();
- const DAY_MS = 24 * 60 * 60 * 1000;
+ const { shortDays, longDays, expectedShort, expectedLong } =
+ selectComplianceWindows(schedules, ctx, { now });
- // Realised expected counts over each window. `expectedSlotsBetween`
- // already excludes PRN / off-cadence days through the canonical engine.
- const from7 = new Date(now.getTime() - 7 * DAY_MS);
- const from30 = new Date(now.getTime() - 30 * DAY_MS);
- const expected7 = expectedSlotsBetween(schedules, from7, now, ctx).length;
- const expected30 = expectedSlotsBetween(schedules, from30, now, ctx).length;
-
- const mode: ComplianceDisplayMode =
- expected30 >= MIN_STABLE_DOSES ? "percent" : "timeline";
-
- // Build the per-dose strip from the same engine the cadence chart uses.
- // Pull a generous past window (90 days) so even a 35-day-rolling med
- // surfaces a few cells, then keep the last N plus a short look-ahead.
- const engineCtx: CadenceEngineContext = {
- startsOn: ctx.startsOn,
- endsOn: ctx.endsOn,
- oneShot: ctx.oneShot,
- createdAt: ctx.createdAt,
- lastIntakeAt: ctx.lastIntakeAt,
- timeZone: ctx.timeZone,
- };
- const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
- id: `display-${i}`,
- windowStart: s.windowStart,
- windowEnd: s.windowEnd,
- daysOfWeek: s.daysOfWeek ?? null,
- rrule: s.rrule ?? null,
- rollingIntervalDays: s.rollingIntervalDays ?? null,
- timesOfDay: s.timesOfDay,
- reminderGraceMinutes: s.reminderGraceMinutes ?? null,
- scheduleType: s.scheduleType ?? null,
- cyclicOnWeeks: s.cyclicOnWeeks ?? null,
- cyclicOffWeeks: s.cyclicOffWeeks ?? null,
- }));
- const normalisedEvents: IntakeEventLike[] = events.map((e) => ({
- scheduledFor: e.scheduledFor,
- takenAt: e.takenAt,
- skipped: e.skipped,
- }));
-
- // Past window for the strip — bounded so a daily med doesn't return
- // hundreds of cells. `buildCadenceTimeline` anchors on `now - windowDays`
- // and projects forward; a small look-ahead is added below.
- const STRIP_PAST_DAYS = 90;
- const past = buildCadenceTimeline(
- normalisedSchedules,
- normalisedEvents,
+ const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, {
now,
- STRIP_PAST_DAYS,
- ctx.createdAt,
- engineCtx.timeZone,
- engineCtx,
- )
- .filter((d) => d.status !== "upcoming")
- .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime());
-
- // Upcoming slots — expand a short forward window and keep the soonest.
- const lookahead = new Date(now.getTime() + 60 * DAY_MS);
- const upcoming = buildCadenceTimeline(
- normalisedSchedules,
- normalisedEvents,
- lookahead,
- 60,
- ctx.createdAt,
- engineCtx.timeZone,
- engineCtx,
- )
- .filter((d) => d.status === "upcoming" && d.windowStart.getTime() > now.getTime())
- .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime())
- .slice(0, TIMELINE_UPCOMING_DOSES);
-
- const recentPast = past.slice(-TIMELINE_PAST_DOSES);
- const doseTimeline = [...recentPast, ...upcoming].map(toTimelineCell);
-
- // Summary over the past cells the strip shows.
- const summaryTaken = recentPast.filter((d) => d.status === "taken").length;
- const summaryTotal = recentPast.filter(
- (d) => d.status === "taken" || d.status === "missed",
- ).length;
-
- // Dose-streak: consecutive taken-or-skipped doses, newest → oldest, from
- // the newest non-upcoming dose. A miss breaks it; a skip does not.
- let doseStreak = 0;
- for (let i = recentPast.length - 1; i >= 0; i--) {
- const s = recentPast[i].status;
- if (s === "missed") break;
- if (s === "taken" || s === "skipped") doseStreak++;
- }
+ medicationContext: ctx,
+ });
+ const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, {
+ now,
+ medicationContext: ctx,
+ });
return {
- mode,
- expected7,
- expected30,
+ shortDays,
+ longDays,
+ expectedShort,
+ expectedLong,
minStableDoses: MIN_STABLE_DOSES,
- doseTimeline,
- recentDoseSummary: {
- taken: summaryTaken,
- total: summaryTotal,
- doseStreak,
- },
+ short: { rate: short.rate, streak: short.streak },
+ long: { rate: long.rate },
};
}