Commit Graph
620 Commits
Author SHA1 Message Date
Marc-André Bombeck b108ae7318 feat(ux): close the feedback gaps a new user runs into first
The Withings OAuth callback finally reports its outcome — connected or
a readable reason — instead of dropping the user on the settings page
without a word. The adherence number carries one name everywhere and an
explanation right where it is shown. Demo mode announces itself with a
banner instead of failing silently in English. Bug reports become
reachable from the mobile menu, the about section returns to the
settings navigation, charts link to the target settings they draw,
integrations and source priority point at each other, and the
medication wizard warns when reminders are switched on without a
configured channel. The longest explainer texts shrink to two
sentences.
2026-06-10 15:40:37 +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 d7bf0df10b chore(ci): pin the supply chain and surface build provenance
Every workflow action is pinned to a commit SHA, dependabot auto-merge
is limited to patch and minor updates behind the now-required status
checks on main, and the audit, Trivy and ESLint gates block instead of
warning. The published image finally carries its build SHA and build
timestamp so the version endpoint can prove which commit is running.
The API-token HMAC key fails closed under 32 characters, compose pins
the database minor and denies privilege escalation, and the changelog
drops working-note references in favour of neutral wording.
2026-06-10 15:05:05 +02:00
Marc-André Bombeck aabc7915cb feat(settings): a time-format preference and one home for insight pills
The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
2026-06-10 13:34:46 +02:00
Marc-André Bombeck 3cf48fa0f1 fix(insights): honest assistant pipeline — gates, tombstones, retries, truthful states
The two-o'clock status crons generated for every account, ignoring both
the per-user coach opt-out and the operator kill switch; they now share
one gated discovery that also skips users the pregenerate pass will
rewarm anyway, and the mood card finally gets the nightly run the other
six always had. Deleted measurements no longer feed any prompt. Locales
without an assistant language fall back to English consistently instead
of German texts with English fallbacks. A rate-limited regenerate stops
claiming success, the dashboard briefing state distinguishes a missing
provider from a pending generation and serves a stale briefing with its
timestamp instead of hiding it, narrative warming actually processes
oldest first, and worker failures reach the error tracker through one
shared reporter while the insight queues gain real retries.
2026-06-10 13:34:37 +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 298e4c34b4 perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
2026-06-10 12:55:56 +02:00
Marc-André Bombeck eb1bbef295 chore(license): move to PolyForm Noncommercial 1.0.0
The project licence changes from AGPL-3.0 to the PolyForm Noncommercial
License 1.0.0 for this and future versions: free to use, modify and
self-host for noncommercial purposes; commercial use needs a separate
arrangement. Releases up to and including v1.15.18 were published under
AGPL-3.0 and remain available under that licence. LICENSE carries the
verbatim PolyForm text, and the SPDX identifier is updated in
package.json, the version endpoint, the OpenAPI info block, the image
labels and the README.
2026-06-10 11:18:12 +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 09dbb1131d fix(medications): guard intake edits against implausible timestamps
An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
2026-06-10 11:17:48 +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 76012bf8e2 chore: scrub instance details and personal data from committed artefacts
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
2026-06-10 11:00:26 +02:00
Marc-André Bombeck b8c2b2e8c5 fix(measurements): keep the mean-consolidation pass alive past unique collisions
The nightly mean-consolidation upsert targets the externalId arbiter, but
the measurement table carries a second full unique on (user, type,
measured_at, source, sleep_stage) that also spans tombstones. Any row
parked exactly on the canonical local-noon instant made the insert throw
P2002, and because the global pass had no per-bucket fence the first
poisoned day aborted consolidation for every remaining user and type.

