Commit Graph
705 Commits
Author SHA1 Message Date
Marc-André Bombeck 05fe2a00bf Surface effective AI availability on the ai-provider endpoint
Add aiAvailable + managedBy to GET /api/user/ai-provider so a server-managed
or local provider is reported as usable even without a personal key. Resolution
origin comes from a presence-only helper that mirrors provider resolution
order; no admin keys or endpoints are exposed.
2026-06-14 17:11:32 +02:00
Marc-André Bombeck ab3153008e fix(cycle): suppress the predicted fertile window on the wire while still learning
Below three observed cycles the prediction is a population-prior guess, not a
data-grounded estimate. The web calendar already hid the fertile window and
predicted ovulation in that state; make the gate structural in the DTO mapper
so iOS — and any client reading prediction.* directly — cannot paint a window
the rest of the app refuses to show. ovulationConfirmed stays goal-gated only:
a confirmed shift is observed data, not a prior.
2026-06-14 15:50:36 +02:00
Marc-André Bombeck a614ca78c6 feat(cycle): deepen phase-education copy with a second context line
Add a per-phase "context" line under cycle.phaseEducation.context.* that
expands the existing descriptive-only education with what someone might
notice — energy, sleep, basal temperature, mood — and a gentle framing of
normal variation. The original whatsHappening lines are kept verbatim; this
sits below them as a second, deeper paragraph in the same warm,
medically-grounded, non-prescriptive register.

Wire the new leaf through phase-copy.ts (PHASE_CONTEXT_KEY) and render it in
PhaseEducationCard, and add the copy across all six locales.
2026-06-14 15:42:58 +02:00
Marc-André Bombeck 609d248252 feat(cycle): choose cervix as the symptothermal secondary symptom
Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.

- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
  second sign the engine confirms against. Casual users keep temperature
  + mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
  (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
  columns (migration 0157), wired through Zod, the day-log upsert, the
  DTO, the sync feed, backup/restore and OpenAPI. Additive and
  back-compatible: existing profiles backfill MUCUS, existing day-logs
  keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
  on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
  day followed by three closed days — the cervix-closure rule) instead of
  the mucus peak. The double-check still requires BOTH the temperature
  shift AND the secondary peak: a single cervix sign never confirms, and
  the still-learning gate and conservative posture are unchanged.

Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
2026-06-14 15:23:26 +02:00
Marc-André Bombeck 2c225ce094 feat(cycle): fold a positive LH/OPK surge into the ovulation estimate
A stored positive ovulation test (LH surge) was ignored by the
predictor. Wire it in as a Marquette/Fehring anchor: a positive surge
predicts ovulation ~24-36h later, so the estimated ovulation is set one
day after the last positive LH test in the current window, sharpening the
estimate over the calendar back-calculation — most valuable in the
thin-data (<3 cycle) case where the median/luteal back-calc is weak.

The LH surge is a SIGNAL that refines, never a sole assertion: it
re-anchors the still-estimated ovulation but never flips
`ovulationConfirmed` (a single indicator is not the symptothermal
double-check), and a confirmed symptothermal/temperature-trend ovulation
always wins over it. The still-learning gate and the un-confirmed status
are preserved. A stale prior-cycle surge whose next-start lands in the
past is rejected.

Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before
ovulation.
2026-06-14 15:06:55 +02:00
Marc-André Bombeck 4bfe9f11d7 feat(cycle): exclude disturbed temperatures from the symptothermal engine
A fever, illness, alcohol or a late/short-sleep reading can fake or mask
a temperature rise. Add a per-day `temperatureExcluded` flag on
CycleDayLog (migration 0156): when set, the engine drops the reading from
the cover-line baseline AND the shift evaluation, so a spike neither
fabricates nor masks a rise (Sensiplan: the cover line is drawn over the
last six unbracketed values).

