fix(medications): tally the cadence chips from the compliance ledger

`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:37:44 +02:00
1 parent e355c6120f
commit feb8058233
6 files changed
+225 -251

No files matched your search

@@ -0,0 +1,55 @@
/**
* Structural guard: medication compliance is tallied from one ledger.
*
* The cadence chips carried a private copy of the ledger build that
* `lib/analytics/compliance/ledger.ts` owns, minus the pause eras and the
* courses, so `/api/medications/[id]/cadence` could report 77 % for a month
* the detail page called 100 %. The chips now tally the shared ledger.
*
* `reconstructDoseHistory` is the step that turns minted bands and intakes
* into dose rows. The set of files that call it is frozen: the ledger, the
* cadence timeline (the chart and the streaks) and the dose-history view. A
* new caller is a new ledger and has to be added here, next to a reason.
* Comments are stripped first; the count floor proves the matcher reads.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const CALLERS: Record<string, string> = {
"lib/analytics/compliance/ledger.ts":
"The compliance ledger every rate and the chips tally.",
"lib/medications/scheduling/cadence.ts":
"The cadence timeline the chart draws and the streaks walk.",
"app/api/medications/[id]/dose-history/route.ts":
"The dose-history view, row by row over the same bands.",
};
const CALL = /\breconstructDoseHistory\s*\(/;
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
describe("one compliance ledger", () => {
it("the dose rows are built only where this file says", () => {
const callers = walkSourceFiles(SRC, { floor: 3000 })
.filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file) &&
file !== "lib/medications/scheduling/dose-history.ts",
)
.filter((file) =>
CALL.test(stripComments(readFileSync(join(SRC, file), "utf8"))),
)
.sort();
expect(callers.length).toBeGreaterThanOrEqual(3);
expect(callers).toEqual(Object.keys(CALLERS).sort());
});
});
+22 -17
View File
@@ -24,10 +24,12 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
type CadenceEngineContext,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import {
buildComplianceMedicationContext,
COMPLIANCE_LEDGER_WINDOW_DAYS,
COURSES_COMPLIANCE_SELECT,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -58,6 +60,11 @@ export const GET = apiHandler(
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware chips/tallies.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// The pause eras and courses, so the chips tally the ledger the
// detail page's rate reads: a paused day or a gap between two
// courses expects nothing.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
courses: COURSES_COMPLIANCE_SELECT,
},
});
if (!med) {
@@ -75,11 +82,13 @@ export const GET = apiHandler(
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
// The events the compliance ledger reads (its full trailing year plus a
// day): the chips tally the same rows as the detail page, and a rolling
// cadence re-anchors on the latest intake even when it predates the
// chart's window.
const from = new Date(
asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs,
asOf.getTime() -
(COMPLIANCE_LEDGER_WINDOW_DAYS + 1) * 24 * 60 * 60 * 1000,
);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
@@ -93,6 +102,8 @@ export const GET = apiHandler(
scheduledFor: true,
takenAt: true,
skipped: true,
// A dose the auto-miss job closed counts as missed, as in the rate.
autoMissed: true,
// v1.15.20 — pinned takes bind by anchor in the chip tally so the
// chips agree with the % + the history on a "zugeordnet" dose.
attributionSource: true,
@@ -121,16 +132,11 @@ export const GET = apiHandler(
// legacy weekday walker (e.g. `FREQ=WEEKLY;BYDAY=MO` expands daily,
// PRN counts as a real cadence) and disagrees with the dashboard.
// Mirrors `analytics/compliance.ts`'s context construction.
const lastIntakeAt = lastNonSkippedTakenAt(events);
const engineCtx: CadenceEngineContext = {
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
lastIntakeAt,
timeZone: userTz,
scheduleRevisions: med.scheduleRevisions,
};
const engineCtx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
const liveSchedules = dueSchedules(med);
@@ -146,11 +152,10 @@ export const GET = apiHandler(
const chips = complianceChips(
liveSchedules,
events,
engineCtx,
asOf,
windowDays,
anchor,
userTz,
engineCtx,
);
const next = computeNextDose(
liveSchedules,
+4 -9
View File
@@ -29,9 +29,9 @@ import {
} from "./windows";
import {
COMPLIANCE_LEDGER_WINDOW_DAYS,
buildComplianceLedgerRows,
tallyComplianceFromLedger,
tallyLedgerRows,
trailingComplianceLedger,
trailingComplianceTally,
} from "./ledger";
import { buildCurrentCycle, type CurrentCycle } from "./cycle";
import { rollingIntakeInstants } from "./adapters";
@@ -564,9 +564,7 @@ export function buildMedicationComplianceBundle(
: ledgerPeriodStart;
const hasSchedules = schedules.length > 0;
const ledgerRows = hasSchedules
? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now)
: [];
const ledgerRows = trailingComplianceLedger(events, schedules, ctx, now);
// ONE cadence timeline over the full ledger window. Each per-window
// streak below walks only its own trailing `effectiveDays`, so sharing
@@ -607,10 +605,7 @@ export function buildMedicationComplianceBundle(
1,
Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS),
);
const tally = tallyLedgerRows(ledgerRows, {
from: periodStart,
to: now,
});
const tally = trailingComplianceTally(ledgerRows, days, now);
const { current: streak } = streaksFromTimeline(
timeline,
now,
+35
View File
@@ -306,6 +306,41 @@ export function tallyLedgerRows(
*/
export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365;
/**
* The ledger over the trailing {@link COMPLIANCE_LEDGER_WINDOW_DAYS} ending at
* `now`: the one set of dose rows every per-medication compliance figure is
* tallied from. A slot before the medication's creation stays in it only when
* a recorded dose claims it (#1028).
*/
export function trailingComplianceLedger(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
now: Date,
): DoseHistoryRow[] {
if (schedules.length === 0) return [];
const from = new Date(
now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
return buildComplianceLedgerRows(events, schedules, ctx, from, now, now);
}
/**
* The tally of the trailing `days` ending at `now`, carved out of
* {@link trailingComplianceLedger}: the same rows, so the same window reads
* the same rate on every surface.
*/
export function trailingComplianceTally(
ledger: DoseHistoryRow[],
days: number,
now: Date,
): LedgerComplianceTally {
return tallyLedgerRows(ledger, {
from: new Date(now.getTime() - days * 24 * 60 * 60 * 1000),
to: now,
});
}
/**
* One day of the cadence-aware compliance series: the day's compliance %
* over the doses the schedule actually expected that day, plus the raw
@@ -8,16 +8,29 @@
import { describe, expect, it } from "vitest";
import { complianceChips, streaksFromTimeline } from "../compliance";
import { buildCadenceTimeline } from "../cadence";
import type {
CadenceEngineContext,
IntakeEventLike,
ScheduleLike,
} from "../cadence";
import type { IntakeEventLike, ScheduleLike } from "../cadence";
import type { ComplianceMedicationContext } from "@/lib/analytics/compliance/types";
import { buildMedicationComplianceBundle } from "@/lib/analytics/compliance/display";
function d(iso: string): Date {
return new Date(iso);
}
/** The medication context the chips read; UTC unless a test says otherwise. */
function ctx(
over?: Partial<ComplianceMedicationContext>,
): ComplianceMedicationContext {
return {
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: d("2025-05-01T00:00:00Z"),
lastIntakeAt: null,
timeZone: "UTC",
...over,
};
}
const DAILY_8AM: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
@@ -27,7 +40,7 @@ const DAILY_8AM: ScheduleLike = {
describe("complianceChips", () => {
it("returns null adherence when no doses are expected in the window", () => {
const NOW = d("2025-06-10T12:00:00");
const result = complianceChips([], [], NOW, 30);
const result = complianceChips([], [], ctx(), NOW, 30);
expect(result.adherenceRate).toBeNull();
expect(result.missedLast30).toBe(0);
});
@@ -44,14 +57,14 @@ describe("complianceChips", () => {
}
// 5-day window: 5 expected, 5 taken, 0 missed (today's slot is upcoming
// because NOW is 12:00 and window is 08:00-09:00... actually past today)
const result = complianceChips([DAILY_8AM], events, NOW, 5);
const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 5);
expect(result.adherenceRate).toBeGreaterThanOrEqual(80);
expect(result.missedLast30).toBeLessThanOrEqual(1);
});
it("computes 0% adherence when no events match any past slot", () => {
const NOW = d("2025-06-10T12:00:00");
const result = complianceChips([DAILY_8AM], [], NOW, 5);
const result = complianceChips([DAILY_8AM], [], ctx(), NOW, 5);
expect(result.adherenceRate).toBe(0);
expect(result.missedLast30).toBeGreaterThan(0);
});
@@ -60,25 +73,25 @@ describe("complianceChips", () => {
const NOW = d("2025-06-10T12:00:00");
// Window: NOW - 3d = 2025-06-07T12:00 → past slots: 08, 09, 10
// Provide one taken + one skipped + one taken for the three slots so
// the denominator excludes the skipped one.
// the denominator excludes the skipped one. A skip names its slot.
const events: IntakeEventLike[] = [
{
scheduledFor: d("2025-06-08T08:30:00"),
scheduledFor: d("2025-06-08T08:00:00"),
takenAt: d("2025-06-08T08:35:00"),
skipped: false,
},
{
scheduledFor: d("2025-06-09T08:30:00"),
scheduledFor: d("2025-06-09T08:00:00"),
takenAt: null,
skipped: true,
},
{
scheduledFor: d("2025-06-10T08:30:00"),
scheduledFor: d("2025-06-10T08:00:00"),
takenAt: d("2025-06-10T08:35:00"),
skipped: false,
},
];
const result = complianceChips([DAILY_8AM], events, NOW, 3);
const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 3);
// 2 taken / (2 taken + 0 missed) = 100, skipped excluded.
expect(result.adherenceRate).toBe(100);
});
@@ -97,7 +110,7 @@ describe("complianceChips", () => {
skipped: false,
});
}
const result = complianceChips([DAILY_8AM], events, NOW, 14);
const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 14);
expect(result.currentStreak).toBeGreaterThanOrEqual(4);
});
@@ -132,7 +145,7 @@ describe("complianceChips", () => {
skipped: false,
},
];
const result = complianceChips([DAILY_8AM], events, NOW, 7);
const result = complianceChips([DAILY_8AM], events, ctx(), NOW, 7);
// Streak ending at today = day 9 + day 10 = 2 (day 8 missed broke it).
expect(result.currentStreak).toBeLessThanOrEqual(2);
// Longest streak in window = max(3 from 5..7, 2 from 9..10) = 3.
@@ -158,7 +171,7 @@ describe("complianceChips", () => {
skipped: false,
},
];
const result = complianceChips([weekly], events, NOW, 14);
const result = complianceChips([weekly], events, ctx(), NOW, 14);
expect(result.adherenceRate).toBe(100);
expect(result.missedLast30).toBe(0);
});
@@ -170,7 +183,7 @@ describe("complianceChips", () => {
daysOfWeek: "1", // Monday only
};
const NOW = d("2025-06-10T12:00:00"); // Tuesday
const result = complianceChips([weekly], [], NOW, 14);
const result = complianceChips([weekly], [], ctx(), NOW, 14);
expect(result.adherenceRate).toBe(0);
expect(result.missedLast30).toBeGreaterThan(0);
});
@@ -199,18 +212,16 @@ describe("complianceChips", () => {
const berlin = complianceChips(
[sched],
events,
ctx({ timeZone: "Europe/Berlin" }),
NOW,
7,
undefined,
"Europe/Berlin",
);
const tokyo = complianceChips(
[sched],
events,
ctx({ timeZone: "Asia/Tokyo" }),
NOW,
7,
undefined,
"Asia/Tokyo",
);
// Both must compute a finite adherence rate (the schedule has
@@ -226,23 +237,10 @@ describe("complianceChips", () => {
});
describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", () => {
function engineCtx(
over?: Partial<CadenceEngineContext>,
): CadenceEngineContext {
return {
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: d("2025-05-01T00:00:00Z"),
lastIntakeAt: null,
timeZone: "Europe/Berlin",
...over,
};
}
const engineCtx = (over?: Partial<ComplianceMedicationContext>) =>
ctx({ timeZone: "Europe/Berlin", ...over });
it("RRULE weekly Monday: legacy walker counts every day missed, engine counts only Mondays", () => {
// `daysOfWeek = null` reads as every-day to the legacy walker, so an
// RRULE-weekly schedule over-counts expected doses without the engine.
it("RRULE weekly Monday: only the Mondays in the window are expected", () => {
const rruleWeekly: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
@@ -250,25 +248,15 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
};
const NOW = d("2025-06-10T12:00:00Z"); // Tuesday
// Saturday: both Mondays in the window are past a weekly dose's late tail.
const NOW = d("2025-06-14T12:00:00Z");
// Legacy path (no engineCtx) expands daily → many missed days.
const legacy = complianceChips([rruleWeekly], [], NOW, 14);
// Canonical path (engineCtx) expands only Mondays in the window.
const engine = complianceChips(
[rruleWeekly],
[],
NOW,
14,
undefined,
"Europe/Berlin",
engineCtx(),
);
const engine = complianceChips([rruleWeekly], [], engineCtx(), NOW, 14);
// The engine path expects far fewer doses (only the Mondays in a
// 14-day window: 2) than the daily legacy expansion (~13).
expect(engine.missedLast30).toBeLessThan(legacy.missedLast30);
expect(engine.missedLast30).toBeLessThanOrEqual(2);
// Two Mondays in a 14-day window, both missed; `daysOfWeek = null`
// must not read as every day.
expect(engine.missedLast30).toBe(2);
expect(engine.adherenceRate).toBe(0);
});
it("PRN: engine short-circuits to zero expected doses (no adherence, no missed)", () => {
@@ -281,15 +269,7 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
};
const NOW = d("2025-06-10T12:00:00Z");
const engine = complianceChips(
[prn],
[],
NOW,
30,
undefined,
"Europe/Berlin",
engineCtx(),
);
const engine = complianceChips([prn], [], engineCtx(), NOW, 30);
// PRN is as-needed — never projected, reminded, or counted in
// compliance-expected. No expected slots → null adherence, zero missed.
@@ -315,20 +295,16 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
const engine = complianceChips(
[cyclic],
[],
engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }),
NOW,
14,
undefined,
"Europe/Berlin",
engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }),
);
const everyDay = complianceChips(
[{ ...cyclic, scheduleType: "SCHEDULED" }],
[],
engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }),
NOW,
14,
undefined,
"Europe/Berlin",
engineCtx({ startsOn: d("2025-05-01T00:00:00Z") }),
);
// The cyclic gate must drop the off-week's doses, so fewer missed
@@ -445,3 +421,43 @@ describe("streaksFromTimeline — DST-correct day walk", () => {
expect(current).toBe(7);
});
});
describe("complianceChips — the detail page's rate", () => {
it("drops a pause from the chips exactly as the compliance payload does", () => {
// Daily 08:00, paused from 10 days ago to 3 days ago, every other dose
// taken. The chips used to tally the paused week as seven missed doses
// (77 %) while the detail page, reading the compliance ledger, said 100.
const NOW = d("2025-06-30T12:00:00Z");
const day = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
const pausedAt = day(10);
const resumedAt = day(3);
const events: IntakeEventLike[] = [];
for (let n = 1; n <= 30; n++) {
const slot = new Date(day(n).setUTCHours(8, 0, 0, 0));
if (slot >= pausedAt && slot < resumedAt) continue;
events.push({ scheduledFor: slot, takenAt: slot, skipped: false });
}
const daily: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
};
const context = ctx({
createdAt: d("2025-01-01T00:00:00Z"),
pauseEras: [{ pausedAt, resumedAt }],
});
const chips = complianceChips([daily], events, context, NOW, 30);
const bundle = buildMedicationComplianceBundle(
events,
[daily],
context,
NOW,
);
expect(chips.missedLast30).toBe(0);
expect(chips.adherenceRate).toBe(100);
expect(chips.adherenceRate).toBe(bundle.compliance30.rate);
});
});
+24 -156
View File
@@ -6,33 +6,28 @@
* rate, current streak (days), longest streak (days), missed in last
* 30 days. Each chip is monochrome — maintainer note: no gamified badges.
*
* Distinct from `src/lib/analytics/compliance.ts.calculateCompliance`:
* that helper computes against expected counts (schedules-per-day ×
* days) and is used by the existing /api/medications/[id]/compliance
* route. This module computes against the pair-matched timeline so
* the chips and the cadence chart agree on every single dose.
* Adherence and missed doses come from the compliance ledger the
* /api/medications/[id]/compliance route reads, so the chips and the
* detail page's rate agree; the streaks walk the pair-matched timeline
* the cadence chart draws.
*
* v1.4.25 W21 Fix-O — accepts an optional IANA `timeZone` argument
* forwarded into the timeline + streak math so a user in Tokyo gets
* the same chip values as a user in Berlin even when the host's
* system time is set differently. Omitting the argument keeps the
* legacy system-local behaviour the W19e tests pin.
* The timeline and streak math run in the context's IANA zone, so a user
* in Tokyo gets the same chip values as a user in Berlin whatever the
* host's system time is.
*/
import type { SlotBand } from "./attribution";
import { type BandMinterMedication } from "./band-minter";
import { buildBandsForSchedulesWithEras } from "./schedule-eras";
import {
buildCadenceTimeline,
startOfLocalDay,
type CadenceEngineContext,
type IntakeEventLike,
type PairedDose,
type ScheduleLike,
} from "./cadence";
import { reconstructDoseHistory, type HistoryIntake } from "./dose-history";
import { type CanonicalSchedule, type RecurrenceContext } from "./recurrence";
import { normaliseDoseWindows } from "./worker-helpers";
import {
trailingComplianceLedger,
trailingComplianceTally,
} from "@/lib/analytics/compliance/ledger";
import type { ComplianceMedicationContext } from "@/lib/analytics/compliance/types";
import { userDayKey } from "@/lib/tz/resolver";
export interface ComplianceChips {
@@ -146,134 +141,19 @@ export function streaksFromTimeline(
}
/**
* v1.15.18 — band-ledger tally counts for the detail-page chips.
*
* When the route threads an engine context (RRULE / rolling / cyclic /
* one-shot / PRN / legacy) the adherence rate + missed count come from the
* SAME unified dose-history ledger the compliance % and the history view
* read — so the chips can never contradict either. The streak still walks
* the cadence timeline (its day-grain "every dose taken or skipped" rule is
* orthogonal to per-dose attribution). Returns null when there is no engine
* context (a pure-math caller) — the caller then falls back to the legacy
* timeline tally.
* The four chips: adherence and missed doses from the compliance ledger the
* detail page's rate reads (`trailingComplianceTally`, with the medication's
* pause eras and courses), the streaks from the cadence timeline (a
* day-grain "every dose taken or skipped" rule, orthogonal to per-dose
* attribution).
*/
function ledgerChipCounts(
schedules: ScheduleLike[],
events: IntakeEventLike[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
): { taken: number; missed: number } | null {
if (!engineCtx) return null;
const DAY_MS = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const userTz = engineCtx.timeZone || timeZone || "UTC";
const medication: BandMinterMedication = {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
timeZone: userTz,
lastIntakeAt: engineCtx.lastIntakeAt,
// v1.40 (#1024) — a medication: `startsOn` is a course start.
rollingAnchor: "courseStart",
};
const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => {
const base: CanonicalSchedule = {
id: s.id ?? `chips-${i}`,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
// Surface windowStart as the single time-of-day for a legacy daily row
// so the band minter's cadence gate mints its daily band (see the
// analytics/compliance tally for the same normalisation).
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!engineCtx.oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
const intakeInstants = events
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: canonicalSchedules,
revisions: engineCtx.scheduleRevisions ?? [],
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
now: asOf,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
// v1.15.20 — pinned takes bind by anchor (taken-late, slot served).
pinned: e.attributionSource === "USER_PIN",
}));
const rows = reconstructDoseHistory(
bands,
intakes,
asOf,
engineCtx.createdAt,
);
let taken = 0;
let missed = 0;
for (const row of rows) {
if (row.status === "taken_on_time" || row.status === "taken_late") taken++;
else if (row.status === "missed") missed++;
}
return { taken, missed };
}
export function complianceChips(
schedules: ScheduleLike[],
events: IntakeEventLike[],
ctx: ComplianceMedicationContext,
asOf: Date,
windowDays = 30,
anchor?: Date,
timeZone?: string,
engineCtx?: CadenceEngineContext,
): ComplianceChips {
const timeline = buildCadenceTimeline(
schedules,
@@ -281,33 +161,21 @@ export function complianceChips(
asOf,
windowDays,
anchor,
timeZone,
engineCtx,
ctx.timeZone,
ctx,
);
// v1.15.18 — adherence + missed come from the unified band ledger when an
// engine context is present (so the chips agree with the % and the history
// view); the legacy timeline tally is the fallback for pure-math callers.
const ledger = ledgerChipCounts(
schedules,
events,
asOf,
const { taken, missed } = trailingComplianceTally(
trailingComplianceLedger(events, schedules, ctx, asOf),
windowDays,
timeZone,
engineCtx,
asOf,
);
const taken = ledger
? ledger.taken
: timeline.filter((d) => d.status === "taken").length;
const missed = ledger
? ledger.missed
: timeline.filter((d) => d.status === "missed").length;
const denom = taken + missed;
const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100);
const { current, longest } = streaksFromTimeline(
timeline,
asOf,
windowDays,
timeZone,
ctx.timeZone,
);
return {
adherenceRate,