writeDay now probes the canonical slot inside the transaction and
resolves the collision deterministically: rows the pass owns (tombstones,
per-sample rows about to fold) shift to the next free second, foreign
live rows keep their instant and the mean row yields instead. The runner
gains an optional per-bucket error fence so one bad day is logged,
counted as daysFailed, and skipped.
2026-06-10 11:00:11 +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 2ee488d541 feat(medications): wire the Verlauf tab onto the dose-history ledger
Replace the Verlauf tab's flat intake table with the per-slot dose-history
ledger the shared band engine mints. The tab now renders EVERY expected slot
with its status — on-time, late, skipped, missed, upcoming — even when the
dose was never taken, and tags every off-schedule intake as a standalone
ad-hoc row at its real time. Rows group by day, most-recent first.

Genommen / Übersprungen sit on the dose row and recompute instantly: the
cached ledger and the headline adherence are mutated optimistically (the
engines are pure, so the client mirrors their accounting) before the write
fires, then the dependent-key invalidation reconciles; a failed write rolls
the optimistic snapshot back. A header add dialog records a dose — including a
backdated or off-schedule one — and an off-window take surfaces the "diesem
Slot zuordnen?" nudge, pinning it onto a chosen slot via forceSlotInstant
rather than orphaning it. A compact took/skipped bar sits at the top of the
tab; the card body still never tints by status.

Adherence is derived from the same ledger the user is reading (taken over
taken + missed; skip, ad-hoc, upcoming excluded), so the history view and the
percentage cannot contradict. New status + dialog copy in all six locales.
2026-06-08 19:23:25 +02:00
Marc-André Bombeck ac39b9ca34 feat(medications): promote the detail page to a tabbed shell
Make /medications/[id] the canonical full-page home for one medication:
a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf ·
Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a
"Vollständig bearbeiten" jump into the wizard for structural edits.
Everyday levers move inline into the tabs.

Übersicht surfaces the adherence bars, Erinnerung owns notifications +
reminder grace, Bestand reads the pen/vial inventory (all medications,
calm empty state), Verlauf renders the editable intake history, and
Injektion (injectable routes only) carries the existing GLP-1 charts +
side-effect log. The ledger, the per-dose window editor and the
injection body-map land in the following waves.

The legacy /medications/[id]/history route now redirects to ?tab=verlauf
for bookmarks and deep links. Tab labels are i18n across six locales.
2026-06-08 19:10:18 +02:00
Marc-André Bombeck e523a3ae00 feat(medications): validate per-dose dose windows on schedule writes
Accept an optional doseWindows array on the schedule create/update routes:
each entry pins an explicit on-time range [start,end] to one of the
schedule's times-of-day, defaulting to the symmetric grace band when
absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the
schedule, an inverted span) with a 422 so the persisted window can never
feed the band minter a degenerate range.

Regenerates the OpenAPI contract for the additive field (iOS-safe).
2026-06-08 18:34:37 +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 c2453d6b8c feat(medications): attribute the intake write path by dose-window band
The per-medication intake POST resolves a taken dose's slot through the band
attributor instead of the ±6h snap: a take inside a slot's configurable dose
window snaps to that slot (canonical upsert), a take outside every window
records as a standalone ad-hoc row, and a PRN medication always inserts
standalone. A skip still binds to its slot by the canonical scheduledFor snap.

Add an optional forceSlotInstant to the intake body so the UI's late-take
"diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot;
the server validates the instant is a real slot of the medication on its day
and rejects a non-slot with 422. The dose-safety future-slot guard stays.
2026-06-08 18:07:47 +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 e3bb33f4f0 feat(ai): unify the briefing, insights, and coach voice toward a motivating advisor
Warm the insight generator (briefing + key findings + insight prose) and
the Coach to the same premium-motivating advisor register already shipped
for the per-metric assessments. The intro and role framing drop the cold
clinical-summariser voice for a warm advisor who reads the user's data
against their own history, names earned wins, and frames an unfavourable
finding as one doable step.