Wired end to end — schema + Prisma, the engine's `detectTempShift`
filter, the Zod input/patch schemas, the day-log upsert (partial merge +
change detection), the read DTO, the calendar/insights/coach selects, the
backup round-trip, and the OpenAPI contract (a new boolean on the day-log
and calendar-day DTOs for the iOS client). The log-day sheet gains a
"mark reading as disturbed" toggle that appears once a temperature is
entered, translated across all six locales.

Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve
auswerten" — Störfaktor bracketing.
2026-06-14 15:04:25 +02:00
Marc-André Bombeck cd55c60fae feat(cycle): confirm the mucus peak with the Sensiplan post-peak count
The mucus peak was taken as the last egg-white/watery day with no
confirmation, so a stray late fertile-mucus entry corrupted the peak.
Implement the Sensiplan Höhepunkt rule: the peak day is the LAST
best-quality (egg-white/watery) day that is followed by at least 3
consecutive drier observed days, each strictly lower quality. The peak is
only confirmable retrospectively, after those 3 days, so a stray late
egg-white entry not yet followed by 3 drier days cannot move an
already-confirmed peak. Quality is scored on the t/f/S/+S ladder;
unobserved days are skipped so a logging gap does not break the count.

Citation: Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP
"Zervixschleim beobachten".
2026-06-14 14:53:24 +02:00
Marc-André Bombeck d98711a246 feat(cycle): add both Sensiplan temperature exception rules
The BBT shift detector only applied the strict 3-over-6 rule (3 readings
above the cover line, the 3rd >=0.2C above), so it under-detected
ovulation on common ovulatory patterns. Add the two published
Ausnahmeregeln:

- 1. Ausnahmeregel (slow/staircase rise): when the 3rd high reading is
  above the cover line but not >=0.2C above, a 4th reading that need
  only be above the line confirms the shift.
- 2. Ausnahmeregel (one value falls back): when exactly one of the three
  readings falls back to/below the cover line, it is discounted and a
  4th reading that again clears 0.2C above the line confirms.

The result now carries a `rule` (0|1|2) provenance field and the
`evaluationCompleteDate` (evening of the 3rd or 4th high reading). The
rules follow the Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann
standard and are mutually exclusive by construction.
2026-06-14 14:51:03 +02:00
Marc-André Bombeck df67b60420 fix(cycle): raise the hard cycle-length ceiling so long cycles survive
The hard upper bound clamped cycle lengths at 45 days, force-excluding any
longer cycle as an outlier before the robust statistics ran. Cycles over 35
days are oligomenorrhea but still real, and adolescent and perimenopausal users
routinely run to fifty days or more — exactly the irregular users for whom an
honest estimate matters most. Clamping their genuine cycles biased the estimate
toward the population middle.

Raise the ceiling to 60 days. It stays a coarse gross-error backstop; the
median-plus-MAD fence and the missed-log heuristic still do the real outlier
work, and because the fence is computed before exclusion, raising the ceiling
does not move it — it only stops the hard rule from pre-empting the fence on
long but consistent cycles.
2026-06-14 14:43:57 +02:00
Marc-André Bombeck f5798e20d8 fix(cycle): gate the calendar grid to a learning state during cold start
The predictions panel suppressed its fertile-window and ovulation rows while
fewer than three cycles were observed, but the month calendar did not — so a
brand-new user who logged a single period saw the grid paint a confident
fertile-window shading, a predicted-ovulation dot, and full phase bands off a
population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation
is unreliable below several recorded cycles, so the grid was asserting days it
could not know.

Thread the same still-learning gate into the calendar build: while fewer than
three cycles are observed, suppress the fertile window, the ovulation dot, and
the phase band (the predicted-period bar stays, mirroring the panel). Surface a
top-level stillLearning flag on the calendar response so the client renders a
calm "learning your cycle" banner over the grid instead of a bare, falsely
confident calendar. The flag is additive and back-compatible; older clients
ignore it and degrade to the previous render.

