mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user