Every safety and quality guard is preserved: no diagnosis, no
prescription, no platitudes, no bare number-echoing, honest about
unfavourable values, the banned positivity openers, the doctor-consult
call-to-action, and the JSON output schema + keyFindings tone enum stay
byte-identical. PROMPT_VERSION 4.27.0 -> 4.28.0 (also covers the Coach,
which imports it). Native FR/ES/IT/PL personas warmed to match.
2026-06-07 21:45:27 +02:00
Marc-André Bombeck 6164a0c5c2 fix(insights): keep every data-having pill in the default nav and reconcile review findings
The default insights layout shipped a curated visible subset (v1.15.11),
authored for the overview Vitals/Mobility grid. Once the tab strip began
gating its nav pills on the same layout, that subset dropped ~20 nav pills
for a fresh account with data in Sleep, Steps, Active-Energy, body
composition, Walking/mobility, Audio, Daylight, etc. — pills that show
today — and caused a flash-then-vanish on first paint.

The grid renders a fixed mapped subset regardless of tile visibility, so
defaulting every tile visible does not bloat it; what it restores is the
nav's everything-with-data default. The user hides what they don't want.

Also:
- mood distribution + weekday charts: switch axis tick fill from the raw
  --dracula-fg (white-on-white on the light-mode card) to the shared
  --muted-foreground axis-text token; dark mode unchanged.
- drop the orphaned insights.editMode.manageTiles key from all six locales
  (replaced by manageSubPages) and add a manageSubPagesHint noting that
  hiding a detail page also removes its top-nav pill.
- pin the all-visible default with a layout test and a tab-strip
  default-nav regression test.
2026-06-07 21:01:26 +02:00
Marc-André Bombeck 0f1b002108 feat(insights): gate the tab strip on the saved layout
The top insights navigation was driven purely by data availability: a
sub-page pill appeared whenever its metric had readings in the 90-day
window, with no connection to the per-tile visibility the customize
panel writes. Hiding a sub-page in Anpassen therefore never removed its
nav pill, and the two surfaces drifted apart.

Thread the resolved insights layout into the tab strip via the layout
shell. A pill now shows iff its metric has data AND its slug is visible
in the saved layout; data availability stays the floor, so a
layout-visible metric with no readings still gets no pill. Pills and
group children order by the saved layout order, falling back to the
natural slug order. While the layout query is in flight the strip keeps
the data-only gate, so first paint is unchanged and a slow or failed
layout fetch never suppresses a pill the user has data for.

Add a total slug to manager-group mapping in sub-page-metric so every
routed sub-page resolves to a category, a superset of the tab-strip nav
grouping that also covers the flat pills and the sleep / mood / events
categories the strip never collapses.
2026-06-07 20:49:07 +02:00
Marc-André Bombeck f2bd051fa7 feat(measurements): shared filter bar and multi-select bulk delete
Extract the duplicated list chrome — SortableHead and the single-row
DeleteButton — into a shared src/components/data-list/ module and add a
page-scoped multi-select with a selection action bar, used by both the
measurements and mood management lists.

Filter bar: each list keeps its primary Select (type / mood level) and
gains a source Select (measurements sends sourceEq, mood sends source)
plus an optional date range wired to the existing from/to params. Every
filter resets pagination to page 1 and is baked into the query key via
the centralised factory (queryKeys.measurementsList / moodEntriesList)
so the cache stays correct per filter combination.

Multi-select is scoped to the current page: a leading checkbox column on
desktop, a checkbox on the mobile cards, and a select-all-on-page header
checkbox. When at least one row is selected an action bar shows the count,
a clear affordance, and a destructive Delete (N) behind an AlertDialog
confirm that POSTs to the bulk-delete route, then invalidates the same
dependent-key bundle the single delete busts and clears the selection.
Selection clears on any page / filter / sort change; the selection math
lives in a pure, unit-tested helper. The locked-type insights subpage
keeps its focused single-metric view (source filter + date range
suppressed).

New strings land in a dataList namespace plus per-list source-filter and
bulk-delete keys, translated across all six locales.
2026-06-07 19:39:41 +02:00
Marc-André BombeckandClaude Opus 4.8 ca262d9839 feat(api): add bulk soft-delete for measurements and mood
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.

Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.

Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:24:16 +02:00
Marc-André BombeckandClaude Opus 4.8 0d69611bbf feat(api): add source filter to measurement and mood lists
Add a source filter to both management lists. Mood gains an optional
`source` param (validated against the mood source set); measurements gain
`sourceEq` (validated against MeasurementSource) — the `source` key on the
measurements list is the rollup-tier opt-in, so the filter rides a distinct
param. Both thread into the list `where` alongside the pinned `userId` +
`deletedAt: null`.

