Commit Graph
5 Commits
Author SHA1 Message Date
Marc-André Bombeck 550a7b87d7 fix(medications): measure a row's nearest occurrence across sibling schedules
The nearest test enumerated only the schedule under test, and production
rows carry no schedule identity. On a medication composed of sibling
schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a
row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's
own occurrences alone never showed that A's 08:00 is nearer: next-due, the
display due and the compliance cycle all answered tomorrow's 08:00 at 09:00
local, the same swallow one level up.

The neighbour set is now the union of every schedule's occurrences within
the radius, memoised per row, since it depends on the row alone and a
predicate asks about many slots. The rolling short-circuit still looks at
the schedule under test only. The era filter in the next-due predicate
compared a field no caller ever populated, so it is gone along with the
test that pinned it; the era floor lives in the overdue search's band
range and in the forward walk's start at now, as it always did in
production.

Tests: sibling schedules in both next-due and the compliance cycle, the
exact tie between two slots, the 25-hour fall-back day (a pair across the
repeated hour, and a row anchored in the old offset), and the ad-hoc
epsilon case now stands on the ad-hoc row alone.
2026-09-10 20:34:36 +02:00
Marc-André Bombeck 95140e5aa7 fix(medications): resolve one occurrence per intake row, the nearest
A taken, skipped or auto-missed row counted as resolving every occurrence
within six hours of its anchor. The radius was sized for the twice-daily
12 h gap, so on any schedule whose neighbouring doses sit closer than that
(19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row
resolved the second occurrence too: the next-due walk jumped to tomorrow,
the card offered tomorrow's first dose, and a skip pressed next landed
there. The second dose could not be logged from the card at all.

Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take
left the 23:59 row pending and the skip was written to tomorrow's 19:49.
The compliance-journey e2e spec exposed the same thing in every run that
started after noon, when its twin slot is 23:59.

Rule, in slot-resolution.ts and shared by the list's next-due, the card's
overdue search and the compliance current-cycle descriptor: a row resolves
exactly one occurrence, the one nearest its anchor, and only within the
drift radius. An exact match short-circuits; a drifted anchor is measured
against every occurrence the schedule emits within the radius and loses to
any nearer one (ties go to the earlier occurrence), so the effective reach
is min(radius, half the gap to the neighbour) and a sibling can never be
swallowed. Rolling cadences skip the nearest test, their occurrences are
at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the
cycle descriptor as well, so the card's due slot and its cycle state agree.

The drift tolerance itself stays: it covers a row whose anchor sits off the
canonical instant, a pre-snap row or an instant that moved under it across
a DST shift, which is what the radius was introduced for in v1.15.10.
2026-09-10 20:17:38 +02:00
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
Marc-André Bombeck 4791d6f543 test: pin the suite's timezone and make the zone-sensitive fixtures say which zone they mean
The suite was not timezone-pinned. CI runs UTC, so a whole class of
day-bucketing defect was invisible to the gate while a contributor outside
UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five
tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests
that need a different HOST zone now set and restore it explicitly.

The resting-pulse fixtures passed offset-less ISO literals, which parse as
host-local, so they could not distinguish the Berlin day-key the
implementation uses from a UTC one — swapping the day-key's zone left the
file green. Build each instant explicitly in Berlin and add cases whose
local day and UTC day genuinely differ; that swap now fails two of them.

Delete the 'DST boundary (spring-forward)' compliance case. It passed no
timezone, so the slot builder took the legacy UTC path where the date
carries no transition, and it asserted totalExpected === 7 — which holds in
every zone and therefore could never see the double-emit its own comment
described. Threading a real zone does not rescue it: the assertion stays
true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is
already pinned properly in the scheduling suite, which walks
buildCadenceTimeline with an explicit zone across both the 23 h
spring-forward and the 25 h fall-back day.
2026-07-19 02:27:03 +02:00
Marc-André Bombeck d978c2b662 refactor(compliance): split the god-module into focused units behind a barrel
The medication-compliance engine lived in one 2,099-line file with 38
exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard
pillar, per-medication tile, insight targets, doctor report, …). A single
edit intended for one surface could silently shift all of them.

Split along the existing internal seams into `analytics/compliance/`:
parsing, types, adapters, dose-status, occurrences, windows, cycle,
ledger, display. `analytics/compliance.ts` is now a thin barrel that
re-exports every public symbol, so no consumer import path changes. Pure
move — no logic edits; the tally, window ladder, ledger and cycle math
are byte-for-byte the code they were. Tests move alongside under
`compliance/__tests__/` and keep exercising the public barrel.
2026-07-18 02:09:33 +02:00