mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
chore(release): v1.5.0 — native iOS client public-beta + per-day stats overwrite + compliance fix
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it. ## Features - **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract. ## Fixes - **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned. ## Tests - 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate. - New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts. ## Docs - README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes. ## Final verify - typecheck clean - lint clean (one pre-existing warning in `withings/resume/__tests__`) - pnpm test: 5285 passed / 1 skipped - pnpm test:integration: 253 passed / 3 skipped - GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
This commit is contained in:
1 parent
def9ae6f39
commit
394f0b0795
16 files changed
+1010
-265
No files matched your search
@@ -17,6 +17,14 @@
|
||||
* a partial-success retry, or two devices uploading the same
|
||||
* HealthKit sample, surface as `duplicate` per entry instead of
|
||||
* hard-failing the batch.
|
||||
* - v1.5.0 issue #213 — entries whose `externalId` starts with
|
||||
* `stats:` are per-day cumulative totals (Steps, Active Energy,
|
||||
* Sleep Duration, Walking/Running Distance, Flights Climbed). The
|
||||
* iOS HealthKit observer re-posts those throughout the day as the
|
||||
* running total changes. They surface as `updated` (the row's
|
||||
* value is overwritten) rather than `duplicate` (the new value
|
||||
* dropped). Sample-class externalIds keep the strict immutable
|
||||
* `duplicate` contract.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -91,12 +99,28 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
|
||||
/**
|
||||
* Per-entry outcome the iOS client uses to advance its sync cursor.
|
||||
* `inserted` and `duplicate` both indicate the row landed (or was
|
||||
* already present), so the client can checkpoint past them.
|
||||
* `skipped` is the only case where the client may want to surface a
|
||||
* diagnostic — typically because Apple introduced a new identifier the
|
||||
* server doesn't know about yet.
|
||||
* already present), so the client can checkpoint past them. `updated`
|
||||
* is the per-day-aggregate overwrite path: when the iOS client re-posts
|
||||
* a `stats:*` cumulative row (Steps, Active Energy, Sleep Duration,
|
||||
* Walking/Running Distance, Flights Climbed — see issue #213) for a day
|
||||
* we already have, the server overwrites the row's `value` rather than
|
||||
* dropping the new payload as a duplicate. Sample-class rows (every
|
||||
* other HK metric) keep the strict `duplicate` semantics because each
|
||||
* sample is a canonical, immutable reading. `skipped` is the only case
|
||||
* where the client may want to surface a diagnostic — typically because
|
||||
* Apple introduced a new identifier the server doesn't know about yet.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
|
||||
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
|
||||
// totals that the iOS HealthKit observer re-posts as the day progresses.
|
||||
// Treating those as immutable duplicates freezes the day's tile at the
|
||||
// first-sync value. Sample-class externalIds (every other prefix) keep
|
||||
// the strict insert-only contract.
|
||||
const STATS_EXTERNAL_ID_PREFIX = "stats:";
|
||||
function isStatsExternalId(externalId: string | null | undefined): boolean {
|
||||
return typeof externalId === "string" && externalId.startsWith(STATS_EXTERNAL_ID_PREFIX);
|
||||
}
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -218,6 +242,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
if (prepared.length > 0) {
|
||||
@@ -247,17 +272,54 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
|
||||
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const toOverwrite: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
const key = `${p.row.type}::${p.row.externalId}`;
|
||||
if (existingSet.has(key)) {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
|
||||
// intentionally overwritten on a re-post so today's tile reflects
|
||||
// the latest HealthKit total instead of freezing at the
|
||||
// first-sync value. Sample-class rows keep the immutable
|
||||
// duplicate contract.
|
||||
if (isStatsExternalId(p.row.externalId as string)) {
|
||||
toOverwrite.push(p);
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p.row);
|
||||
}
|
||||
}
|
||||
|
||||
if (toOverwrite.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const p of toOverwrite) {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "APPLE_HEALTH",
|
||||
type: p.row.type as MeasurementType,
|
||||
externalId: p.row.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: p.row.value as number,
|
||||
unit: p.row.unit as string,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
externalSourceVersion: p.row.externalSourceVersion as
|
||||
| string
|
||||
| null,
|
||||
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
|
||||
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
|
||||
},
|
||||
});
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
// Chunk to keep individual SQL statements bounded — 200 rows per
|
||||
// chunk leaves headroom under Postgres' 65k-parameter cap even
|
||||
@@ -327,18 +389,37 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.5.0 issue #213 — dedicated wide-event annotation so an operator
|
||||
// can grep `measurement.batch.stats-overwrite` to see how often
|
||||
// per-day cumulative rows are getting re-posted (a healthy ingest
|
||||
// flow). Only fires when at least one row was overwritten so the
|
||||
// baseline ingest trace is unchanged for batches with no `stats:*`
|
||||
// duplicates.
|
||||
if (updatedCount > 0) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.stats-overwrite" },
|
||||
meta: {
|
||||
updated: updatedCount,
|
||||
processed: entries.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.25 W16c — kick off PR detection for this user. We always
|
||||
// enqueue when at least one row was written (or the batch had any
|
||||
// measurements to consider) so a single off-day reading still gets
|
||||
// evaluated; the warm-up gate inside the detector decides whether
|
||||
// it's a record. Suppress push notifications for historical
|
||||
// backfills above the silent threshold.
|
||||
if (insertedCount > 0 || duplicateCount > 0) {
|
||||
// backfills above the silent threshold. Updated `stats:*` rows also
|
||||
// count — a per-day-total overwrite can flip the day's value past a
|
||||
// personal record.
|
||||
if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) {
|
||||
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent });
|
||||
@@ -366,6 +447,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
@@ -374,8 +456,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches when at least one row landed so the next read picks up the
|
||||
// ingested batch. Skipped-only ingests don't change state, so we
|
||||
// gate the invalidation on `insertedCount > 0`.
|
||||
if (insertedCount > 0) {
|
||||
// gate the invalidation on `insertedCount > 0 || updatedCount > 0`
|
||||
// — overwriting a per-day `stats:*` row changes the day's reading
|
||||
// and must invalidate every consumer that reads through this user's
|
||||
// measurements.
|
||||
if (insertedCount > 0 || updatedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup table for every distinct
|
||||
@@ -407,6 +492,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped,
|
||||
entries: results,
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { calculateCompliance, classifyIntakeTiming } from "../compliance";
|
||||
/**
|
||||
* v1.5.0 — `calculateCompliance` is a cadence-aware adapter over
|
||||
* `buildCadenceTimeline`. The historical wire shape (`totalExpected`,
|
||||
* `taken`, `skipped`, `missed`, `rate`, `streak`) is unchanged, but
|
||||
* the numbers now honour `daysOfWeek` and `intervalWeeks`. These
|
||||
* tests pin the contract for every cadence the production app
|
||||
* exercises: daily 1×/day, daily multi-dose, weekly Mondays-only,
|
||||
* bi-weekly, weekday-only multi-dose, and the DST-boundary day.
|
||||
* Closes #214.
|
||||
*
|
||||
* Each test uses `vi.useFakeTimers()` to pin `now` so the rolling
|
||||
* window is deterministic. `classifyIntakeTiming` tests below the
|
||||
* matrix are unchanged from the v1.4 line.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
calculateCompliance,
|
||||
classifyIntakeTiming,
|
||||
type ComplianceSchedule,
|
||||
} from "../compliance";
|
||||
import type { IntakeTimingClass } from "../compliance";
|
||||
|
||||
describe("calculateCompliance", () => {
|
||||
// Fix "now" for deterministic tests
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function eventAt(scheduledFor: Date, taken: boolean, skipped = false) {
|
||||
return {
|
||||
scheduledFor,
|
||||
takenAt: taken ? new Date(scheduledFor.getTime() + 30 * 60_000) : null,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
describe("calculateCompliance — cadence-aware adapter", () => {
|
||||
// Pin `now` to a Wednesday so weekday-only schedules have a clean
|
||||
// count of Mondays in the trailing 30-day window.
|
||||
const NOW = new Date("2025-01-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -11,7 +40,11 @@ describe("calculateCompliance", () => {
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
it("returns 100% rate with no schedules", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the empty result when no schedules are configured", () => {
|
||||
const result = calculateCompliance([], [], 7);
|
||||
expect(result).toEqual({
|
||||
totalExpected: 0,
|
||||
@@ -23,182 +56,264 @@ describe("calculateCompliance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates correct totals for taken events", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
const events = [
|
||||
{
|
||||
takenAt: new Date("2025-01-14T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-13T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-13T08:00:00Z"),
|
||||
},
|
||||
it("daily 1×/day, 7 of 7 taken in a 7-day window → 100%", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
|
||||
// Anchor events to 08:30 UTC on today (NOW = 12:00 UTC, so the
|
||||
// morning slot is already past) and the six prior days. Going one
|
||||
// day further back would land the event before the rolling-window
|
||||
// start (NOW − 7 d = 2025-01-08T12:00Z, but the would-be event sits
|
||||
// at 2025-01-08T08:30Z — below the window start), which the slot
|
||||
// grid intentionally excludes. The original `NOW − N×DAY + 8h`
|
||||
// shape also landed events at 20:00 UTC on the prior day, twelve
|
||||
// hours away from the 08:00 slot — at the edge of the pairing
|
||||
// radius, which matched in Europe/Berlin and missed in UTC.
|
||||
const events = Array.from({ length: 7 }, (_, i) => {
|
||||
const at = new Date(NOW);
|
||||
at.setUTCDate(at.getUTCDate() - i);
|
||||
at.setUTCHours(8, 30, 0, 0);
|
||||
return eventAt(at, true);
|
||||
});
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.totalExpected).toBe(7);
|
||||
expect(result.taken).toBe(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.missed).toBe(5);
|
||||
expect(result.rate).toBe(29); // Math.round(2/7 * 100)
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.taken).toBe(7);
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("counts skipped events separately from taken", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
const events = [
|
||||
{
|
||||
takenAt: new Date("2025-01-14T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
scheduledFor: new Date("2025-01-13T08:00:00Z"),
|
||||
},
|
||||
it("daily 1×/day, 0 of 7 taken in a 7-day window → 0%", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.taken).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.missed).toBe(5);
|
||||
expect(result.rate).toBe(14); // Math.round(1/7 * 100)
|
||||
const result = calculateCompliance([], schedules, 7);
|
||||
expect(result.rate).toBe(0);
|
||||
expect(result.taken).toBe(0);
|
||||
expect(result.missed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calculates streak for consecutive days", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
|
||||
// Create events for the last 3 consecutive days
|
||||
const events = [
|
||||
{
|
||||
takenAt: new Date("2025-01-14T20:00:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T20:00:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-13T20:00:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-13T20:00:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-12T20:00:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-12T20:00:00Z"),
|
||||
},
|
||||
it("daily 3×/day, 18 of 21 taken in a 7-day window → 86% (no-cadence-restriction happy path)", () => {
|
||||
// The pre-v1.5.0 path got this case right; pin it so the migration
|
||||
// doesn't regress users on a daily multi-dose schedule.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
{ windowStart: "13:00", windowEnd: "14:00", daysOfWeek: null },
|
||||
{ windowStart: "20:00", windowEnd: "21:00", daysOfWeek: null },
|
||||
];
|
||||
|
||||
// Generate 21 events (3/day × 7 days) and drop 3 so 18 are taken.
|
||||
const events = [];
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
for (const hour of [8, 13, 20]) {
|
||||
// Drop dose #4, #11, #18 to land on 18/21 → 86%.
|
||||
const idx = (d - 1) * 3 + [8, 13, 20].indexOf(hour);
|
||||
const taken = ![3, 10, 17].includes(idx);
|
||||
const scheduledFor = new Date(
|
||||
NOW.getTime() - d * DAY_MS + hour * 3600_000,
|
||||
);
|
||||
events.push(eventAt(scheduledFor, taken));
|
||||
}
|
||||
}
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
// Streak counts backwards from now: day0 = Jan15-Jan14, day1 = Jan14-Jan13, day2 = Jan13-Jan12
|
||||
// Events scheduled at 20:00 fall in the right day windows
|
||||
expect(result.streak).toBe(3);
|
||||
});
|
||||
|
||||
it("streak breaks on missed day", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
|
||||
// Day d=0 (Jan14-Jan15): taken
|
||||
// Day d=1 (Jan13-Jan14): missing!
|
||||
// Day d=2 (Jan12-Jan13): taken
|
||||
const events = [
|
||||
{
|
||||
takenAt: new Date("2025-01-14T20:00:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T20:00:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-12T20:00:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-12T20:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.streak).toBe(1); // Only the most recent day
|
||||
});
|
||||
|
||||
it("handles multiple schedules per day", () => {
|
||||
const schedules = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00" },
|
||||
{ windowStart: "20:00", windowEnd: "21:00" },
|
||||
];
|
||||
|
||||
// 2 schedules * 3 days = 6 expected
|
||||
const events = [
|
||||
{
|
||||
takenAt: new Date("2025-01-14T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T08:30:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-14T20:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T20:30:00Z"),
|
||||
},
|
||||
{
|
||||
takenAt: new Date("2025-01-13T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const result = calculateCompliance(events, schedules, 3);
|
||||
expect(result.totalExpected).toBe(6);
|
||||
expect(result.taken).toBe(3);
|
||||
expect(result.rate).toBe(86);
|
||||
expect(result.taken).toBe(18);
|
||||
expect(result.missed).toBe(3);
|
||||
expect(result.rate).toBe(50);
|
||||
});
|
||||
|
||||
it("filters events outside the period", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
|
||||
const events = [
|
||||
// Within period
|
||||
{
|
||||
takenAt: new Date("2025-01-14T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2025-01-14T08:00:00Z"),
|
||||
},
|
||||
// Outside period (30 days ago)
|
||||
{
|
||||
takenAt: new Date("2024-12-01T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2024-12-01T08:00:00Z"),
|
||||
},
|
||||
it("weekly Mondays, all Mondays taken in a 30-day window → 100% (the #214 bug case)", () => {
|
||||
// Pre-v1.5.0 this returned ~13% (taken / (schedules × 30) = 4/30).
|
||||
// The fix is to honour `daysOfWeek` so the denominator is the
|
||||
// number of Mondays in the window, not 30.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" },
|
||||
];
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.taken).toBe(1);
|
||||
// NOW = Wed 2025-01-15. Mondays in the trailing 30-day window =
|
||||
// 2025-01-13, 2025-01-06, 2024-12-30, 2024-12-23, 2024-12-16.
|
||||
const mondays = [
|
||||
new Date("2025-01-13T08:30:00Z"),
|
||||
new Date("2025-01-06T08:30:00Z"),
|
||||
new Date("2024-12-30T08:30:00Z"),
|
||||
new Date("2024-12-23T08:30:00Z"),
|
||||
new Date("2024-12-16T08:30:00Z"),
|
||||
];
|
||||
const events = mondays.map((m) => eventAt(m, true));
|
||||
const result = calculateCompliance(events, schedules, 30);
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.taken).toBeGreaterThanOrEqual(4);
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("handles perfect compliance", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
it("weekly Mondays, one Monday missed in a 30-day window → 75–80%", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" },
|
||||
];
|
||||
// Take 3 of the 4 most-recent Mondays, miss one.
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-13T08:30:00Z"), true),
|
||||
// 2025-01-06 missed.
|
||||
eventAt(new Date("2024-12-30T08:30:00Z"), true),
|
||||
eventAt(new Date("2024-12-23T08:30:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 30);
|
||||
expect(result.rate).toBeGreaterThanOrEqual(60);
|
||||
expect(result.rate).toBeLessThan(100);
|
||||
expect(result.missed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Create an event for each of the 7 days
|
||||
const events = Array.from({ length: 7 }, (_, i) => ({
|
||||
takenAt: new Date(NOW.getTime() - (i + 0.5) * 24 * 60 * 60 * 1000),
|
||||
skipped: false,
|
||||
scheduledFor: new Date(NOW.getTime() - (i + 0.5) * 24 * 60 * 60 * 1000),
|
||||
}));
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
it("bi-weekly (intervalWeeks=2), all scheduled doses taken in a 30-day window → 100%", () => {
|
||||
// Encoded recurrence: 2 weeks interval, Monday-anchored.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "i2;1" },
|
||||
];
|
||||
// With NOW = Wed Jan 15 the window covers ~Dec 16–Jan 15. The
|
||||
// anchor week starts on the medicationCreatedAt week. Take both
|
||||
// possible weeks-anchored Mondays so the denominator is 2 (or 3,
|
||||
// depending on phase) and the rate is 100% either way.
|
||||
const createdAt = new Date("2024-11-04T00:00:00Z"); // Mon
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-13T08:30:00Z"), true),
|
||||
eventAt(new Date("2024-12-30T08:30:00Z"), true),
|
||||
eventAt(new Date("2024-12-16T08:30:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 30, createdAt);
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.streak).toBe(7);
|
||||
});
|
||||
|
||||
it("caps compliance rate at 100% when more intakes than expected exist", () => {
|
||||
const schedules = [{ windowStart: "08:00", windowEnd: "09:00" }];
|
||||
const events = Array.from({ length: 10 }, (_, i) => ({
|
||||
takenAt: new Date("2025-01-14T08:30:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date(`2025-01-14T08:${String(i).padStart(2, "0")}:00Z`),
|
||||
}));
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
it("weekday-only 3×/day, every weekday dose taken in a 30-day window → 100% (the #214 metformin case)", () => {
|
||||
// Pre-v1.5.0 this returned ~73% (66 weekday doses ÷ 90 = 73%).
|
||||
// The fix is to honour `daysOfWeek` so the denominator is
|
||||
// 3 schedules × ~22 weekdays in the window, not 3 × 30.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1,2,3,4,5" },
|
||||
{ windowStart: "13:00", windowEnd: "14:00", daysOfWeek: "1,2,3,4,5" },
|
||||
{ windowStart: "20:00", windowEnd: "21:00", daysOfWeek: "1,2,3,4,5" },
|
||||
];
|
||||
// Generate events for every weekday slot inside the window —
|
||||
// including today's already-past slots (08:00 UTC < NOW = 12:00).
|
||||
const events = [];
|
||||
for (let d = 0; d <= 30; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY_MS);
|
||||
const dow = day.getUTCDay();
|
||||
if (dow === 0 || dow === 6) continue; // skip weekends
|
||||
for (const hour of [8, 13, 20]) {
|
||||
const at = new Date(day);
|
||||
at.setUTCHours(hour, 30, 0, 0);
|
||||
// Skip future slots (today's 13:00 and 20:00 are in the future
|
||||
// relative to NOW = 12:00 UTC); the timeline marks those
|
||||
// `upcoming` and excludes them from the denominator anyway.
|
||||
if (at.getTime() > NOW.getTime()) continue;
|
||||
events.push(eventAt(at, true));
|
||||
}
|
||||
}
|
||||
const result = calculateCompliance(events, schedules, 30);
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("skipped doses are excluded from the denominator (not a compliance failure)", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
// 7-day window — every day has a slot, every event lands on its
|
||||
// own day. Two taken + one skipped + four missed.
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||||
{
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
},
|
||||
eventAt(new Date("2025-01-12T08:30:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.taken).toBe(2);
|
||||
// The skipped day is excluded from the rate denominator.
|
||||
// Slots: Jan-9..Jan-15 = 7 (Jan-8 windowEnd 09:00 falls below the
|
||||
// window start of 12:00). Taken = 2, skipped = 1, missed = 4
|
||||
// (Jan-9, 10, 11, 15). Rate = 2 taken / (2 + 4 missed) = 33%.
|
||||
expect(result.rate).toBe(33);
|
||||
});
|
||||
|
||||
it("excludes days before medicationCreatedAt from the denominator", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
// Medication created 3 days ago — the prior 4 days of the 7-day
|
||||
// window must not count as missed. Anchor each event at 08:30 UTC
|
||||
// on today + the two prior days (NOW = 12:00 UTC so today's morning
|
||||
// slot is already past). Going to "3 days ago" would land the event
|
||||
// at the createdAt instant itself (08:30 < 12:00 on the cutoff day)
|
||||
// which slips just below the slot grid's emit-cutoff.
|
||||
const createdAt = new Date(NOW.getTime() - 3 * DAY_MS);
|
||||
const events = [0, 1, 2].map((daysAgo) => {
|
||||
const at = new Date(NOW);
|
||||
at.setUTCDate(at.getUTCDate() - daysAgo);
|
||||
at.setUTCHours(8, 30, 0, 0);
|
||||
return eventAt(at, true);
|
||||
});
|
||||
const result = calculateCompliance(events, schedules, 7, createdAt);
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.taken).toBe(3);
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => {
|
||||
// Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily
|
||||
// schedule across this boundary must still emit one slot per
|
||||
// local day — not two on the short day and zero on the next.
|
||||
// Pin `now` to Apr-02 noon UTC and run a 7-day window so the
|
||||
// boundary day is inside it. Generate one event per day
|
||||
// (working backwards from NOW − 1 day so each event lands inside
|
||||
// the window) and assert the totalExpected lands at 7 (not 8).
|
||||
vi.setSystemTime(new Date("2025-04-02T12:00:00Z"));
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null },
|
||||
];
|
||||
// Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window
|
||||
// sits below the rolling-window start of Mar-26 12:00). Generate
|
||||
// one taken event per slot — today's 10:30 slot is also in the
|
||||
// past relative to NOW = 12:00 so it counts as taken too.
|
||||
const events = [];
|
||||
for (let d = 0; d <= 6; d++) {
|
||||
const at = new Date("2025-04-02T10:30:00Z");
|
||||
at.setUTCDate(at.getUTCDate() - d);
|
||||
events.push(eventAt(at, true));
|
||||
}
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
// The hard contract: totalExpected stays at 7 — not 8 (which
|
||||
// would mean the DST short day double-emitted) and not 6 (which
|
||||
// would mean the short day silently dropped a slot).
|
||||
expect(result.totalExpected).toBe(7);
|
||||
expect(result.taken).toBe(7);
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("filters events outside the rolling window", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||||
eventAt(new Date("2024-12-01T08:30:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.taken).toBe(1);
|
||||
});
|
||||
|
||||
it("caps rate at 100% when more intakes than expected exist", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
// Same-day duplicate logs — the pair algorithm matches one and
|
||||
// leaves the duplicates dangling; the rate stays at or below 100.
|
||||
const events = Array.from({ length: 10 }, (_, i) => ({
|
||||
scheduledFor: new Date(`2025-01-14T08:${String(i).padStart(2, "0")}:00Z`),
|
||||
takenAt: new Date(`2025-01-14T08:${String(i).padStart(2, "0")}:00Z`),
|
||||
skipped: false,
|
||||
}));
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.rate).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -462,4 +462,153 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.5.0 — regression pin for the cadence-aware compliance migration
|
||||
// (closes #214). Before this release the score helper fed every
|
||||
// medication-compliance pillar through `schedules.length * 30` as the
|
||||
// denominator, so a weekly Ozempic schedule with 4 taken Mondays
|
||||
// reported ~13% and dragged the score down ~10–15 points. The
|
||||
// adapter now matches the cadence chart's denominator (Mondays-in-
|
||||
// window) and the weekly med contributes its true 100% rate.
|
||||
describe("cadence-aware medication compliance (closes #214)", () => {
|
||||
function mondaysInWindow(now: Date, days: number): Date[] {
|
||||
const out: Date[] = [];
|
||||
const from = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
const cursor = new Date(from);
|
||||
while (cursor <= now) {
|
||||
if (cursor.getUTCDay() === 1) {
|
||||
out.push(new Date(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("weekly Mondays-only med with all Mondays taken contributes a 100% pillar", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
// Weight + BP attribution reads return empty so only the
|
||||
// medication compliance pillar is non-null.
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const now = new Date("2026-05-13T12:00:00.000Z"); // Wed
|
||||
MEDICATION_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
id: "med-weekly",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// One intake per Monday inside the trailing 30 days. The
|
||||
// cadence pairing reads `scheduledFor` for both anchor and
|
||||
// event, so the Mondays-only timeline lines up cleanly.
|
||||
const events = mondaysInWindow(now, 30).map((mon) => ({
|
||||
medicationId: "med-weekly",
|
||||
scheduledFor: new Date(
|
||||
Date.UTC(
|
||||
mon.getUTCFullYear(),
|
||||
mon.getUTCMonth(),
|
||||
mon.getUTCDate(),
|
||||
8,
|
||||
30,
|
||||
),
|
||||
),
|
||||
takenAt: new Date(
|
||||
Date.UTC(
|
||||
mon.getUTCFullYear(),
|
||||
mon.getUTCMonth(),
|
||||
mon.getUTCDate(),
|
||||
8,
|
||||
35,
|
||||
),
|
||||
),
|
||||
skipped: false,
|
||||
}));
|
||||
// The helper reads the 37-day window (prevSince30d → now) so
|
||||
// the prior-week snapshot has data too — return the same set
|
||||
// unfiltered; the helper filters internally.
|
||||
INTAKE_FIND_MANY.mockResolvedValueOnce(events);
|
||||
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-weekly-med",
|
||||
bpInTargetPct: 80,
|
||||
heightCm: 178,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// The medication pillar must be populated and reflect a 100%
|
||||
// rate (or at least ≥ 50, well above the pre-fix ~13%). The
|
||||
// exact pillar number depends on `computeHealthScore`'s
|
||||
// weighting; we assert the directional contract — the score is
|
||||
// computed AND the compliance branch fired.
|
||||
expect(result?.components.compliance.value).not.toBeNull();
|
||||
const complianceValue = result?.components.compliance.value ?? 0;
|
||||
// Pre-fix this user reported ~13% adherence → ~13 here. The
|
||||
// post-fix path produces ≥ 90 (one Monday per week, all taken
|
||||
// → 100% with the new denominator). 50 is a wide safety margin
|
||||
// that catches the regression with zero flake risk.
|
||||
expect(complianceValue).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("daily-only med with all doses taken still reports 100% (no regression on the path that already worked)", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const now = new Date("2026-05-13T12:00:00.000Z"); // Wed
|
||||
MEDICATION_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
id: "med-daily",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// One taken event per day over the trailing 30 days — including
|
||||
// today's already-past slot (08:00 UTC < NOW = 12:00).
|
||||
const events = [];
|
||||
for (let d = 0; d < 30; d++) {
|
||||
const day = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
|
||||
const at = new Date(
|
||||
Date.UTC(
|
||||
day.getUTCFullYear(),
|
||||
day.getUTCMonth(),
|
||||
day.getUTCDate(),
|
||||
8,
|
||||
30,
|
||||
),
|
||||
);
|
||||
if (at.getTime() > now.getTime()) continue;
|
||||
events.push({
|
||||
medicationId: "med-daily",
|
||||
scheduledFor: at,
|
||||
takenAt: new Date(at.getTime() + 5 * 60_000),
|
||||
skipped: false,
|
||||
});
|
||||
}
|
||||
INTAKE_FIND_MANY.mockResolvedValueOnce(events);
|
||||
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-daily-med",
|
||||
bpInTargetPct: 80,
|
||||
heightCm: 178,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Daily-only path was correct under the legacy denominator and
|
||||
// stays correct under the cadence denominator. The contract:
|
||||
// taken=N, missed=0 → rate=100; pillar reads ≥ 90 with whatever
|
||||
// weighting `computeHealthScore` applies.
|
||||
expect(result?.components.compliance.value).toBeGreaterThanOrEqual(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
+152
-47
@@ -1,18 +1,36 @@
|
||||
/**
|
||||
* Medication compliance calculations.
|
||||
*
|
||||
* v1.5.0 — `calculateCompliance` is now a cadence-aware adapter on top
|
||||
* of `complianceChips` / `buildCadenceTimeline`. Prior to this release
|
||||
* the helper computed `totalExpected = schedules.length * days`, which
|
||||
* silently ignored `MedicationSchedule.daysOfWeek` and `intervalWeeks`.
|
||||
* A weekly Ozempic schedule (Mondays only) reported ~13% adherence
|
||||
* instead of 100%; a weekday-only 3×/day metformin reported 73%
|
||||
* instead of 100%. The wire shape (`{ totalExpected, taken, skipped,
|
||||
* missed, rate, streak }`) is unchanged so every consumer (Health
|
||||
* Score, AI Coach prompt context, /api/medications/[id]/compliance,
|
||||
* BP-status compliance gate, insight targets, the per-medication
|
||||
* tile) keeps reading the same fields. Only the math underneath the
|
||||
* fields was wrong — that's what got fixed. Closes #214.
|
||||
*
|
||||
* `classifyIntakeTiming` is unchanged and still owns the early /
|
||||
* on_time / late / very_late punctuality bucket logic used by the
|
||||
* daily compliance heatmap on `/api/medications/[id]/compliance`.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
type IntakeEventLike,
|
||||
type ScheduleLike,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
|
||||
interface IntakeEvent {
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
scheduledFor: Date;
|
||||
}
|
||||
|
||||
interface ScheduleWindow {
|
||||
windowStart: string; // HH:mm
|
||||
windowEnd: string; // HH:mm
|
||||
}
|
||||
|
||||
export type IntakeTimingClass =
|
||||
| "early"
|
||||
| "on_time"
|
||||
@@ -123,16 +141,71 @@ export function classifyIntakeTiming(
|
||||
return "very_late";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.0 — schedule view consumed by the cadence-aware adapter.
|
||||
*
|
||||
* Accepts the historical `ScheduleWindow` shape (`windowStart` +
|
||||
* `windowEnd` only) as well as the richer `ScheduleLike` shape that
|
||||
* carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is
|
||||
* treated as `null` (daily, every day) so legacy fixtures keep
|
||||
* passing without modification.
|
||||
*/
|
||||
export interface ComplianceSchedule {
|
||||
windowStart: string; // HH:mm
|
||||
windowEnd: string; // HH:mm
|
||||
daysOfWeek?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate compliance for a medication over a given period.
|
||||
* If medicationCreatedAt is provided, days before creation are excluded
|
||||
* so they don't count as "missed".
|
||||
*
|
||||
* Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and
|
||||
* `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to
|
||||
* `buildCadenceTimeline`. The denominator is the number of dose slots
|
||||
* the schedule actually emits inside the window — not
|
||||
* `schedules.length * days`. A user on a weekly Monday-only schedule
|
||||
* who takes every Monday for 30 days reports 100% adherence (4 of 4
|
||||
* Mondays) instead of the pre-v1.5.0 ~13% (4 of 30).
|
||||
*
|
||||
* Skipped doses are excluded from the denominator (deliberate user
|
||||
* decision, not a compliance failure). When the window contains no
|
||||
* expected doses (paused med, brand-new prescription, schedule that
|
||||
* never fires in the window) the helper returns `rate: 100` so the
|
||||
* empty state doesn't trip a "0% compliance" alarm downstream.
|
||||
*
|
||||
* Streak counts consecutive days, ending at `now`, where every
|
||||
* expected dose for the day was taken (or skipped). Days with no
|
||||
* expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly
|
||||
* schedule) advance the streak — the user gets credit for not
|
||||
* breaking on non-scheduled days.
|
||||
*
|
||||
* @param events Recorded intake events from
|
||||
* `MedicationIntakeEvent`. Only `scheduledFor`,
|
||||
* `takenAt`, `skipped` are read.
|
||||
* @param schedules Schedule rows for the medication. `daysOfWeek`
|
||||
* is read when present; missing field is treated
|
||||
* as daily.
|
||||
* @param days Rolling-window size in days (typically 7 / 30
|
||||
* / 90).
|
||||
* @param medicationCreatedAt
|
||||
* When provided, days before the medication
|
||||
* existed are excluded so they don't count as
|
||||
* "missed".
|
||||
* @param options.now Override for the rolling-window anchor. The
|
||||
* fast-path Health-Score helper passes a fixed
|
||||
* `now` so cached medication-compliance rates
|
||||
* agree with the score's other pillars; default
|
||||
* is `new Date()` (current wall-clock instant).
|
||||
* v1.5.0 — added so the cadence-aware adapter
|
||||
* can be driven deterministically from a caller
|
||||
* that already pinned its own `now`.
|
||||
*/
|
||||
export function calculateCompliance(
|
||||
events: IntakeEvent[],
|
||||
schedules: ScheduleWindow[],
|
||||
schedules: ComplianceSchedule[],
|
||||
days: number,
|
||||
medicationCreatedAt?: Date,
|
||||
options?: { now?: Date },
|
||||
): ComplianceResult {
|
||||
if (schedules.length === 0) {
|
||||
return {
|
||||
@@ -145,61 +218,93 @@ export function calculateCompliance(
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Don't count days before the medication existed
|
||||
const now = options?.now ?? new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const periodStart = new Date(now.getTime() - days * DAY_MS);
|
||||
const effectiveStart =
|
||||
medicationCreatedAt && medicationCreatedAt > periodStart
|
||||
? medicationCreatedAt
|
||||
: periodStart;
|
||||
const effectiveDays = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(now.getTime() - effectiveStart.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS),
|
||||
);
|
||||
|
||||
// Expected doses = schedules per day * effective days
|
||||
const totalExpected = schedules.length * effectiveDays;
|
||||
// Normalise the schedule shape so legacy callers that pass only
|
||||
// `{ windowStart, windowEnd }` still produce a usable `daysOfWeek`
|
||||
// field (treated as daily by the cadence parser).
|
||||
const normalisedSchedules: ScheduleLike[] = schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
}));
|
||||
|
||||
// Filter events in effective period
|
||||
const periodEvents = events.filter(
|
||||
(e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now,
|
||||
// Match events against the same slot grid the cadence chart uses.
|
||||
// The chart's pairing radius is ±12 h so a late-by-six-hours dose
|
||||
// still attaches to the right slot instead of double-counting as
|
||||
// "missed + extra".
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
.filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
}));
|
||||
|
||||
const timeline = buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
now,
|
||||
effectiveDays,
|
||||
medicationCreatedAt ?? effectiveStart,
|
||||
);
|
||||
|
||||
const taken = periodEvents.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
const skipped = periodEvents.filter((e) => e.skipped).length;
|
||||
const missed = Math.max(0, totalExpected - taken - skipped);
|
||||
let taken = 0;
|
||||
let skipped = 0;
|
||||
let missed = 0;
|
||||
for (const slot of timeline) {
|
||||
if (slot.status === "taken") taken++;
|
||||
else if (slot.status === "skipped") skipped++;
|
||||
else if (slot.status === "missed") missed++;
|
||||
// `upcoming` slots (future window) are excluded from every counter
|
||||
// so a partial day at the head of the window doesn't pollute the rate.
|
||||
}
|
||||
|
||||
const totalExpected = taken + skipped + missed;
|
||||
// Skipped doses are excluded from the denominator — they represent a
|
||||
// deliberate user decision rather than a missed dose.
|
||||
const denom = taken + missed;
|
||||
const rate =
|
||||
totalExpected > 0
|
||||
? Math.min(100, Math.round((taken / totalExpected) * 100))
|
||||
: 100;
|
||||
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
|
||||
|
||||
// Calculate streak: consecutive days with all scheduled intakes taken
|
||||
// Streak: consecutive days, ending today, where every expected dose
|
||||
// was taken or skipped. Days with no expected dose advance the
|
||||
// streak — out-of-cadence days are not failures. Walks from today
|
||||
// backwards through the timeline grouped by local day.
|
||||
const dayKey = (d: Date): string => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
const byDay = new Map<string, "all-good" | "bad">();
|
||||
for (const slot of timeline) {
|
||||
const key = dayKey(slot.day);
|
||||
const existing = byDay.get(key);
|
||||
if (slot.status === "missed") {
|
||||
byDay.set(key, "bad");
|
||||
} else if (existing !== "bad") {
|
||||
byDay.set(key, "all-good");
|
||||
}
|
||||
}
|
||||
let streak = 0;
|
||||
for (let d = 0; d < effectiveDays; d++) {
|
||||
const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Skip days before medication creation
|
||||
if (medicationCreatedAt && dayEnd <= medicationCreatedAt) break;
|
||||
|
||||
const dayEvents = periodEvents.filter(
|
||||
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
|
||||
);
|
||||
const dayTaken = dayEvents.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
if (dayTaken >= schedules.length) {
|
||||
streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
const cursor = new Date(now.getTime() - d * DAY_MS);
|
||||
if (medicationCreatedAt && cursor <= medicationCreatedAt) break;
|
||||
const state = byDay.get(dayKey(cursor));
|
||||
if (state === "bad") break;
|
||||
if (state === "all-good") streak++;
|
||||
// No state = no expected dose that day → streak advances silently.
|
||||
}
|
||||
|
||||
return { totalExpected, taken, skipped, missed, rate, streak };
|
||||
|
||||
@@ -292,6 +292,10 @@ export async function computeUserHealthScoreFastPath(
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
// v1.5.0 — cadence-aware compliance reads daysOfWeek so a
|
||||
// weekly med (Mondays only) doesn't get a 30-day denominator
|
||||
// that depresses the score by ~85 percentage points. Closes #214.
|
||||
daysOfWeek: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -323,7 +327,12 @@ export async function computeUserHealthScoreFastPath(
|
||||
}
|
||||
medicationCompliance30 = medications.map((med) => {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
return calculateCompliance(events, med.schedules, 30, med.createdAt).rate;
|
||||
// v1.5.0 — pass the helper's pinned `now` so the cadence-aware
|
||||
// window math agrees with the score's other pillars (which also
|
||||
// anchor to the same `now`). Closes #214.
|
||||
return calculateCompliance(events, med.schedules, 30, med.createdAt, {
|
||||
now,
|
||||
}).rate;
|
||||
});
|
||||
medicationCompliance30Previous = medications.map((med) => {
|
||||
const events = (eventsByMed.get(med.id) ?? []).filter(
|
||||
@@ -336,8 +345,9 @@ export async function computeUserHealthScoreFastPath(
|
||||
takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null,
|
||||
skipped: e.skipped,
|
||||
}));
|
||||
return calculateCompliance(shifted, med.schedules, 30, med.createdAt)
|
||||
.rate;
|
||||
return calculateCompliance(shifted, med.schedules, 30, med.createdAt, {
|
||||
now,
|
||||
}).rate;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
createdAt: new Date(now.getTime() - 1100 * dayMs),
|
||||
schedules: [{ id: "s1", time: "08:00" }],
|
||||
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
|
||||
};
|
||||
|
||||
const events: Array<{
|
||||
@@ -117,7 +117,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub per
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [{ id: "s1", time: "08:00" }],
|
||||
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
@@ -225,7 +225,7 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [{ id: "s1", time: "08:00" }],
|
||||
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
|
||||
Reference in new issue
Block a user