diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 2ce5e35fb..9ecd56f02 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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", ); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 3ed22652a..854832943 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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"; }