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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:26:06 +02:00
1 parent ef10ac5581
commit f5889affb7
6 files changed
+336 -3

No files matched your search

@@ -1234,6 +1234,43 @@ describe("classifyIntakeTiming", () => {
);
});
// A degenerate POINT window (`windowStart === windowEnd`) is a real shape on
// legacy rows. It used to take the overnight branch and inflate into a ~30h
// on-time band, so a dose taken many hours late still counted `on_time` and
// fed a perfect-compliance streak. It now widens symmetrically by the default
// on-time half-width, matching `resolveLegacyWindowBounds`.
describe("degenerate point window", () => {
it('keeps a dose at the point itself "on_time"', () => {
const takenAt = new Date("2025-01-15T07:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
).toBe("on_time");
});
it('does not classify a dose ~16h late as "on_time"', () => {
const takenAt = new Date("2025-01-15T23:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
).toBe("very_late");
});
it("does not swallow the whole day into the on-time band", () => {
// 13:00 is 6h past the point — outside the widened band + graces.
const takenAt = new Date("2025-01-15T13:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
).not.toBe("on_time");
});
it("still wraps a genuine overnight window", () => {
// 00:30 the next day sits inside a 23:00 -> 01:00 window.
const takenAt = new Date("2025-01-16T00:30:00Z");
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate),
).toBe("on_time");
});
});
it('returns "on_time" when taken within the window', () => {
const takenAt = new Date("2025-01-15T08:30:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
+14 -3
View File
@@ -1,6 +1,7 @@
// 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 =
@@ -68,11 +69,21 @@ export function classifyIntakeTiming(
): IntakeTimingClass {
if (takenAt === null) return "missed";
const start = toDateOnDay(windowStart, scheduledDate, options?.tz);
let start = toDateOnDay(windowStart, scheduledDate, options?.tz);
let end = toDateOnDay(windowEnd, scheduledDate, options?.tz);
// Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00")
if (end <= start) {
// 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);
}