fix(compliance): widen grace window and add early bucket

The classifier flushed any intake outside the 1h grace into
`very_late`, so a stored UTC offset between `takenAt` and the
schedule window (e.g. a 06:00 UTC log against an 08:00 UTC window)
flagged a fully-compliant day as orange. Widen the pre-window grace
to 3h, introduce a dedicated `early` bucket for doses logged inside
that 3h pre-window band, and extend the on-time tolerance after
windowEnd to 3h as well. The configurable `lateMinutes` option now
sizes the `late` band past the 3h tail; doses beyond that tail still
fall into `very_late`.

Tests rebuild the boundary matrix with a parameterised offset suite
covering -3.5h .. +6h around the window, exercises overnight
schedules with an `early` case, and pins the `lateMinutes` knob.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:24:44 +02:00
1 parent 3d309bb91b
commit 36d5398c92
2 files changed
+109 -39

No files matched your search

+67 -28
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { calculateCompliance, classifyIntakeTiming } from "../compliance";
import type { IntakeTimingClass } from "../compliance";
describe("calculateCompliance", () => {
// Fix "now" for deterministic tests
@@ -202,6 +203,12 @@ describe("calculateCompliance", () => {
});
describe("classifyIntakeTiming", () => {
// v1.4.34 IW-C — the classifier now widens the pre-window grace to
// 3h and introduces an `early` bucket so a proactive logger (10 min
// before the window) is no longer flushed to `very_late`. Doses up
// to 3h past `windowEnd` stay `on_time`; `late` spans the next 2h
// tail; anything beyond is `very_late`. Overnight windows are
// exercised in parallel by the parameterised matrix below.
const scheduledDate = new Date("2025-01-15T00:00:00Z");
it('returns "missed" when takenAt is null', () => {
@@ -231,31 +238,35 @@ describe("classifyIntakeTiming", () => {
);
});
it('returns "on_time" when taken within 1h grace period before windowStart', () => {
const takenAt = new Date("2025-01-15T07:15:00Z"); // 45 min before 08:00
// Parameterised offset matrix relative to a `08:00 → 09:00` window.
// Negative offsets are minutes before `windowStart` (08:00); positive
// offsets are minutes after `windowEnd` (09:00). The reference
// boundaries are: `early` from -180 to -1 min before start; `on_time`
// from start through windowEnd + 180 min; `late` for the next 120
// min; `very_late` past that or beyond the 3h pre-window grace.
it.each<[label: string, offsetMin: number, expected: IntakeTimingClass]>([
["3.5h before window → very_late", -210, "very_late"],
["exactly 3h before window → early", -180, "early"],
["1h before window → early", -60, "early"],
["10 min before window → early", -10, "early"],
["exactly at windowStart → on_time", 0, "on_time"],
["10 min into the window → on_time", 10, "on_time"],
["20 min past windowEnd → on_time", 80, "on_time"],
["1h past windowEnd → on_time", 120, "on_time"],
["exactly 3h past windowEnd → on_time", 240, "on_time"],
["3.5h past windowEnd → late", 270, "late"],
["5h past windowEnd → late (tail boundary)", 360, "late"],
["5h 1min past windowEnd → very_late", 361, "very_late"],
])("offset case: %s", (_label, offsetMin, expected) => {
// The matrix expresses offsets relative to windowStart (negative)
// or relative to windowEnd (positive). 0 sits at windowStart, +60
// sits at windowEnd, then positive offsets accumulate past
// windowEnd. With windowStart=08:00 and windowEnd=09:00, the
// base instant is 08:00Z; positive offsets walk forward from there.
const baseMs = new Date("2025-01-15T08:00:00Z").getTime();
const takenAt = new Date(baseMs + offsetMin * 60 * 1000);
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
});
it('returns "late" when taken within 2h after windowEnd', () => {
const takenAt = new Date("2025-01-15T10:30:00Z"); // 1.5h after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"late",
);
});
it('returns "late" when taken right after windowEnd', () => {
const takenAt = new Date("2025-01-15T09:01:00Z"); // 1 min after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"late",
);
});
it('returns "very_late" when taken more than 2h after windowEnd', () => {
const takenAt = new Date("2025-01-15T14:00:00Z"); // 5h after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"very_late",
expected,
);
});
@@ -266,6 +277,26 @@ describe("classifyIntakeTiming", () => {
);
});
it('respects the configurable `lateMinutes` tail', () => {
// With lateMinutes=30 the late tail collapses to 30 min after the
// 3h on-time grace. windowEnd is 09:00 so `on_time` extends to
// 12:00 and `late` extends to 12:30. A dose at 12:15 falls in
// `late`; one at 12:45 falls in `very_late`.
const takenLate = new Date("2025-01-15T12:15:00Z");
expect(
classifyIntakeTiming(takenLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("late");
const takenVeryLate = new Date("2025-01-15T12:45:00Z");
expect(
classifyIntakeTiming(takenVeryLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("very_late");
});
it("handles overnight windows (windowEnd < windowStart)", () => {
// Schedule: 23:00 - 01:00 (overnight)
const takenAt = new Date("2025-01-15T23:30:00Z");
@@ -274,17 +305,25 @@ describe("classifyIntakeTiming", () => {
);
});
it("handles overnight window early intake", () => {
// Schedule: 23:00 - 01:00, taken at 22:30 (30 min before windowStart)
const takenAt = new Date("2025-01-15T22:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"early",
);
});
it("handles overnight window late intake", () => {
// Schedule: 23:00 - 01:00, taken at 02:00 (1h late)
const takenAt = new Date("2025-01-16T02:00:00Z");
// Schedule: 23:00 - 01:00, taken at 04:30 (3.5h past 01:00)
const takenAt = new Date("2025-01-16T04:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"late",
);
});
it("handles overnight window very late intake", () => {
// Schedule: 23:00 - 01:00, taken at 04:00 (3h late)
const takenAt = new Date("2025-01-16T04:00:00Z");
// Schedule: 23:00 - 01:00, taken at 07:00 (6h past 01:00)
const takenAt = new Date("2025-01-16T07:00:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"very_late",
);
+42 -11
View File
@@ -13,7 +13,12 @@ interface ScheduleWindow {
windowEnd: string; // HH:mm
}
export type IntakeTimingClass = "on_time" | "late" | "very_late" | "missed";
export type IntakeTimingClass =
| "early"
| "on_time"
| "late"
| "very_late"
| "missed";
export interface ComplianceResult {
totalExpected: number;
@@ -24,7 +29,13 @@ export interface ComplianceResult {
streak: number; // consecutive days with all taken
}
/** Daily compliance data including timing breakdown. */
/**
* Daily compliance data including timing breakdown.
*
* `early` is the v1.4.34 bucket for doses taken before the window's
* 3-hour grace start. Consumers that group by compliance status
* should treat `early` as compliant (alongside `onTime`).
*/
export interface DailyComplianceEntry {
expected: number;
taken: number;
@@ -32,6 +43,7 @@ export interface DailyComplianceEntry {
onTime: number;
late: number;
veryLate: number;
early?: number;
}
/**
@@ -55,12 +67,25 @@ function toDateOnDay(time: string, day: Date): Date {
/**
* Classify how punctual an intake was relative to a schedule window.
*
* - "on_time": within windowStart-1h .. windowEnd (1h early grace)
* - "late": within windowEnd .. windowEnd+2h
* - "very_late": after windowEnd+2h
* v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced
* a dedicated `early` bucket so a proactive logger (e.g. 10 min before
* the window) no longer gets flushed into `very_late`. The post-window
* grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`.
* Only doses beyond a 3-hour late tolerance fall into `late`, and only
* doses beyond a further `lateMinutes` tail fall into `very_late`.
*
* Buckets:
* - "early": within (windowStart - 3h) .. windowStart (compliant)
* - "on_time": windowStart .. (windowEnd + 3h) (compliant)
* - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail)
* - "very_late": before windowStart - 3h, or after the late tail
* - "missed": takenAt is null
*
* Handles overnight windows (windowEnd < windowStart means next day).
*
* @param options.lateMinutes Width of the `late` band beyond the 3-hour
* on-time tolerance, in minutes. Defaults to 120. Doses past this
* tail land in `very_late`.
*/
export function classifyIntakeTiming(
takenAt: Date | null,
@@ -79,16 +104,22 @@ export function classifyIntakeTiming(
end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
}
// 1h grace period before windowStart
const graceStart = new Date(start.getTime() - 60 * 60 * 1000);
// Configurable tolerance after windowEnd (default 120 min)
const HOUR_MS = 60 * 60 * 1000;
// 3h pre-window grace: doses inside this window are `early` (still
// compliant). Doses before it are `very_late`.
const earlyStart = new Date(start.getTime() - 3 * HOUR_MS);
// 3h post-window grace: doses up to here are still `on_time`.
const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS);
// Configurable `late` tail past the on-time grace (default 120 min).
const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000;
const lateEnd = new Date(end.getTime() + lateTolerance);
const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance);
const t = takenAt.getTime();
if (t >= graceStart.getTime() && t <= end.getTime()) return "on_time";
if (t > end.getTime() && t <= lateEnd.getTime()) return "late";
if (t < earlyStart.getTime()) return "very_late";
if (t < start.getTime()) return "early";
if (t <= onTimeEnd.getTime()) return "on_time";
if (t <= lateEnd.getTime()) return "late";
return "very_late";
}