Migration 0136 adds two additive indexes — `(userId, source, measuredAt)`
on measurements and `(userId, source, moodLoggedAt)` on mood — so a source
filter + recency sort is an index range scan rather than a userId scan plus
in-memory sort at 100-200k rows per user. Pure `CREATE INDEX IF NOT EXISTS`,
no backfill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:23:59 +02:00
Marc-André Bombeck e7641288fe fix(health-score): smooth the hypotension curve and align rollup and live grading
Close four findings from the health-score audit:

- Hypotension cliff: the below-floor branch reused the over-target table,
  so 1 mmHg under the clinical floor dropped from 100 to ~81 — a 19-point
  discontinuity. A dedicated under-floor curve now starts at 100 AT the
  floor and descends smoothly (floor-10->70, -20->45, -30->20), so the
  score is continuous on both sides of every boundary on both axes.
- Rollup vs live divergence: the graded BP score weighed a high-variance
  day 1:1 on the rollup path (one per-day-mean pair) but N:1 on the live
  path (N per-event pairs), drifting the pillar by up to ~20 points
  depending on DAY-bucket warmth. gradeBpScoreFromSeries now takes an
  optional per-pair count; the rollup path passes perDayPairCount so both
  paths produce the same score for the same data.
- Resting proxy degenerate days: a day with fewer than three PULSE
  samples no longer contributes a resting estimate, so a lone workout
  reading can't masquerade as resting HR.
- Pulse-status proxy day boundary: thread the user's timezone into the
  resting-proxy day-key so it buckets identically to the targets route.

Fairness anchors unchanged (134/87 -> 57, 165/105 -> 22).
2026-06-07 18:16:38 +02:00
Marc-André BombeckandClaude Opus 4.8 9ac5df42c8 feat(ai): warm the assessment voice toward a motivating advisor
The per-metric and wellness-score assessment prompts gain a premium-advisory
layer: name the earned win, build a little momentum, frame an unfavourable
finding as an opportunity, and close forward-looking. Every existing guard
stays in force — no platitudes, no bare number-echoing, autonomy-supporting,
never alarming, moralising or diagnostic, and the banned-opener and
forbidden-phrase lists are unchanged. The composite-score archetype warms to
a WHOOP/Oura-style recovery coach while staying descriptive, not clinical.

PROMPT_VERSION 5.0.0 -> 5.1.0 so the provenance can slice prose before/after
the tone edit. Cached accounts converge to the new voice within ~24h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:03:56 +02:00
Marc-André Bombeck 7b171cc5e3 feat(admin): surface the running version on the overview
Add a dedicated version tile to the admin overview showing the running
version, short build SHA, and build timestamp, alongside a best-effort
'update available' hint computed against the latest GitHub release tag. The
hint reuses the existing /api/version/check-updates proxy (server-side
safeFetch past the CSP) so no new route or OpenAPI schema is introduced; the
check is day-stale and never blocks the render. Cookie-admin only by virtue of
the admin layout.
2026-06-07 17:46:31 +02:00
Marc-André Bombeck 8cd0d3f1e9 fix(admin): resolve login location online-first with offline upgrade
Invert the geo-resolution precedence so the online ipwho.is lookup is the
baseline that always works, with the MaxMind GeoLite2 MMDB tier as an
optional upgrade used only when its databases are present. A self-host with
no MAXMIND_LICENSE_KEY now resolves a city/country out of the box instead of
showing only a dash in the admin login overview.