Also pin the observed-cycle count to a single source — the engine's
post-exclusion value — so the response never carries two divergent counts.
2026-06-14 14:43:50 +02:00
Marc-André Bombeck e1cee6086d fix(validation): drop the orphaned medication clock-skew constant
The shared validateEntryInstant guard now owns the future clock-skew
allowance, so the medication-local TAKEN_AT_CLOCK_SKEW_MS export is dead.
Remove it; medication intake keeps its tighter 5-year floor via
TAKEN_AT_MAX_AGE_MS. Clears the dead-code gate.
2026-06-14 14:05:32 +02:00
Marc-André Bombeck 3bb130d4a1 fix(validation): stop a future timestamp emitting a spurious past-bound error
Review follow-up to the shared entry-instant guard. The past-floor refine
re-ran the full plausibility predicate, so a future-dated instant tripped
both refines and the 422 carried a nonsensical "too far in the past" message
alongside the correct future one. The floor refine now checks the past bound
alone; a future value emits only the future message, matching the pre-refactor
single-message contract.

Also harden the Health-Score BP inputs and de-duplicate the tile-window
derivation surfaced in the same review:

- the prior-week graded score now keys off the prior-week window's own
  sufficiency rather than the current window's, so the week-over-week graded
  delta never pairs a confident current value against a thin-sample prior one;
