Files
HealthLog/src/lib/analytics/compliance/parsing.ts
T
Marc-André Bombeck f5889affb7 fix(compliance,ingest): bound the point window and the two unbounded write paths
`classifyIntakeTiming` treated `windowStart === windowEnd` as an overnight
wrap, inflating a degenerate point window into a ~30h on-time band — a dose
taken many hours late still classified `on_time` and fed a perfect-compliance
streak. Widen the point window symmetrically instead, matching the treatment
`resolveLegacyWindowBounds` already applies.

`/api/measurements/batch` never applied the entry-instant plausibility bound
its single-entry twin has carried since v1.17 W1b, so a future-dated row landed
and permanently won 'latest reading' while poisoning the rollup buckets and the
canonical picker. Skip per entry, like the range guard. The past floor stays at
1900 — a historical backfill is a legitimate shape here.

The intake CSV import had neither an upper date bound nor a rate limiter, while
running up to 1000 sequential find + create + consume iterations per call. Add
both, on the bulk twin's bucket shape and budget.
2026-07-19 13:26:06 +02:00

108 lines
4.6 KiB
TypeScript

// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
// (the barrel) for the module map. Pure move — no logic changes.
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import { localHmAsUtc } from "@/lib/tz/local-day";
export type IntakeTimingClass =
"early" | "on_time" | "late" | "very_late" | "missed";
/**
* Parse "HH:mm" into hours and minutes.
*/
function parseHHmm(time: string): { hours: number; minutes: number } {
const [h, m] = time.split(":").map(Number);
return { hours: h, minutes: m };
}
/**
* Build the UTC instant of local "HH:mm" on the calendar day `day` falls on.
*
* When `tz` is supplied the window `HH:mm` is interpreted in the user's zone
* (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is
* 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring
* (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC
* must pass its zone or the punctuality band is offset by the zone's offset.
*/
function toDateOnDay(time: string, day: Date, tz?: string): Date {
const { hours, minutes } = parseHHmm(time);
if (tz) {
return localHmAsUtc(day, tz, hours, minutes);
}
const d = new Date(day);
d.setUTCHours(hours, minutes, 0, 0);
return d;
}
/**
* Classify how punctual an intake was relative to a schedule window.
*
* 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`.
* @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply
* the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps
* the legacy UTC interpretation (correct only for a UTC user).
*/
export function classifyIntakeTiming(
takenAt: Date | null,
windowStart: string, // "HH:mm"
windowEnd: string, // "HH:mm"
scheduledDate: Date, // the date this was scheduled
options?: { lateMinutes?: number; tz?: string },
): IntakeTimingClass {
if (takenAt === null) return "missed";
let start = toDateOnDay(windowStart, scheduledDate, options?.tz);
let end = toDateOnDay(windowEnd, scheduledDate, options?.tz);
// A degenerate POINT window (`windowStart === windowEnd` — a single dose time
// echoed into both fields, a real shape on legacy rows) used to fall into the
// overnight branch below and inflate into a ~30h on-time band, so a dose taken
// many hours late still classified `on_time` and fed a perfect-compliance
// streak. Widen it symmetrically by the default on-time half-width instead,
// matching `resolveLegacyWindowBounds` in `@/lib/medications/window-status`.
if (end.getTime() === start.getTime()) {
const halfMs = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 60 * 1000;
start = new Date(start.getTime() - halfMs);
end = new Date(end.getTime() + halfMs);
} else if (end < start) {
// Genuine overnight window (e.g. windowStart="23:00", windowEnd="01:00").
end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
}
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(onTimeEnd.getTime() + lateTolerance);
const t = takenAt.getTime();
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";
}