Cache resolved locations per IP (positive + negative) for a bounded TTL so a
burst of audit events from the same client does not fan out one request each
to the provider. Trim the online timeout below the audit-log race window so a
slow-but-successful lookup still lands before the race resolves null.
2026-06-07 17:45:25 +02:00
Marc-André Bombeck 55c029daae fix(pulse): score resting heart rate, exclude workout samples from target
Every surface that applied a resting-pulse target judged the raw PULSE
series against a resting band. Apple Health maps all heart-rate samples
— including workout HR — onto PULSE, and the resting figure onto a
separate RESTING_HEART_RATE type. A single workout emits hundreds of
expected-high samples, so the resting-target evaluation read a large
share "outside target" and tanked the in-target % / consistency even
when the clean resting series averaged a healthy figure.

Judge the resting band against a resting series. A shared helper prefers
RESTING_HEART_RATE; when the user has none it derives a proxy from the
low percentile of each day's PULSE samples, so a dense workout burst
sitting in the upper tail cannot drag the resting estimate up. Applied
to the resting-pulse tile + its consistency strip, the pulse in-target %
in the assessment snapshot, and the dashboard / sub-page classification.
Raw heart rate, where it is merely charted, no longer carries the
resting-band overlay. The doctor report is unchanged — it reports raw
heart-rate stats factually, with no resting-target judgment to mislead.
2026-06-07 17:36:25 +02:00
Marc-André Bombeck 60359dbcb7 fix(health-score): grade the blood-pressure pillar by clinical proximity
The BP pillar value equalled the binary all-time in-target rate: the
share of paired readings where both systolic and diastolic sat at or
below the age-band ceiling. A borderline stage-1 profile (diastolic a
few mmHg over the ceiling on most readings) collapsed the pillar to the
teens even though the reading is "a bit high", not catastrophic — and
improvement never surfaced because the rate is all-time and binary.

Replace the binary rate AS THE SCORE with a smooth clinical-proximity
grade. A recency-weighted representative reading (45-day half-life) is
mapped per axis onto a 0-100 curve anchored on the same age-band ceiling
the rest of the app uses — optimal ≈ 100, at the ceiling ≈ 85, then
piecewise-linear down through the over-target bands; hypotension is
penalised symmetrically below the clinical floors. The pillar takes the
worse of the two axes, so an elevated diastolic counts without
binary-zeroing the result. A 134/87 profile against a 129/79 ceiling now
reads in the mid-50s.

The in-target rate stays available as a secondary "X% of readings in
range" stat. The healthScore.components.bp shape is unchanged — only the
value moves.
2026-06-07 17:36:13 +02:00
Marc-André Bombeck 6ebac22c65 fix(insights): reconcile customizable-overview review findings
- Layout PUT accepts version 1 as well as 2 (live iOS client still PUTs
  version 1); the serializer normalises to the canonical v2 blob, so a v1
  body never 422s. Mirrored in the OpenAPI schema + description; yaml regen.
- Add the Weight tile to the inline tile editor so it can be hidden /
  reordered like every other Vitals grid tile.
- Replace the non-transitive Vitals tile-drag comparator with a pure,
  total-order rebuild helper (reorder the Vitals subset, substitute it back
  into the full list in original-order slots, densify).
- Gated-off section rows keep a disabled eye toggle in the same position
  with the gate hint as a caption below the title, so the row never jumps.
- Cap the disclosed Vitals tile list height (max-h-[60vh] scroll,
  overscroll-contain) so a tile drag does not fight the page scroll.
- Gate edit mode until the layout GET settles so a save can never flush
  defaults over a real saved layout.
- Drop the duplicate top-right "Done" affordance: hide the customize toggle
  while editing; the in-card Done / Reset own save and reset.
- Restore focus on enter (edit-card heading) and on close (customize toggle).