- the BD-Zielbereich 90-day count + effective span derive through one shared
  `deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick
  slice can never compute the gate or the label span differently.
2026-06-14 13:59:56 +02:00
Marc-André Bombeck 3e7aa49eb0 feat(validation): bound entry timestamps with a shared plausibility guard
Only medication intake bounded its capture instant — a hand-rolled POST or a
faulty import could backdate a measurement to epoch zero or forward-date a
mood entry into next year, and both sailed through validation.

Extract the intake bound into a shared `validateEntryInstant` helper: reject
any instant more than a five-minute clock-skew tolerance in the future, and
reject anything before a 1900 far-past floor. Callers can clamp the past
window tighter — medication intake keeps its five-year floor — and override
the past-bound message to preserve an established contract.

Apply it to the measurement create/edit schema (`measuredAt`), the mood
schema (`moodLoggedAt`), and the cycle period + day-log (`loggedAt`);
medication intake now delegates to the same helper. The capture forms gain a
matching `max` on the datetime picker so a future instant can't be selected
in the UI either. The wire shape is unchanged, so the OpenAPI contract holds.
2026-06-14 13:51:51 +02:00
Marc-André Bombeck e9138bb4f6 feat(blood-pressure): label the in-target window dynamically and gate thin data
The BD-Zielbereich tile narrated a static "· 90 T" scope even for a user
with only a few weeks of history or a handful of readings — a headline that
reads like a stable quarter-long average when it is really a thin sample.

Add a shared, reusable window-confidence helper that, given a window length
and the readings observed inside it, returns the EFFECTIVE label span (the
real calendar span capped at the window, so "· 23 T" until ~90 days of
history exist) and a sufficiency flag (false below five readings). The helper
is metric-agnostic so the glucose time-in-range / GMI and sleep tiles can
reuse it.

Wire it into the in-target headline: below the confidence floor the tile
shows "sammelt noch Daten" instead of a hard percentage, with the dynamic
span label; above it the percentage shows with the real span. The
Health-Score BP pillar reads the same sufficiency notion through the shared
input builder, so a thin-data user no longer gets a confident pillar grading
off a few readings — the all-time rate still rescues a deep-history account
with a quiet quarter. The 90-day pair count and effective span thread from
the analytics route and the dashboard snapshot through to the tile.
2026-06-14 13:43:41 +02:00
Marc-André Bombeck 461ed5b5e0 fix(doctor-report): label the adherence denominator column as Expected
The dose-ledger adherence rate divides taken by taken + missed (deliberate
skips excluded), so the PDF compliance table's denominator column no longer
equals Taken + Skipped + Missed. Relabel it "Expected" so each row stays
internally coherent — Taken + Missed = Expected, with Skipped shown alongside
as informational and the rate reading Taken / Expected.

Also tighten the coach adherence-block wording: the headline is the
dose-weighted pool of taken / (taken + missed) across the user's scheduled
medications — exactly one medication's card rate for a single-med user, and the
honest overall adherence for several — never a per-day or per-week denominator
of its own.
2026-06-14 13:27:21 +02:00
Marc-André Bombeck 8e50a9b4cd fix(doctor-report): compute medication adherence from the dose ledger
The doctor-report PDF and FHIR export tallied raw intake rows — taken when
takenAt was set, else skipped, else missed, with every row counting toward the
denominator. That double-counted cross-source duplicate rows (a reminder row
and an API row for the same slot both read as taken), ignored band timing (a
pre-window or ad-hoc take counted as a plain take, a pending row counted as a
miss regardless of the cutoff), and honoured no cadence, so off-week rolling or
cyclic days could enter the tally. A clinician comparing the printed adherence
% against the app's medication detail page could see two different numbers for
the same drug.

Route the report through the same dose-ledger compliance the detail page uses:
per scheduled medication, mint the cadence-aware bands over the report window
and tally taken / missed / skipped from the unified dose-history ledger, with
the denominator being taken + missed so the renderers' taken/total rate equals
the ledger rate. PRN / as-needed medications stay excluded. The compliance
assembly is extracted into a pure, unit-tested `buildLedgerCompliance` helper;
the medication query now loads the archived schedule eras and the report reads
the user timezone so past days attribute against the schedule that was live
then.
2026-06-14 13:17:48 +02:00
Marc-André Bombeck efd77830f5 fix(coach): quote medication adherence from the compliance authority
The coach snapshot built its own medication-adherence figures: a per-day and
per-week timeline tallied off a "countable ledger rows in window" denominator
that differs from the cadence-aware `calculateCompliance(...).rate` every other
surface — the medication card, the detail page, the dashboard pillar, the
insights — reads. On an irregular, rolling, or partial-window schedule the two
denominators diverge, so the coach could reason from a rate the card never
shows the user.

Route the coach's headline adherence % through `calculateCompliance(...).rate`
with the medication context (the ledger path), aggregating taken / (taken +
missed) across the user's scheduled medications over the coach window — exactly
the arithmetic the card's rate uses. The snapshot now carries a single
`compliance.rate` headline alongside the supplementary timeline, so the coach
quotes the same number the card renders.
2026-06-14 13:11:59 +02:00
Marc-André Bombeck f4f1c0c8b0 fix(health-score): build the BP pillar from one shared input builder
The Personal Health Score was assembled differently by its two callers. The
analytics route (the Insights card) graded the BP pillar off one window and
passed the prior-week values for the week-over-week delta; the dashboard
snapshot (the hero ring) graded it off a different window and omitted the
prior-week values, so the ring's delta zeroed BP out and the pillar's presence
gate could flip. The same user could see two different scores and two
different bands on two surfaces.

Extract `buildHealthScoreBpInputs` — one pure builder that turns the current
and prior-week in-target envelopes into the BP-pillar inputs (the 90-day rate
with an all-time fallback, the graded clinical-proximity score, and the
prior-week counterparts for the delta). Both callers now run the same two
in-target probes and feed the pillar through this builder, so the window
choice, the fallback, the graded-score wiring and the delta inputs are
identical by construction and can never drift between the surfaces again.

The dashboard ring now runs the prior-week probe it previously skipped, so its
week-over-week delta reflects BP movement to match the Insights card.
2026-06-14 13:07:09 +02:00
Marc-André Bombeck 114db6bbca fix(blood-pressure): unify in-target window on 90 days across headline, score, and tile
The "BD im Zielbereich" share was narrated on three different windows on the
same screen: the dashboard headline read the trailing 30 days, the dashboard
Health-Score BP pillar read all-time, and the analytics route's pillar read
all-time while its headline read 30 days. A user could see one in-target rate
on the tile and a pillar graded off a different window directly above it.

Standardise the headline, the Health-Score BP pillar input, and the
week-over-week delta on a single trailing-90-day window everywhere — a span
wide enough to stay stable yet recent enough to track current control.
`computeBpInTargetWindows` and the rollup fast-path now both surface a
`last90Days` window; the analytics route and the dashboard snapshot route the
headline and the pillar through it, and both feed the pillar from the identical
value so the dashboard ring and the insights card can never disagree.

Label the window in the tile ("BD-Ziel · 90 T") so the scope is visible; the
30-day figure stays as the short-horizon caption and the all-time aggregate
stays carried for the detail view. Visible-number change is expected: the
headline now reflects the quarter rather than the month.
2026-06-14 13:02:17 +02:00
Marc-André Bombeck 3077adfe06 docs(whoop): correct the sync overlap comment after the workout widening
The header comment still described WHOOP_RECOVERY_SLEEP_OVERLAP_MS as 24 h
and claimed workout ingest used the narrow default. The constant has been
7 days, and workout ingest now shares it so a late phone-sync to the WHOOP
cloud no longer drops the workout outside the window. Comment only.
2026-06-14 11:03:24 +02:00
Marc-André Bombeck 6a4d3fd553 fix(whoop): widen workout ingest overlap to match recovery and sleep
The WHOOP workout sync used the default 1 h incremental overlap while
recovery and sleep use the wider re-score overlap. The activity collection
filters on the workout's own time range, not on when it reached the cloud,
so a workout whose phone sync lands more than an hour after its window fell
permanently before the cursor and was never ingested — operator-visible
data loss. The workout sync now reuses the recovery/sleep overlap; the
externalId upsert keeps the re-fetch idempotent.
2026-06-14 10:36:53 +02:00
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 f9876f9492 feat(consent): mint AI consent receipt from the web client
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.

Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.

Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.

Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
2026-06-14 10:25:18 +02:00
Marc-André Bombeck 7fa10b43a0 fix(insights): preserve stored layout dimension on a partial PUT
A PUT that omits sections (an iOS tiles-only reorder) reset the user's
section customization to defaults, and symmetrically a sections-only PUT
reset the stored tiles. serializeInsightsLayout now takes the resolved
stored layout and falls back to it — not to defaults — for whichever
dimension the body omits; the PUT handler loads the stored layout first.
A first-ever single-dimension PUT (no stored layout) still fills
defaults, so the v2 blob stays complete. Pins the merge behaviour with
three unit cases.
2026-06-14 10:15:47 +02:00
Marc-André Bombeck 6db4bd9793 perf(cache): widen the analytics and insights stale window to an hour
The analytics and derived-insight reads served stale for ten minutes, but a
single reader visits the dashboard and insights every twenty to thirty
minutes — so nearly every return landed past the window and paid the full
cold rebuild instead of an instant stale serve. At an hour the return
serves the prior snapshot immediately and refreshes in the background, and
each stale-serve re-warms the entry; the dashboard's time-derived fields
stay bounded and self-correct through the snapshot client's poll.
2026-06-13 16:08:08 +02:00
Marc-André Bombeck ff92ba46c2 fix(medications): the cards hold one shape and the list refetches on return
The low-supply notice moves onto the adherence meta row beside the streak —
a slot every card already reserves in every state — so a card that is low
on stock no longer inserts a row that pushes its bars out of line with the
card beside it. The gap between the drug class and the first line tightens
at the same time.

The medications list, compliance summary and supply reads now refetch on
every mount. App Router unmounts the page on navigation away and remounts
it on return; inside the client staleTime that return served a cache that
could be minutes old, hiding a dose logged on another device or surface. A
Playwright spec guards the return-navigation refetch.
2026-06-13 16:08:01 +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 a1511b33b3 chore(mood): drop the manage module's unused exports
The layout hook, the cap mirrors and the layout type shipped unused —
the dead-code gate flags them; the section reads everything through the
manage query and the caps surface as server 422 toasts.
2026-06-12 23:58:31 +02:00
Marc-André Bombeck 4ced89553d fix(medications): the schedule flip cannot rewrite history and the doctor report stops inventing a rate
Switching an as-needed medication onto a fixed schedule archived no
revision (there were no prior rows), so the era segmentation attributed
the entire schedule-less stretch to the new schedules — every as-needed
day would have read as missed across the era-aware consumers. The flip
now archives an empty-payload era covering the stretch, which expects
nothing. The doctor report's compliance table excludes as-needed
medications: with no schedule every logged intake is taken by
definition, and a fabricated 100% has no place on a clinical document;
the medication list itself still carries them.
2026-06-12 23:47:43 +02:00
Marc-André Bombeck 6fef2081d0 fix(medications): the audit findings on the new supply and dose surfaces
- The escalated card row keeps its time anchor: the red badge carries
  the matched window or the overdue-slot value beside it, and the
  inline row's range stays visible on mobile, where it was the only
  clock on the card.
- Take-all-due collects what the cards offer: a dose escalated past its
  catch-up band joins the set with exactly the slot the card's own
  button would record; as-needed medications never join.
- The table's stock warning follows the user's runway threshold instead
  of a leftover four-dose constant; threshold off means no amber, empty
  stays red.
- The card itself shows the remaining runway in the warning tone once
  it crosses the threshold — only then, the card stays calm above.
- The low-stock push deep-links straight into the supply tab and its
  APNs payload keeps url, runwayDays and thresholdDays; supply tab and
  threshold setting link to each other; the reminder-thresholds and
  notification-prefs endpoints enter the OpenAPI contract.
2026-06-12 23:17:20 +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 550f314a54 test(measurements,whoop): the workout fall-through test reaches its path again
Ladder reconciliation ranks every enum source, so the unranked
fall-through needs out-of-enum fixtures now; a stored single-source
ladder resolving one canonical row is pinned as the new contract. The
body-sync test sheds the import its retired weight path used.
2026-06-12 22:56:03 +02:00
Marc-André Bombeck 97822e9e6d fix(mood): entry edits spare archived-tag history and insights speak the custom labels
Editing any field of a mood entry re-sent its tag set, and archived
tag keys can no longer resolve — the diff deleted the entry's links to
them invisibly, against the archive contract the tag suite just
shipped. Submitted tag keys now govern active links only; links to
archived tags survive every edit, and the explicit purge stays the one
removal path. The insights surfaces — tag breakdown, influence, better
days, crosstab, the model snapshot and the narratives — render the
decrypted custom-tag label instead of the raw key, decrypted once per
tag server-side.
2026-06-12 22:55:50 +02:00
Marc-André Bombeck 1f45cf4da4 fix(observability,settings): the lag event names its real fields and ladders shed duplicates
The wide-event type declared loop_p99_ms while the monitor publishes
loop_last_ms — a dashboard built on the declared field would find
nothing; the type now states what is emitted. Ladder reconciliation
collapses duplicate entries first-occurrence-wins, so a duplicate-laden
ladder written through the API can no longer overflow the schema's
eight-entry cap when the settings page echoes the resolved shape back.
2026-06-12 22:38:03 +02:00
Marc-André Bombeck e122b50477 fix(whoop): the profile weight stops posing as a measurement
WHOOP's body endpoint carries a self-reported profile weight, not a
reading — the overwrite-in-place row took a fresh measuredAt on every
sync, so a stale manual entry kept resurfacing as the newest weight no
matter what the scale said. The sync no longer ingests it and clears
the legacy row off existing accounts on its next pass.
2026-06-12 22:34:13 +02:00
Marc-André Bombeck a86c59b77f fix(settings): saved source ladders learn about sources added later
A stored per-metric ladder replaced the default wholesale, so a source
integrated after the user last saved — WHOOP on a pre-v1.11 sleep
ladder, Fitbit on pre-v1.12 ones — never surfaced again: the settings
page renders only the stored array and offers reorder, not add, and
the picker ranked the invisible source behind everything. The resolver
now appends default-ladder sources missing from the stored ladder
after the user's explicit order — visible, reorderable, and ranked
where the old behaviour already ranked them.
2026-06-12 22:16:47 +02:00
Marc-André Bombeck 058d1d7a69 feat(mood): tags get groups, icons and a home of their own
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
2026-06-12 22:03:01 +02:00
Marc-André Bombeck 19a24a5160 fix(dashboard): a dose taken on the dashboard updates the dashboard
The hero band, dose tally, verdict and checklist all read one snapshot
query that neither refetches on mount nor on focus — and no mutation
ever invalidated its key, so a dose taken right there stayed visibly
due for up to the next two-minute poll. The snapshot key joins the
medication-dependent invalidation bundle; the intake routes already
hard-evict the server-side bucket, so the triggered refetch returns
post-write data immediately. (#316)
2026-06-12 21:47:47 +02:00
Marc-André Bombeck cac9a32756 feat(medications): low-stock notifications with a runway threshold
A daily job projects each tracked medication's remaining supply against
its schedules and notifies through the normal channel cascade when the
runway falls below the user's threshold (default seven days, 1-60 or
off, set beside the reminder thresholds). One notification per
crossing: a stamp on the medication row re-arms on refill or threshold
change, and a failed dispatch leaves it clear for the next day's retry.
The GLP-1 card's fixed four-dose flag stays as the locked iOS read
contract; the engine covers it like any other medication.
2026-06-12 21:04:09 +02:00
Marc-André Bombeck 954701b5f3 feat(observability): every wide event carries the recent event-loop lag
Production showed multi-second windows where every response — routes,
RSC payloads, static assets — waited together while handler durations
stayed under half a second: the process stalled, and nothing
route-shaped could name it. A self-rescheduling tick now records how
late it fires; a tick past the stall threshold emits its own
event_loop_stall background event, and every wide event carries the
recent worst lag so the first request finishing after a stall is
annotated with it. Measured via a timeout chain rather than
perf_hooks.monitorEventLoopDelay — the histogram's sampling timer races
a sibling read-and-reset interval in the same timers phase and the
stall sample can vanish into the reset window.
2026-06-12 20:58:30 +02:00
Marc-André Bombeck 5859072561 fix(sleep): the hypnogram keeps its timeline when a summary writer joins the night
A writer whose stage rows all share one timestamp carries night totals,
not segments — reconstructing them as segments paints every stage
flush against the night's right edge. The session picker now prefers a
writer with genuinely timed rows among the granular candidates, so a
stage summary backfilled for a night an actually-timed writer also
recorded no longer evicts the real timeline; summary-only nights still
resolve. The hypnogram itself degrades a summary-shaped session to the
stage breakdown instead of drawing the false skyline.
2026-06-12 20:30:59 +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 8a5a26b49a feat(dashboard): the daily-overview band becomes opt-in
The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
2026-06-12 19:03:32 +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 d142a6ed31 fix(dashboard,rollups): the score survives new data types and folds stop eating buckets
The snapshot's thick phase gated on every type the account had ever
logged having rollup coverage — one fresh reading from a new
integration nulled the health score and the blood-pressure extras
while the insights page showed a score for the same account. The gate
now checks only the three types the phase reads, per the established
per-type pattern. Underneath sat the destabiliser: the large rollup
fold deleted the window's buckets and re-inserted without a
transaction or duplicate tolerance, so a racing sync write aborted the
insert and left the delete committed — whole types lost their buckets
until the next clean fold, and accounts with data older than the
five-year fold window re-ran the full backfill on every boot because
discovery scanned unbounded history. Delete and insert now commit
atomically with duplicate tolerance, discovery shares the fold's
bound, the provisional ring says the score is being computed when the
inputs exist and reserves the no-data wording for accounts that have
none, and the greeting leads the band typographically.
2026-06-12 17:42:44 +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 ea29e9cf1b feat(dashboard): open the day with a verdict
The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
2026-06-12 09:26:55 +02:00