Commit Graph
74 Commits
Author SHA1 Message Date
Marc-André Bombeck 25a7834dec fix(medications): attribute historical intake edits against the era live then
The write/edit slot resolver loaded a medication's archived schedule
revisions but dropped them before running band attribution, so editing a
historical intake snapped it onto the LIVE schedule's slots instead of the
era that was valid at the dose's time. The read side (dose-history,
compliance) already era-resolves; the write side now matches.

`loadAttributeMedication` threads `scheduleRevisions` into the attributor
projection. Both `resolveSlotForWriteByBand` and `resolveForcedSlotForWrite`
now pick the correct era for any past instant.
2026-06-14 10:33:20 +02:00
Marc-André Bombeck f4dec8c28f feat(medications): fractional dosing via decimal units per dose (#316)
A dose can now consume a fraction of a unit — a half, third or quarter
tablet for a split pill. The wizard offers a curated fraction set beside
the whole numbers (the most error-resistant input); units-per-dose and the
per-container counts widen from Int to Decimal, so a 30-tablet pack dosed
at a half reads 29.5 after one dose and the days-left projection follows.
The consumption ledger stamps and refunds the exact fraction, and existing
whole-number doses are untouched. OpenAPI regenerated — the three unit
fields move from integer to number — and the units-per-dose hint is
updated across the six locales.

Also anchors a first-use date relative to now in an inventory route test
that had aged past its 30-day in-use window.
2026-06-13 16:07:50 +02:00
Marc-André Bombeck 3943dcfb72 feat(medications): as-needed medications log without a schedule
A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
2026-06-12 22:56:03 +02:00
Marc-André Bombeck 9dc0ccd03a fix(medications): release-QA findings on the inventory overhaul
- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall
  back to ledger-derived pen/dose figures when a medication has no
  inventory items; items always win once they exist. The delta branch
  on the POST is marked deprecated in favour of the inventory endpoints.
- Expired containers count as available nowhere: one shared supply
  summary (ACTIVE/IN_USE with units) feeds the overview row, the
  Bestand summary, the list payload, the GLP-1 card, and the runway
  estimate; expired units surface as a muted suffix instead.
- The inventory endpoints speak one wire dialect: request fields renamed
  to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated.
- The reorder dialog mirrors the page's grouping: active and inactive
  sections, each sortable within itself, so a saved order can never
  interleave them.
- Polish strings avoid the 2-4 plural trap, sub-dose quantities render
  as below one dose instead of approximately zero, and the table's name
  sort collates by the active locale instead of hardcoded German.
2026-06-12 19:11:53 +02:00
Marc-André Bombeck 7eeab2ae98 fix(medications): stock claims are atomic and only a dose becoming taken consumes
Two hardening passes on the consumption stamp:

- consumeForIntake claims the stamp with a single conditional UPDATE
  (NULL-stamp predicate + row lock) instead of read-then-write, so a
  double tap racing a sync replay decrements exactly once; the loser
  sees count 0. restoreForIntake clears the stamp the same way, so
  concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
  replay that re-posts an already-taken history row, or an edit that
  corrects the timestamp of an old dose, never touches today's stock.
2026-06-12 18:48:30 +02:00
Marc-André Bombeck e21dfd040e feat(medications): a sortable table view with an order you choose
The medications page gains a compact table beside the card view — one
row per medication with the same served-due status pill, next-intake
line, cadence-scaled adherence bars, dose-derived stock and the take
and skip actions the cards use, all from the payloads the cards
already read; the list response aggregates stock in the same round
trip. Columns sort tri-state with nulls last, the default order is
yours to arrange — the same drag dialog the dashboard and insights
use, applied to both views — and the chosen view and order persist
per user on their own settings column with field-scoped writes, so
toggling the view never touches the order. Inactive medications pin
to the end, the first column stays put under horizontal scroll, and
the loading skeleton follows the view you left.
2026-06-12 18:10:21 +02:00
Marc-André Bombeck 7d3557a3fd fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
2026-06-12 17:42:31 +02:00
Marc-André Bombeck b4d2d51d46 fix(dashboard,medications): review findings before the cut
A fresh crisis-level blood-pressure reading outranks an overdue dose in
the daily verdict, and once it ages past a day the dose leads again.
The greeting returns to the page header whenever the daily band is
hidden, and the settings toggle says it rides along. The nightly
convergence pass honours the early grace, so a take recorded shortly
before a window that starts at the dose time heals like the write path
credits it. The early-take context line states a fact — last dose
yesterday, last dose n days ago — instead of claiming an early intake
after a late one, the quick-add default pick runs in the profile
timezone, a dose line without a medication name reads cleanly, the
early-grace constant has one home, and the Telegram confirm writes
intake and snooze atomically again.
2026-06-12 11:22:21 +02:00
Marc-André Bombeck aaecd141ee fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
2026-06-12 10:43:32 +02:00
Marc-André Bombeck b346e78cc7 perf(medications): one compliance round trip and a memoised formatter hot path
Intl.DateTimeFormat instances are memoised per locale, timezone and
options signature — construction dominated the 365-day ledger build and
the hot path drops about fivefold. A batched GET /api/medications/compliance
serves every card from one request through the same per-medication cache
cells the detail page reads, replacing the per-card fan-out; the list
response stays untouched so the due line never waits on a ledger build.
The medications and compliance buckets serve stale-while-revalidate,
interactive writes evict while background bulk sync marks stale, the
per-id route defers ownership and schedule reads until a cache miss,
and the card body reserves constant slots for the status and cycle
lines so late data stops shifting the layout.
2026-06-11 21:17:17 +02:00
Marc-André Bombeck 40f7196c20 feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
2026-06-11 13:57:15 +02:00
Marc-André Bombeck 9445d89b76 refactor(api,tz): one typed client for every call and one home for wall-clock math
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 7e7e17c68c feat(medications,coach,insights): record the dose you actually took
An intake can carry the dose that was actually taken — editable in the
quick-add and log dialogs, shown as a quiet deviation line in the
history. The measurements and mood lists refresh by pull on touch, the
insight sub-pages gain the same customize entry as the overview, and an
answered coach follow-up offers to adopt the answer into the matching
self-context field.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck bcbb05c770 fix(charts,medications): hold the overdue dose on the card and floor non-negative axes
A dose past its slot time but still inside its catch-up window now stays
on the medication card as overdue and takeable instead of yielding to
the next occurrence — the next-due field can therefore carry a past open
slot, flagged with nextDueOverdue. Y-axis padding no longer dips below
zero for series that cannot be negative, so a steps chart stops
suggesting minus nine hundred steps.
2026-06-10 22:41:28 +02:00
Marc-André Bombeck bd78cd7f7a feat(medications): schedules remember their history
Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
2026-06-10 22:21:44 +02:00
Marc-André Bombeck 5e1035e47a fix(medications): drop the due-context line when no slot is plausibly near
History rows recorded before a schedule's first minted slot paired with
an anchor days or weeks away and read 'due 09:00 (-1904 h)'. The
nearest-anchor fallback now caps at the band's capture reach plus one
inter-slot gap; beyond that the row simply reads off-schedule.
2026-06-10 21:41:12 +02:00
Marc-André Bombeck 258e2fd365 feat(medications): an api tab, a stockable inventory and a next-due that tells the truth
The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
2026-06-10 20:48:46 +02:00
Marc-André Bombeck a5650d0617 fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape
Every standalone intake anchors its slot on the take time since
v1.15.19, so guarding the nightly dedup on that shape excluded ordinary
drift duplicates from collapsing — the original point of the sweep.
Releasing a pinned dose now persists the user's choice on the row, the
dedup and the repair script key their exclusion on that flag, the read
model reports a released row as deliberately off-schedule instead of
re-snapping it within the anchor epsilon, and the attributed badge only
shows on rows actually bound to a slot. A few fixture names and stray
comments also drop personal references.
2026-06-10 17:33:56 +02:00
Marc-André Bombeck 4ca3de5ff3 fix(medications): make the nightly dedup and the pin path respect each other
A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
2026-06-10 16:54:15 +02:00
Marc-André Bombeck 0cf9bc9348 feat(medications): attribute an off-schedule intake to its slot, visibly and reversibly
An off-schedule row now tells the user when the dose had been due, and
its row menu offers to attribute it to that slot when the slot is still
unserved. A pinned intake is a deliberate decision with a visible badge,
it always counts as taken late — never on time — and the pin can be
released again. The read model binds pinned rows by their slot anchor so
a pin outside the catch-up window survives reloads, and the suggestion
respects the schedule's own windows instead of a hard-coded tolerance.
2026-06-10 15:39:55 +02:00
Marc-André Bombeck 0a4bf34a2b fix(medications): align the slot lifecycle with the band model end to end
The auto-miss cron stamped every pending row missed a flat day after
its slot even though weekly schedules stay takeable for days, touched
tombstones because it never filtered deleted rows, and a verdict stuck
even after the dose was taken. The cron now derives its cutoff from the
schedule's window reach, skips deleted rows and bumps the sync counter,
and a take clears the auto-miss flag. Editing a schedule tombstones the
open pending rows of the old anchors and reconciles a stale window onto
the new times. The bulk write path attributes takes through the same
band engine as the single-intake routes and accepts forceSlotInstant
per entry. Configured dose windows now reach every compliance call site
through one shared schedule select, and the card window pill takes a
timezone, treats a degenerate window as a point and counts dose times
instead of schedule rows.
2026-06-10 13:34:26 +02:00
Marc-André Bombeck 9e6d0d5acb fix(medications): converge intake writes onto existing slot rows across sources
A take whose slot the legacy resolver rejected fell back to a standalone
insert that kept the client's scheduledFor, parking a second live row on
an anchor the reminder projector had already minted — the partial unique
index permits that because it spans source. Both intake write routes now
probe the slot source-agnostically and converge onto the existing row
before inserting, and a standalone insert anchors on the take time per
the ad-hoc contract. The compliance rollup counts distinct slots instead
of rows, which also corrects historical duplicates on the next recompute,
and the slot dedup pass runs nightly instead of only at worker boot.
2026-06-10 11:18:00 +02:00
Marc-André Bombeck 0761ce2346 fix(medications): widen write-path band minting to cover day-scale tails
The write path minted attribution bands over a fixed ±1 day around the
take, so a weekly or rolling dose taken two to four days late — inside
its own overdue tail — never found its anchor and was stored ad hoc
while the slot stayed missed. Day-scale schedules now mint over the full
on-time-plus-overdue reach derived from the configured dose windows;
minute-scale schedules keep the ±1 day probe.
2026-06-10 11:17:38 +02:00
Marc-André Bombeck eb2c7ced73 fix(medications): read pending doses as upcoming until their window closes
A server-minted pending row (taken_at null, not skipped, not auto-missed)
was scored missed the moment its slot time passed, so the history tab and
the compliance rate punished doses that were still takeable under the
band model. Pending rows now read upcoming until the band's overdue end;
auto-missed keeps its terminal verdict. Pending rows outside the minted
band window are dropped instead of surfacing as phantom ad-hoc lines.
2026-06-10 11:00:02 +02:00
Marc-André Bombeck dfb6a7f298 fix(medications): keep dose-window defaults off the client server graph
The dose-window editor pulled DOSE_WINDOW_DEFAULTS from
@/lib/analytics/compliance, dragging the server-only scheduling graph
(which imports node:async_hooks) into the client bundle. Turbopack
rejected the client chunk; tsc does not model the boundary so the type
check stayed green.

Move the constant into a leaf module with no server imports and
re-export it from compliance so the server path is unchanged. The
client-reachable editor now imports the leaf directly.
2026-06-08 20:09:09 +02:00
Marc-André Bombeck 6526780f3b feat(medications): build the on-time band from the persisted dose window
Thread the persisted `doseWindows` through the shared band minter so the
on-time band for a dose time uses its explicit `[start, end]` range
(minted DST-correctly on the slot's local day) instead of the symmetric
±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows`
helper coerces the stored JSON into a clean entry list, dropping anything
malformed so the read/write paths never defend against an arbitrary shape.

Every band consumer reads the same field: the dose-history endpoint, the
compliance %, the cadence timeline (card last/next dose), and the
write/edit slot attribution all resolve through one minter, so the
history view and the rate can never disagree. The write/edit and
health-score schedule selects now pull the column.
2026-06-08 18:29:17 +02:00
Marc-André Bombeck 7b7253b989 feat(medications): band-model slot attribution for the intake write/edit paths
Add a shared write-side attributor that runs the SAME cadence-aware band
minter the read ledger and the compliance % consume, so a take is bound to a
slot identically wherever the decision is made. `attributeTakenToSlot` maps a
takenAt onto a slot by dose-window membership (on-time / late) or returns null
for an off-window ad-hoc take; `resolveForcedSlotInstant` validates a
client-pinned slot against the real band anchors. The DB-loading resolvers
(`resolveSlotForWriteByBand`, `resolveForcedSlotForWrite`) load the
medication's schedules + rolling anchors and run the pure core.

This retires the wide ±6h nearest-snap (`snapToleranceMs`) that pulled an
11:29 take onto the 07:00 slot and a 13:02 take onto the 19:00 slot.
2026-06-08 18:07:31 +02:00
Marc-André Bombeck 79e7c866e4 fix(medications): unify compliance % and per-dose status onto one dose-history ledger
The medication compliance percentage and the history view drew their
per-dose attribution from four different matchers — the ±12h pairDoses
proximity used by calculateCompliance, the deriveDoseStatus bands (open
dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a
raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a
dose could read "taken late" in the rate while the history called it an
off-schedule ad-hoc take.

Route the rate through the shared cadence-aware band minter +
reconstructDoseHistory so the percentage is a tally over the SAME ledger
the history view renders. The same intake can no longer be taken in the %
and ad-hoc in the ledger.

- calculateCompliance: when a medication context is supplied, the
  count fields (taken / skipped / missed / rate) come from
  tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a
  late dose still counts as taken — the taking-vs-timing split is kept
  available); denominator = taken + missed; skipped, ad-hoc, upcoming and
  whole PRN groups are excluded; rate capped at 100. The streak still
  walks the cadence timeline. Context-less pure-math callers keep the
  legacy timeline tally byte-stable.
- complianceChips + missedDoses: adherence rate + missed count come from
  the same ledger when an engine context is threaded.
- /api/medications/[id]/compliance heatmap: the per-day timing split and
  missed marks are bucketed from the one ledger instead of the per-day
  classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree
  with the headline rate.
- medication_compliance_rollups stays a raw cross-medication daily
  coverage count, documented as distinct from the band-attributed % (the
  band minter is per-cadence and cannot run inside a single SQL tally).

Downstream readers (health score, insight compliance status) keep calling
calculateCompliance; their numbers shift to the honest band-attributed
values. Some currently-counted off-time takes (e.g. an 11:29 take of a
07:00 dose) now read ad-hoc with the slot missed rather than taken-late —
the configurable per-dose window is the lever to widen the on-time band.

Tests: the irregular twice-daily, single-time-late heatmap, and legacy
parity fixtures are updated with rationale for the intended number
changes; new tally tests pin the taking/timing split, the skip / ad-hoc /
upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling
retrospective families, and the cross-consistency invariant.
2026-06-08 17:50:06 +02:00
Marc-André Bombeck 56bc46417e fix(medications): keep degenerate ≤1-day rolling on the minute scale
Self-review: a rolling interval of 1 day is effectively daily, so a ±1-day
day-scale on-time window would over-widen and let one take claim adjacent
slots. Classify rolling by its interval — ≥2 days is day-scale (the canonical
weekly+ injection), ≤1 day stays minute-scale. Two tests pin the distinction.
2026-06-08 17:30:31 +02:00
Marc-André Bombeck 75bf9829a8 feat(medications): shared cadence-aware band minter
Add the keystone that builds the correct SlotBand[] for every medication
cadence, so the dose-history read-model, the compliance rate, and the
write/edit attribution all consume one source of slot windows and can never
contradict each other.

Per cadence:
- daily / multi-time: minute-scale on-time (±60min default) + daily late tail;
- fixed weekdays / every-N-weeks: family derived from the realised inter-slot
  gap, not the field-shape heuristic that mislabels legacy daysOfWeek meds as
  daily; day-scale on-time + 4-day tail;
- rolling (GLP-1): retrospective bands anchored at each logged intake, not the
  forward-only single slot that would orphan every past shot to ad-hoc;
- cyclic: off-week slots already dropped by the engine; band the survivors;
- one-shot: a single whole-day on-time band, never auto-missed;
- PRN / empty / malformed rrule: no bands, hasExpectedSlots=false;
- DST day-scale bounds minted via localHmAsUtc on calendar days, not ±N·DAY_MS;
- multi-schedule: bands per schedule so a weekly tail is never clipped by a
  daily anchor.

Pure and synchronous; the rolling intake instants are pre-fetched and threaded
in like resolve-slot-instant. 25 tests covering each cadence plus DST
summer/winter, the realised-gap family, and the per-schedule no-clip invariant.
2026-06-08 17:28:55 +02:00
Marc-André Bombeck e5221b45e6 feat(medications): traceable dose-history read-model
Reconstructs a complete, attributable ledger from the expected-slot bands +
the actual intakes: every expected slot carries a status (taken_on_time /
taken_late / skipped / missed / upcoming) even when never taken, and a taken
intake that matches no band is emitted as a tagged ad-hoc row at its real
time instead of vanishing.

A taken intake attributes by its real takenAt (not the stored, possibly
mis-snapped scheduledFor), so corrected bands converge forward over legacy
rows with no data migration. Skipped / auto-missed / pending rows bind to
their slot by anchor. Pure and instant-based. 9 tests.
2026-06-08 16:08:12 +02:00
Marc-André Bombeck 13d885b8b3 feat(medications): window-band intake→slot attribution engine
Pure band-membership attribution replacing the wide nearest-neighbour
matchers that mis-assigned irregular intakes (a 13:02 take snapped onto
the 19:00 slot, an 15:46 take onto the 07:00 slot). A take pairs to a
slot only inside that slot's dose window plus a capped late tail:

  intake in [onTimeStart, onTimeEnd]  -> on_time
  intake in (onTimeEnd, overdueEnd]   -> late
  otherwise                           -> ad-hoc (standalone row)

The on-time band is the per-dose window; overdueEnd is capped at the next
slot's on-time start so adjacent slots never both claim one intake. Pure
and instant-based; callers mint the bounds DST-correctly via localHmAsUtc.
13 tests pin summer/winter, on-time/late/ad-hoc, the cap, and a widened
window.
2026-06-08 16:03:52 +02:00
Marc-André Bombeck faeb770cbb fix(medications): advance the next dose past already-resolved slots
The card's next dose did not move on after a dose was logged: the server's
next-due and the open-cycle descriptor both picked the next slot purely by
clock time, ignoring whether that slot was already taken, skipped, or missed.
For a twice-daily med whose remaining slots today were all logged, the card
kept surfacing a present/past resolved slot instead of advancing to the next
genuinely-open one.

Skip resolved slots in both paths: computeNextDueAt and buildCurrentCycle now
walk occurrences forward and return the first slot no intake has resolved
(taken / skipped / auto-missed), so a med whose slots today are all logged
advances to tomorrow's first open slot. The list and detail routes read the
acted-on slots near now and thread them in; both walks are bounded so a
logged-ahead history cannot spin. The card's compliance/next-dose queries
already ride the ["medications"] invalidation prefix, so logging a dose
refreshes the card promptly once the server value advances.
2026-06-07 14:41:33 +02:00
Marc-André Bombeck 311ac65f81 fix(medications): pair off-time intraday doses to their own slot
The dose-to-slot pairing matched a logged intake against the slot's window
*centre*. For a wide intraday window (a twice-daily 07:00–19:00 med spans 12h,
so its 07:00 slot centres at 13:00 and its 19:00 slot at 01:00 the next day)
the centre sits between the real dose times, so an off-time take paired to the
wrong slot: a morning dose logged late could land on the evening slot, a
deliberate user-skip could read as taken, and a late evening take could read
as missed — distorting the compliance rate for an irregular twice-daily taker.

The intake write path already snaps each dose's scheduledFor to its canonical
slot instant, which identifies the slot unambiguously. Add an exact-anchor
pass that claims the slot whose windowStart matches the intake's scheduledFor
(±1 min) before the proximity fallback. Rows that don't line up on a slot
instant (legacy un-snapped data, sparse off-day takes) keep the existing
proximity match, so every prior cadence fixture is byte-stable.
2026-06-07 14:41:24 +02:00
Marc-André Bombeck cb239c2803 feat(medications): cadence-aware dose-state windows + miss-counting engine
Thread the auto_missed flag through the cadence timeline so a forgotten
dose pairs to a missed slot (against the rate) rather than a neutral skip;
a deliberate user skip stays excluded. This unifies the skip semantics
across every compliance surface: user skip = excluded, forgotten = missed.

Add a per-dose grace/miss window model the card will render:
deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time
/ taken_late / skipped / upcoming from the target instant, cadence family,
and now. Daily/intraday uses minute-scale windows (on-time +/-60min,
overdue to +240min, capped at the next dose); weekly/rolling uses the
clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are
centralised named constants for later per-medication configurability.

Surface taken / expected / missed counts on each compliance-display row so
the card can show the trustworthy 'taken / expected . rate%' triple, and
pin a single now through the per-medication compliance route so the two
windows can't straddle a day boundary on a slow request.
2026-06-07 13:26:51 +02:00
Marc-André Bombeck 3ec80adbd2 fix(medications): never snap a taken intake onto a future dose slot
A taken intake whose write named (or snapped near) a slot still in the
future was attributed to that slot and rendered as taken before its time —
a late morning dose collapsing onto the evening slot of a twice-daily med.
The slot resolver now takes an isTakenWrite flag and a now reference: a
taken write skips any candidate slot more than a small clock-skew grace
into the future, so it snaps to a current/past slot or keeps its own row.
Pending sync echoes are unaffected and still map to future slots.
2026-06-06 16:22:07 +02:00
Marc-André Bombeck a55816bf58 fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor 2026-06-05 22:09:06 +02:00
Marc-André BombeckandClaude Opus 4.8 6871e8ea77 fix(medications): stop defaulted-now intake from auto-taking a far slot
A slot-less "taken now" write (no client scheduledFor) defaulted its
instant to now/takenAt and snapped across the wide ±halfGap window onto
the nearest scheduled slot. For a 07:00 / 19:00 med a midday write
captured the 07:00 morning slot and stamped it taken — a phantom morning
dose. The web fix only threaded the displayed slot on the card; any
client posting takenAt-only (iOS bulk/sync) still tripped it server-side.

Thread an instantIsExplicit flag through resolveSlotInstantForWrite into
the canonical resolver. An explicit scheduledFor keeps the full ±halfGap
snap unchanged. A defaulted-now write snaps only inside the tight
dose-grace window and otherwise resolves to null (PRN), so it records a
standalone taken-now row instead of back-filling a far slot. Both intake
call sites pass scheduledFor !== undefined; takenAt alone is not explicit.

Add resolver unit coverage for midday defaulted-now writes (07:00/19:00 →
null at 11:00/12:00/13:00, explicit 07:00 still snaps) and a DB-backed
regression that a takenAt-only midday write leaves the 07:00 slot untaken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:59:02 +02:00
Marc-André BombeckandClaude Opus 4.8 037da560ad fix(compliance): compute medication streak in the user's timezone
The analytics compliance streak keyed days with host-local
getFullYear/getMonth/getDate against a timeline whose slot.day is minted
in the user's IANA zone, so the streak drifted off by a day whenever the
server clock's zone differed from the user's. Consolidate onto the
detail-page streaksFromTimeline helper threaded with the medication's
timezone so both call sites share one correct day-keyed walk.

Also fix the DST step in that helper: it stepped a fixed +24h while its
comment claimed 25h, which could skip a local day on the 23h
spring-forward boundary and run the window short. Snap each cursor to
the user-local midnight and advance +25h then re-snap, mirroring the
cadence expander, so every local calendar day is visited exactly once
across 23/24/25h boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André Bombeck e0f60d82cc fix(medications): correct weekly-cadence compliance returning 0% despite recorded intakes
A weekly injectable (e.g. a GLP-1 dose) with real recorded intakes
reported 0% compliance on the medication card across every window
(7 / 30 / 90 days). The web reads the server-computed rate verbatim,
so the server value itself was wrong.

The intake-to-slot matcher used a fixed +/-12h pairing radius. That is
correct for a daily cadence (24h inter-slot gap) but far too tight for a
once-weekly dose: a real intake is rarely logged within 12h of the
schedule's configured time-of-day, because the user takes the shot on
whichever day and time of the dosing week suits them. Those intakes fell
outside the radius, every weekly slot read "missed", and the rate
collapsed to 0% while the matching intakes were orphaned.

Scale the pairing radius with the cadence gap. `pairDoses` now derives a
per-slot radius from half the distance to its nearer neighbouring slot
(so two adjacent slots never double-claim one intake), floored at the
12h base so daily and multi-dose-daily cadences keep their exact prior
behaviour. `buildCadenceTimeline` additionally probes the schedule's
intrinsic cadence over a padded window and passes half the minimum
inter-slot gap as a radius floor, so a window holding a single expected
slot (a weekly med over a 7-day window) widens correctly too.

A weekly cadence now pairs an intake logged anywhere in the dosing week
to that week's slot, reporting the true rate. Daily-cadence matching is
unchanged.

Adds unit coverage for the widened radius and a real-Postgres integration
test mirroring the weekly-injectable row + off-time intakes.
2026-06-04 23:34:25 +02:00
Marc-André Bombeck 793602f464 fix(medications): cap intake snap tolerance to the inter-dose gap so multi-dose orals don't collapse
A daily oral with multiple timesOfDay and a wide schedule window had its
dose-slot capture zones overlap, so a second same-day dose could snap onto
an already-actioned slot and be dropped by the no-downgrade guard — the
intake never landed as its own row. A twice-daily 08:00 / 20:00 schedule
with an 08:00-22:00 window yielded a +/-7h half-span tolerance that
exceeded half the 12h inter-slot gap, so the evening write collapsed onto
the morning slot. Weekly and single-dose schedules (one slot/day) were
immune.

snapToleranceMs now caps the tolerance at half the minimum gap between
adjacent sorted timesOfDay entries (the midnight wrap counted as one of
those gaps) whenever a schedule has more than one slot, keeping the two
nearest slots' capture zones disjoint. Single-slot schedules keep the
full half-window-span behaviour, and the >=1-minute drift floor is
preserved, so weekly injectables and once-daily orals are unchanged.

Adds unit coverage for the wide-window two-dose case (distinct slots, no
collapse) plus an explicit weekly single-dose regression, and an
integration test driving both same-day writes through the DB-backed slot
resolver and canonical-slot upsert to assert two distinct live rows.
2026-06-04 23:04:40 +02:00
Marc-André Bombeck 68cad9d11c fix(medications): raise the upper-arm markers to the deltoid and the thigh markers to the upper thigh 2026-06-04 21:39:06 +02:00
Marc-André Bombeck 33ca595405 feat(medications): replace the injection-site body map with a refined illustration
Swap the hand-authored body silhouette in the injection-site picker for a
clean, gender-neutral medical line-art figure with correct proportions
(arms to mid-thigh, head-to-body ≈1:7.5, separated legs, feet). The figure
ships as a small transparent raster (~8 KB) and is painted theme-aware: its
alpha drives an SVG mask over a currentColor rect, so the strokes take the
picker's text colour and tint correctly in light and dark.

Keeping the figure inside the SVG (rather than a CSS-masked sibling) means
it shares the dots' 100×200 viewBox, so alignment stays responsive by
construction. Recalibrate SITE_COORDS so the upper-arm, abdomen-quadrant
and thigh anchors land on the new figure's anatomy; the pointer overlay,
keyboard targets, recommendation ring, last-used marker and disabled-set
logic are unchanged.
2026-06-04 21:34:10 +02:00
Marc-André BombeckandClaude Opus 4.8 ba4349eda0 refactor(db): extract the P2002 predicate into one shared helper
The Prisma unique-constraint (P2002) narrowing predicate had been
hand-copied into five call sites. Fold them into a single isP2002 in
src/lib/prisma-errors.ts and import it everywhere (the two step-drain
sites keep their local isUniqueConstraintViolation name via an import
alias). Behaviour-identical; one place to chase a future P2002-semantics
change instead of five.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:26:07 +02:00
Marc-André Bombeck bfdf0a12a0 fix(medications): route injection-site taps to the nearest quadrant centre
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.

Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 09af60303d feat(medications): redraw the injection-site picker as an anatomical silhouette
Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.

Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.

Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 3f9fd9095f feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.

The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.

Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck 7e7702d6c9 refactor(medications): single-source the injection-site allow check
resolveInjectionSiteForWrite reimplemented the effectiveAllowedSites(...)
.includes(...) membership test inline, duplicating the isSiteAllowed
helper that already wraps it. Call isSiteAllowed so the write-path guard
and the helper share one definition.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck fef88787e9 fix(medications): surface a multi-day-overdue first rolling dose
A rolling cadence with no intake logged anchors its first dose at
startsOn ?? createdAt. When that anchor falls on a prior calendar day
the dose is overdue by a day or more, but nextOccurrenceAfter floored
the search window to the start of the user's current day and dropped it
— so the dashboard showed no next-due and the reminder worker, which
shares this engine, emitted nothing. Clamp the floor to the first-dose
day in the no-intake branch so a multi-day-overdue start dose surfaces
as overdue. The post-intake re-anchor (lastIntakeAt + N) is unaffected.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck b99a015266 feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.

- Schema: add medications.track_injection_sites + allowed_injection_sites
  and users.global_excluded_injection_sites (migrations 0099, 0100;
  additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
  status-toggle, and bulk intake routes. Persist it only on a taken
  injection with tracking enabled, validated against the medication's
  effective allowed set (per-med allowed sites minus the user-level
  global exclusion, deny wins) — a disallowed site returns 422 (the bulk
  route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
  filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
  wizard step, a skippable post-dose site prompt on both medication
  cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
  regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.

The site capture is always optional and never blocks logging a dose.
2026-06-01 20:25:34 +02:00