Tests: route regression for a version-1 PUT persisting a resolved v2 layout;
unit coverage for the new Vitals rebuild helper (order, non-Vitals stability,
per-tile visibility, no mutation).
2026-06-07 17:08:25 +02:00
Marc-André Bombeck 9b13b41f1a feat(insights): add inline edit mode to reorder and hide overview cards 2026-06-07 16:55:28 +02:00
Marc-André Bombeck e3539d7c94 feat(insights): drive overview section + tile render from saved layout
Read the resolved insights layout on /insights and render the customizable
region from it. Sections render in saved order, hidden ones drop out, and
the Vitals grid honours the per-tile order + visibility. HeroStrip stays
anchored above the customizable region; every existing feature/data gate
(briefing flag, cycle-enabled, per-tile data) still applies on top, so the
default layout reproduces the prior page.

- useInsightsLayout() reads GET /api/insights/layout through the query-key
  factory, defaulting to DEFAULT_INSIGHTS_LAYOUT while in flight.
- orderedVisibleSectionIds + resolveTileLayout: pure, unit-tested helpers.
- Widen DEFAULT_VISIBLE_TILE_IDS with the Vitals/Mobility grid tiles so a
  fresh account keeps the tiles it shows today.
2026-06-07 16:45:26 +02:00
Marc-André Bombeck f6221623a4 feat(insights): extend layout model to v2 with section ordering
Add an additive, optional `sections` array to the persisted insights
layout alongside the existing per-metric `tiles`, with the same
merge/dedupe/dense-order semantics. v1 blobs resolve forward to a valid
v2 layout with every section default-visible; the iOS `tiles` contract is
unchanged and a PUT may omit either array (defaults fill in).
2026-06-07 16:36:23 +02:00
Marc-André Bombeck 9c282ee66d score the weight trend even without a target weight
The Health Score weight pillar was gated on a derived weight target, and
the only target source is the BMI-22 fallback from the profile height. A
profile with no height yields a null target, so the pillar came back
empty ({value:null, weight:0, source:"none"}) however much weight data
the user had logged — the weight trend never factored into the score.

Decouple the pillar from requiring a target: when no target is derivable
but the series has at least two readings, score the bare weight trend on
its own. A flat trend anchors at 75 (maintenance is a solid result), a
decline lifts toward 100, and a sustained rise drops toward 25 — the one
direction that is broadly adverse independent of any individual goal. The
target-aware scoring is unchanged when a height is set. No fabricated
target: the trend is scored honestly, never against a pretend goal.

The series.length < 2 null guard stays — genuinely too little data still
returns null.
2026-06-07 14:45:09 +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 6e49b57a6f fix(medications): collapse an equal-bound dose time to a single time
A next-dose / status line whose window start equals its end rendered a
degenerate range — "07:00 bis 07:00 Uhr" (DE) / "07:00 – 07:00" (EN) — for a
single target time or a zero-span window. Reserve the "bis" / "–" separator
for a genuine range and render one time when start == end (the DE branch
keeps its "Uhr" suffix on the single time).
2026-06-07 14:41:15 +02:00
Marc-André Bombeck 939a732aa6 expose the open dose's per-dose status on the compliance read
Derive a single DoseStatus for the open cycle in buildComplianceDisplay
and surface it as complianceDisplay.currentDose, so the medication card
renders its take-window / overdue presentation from one server authority
instead of re-spelling the window math client-side. Add the field to the
OpenAPI ComplianceDisplay schema and regenerate the spec.
2026-06-07 13:38:24 +02:00
Marc-André Bombeck 7bbb473070 fix(medications): rebase dashboard compliance denominator onto the schedule
The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.

Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.

Regenerate the OpenAPI contract for the new expected/missed count fields.
2026-06-07 13:26:58 +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 29fa982e11 fix(medications): count forgotten doses as missed, not skipped
A never-acted dose past its 24h cutoff was flipped to skipped=true by the
hourly cron. The compliance engine excludes skipped doses from the
denominator (a deliberate user pause), so a forgotten dose then counted
neither as taken nor missed — silently inflating adherence.

Add a dedicated auto_missed terminal state. The cron now sets auto_missed
(not skipped) on a never-acted pending row, so the engine counts it as a
real miss against the rate, while a user-initiated skip stays excluded.
The two are mutually exclusive on a live row by construction.
2026-06-07 13:26:41 +02:00