Compare commits

...
353 Commits
Author SHA1 Message Date
Marc-André Bombeck 0241714bd4 Merge pull request #334 from MBombeck/release/v1.17.1
Release v1.17.1 — preventive care, lab results, and more of your data
2026-06-15 02:21:17 +02:00
Marc-André Bombeck f903b320af test(rollups): compare rounded rollup means to live SQL within hundredths, not bit-for-bit
The rollup tier rounds its bucket mean to two decimals and live SQL rounds the
full-day AVG to two decimals; rounding through different intermediate paths can
land one cent apart on a half-cent boundary, so the 5-decimal toBeCloseTo was a
data-dependent flake. Assert to within a few hundredths; min/max stay exact.
2026-06-15 02:11:04 +02:00
Marc-André Bombeck cbd5c2ba6f fix(withings): key sleep re-sync on the stable externalId, not the shifting measuredAt
With measuredAt now stamped at the segment END, a night that Withings
re-aggregates between syncs shifts its enddate, so the measuredAt-keyed
lookup missed the prior row and the create then collided with the unique
externalId — leaving the night stuck at its stale value. Key the lookup on
the stable externalId and refresh value, measuredAt and stage on update.
Caught by the withings-sleep-sync integration test against real Postgres.
2026-06-15 02:02:12 +02:00
Marc-André Bombeck 78f0e067b5 fix(migrations): reference the mapped measurement_type enum in the reminders table
The generated 0162 migration typed the measurement_reminders.measurement_type
column as the Prisma enum name "MeasurementType" rather than its @@map'd
Postgres type "measurement_type", so a fresh migrate deploy failed with
'type "MeasurementType" does not exist'. Unit typecheck passed (the generated
client is unaffected); only a real-Postgres deploy surfaces it. Verified the
full 0001-0166 chain applies clean against Postgres 16.
2026-06-15 01:49:28 +02:00
Marc-André Bombeck 497982b4b4 chore(release): v1.17.1 — preventive care, lab results, and more of the data you already have 2026-06-15 01:40:36 +02:00
Marc-André Bombeck d7691b6cbe merge(integrations): honest Polar sleep timeline, shared reconstruction, OAuth returns, status envelope 2026-06-15 01:35:27 +02:00
Marc-André Bombeck 9430e89889 feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with
`?whoop=connected|error` / `?fitbit=connected|error`, but the settings page
only read that param for Withings, Polar and Oura — a user returning from a
WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast.
Extend the generic return handler to all four OAuth providers, add the
matching connected/failed/error i18n keys across every locale, and invalidate
both the per-card key and the consolidated envelope on a successful return.

Polar and Oura already write the shared IntegrationKey ledger but each fired
its own `/api/<provider>/status` round-trip from the card, so the page issued
one consolidated request plus three per-card ones. Fold Polar and Oura into
`/api/integrations/status` and let the cards read a passed view-model instead
of fetching, retiring the extra round-trips. The per-provider status routes
stay for the iOS and test callers.
2026-06-15 01:34:30 +02:00
Marc-André Bombeck 38a2c7127e fix(integrations): audit WHOOP and Fitbit credential teardown
The Polar and Oura credential-DELETE handlers emit an auditLog entry and park
the integration ledger at disconnected; the WHOOP and Fitbit handlers cleared
the token row and BYO credentials but logged nothing. Credential teardown is a
sensitive op — add auditLog plus markDisconnected to both so the audit trail
and the ledger state stay uniform across every BYO-key integration.
2026-06-15 01:34:12 +02:00
Marc-André Bombeck 7574ea00ff merge(reliability): SMTP timeouts, cron index use, doctor-report sleep parity, VAPID dialog, redaction 2026-06-15 01:29:06 +02:00
Marc-André Bombeck 1ed2e5d2a2 docs(recovery): note the date-anchor bucketing gap for negative-UTC users
Oura and Polar recovery rows are bucketed on the local day of their raw
measuredAt, on the assumption that both stamp the wake morning. Polar
always anchors at UTC midnight of the wake date and Oura falls back to the
same anchor when no real wake instant is present, so for a user in a far
negative UTC zone that anchor reads as the previous local evening and a
single night can split across two recovery days near the date line. A
correct fix needs an anchor-kind signal threaded through the recovery
read so the date-anchored sources read their wake day in UTC while a real
wake instant stays local — out of scope here. Document the assumption at
the bucketing site; positive-UTC users are unaffected.
2026-06-15 01:28:26 +02:00
Marc-André Bombeck aeb30464ac chore(observability): add headerValue to the redact denylist
The webhook shared secret lives only inside the AES-GCM-encrypted channel
config blob and the GET masks it, so no current path excerpts it into a
wide event. Add headervalue to the sensitive-key denylist as defence in
depth: if a future change ever routes a webhook body through the payload
diagnostic, the secret redacts instead of landing verbatim. The Live
Activity push token is already covered by the token pattern.
2026-06-15 01:28:19 +02:00
Marc-André Bombeck b336ed5b73 fix(settings): confirm VAPID regenerate in-app; let labs subtitle wrap
The VAPID regenerate overwrite guard used a native window.confirm for a
subscription-invalidating destructive action, where the rest of the
release uses the project's AlertDialog. Drive the overwrite confirm
through an AlertDialog (calm in-app dialog, destructive action variant)
and retry with force from its action.

The /labs header subtitle used truncate, clipping mid-word in longer
locales on a 375px viewport. Drop truncate so it wraps; the add button
stays shrink-0 and the row aligns to the top.
2026-06-15 01:28:12 +02:00
Marc-André Bombeck 790fecddb8 fix(doctor-report): aggregate sleep per night, not per stage
The doctor-report PDF computed SLEEP_DURATION min/avg/max over the raw
per-stage rows (a 40-minute DEEP block, a 12-minute AWAKE block), so its
sleep figure was per-stage-row minutes while every other surface — the
dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed —
shows the reconstructed per-night asleep total. The sleep-stamping work
rewrites exactly those raw rows, so the report could drift independently.
Route the report sleep value through the night reconstruction engine, the
same way the recovery score routes through the canonical recovery resolver
— one number, every surface. The user's source priority feeds the
reconstruction so the multi-source de-dup matches the dashboard.
2026-06-15 01:28:04 +02:00
Marc-André Bombeck 33e7f20736 perf(reminders): bound the Vorsorge cron scan to due reminders
The measurement-reminder tick loaded every enabled reminder across all
tenants every 15 minutes and gated due-ness in Node, so the shipped
measurement_reminders_user_id_next_due_at_idx went unused. Add a
nextDueAt bound (not null, lte now plus one tick of slack) to the query
so Postgres scans only rows that could plausibly fire. nextDueAt is
stamped at the notify-hour boundary, so anything due is already past;
non-recurring reminders carry a null nextDueAt and can never fire, which
the in-Node predicate already short-circuits. A reminder satisfied early
re-anchors once it crosses due, before any nudge, so dropping future rows
is safe. The in-Node hour gate stays the authoritative fire decision.
2026-06-15 01:27:57 +02:00
Marc-André Bombeck b87ded090c fix(notifications): bound the SMTP transport with explicit timeouts
The email sender built its nodemailer transport without connection,
greeting, or socket timeouts, so nodemailer's defaults applied — up to a
10-minute socket timeout. A mail server that accepts the TCP connection
then stalls (firewalled relay, overloaded Postfix, blackholed smarthost)
could hold a dispatch worker for minutes per notification, while every
other egress in the cascade is bounded. Pin connection/greeting at 10s
and socket at 20s so a stall maps to the transient send outcome the SMTP
classifier already retries, matching the webhook sender's ethos.
2026-06-15 01:27:49 +02:00
Marc-André Bombeck eab86b21d5 merge(nav): top-level Vorsorge home, shared utility destinations with parity guard 2026-06-15 01:24:50 +02:00
Marc-André Bombeck 34875ce8c5 merge(import): success token, CSV header single-source, Link nav, responsive grid, counters 2026-06-15 01:24:10 +02:00
Marc-André Bombeck 3fc195e1c0 feat(settings): add a back-to-hub link on the Layout child editors
The four Layout personalization editors (dashboard, insights, medications,
mood) are reached through the Layout & Personalization hub or a page-header
cog, and the left rail highlights the hub while the body shows the child.
The only return path was tapping the highlighted rail entry — no explicit
up-affordance, sharper on mobile where the rail collapses to a chip strip.
Add a shared back-to-hub link at the top of each child so the hub → child
→ hub loop reads clearly.

Also fix the stale doc-comment on the settings section route: the slug set
is 16 (12 nav-visible plus the four routable-but-hidden Layout children),
not eight.
2026-06-15 01:24:03 +02:00
Marc-André Bombeck 6a994cd1c3 feat(nav): give Vorsorge a nav home and fold the utility tail into one model
Vorsorge (preventive-care) was a top-level tracking surface reachable only
three taps deep through Settings → Reminders, while peer surfaces (Labs,
Recovery) already had a top-level nav home. Add it to the one shared
destination model in the clinical spine, so it renders on the desktop
sidebar and the mobile More hub like every other feature, with a
nav.vorsorge key across all six locales.

Bug Report, Settings and Notifications were still a second hand-curated
list on each bar — the exact drift the one-model contract set out to kill,
just pushed down a level. Promote them to a shared NAV_UTILITY_DESTINATIONS
list both bars consume: the sidebar footer + avatar menu and the bottom-nav
More-hub tail now read from one source and can no longer disagree. The
More hub itself is now computed by mobileMoreHubDestinations() so the
headline invariant (hub == feature list minus primary slots, plus the
utility tail) is a tested model function rather than inline bar logic.

Add a sidebar utility-parity guard and a mobile More-hub invariant test.
2026-06-15 01:23:50 +02:00
Marc-André Bombeck 7637657fa2 merge(insights): unify device-score/labs/vorsorge loading, empty, and tile primitives 2026-06-15 01:23:43 +02:00
Marc-André Bombeck dba107de51 fix(vorsorge): add a loading state and align the cards on the shared primitives
The reminder list read isLoading but rendered nothing, so the page
showed a header over blank space while fetching.

- Loading now paints a card-shaped Skeleton stack via the shared
  primitive.
- The no-data case routes through the shared EmptyState with an icon
  and an add action, matching the lab empty.
- Reminder cards title themselves with CardHeader/CardTitle + CardAction
  and the form card drops its pt-6 override, so the card token owns the
  vertical rhythm and the double-pad goes away. The card stays neutral —
  due state still reads only through the discreet badge.
2026-06-15 01:22:57 +02:00
Marc-André Bombeck bc237d61e2 fix(labs): converge the lab tile + loading onto the shared primitives
The lab list shipped a second bespoke compact-tile shape and a
centred Loader2 that disagreed in size with the page spinner above it.

- Lab cards now title themselves with CardHeader/CardTitle and pin the
  sparkline + delete control in CardAction, matching the canonical card
  contract; the py-4 content override is dropped so the card token
  drives spacing.
- Loading paints a tile-shaped Skeleton stack via the shared primitive
  instead of the bespoke spinner, removing the size drift and the
  layout pop-in.
2026-06-15 01:22:50 +02:00
Marc-André Bombeck dad0b89a8c fix(insights): unify device-score tiles onto the shared card and skeleton primitives
Route the recovery and sleep-quality device-score surfaces through the
canonical card and loading primitives so they read as one family:

- DeviceScoreTile titles itself with CardHeader/CardTitle + CardAction
  and drops its bespoke header row and padding override, so it matches
  the sibling sleep-debt and chronotype cards on the unified
  p-4 md:p-6 token.
- Add DeviceScoreTileSkeleton / DeviceScoreGridSkeleton, a shared
  tile-shaped placeholder that mirrors the loaded layout. Recovery and
  sleep-quality paint it while their analytics slice loads instead of
  popping in.
- Recovery's no-data branch now renders the shared EmptyState with an
  icon rather than a hand-rolled div; the sleep-quality sub-section
  keeps its calm collapse-to-null once data lands.
- New insights.recovery.emptyTitle key across all six locales.
2026-06-15 01:22:43 +02:00
Marc-André Bombeck 9f960a59d0 feat(import): show character counts and a Preview busy state
The JSON and CSV paste boxes now cap input at the 16 MB body ceiling the
routes enforce and show a live character count beneath each, so an over-long
paste is bounded with feedback instead of silently rejected at submit. The
CSV Preview button also renders the same spinner the Import button does while
a dry-run is in flight, with motion-reduce honoured, so the tap no longer
looks inert on a slow preview.
2026-06-15 01:21:45 +02:00
Marc-André Bombeck c6beba2229 fix(import): keep the import cards on the SPA and balance the trio
The JSON and CSV cards linked to the in-app docs with a raw anchor, so
tapping the schema link triggered a full-page reload that discarded an
in-progress paste or upload in the sibling card; route them through the
Next Link instead. The three import cards also dropped to a two-column grid
at md, orphaning the CSV card alone on a second row — add a three-column
track at xl so the set reads as one family.
2026-06-15 01:21:35 +02:00
Marc-André Bombeck 5a62d04651 fix(import): use the success token for import success ticks
The three import success check-icons hardcoded text-emerald-500, bypassing
the semantic text-success token every other surface uses. The token is
AA-tuned per theme (a darker green in light mode) where emerald-500 is a
single fixed value, so the import ticks sat off-tone in light mode and could
fall short of the AA bar the light tokens are tuned for. Swap to text-success
to match the integration-card family.
2026-06-15 01:21:10 +02:00
Marc-André Bombeck 7a96a934bf refactor(import): derive the CSV example header from the shared column list
The downloadable CSV example pinned its header as an inline string literal
while csv-measurements.ts exported CSV_EXAMPLE_COLUMNS as the documented
single source of truth for that order — so the two could silently drift.
Derive the example header from the constant; the column order now has one
owner and the previously unused export is wired in.
2026-06-15 01:20:49 +02:00
Marc-André Bombeck 290f46fcda fix(sleep): label Polar nights reconstructed and emit their awake time
Polar exposes only per-stage duration totals, so its mapper already
reconstructs a contiguous CORE-DEEP-REM order exactly like WHOOP and flags
each segment reconstructed. The night reader's honesty allow-list only held
WHOOP, so a Polar-won night was served with reconstructed:false — presenting
a synthetic stage order to web and iOS as if it were the device's measured
timing. Add POLAR to the allow-list so the layout is labelled approximate.

Polar's total_interruption_duration carried the night's awake time but was
dropped, so every Polar night read as fully consolidated (awakeMinutes:null,
awakenings:0) with overstated asleep-vs-in-bed efficiency. Lay it as a
leading AWAKE segment so the asleep stages partition the remaining window and
the reader surfaces real awake time.

WHOOP and Polar independently implemented the same contiguous-timeline walk.
Extract a shared reconstructContiguousSleepTimeline helper under src/lib/sleep
and call it from both; each mapper keeps only its field normalisation. Oura's
measured 5-min hypnogram is a different algorithm and is left untouched.
2026-06-15 01:19:25 +02:00
Marc-André Bombeck a4b508f720 merge(labs): drop orphaned lab read helper and dead trend query-key 2026-06-15 01:17:25 +02:00
Marc-André Bombeck 3d6127a138 chore(labs): drop the unwired Vorsorge lab-lookup and its dead trend key
The annual-blood-panel auto-resolve that findLatestLabResultForAnalytes was
meant to feed never got wired into the Vorsorge reminder, so the export sits
with zero callers and the labAnalyteTrend query-key has no consumer (the
sparkline is prop-fed and there is no labs trend endpoint). Remove both so the
knip gate is clean; the Vorsorge lab read-back can come back with the wiring
that needs it.
2026-06-15 01:17:01 +02:00
Marc-André Bombeck a27247f93c merge(settings): reminders hub, uniform integration cards, per-integration setup-guide links 2026-06-15 01:00:43 +02:00
Marc-André Bombeck ac69107169 chore(compose): whitelist VAPID and APNs env vars so the documented env path reaches the container 2026-06-15 01:00:43 +02:00
Marc-André Bombeck 78c110a6c8 feat(settings): add a Reminders & Notifications home
Reminders used to be scattered across three places: medication reminders on
each med card, mood / low-stock / coach-nudge inside the Notifications channel
screen, and the Vorsorge reminders on their own page. The new Reminders
section is the single front door. It draws a clean line between the two
concepts — reminder categories (the what/when) and notification channels (the
how/where) — and links to each canonical editor, which keeps its own route and
deep link intact. Consolidation by linking, not a rewrite.

The section sits right after Notifications in the settings nav.
2026-06-15 00:59:47 +02:00
Marc-André Bombeck 001c158203 feat(integrations): add a Setup-guide doc-link to every integration card
Each integration card now carries the same discreet "Setup guide" link to
its runbook under docs.healthlog.dev/integrations/<provider>, so a user who
is mid-setup always knows where to go. The docs host lives in one shared
constant and the affordance renders identically across all six providers
(WHOOP, Withings, Fitbit, Polar, Oura, Nightscout).
2026-06-15 00:59:33 +02:00
Marc-André Bombeck a8ff52e998 merge(admin): generate VAPID keys in-app and self-hoster backup/env docs 2026-06-15 00:59:22 +02:00
Marc-André Bombeck 266109f501 docs(self-hosting): VAPID generate button, env names, backup and whitelist notes
Document the new Generate keys button in notifications.md as the easiest
Web Push setup path, demoting the CLI to an alternative. Note that
clientManaged medication-reminder suppression is an iOS-app-only contract
so a PWA-only operator does not go hunting for a web toggle.

Add the VAPID env-var names and APNS_KEY_B64 to .env.production.example
(the loader and APNs sender read them, but the example omitted them),
both as commented placeholders, with a note that Web Push needs no Apple
account and that these vars are not in the compose whitelist.

Add a Backup and restore section to getting-started.md: a local pg_dump
one-liner and the load-bearing warning that the ENCRYPTION_KEYS /
ENCRYPTION_KEY must be backed up alongside the dump or the encrypted data
is unrecoverable, cross-linking the off-host backup and key-rotation
runbooks. Add troubleshooting entries for the compose env-var whitelist
and pull_policy: always.
2026-06-15 00:58:39 +02:00
Marc-André Bombeck d0ba4b061b feat(notifications): generate VAPID keys from the admin panel
Add a server-side VAPID keypair generator so a self-hoster no longer has
to run a shell command to enable Web Push. A new admin-only route
(POST /api/admin/settings/web-push-vapid/generate) calls web-push's
generateVAPIDKeys(), stores the private key encrypted at rest, seeds a
placeholder mailto: subject the operator can edit, and returns only the
public key. The route is cookie-only via requireAdmin(); a Bearer token
cannot reach it. The private key is never returned and never logged — the
audit detail carries a marker only.

The admin Web Push VAPID card gains a Generate keys button that fills the
public key and subject fields on success. When a keypair already exists
the route refuses with 409 and the card confirms before retrying with
force, warning that regenerating invalidates existing browser
subscriptions.
2026-06-15 00:58:30 +02:00
Marc-André Bombeck 45ccdcb305 merge(notifications): generic webhook + SMTP email channels and operator delivery health 2026-06-15 00:47:16 +02:00
Marc-André Bombeck c04bdc8f4e feat(notifications): document SMTP env and translate channel strings
Add the webhook + email Settings copy and the admin notification-health
panel labels across all six locales. Add the SMTP_* operator vars to the
env manifest (all-or-none: HOST + PORT + FROM enable the channel,
USER/PASS optional), to .env.production.example, and to the docker-compose
environment whitelist so an operator's .env values actually reach the
container.
2026-06-15 00:46:28 +02:00
Marc-André Bombeck 17dd10c3b9 feat(admin): operator-wide notification health panel
Surface the webhook + email cards on the notifications settings page, and
add an operator-wide delivery-health view the per-self diagnostic could
not give. A single groupBy(channel, result) over the trailing 24h of
push_attempts lets the operator answer "are anyone's pushes failing?"
across all users without DB shell, and a count of auto-disabled channels
(enabled=false with an automated disabledReason) flags a flapping upstream
that silently disabled a channel after five failures. The aggregate is
anonymous — it counts channel x result, never names a user. Admin-only by
construction (cookie auth, never Bearer); the window is clamped 1..168h.
The panel rides the existing admin Reminders section.
2026-06-15 00:46:21 +02:00
Marc-André Bombeck 909c2aa99e feat(notifications): add SMTP email channel
Alerts and reminders over email — the one channel every self-hoster
already has. The SMTP transport (host/port/auth/from) is operator-supplied
via SMTP_* env and loaded once per process like the APNs key; the only
per-user value is the recipient address, so SMTP credentials never enter a
user's encrypted channel blob. Absent SMTP env disables the channel
cleanly: the dispatcher skips it and the Settings card hides itself.

Bodies are plain text — HTML is stripped and routine reminders run through
the emoji-strip path; no markdown library is added. SMTP 5xx maps to a
hard reject (permanent — auto-disable); connection/4xx errors are
transient and ride the backoff schedule. Adds nodemailer (the standard,
well-audited mail transport) and its types.
2026-06-15 00:46:12 +02:00
Marc-André Bombeck 7532bbcc05 feat(notifications): add generic webhook channel
A single WEBHOOK channel covers Gotify, Discord, Slack, Matrix, and Home
Assistant: the user supplies a public URL and an optional shared-secret
header, and the dispatcher POSTs a small plain-text JSON envelope. The
outbound goes through safeFetch with requirePublicHost so the SSRF floor
and the connect-time DNS-rebinding pin apply to the user-supplied host,
exactly as they do for ntfy. Responses classify 404/410/401/403 as hard
rejects (auto-disable) and 5xx/429/timeout as transient (backoff).

Settings card mirrors the ntfy card with a save + test-connection flow;
the header value is never returned by the GET and is preserved across a
save round-trip when left blank.
2026-06-15 00:45:50 +02:00
Marc-André Bombeck aec41e154f feat(notifications): register webhook + email channel types
Add WEBHOOK and EMAIL to the channel set, the dispatcher cascade, the
per-event default policy, the push-attempt ledger union, and the admin
diagnostic config-presence check. Channels are string-typed, so no
migration is needed. The dispatcher routes each new type through its
sender with the same decrypt + parse + classify path the existing
channels use; the cascade orders webhook + email between the self-host
relays and the Web Push fallback.

Add the webhook + email channel config shapes and their settings-form
validation schemas, plus the query-key entries for the two new config
reads.
2026-06-15 00:44:26 +02:00
Marc-André Bombeck 168b69b334 merge(insights): surface stored sleep/recovery/strain metrics and a recovery sub-page 2026-06-15 00:40:27 +02:00
Marc-André Bombeck 1b0c4b2b7d test(insights): pin the device-score data-gating across the new surfaces
Cover the contract that matters for the vendor-native score tiles: a tile or
section appears only when its metric has stored readings. Asserts the empty
tile collapses to nothing, the sparse series swaps to the learning state, the
Sleep quality block renders only the metrics with data, and the Recovery page
shows its calm empty note with no data and groups + cross-links the score once
present.
2026-06-15 00:39:34 +02:00
Marc-André Bombeck 0be1a6b8a7 feat(insights): surface the Oura body-temperature deviation on skin temperature
Oura's overnight body-temperature deviation — a signed delta from the personal
baseline that tracks illness and cycle onset — was stored but never shown. Add
it as a data-gated tile beneath the chart on the metabolic skin-temperature
page, its most coherent home. It mounts only when the Oura series has readings,
so a non-Oura account renders the page unchanged.
2026-06-15 00:39:26 +02:00
Marc-André Bombeck 866f3dc0e2 feat(insights): add a Recovery sub-page for the device-native strain scores
WHOOP and Polar record day strain, workout strain, ANS charge, cardio load, and
the day's whole-cycle average / max heart rate and kilojoule energy expenditure
— all stored end-to-end with no render host. The composite recovery score had
only its anatomy detail at /insights/scores/recovery; the raw signals around it
appeared nowhere.

Add /insights/recovery as the home for them, grouped into recharge, strain &
load, and the day's heart & energy. Each block is data-gated, so an account
without a strap or ring lands on a calm empty note rather than a broken surface.
When a recovery score is present the page cross-links to its anatomy view rather
than duplicating the ring. Server-authoritative throughout.

Give the page a single nav home alongside Workouts in the unified destination
model so it is discoverable on both the sidebar and the mobile More hub.
2026-06-15 00:39:18 +02:00
Marc-André Bombeck e063f79353 feat(sleep): surface the nightly sleep-quality scores on the Sleep page
WHOOP and Oura write sleep efficiency, performance, consistency, need, the
disturbance count, and the Oura sleep score every night; until now none of them
rendered anywhere. Add a Sleep quality block after the rhythm section that
surfaces all six through the device-score tile.

Each tile is data-gated, so a non-wearable account never sees an empty card and
the whole block collapses metric-by-metric down to nothing when no source
writes them. It reads the same analytics summaries slice the page already
fetched for its duration gate, so it costs no extra round-trip.
2026-06-15 00:39:07 +02:00
Marc-André Bombeck 794192c089 feat(insights): add a calm device-score tile for vendor-native metrics
The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
charge, body-temperature deviation, …) is ingested end-to-end and lit in the
AI chart-token allowlist, but no page renders it. These scores sit outside the
metric-status registry by design — they are vendor-rolled-up values, not
signals the app re-derives — so they carry no assessment card.

DeviceScoreTile is the surface for them: a title, the latest reading, the
trailing-window mean, and a small sparkline, read straight from the stored
analytics summaries slice (server-authoritative, never recomputed). One neutral
card, never a status colour. When the series is still sparse the sparkline gives
way to the shared LearningGate so a two-night-old strap never paints a jagged
two-point line. The connected variant reads its own summary so it can drop onto
a page that already owns an analytics read.

Add the page-level copy in all six locales.
2026-06-15 00:38:59 +02:00
Marc-André Bombeck d1055b4047 merge(coherence): unify nav, layout settings, mood source, learning gates, coach nav home
# Conflicts:
#	src/lib/personal-records/pr-direction.ts
2026-06-15 00:23:54 +02:00
Marc-André Bombeck 347143c4b7 fix(personal-records): map the new derived metric types to a null PR direction
ANS charge, cardio load, the sleep score, and the body-temperature
deviation reached the MeasurementType enum without a branch in
getPRDirection, which left the exhaustive switch non-total — typecheck
fell through to an implicit undefined return and the drift guard failed.

These are derived / homeostatic composites with no goal axis, so they
join the other scores and temperature metrics on the null PR direction.
2026-06-15 00:21:42 +02:00
Marc-André Bombeck cf31211fbc refactor(settings): gather layout personalization under one home
Personalizing how the app is laid out was split across four unrelated
settings sections — Dashboard, Insights, Medications, Mood — with no
shared framing, so the same concept (arrange, reorder, show/hide) read
as four disconnected places, plus the settings nav carried four separate
arrange entries.

Add one Layout & Personalization hub that presents the four editors as a
consistent set of linked cards and replaces the four scattered nav
entries with a single home. Each editor keeps its own route and mutation
flow — deep links, the page-header cogs, and the hub's links all still
resolve — and a Layout child route now highlights the hub in the nav.
2026-06-15 00:20:16 +02:00
Marc-André Bombeck 2736e23092 merge(oura): real sleep timeline, SpO2, sleep score, temperature deviation, contributors + reconcile
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	prisma/schema.prisma
#	src/components/measurements/measurement-list-meta.ts
#	src/lib/__tests__/measurement-type-enum-coverage.test.ts
#	src/lib/insights/chart-tokens.ts
#	src/lib/measurements/__tests__/apple-health-mapping.test.ts
#	src/lib/measurements/categories.ts
#	src/lib/personal-records/pr-direction.ts
#	src/lib/validations/measurement.ts
2026-06-15 00:17:51 +02:00
Marc-André Bombeck 412b22e68e merge(polar): pull ANS charge, cardio load, SpO2, distance; fix sleep timeline; invalid_grant + reconcile 2026-06-15 00:13:01 +02:00
Marc-André Bombeck a0662d1b20 fix(oura): audit credential deletes and trim integration vestiges
Deleting a user's stored Oura BYO credentials drops a live connection (a
token minted against the deleted app is orphaned), but the credentials
DELETE route skipped the auditLog + markDisconnected the disconnect route
performs, leaving the integration ledger parked at its last state with no
audit trail. Mirror the disconnect route when a token was present.

Drop two unused classifier type aliases and unexport the internal
invalid-grant helper, and refresh the stale 'full OAuth from env' headers on
the connect route and the env-credentials helper to the v1.17.1 DB-first
framing. Add the missing route tests for the BYO surface this release
touched: credentials GET/PUT/DELETE (422 reject, encrypt-on-write, the
audit/ledger branch) and the callback CSRF / cross-user / nocreds / success
paths.
2026-06-15 00:09:56 +02:00
Marc-André Bombeck c5e91c4cb6 feat(oura): pull the real sleep timeline, SpO2, Sleep Score and more
Widen the Oura sync from a thin slice to the signals the v2 API actually
exposes, all server-authoritative:

  - Real measured sleep timeline. mapSleep now reads the per-stage
    sleep_phase_5_min hypnogram and emits one timed SLEEP_DURATION row per
    contiguous 5-minute run, each stamped at its own end instant from
    bedtime_start. The per-night reader reconstructs the true clock-time
    timeline (measured, not synthesised) instead of the degraded
    all-on-bedtime_end shape. Falls back to the four stage totals when no
    hypnogram is present.
  - daily_spo2 → OXYGEN_SATURATION and daily_sleep → SLEEP_SCORE, filling
    two ladder slots that nothing wrote before.
  - daily_readiness temperature_deviation → BODY_TEMPERATURE_DEVIATION, and
    the eight readiness contributors are parsed (no longer dropped at the
    JSON boundary) for the Insights surface.
  - daily_activity equivalent_walking_distance → WALKING_RUNNING_DISTANCE,
    so an Oura-only day no longer contributes zero distance.

Every sleep row now carries a record-scoped externalId
(sleep:<record-id>:<tag>) so a nap and the main sleep on one day stay
distinct instead of overwriting each other on a shared day key.
2026-06-15 00:09:45 +02:00
Marc-André Bombeck 3cdbab908c feat(measurements): add Sleep Score and body-temperature deviation types
Two additive measurement_type values for the Oura coverage completion:

  - SLEEP_SCORE — the headline 0-100 sleep-quality score (unit score),
    kept apart from the WHOOP sleep sub-scores so two vendors' headline
    numbers never share a bucket.
  - BODY_TEMPERATURE_DEVIATION — a signed nightly body-temperature offset
    from the personal baseline (celsius). It is a deviation, not an
    absolute reading, so it cannot share the body / skin / wrist
    temperature buckets.

Migration 0165 extends the enum (additive, no backfill). Register both in
the validation enum, units, plausibility ranges, category overlay, list-UI
label/icon/colour maps, chart-token allowlist, and the PR-direction switch
(both resolve to no record direction). Both are descriptive signals, not
clinical vitals, so they stay out of the doctor-report PDF. Localise the
two list labels across all six locales and regenerate the OpenAPI contract.
2026-06-15 00:09:33 +02:00
Marc-André Bombeck 87e570abfe fix(polar): lift invalid_grant to reauth and harden the credentials surface
classifyPolarError now lifts a 400 carrying invalid_grant on the token
endpoint from persistent to reauth_required, mirroring Oura via the shared
isOAuthInvalidGrant helper. A stale or replayed authorization code at connect
time now surfaces the reconnect prompt instead of silently parking after 24 h.

The BYO-credentials DELETE now audit-logs and parks the integration ledger at
disconnected for parity with the dedicated disconnect route, and the PUT
audit-logs the credential write. Refresh the connect-route comment that still
described Polar as env-only OAuth; it has resolved BYO-first since v1.17.1.

Add the invalid_grant classification test plus the previously-missing
callback (happy path, CSRF mismatch, cross-user, off-spec token), connect, and
credentials route tests.
2026-06-15 00:09:22 +02:00
Marc-André Bombeck 574b860eb4 feat(polar): complete the AccessLink pull surface
Polar read three collections off the wire and dropped several useful signals
at the mapper. Close the gaps, keeping every value server-authoritative and
canonical-unit:

- Sleep now follows the END-instant convention the canonical night-grouper
  depends on. mapSleep reconstructs an ordered, contiguous CORE/DEEP/REM
  timeline from the parsed sleep_start_time / sleep_end_time, stamping each
  segment at its own end instant with an indexed externalId, plus an IN_BED
  envelope and the score at the sleep end. This restores the hypnogram (the
  stages no longer collapse onto one midnight-UTC instant) and fixes wake-day
  mis-attribution for negative-UTC users. Records without a usable window fall
  back to the prior midnight anchor. Synthetic ordering is flagged
  reconstructed.
- ans_charge maps to ANS_CHARGE (through-as-is; a baseline-relative deviation
  that can run negative).
- distance_from_steps maps to WALKING_RUNNING_DISTANCE (metres), filling the
  Polar distance-ladder slot that previously had no producer.
- New fetchers + mappers for Training Load Pro (cardio_load -> CARDIO_LOAD)
  and the Elixir SpO2 collection (-> OXYGEN_SATURATION), with Polar added to
  the SpO2 source ladder below the established straps.

No strap/profile weight is fetched; egress stays on safeFetch.
2026-06-15 00:07:42 +02:00
Marc-André Bombeck fd39580ee6 feat(measurements): add ANS_CHARGE and CARDIO_LOAD measurement types
Polar Nightly Recharge and Training Load Pro carry two native signals with
no existing home: the HRV-based autonomic-nervous-system charge (distinct
from the 1-6 recovery band that maps to RECOVERY_SCORE) and the device-native
cardio-load strain figure (distinct from WHOOP DAY_STRAIN and the COMPUTED
STRAIN_SCORE). Add both to the canonical enum (migration 0166) and wire them
through the validation enum, canonical units, plausibility ranges, category
cluster, chart-token allowlist, PR-direction resolver, and the measurement-
list label/icon/colour maps, with labels across all six locales. Both are
device-derived composites, so they read no PR direction and stay out of the
clinical vitals PDF, matching the WHOOP-native score classes.

Regenerate the OpenAPI contract for the two new enum members.
2026-06-15 00:07:30 +02:00
Marc-André Bombeck 9bedd6b4f8 feat(nav): give the Coach a single primary nav home
The Coach is reachable from seven scattered entry points — a floating
button, the hero CTA, suggested-prompt chips, every metric empty state,
a per-metric icon, the conversations drawer — but it had no entry in the
nav. Power users found it instantly; a new user could miss the
differentiator entirely behind a 56px floating circle.

Add the Coach to the shared destination model as one labeled, primary
home, consistent with every other destination across desktop and mobile.
The existing entry points stay; this adds the front door they lacked.
2026-06-15 00:06:28 +02:00
Marc-André Bombeck c90459a7bd refactor(nav): drive desktop and mobile from one destination model
The desktop sidebar and the mobile bottom-nav each hand-curated their
own destination list, and the two had drifted: the sidebar listed
Measurements and Mood inline but hid Workouts; the bottom bar buried
Measurements and Mood in More but promoted Workouts. A user who learned
the product on one platform re-learned it on the other.

Extract one ordered destination model both bars consume. The sidebar
renders every entry in order; the bottom bar keeps its 5-slot shape and
derives its More hub from the same list, so the two surfaces tell one
story. Workouts is now first-class on both. A shared active-resolver
prefers the most-specific sibling so a sub-route no longer lights up its
parent.
2026-06-15 00:04:13 +02:00
Marc-André Bombeck 3606610751 merge(medications): silent intake-sync push and Live Activity token for cross-device clearing 2026-06-14 23:59:14 +02:00
Marc-André Bombeck 6d8dd75fff feat(devices): accept a Live Activity push token on registration
Add an optional hex liveActivityPushToken to device registration. The iOS
client registers the current per-Activity ActivityKit push token so the
server can address a Live Activity update or end push on a
medication-intake mutation, and sends null to clear it when no Activity is
running. Following the medicationDelivery override convention, the field
is only touched when present, so an ordinary re-register keeps the prior
value. Regenerate the OpenAPI contract.
2026-06-14 23:58:27 +02:00
Marc-André Bombeck 836fd7405e feat(medications): dispatch the cross-device intake sync on a dose mutation
Wire the silent intake-sync push into both intake writers. The single
route fires once for the affected slot after the row is persisted; the
bulk route collects the distinct (medicationId, scheduledFor) slots whose
state actually changed and fires one push per slot, so a 500-row backfill
wakes a device per affected slot rather than per row. Pending echoes,
duplicates, and no-downgrade no-ops change nothing visible and are
excluded.

Both pass the originating device through the X-Device-Id header so the
device that recorded the dose is skipped. The dispatch is best-effort and
fire-and-forget: the canonical rows are already written, so a push miss
never affects the response.
2026-06-14 23:58:21 +02:00
Marc-André Bombeck a5ec4ef8dd feat(notifications): add silent medication-intake cross-device sync push
Introduce the MEDICATION_INTAKE_SYNC event and the server-side dispatcher
that wakes a user's other iOS devices after a dose is logged on one of
them, so a running Live Activity or Home-Screen widget reconciles against
the canonical server state.

The dispatch is APNs-only by construction: it sends a silent
apns-push-type: background push (content-available, no alert) carrying
the affected medicationId + scheduledFor, and never fans out to Telegram,
ntfy, or Web Push, which have no silent-sync transport. A new raw APNs
send helper carries the background and liveactivity push types without
the alert/sound/badge enrichment the alert path injects. Devices APNs
reports as permanently dead are reaped; the originating device is
excluded from the fan-out via its registered device token.

When a device has a stored Live Activity push token, an additional
liveactivity end push goes to the per-Activity topic so the lock-screen
Activity ends immediately rather than waiting for the next background
wake.
2026-06-14 23:58:11 +02:00
Marc-André Bombeck b1a81bb65e refactor(insights): unify the learning-state gates behind one primitive
Glucose, sleep debt, chronotype, the trajectory forecast, and the
composite-score anatomy each hand-rolled the warm 'still learning your X'
paragraph — five copies with different markup, markers, and no shared
accessibility contract. The calm voice held only because each author
copied the tone by hand.

Add a LearningGate primitive that owns the presentation and behaviour of
a learning state (the muted body, an optional caveat line, the learning
marker, and the polite live region) and repoint all five surfaces to it.
Each keeps its own card chrome and its metric-specific copy; only the
gate's look and behaviour are now shared, so they can no longer drift.
2026-06-14 23:57:15 +02:00
Marc-André Bombeck fd5cc78377 refactor(mood): read the dashboard and analytics mood series from one engine
The dashboard snapshot and the /api/mood/analytics route each carried a
hand-copied rollup read, coverage fallback, and daily summarize of the
same numbers. Two reads of one metric is the seam where the dashboard
tile and the insights mood sparkline can drift apart.

Extract buildMoodDailySeries as the single mood engine and route both
callers through it, so the same mood number reads identically on the
dashboard, the insights surface, and the iOS client.
2026-06-14 23:53:22 +02:00
Marc-André Bombeck 44eed87e89 fix(vorsorge): pair the loading spinner with motion-reduce:animate-none 2026-06-14 23:44:44 +02:00
Marc-André Bombeck 4f039626be merge(labs): structured lab results with reference ranges, doctor-report and FHIR
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/openapi/registry.ts
#	src/lib/openapi/routes/index.ts
#	src/lib/query-keys/index.ts
2026-06-14 23:43:47 +02:00
Marc-André Bombeck 72fb51c257 feat(labs): add the Laborwerte page, navigation, and OpenAPI contract
A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.

Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
2026-06-14 23:39:52 +02:00
Marc-André Bombeck fbecb6b5c8 feat(labs): surface lab results in the doctor report and FHIR export
Add a Lab results section to the report aggregator (latest reading per
analyte over the window), a new labs section toggle (ON by default, the
same stance as BP and weight since the user recorded these to share with
a clinician), a PDF table with a quiet non-colour in/out-of-range marker,
and one laboratory Observation per result in the FHIR bundle carrying the
lab's reported referenceRange. Everything stays server-authoritative.
2026-06-14 23:39:29 +02:00
Marc-André Bombeck 271cc0dcfa feat(labs): add structured lab-result store with /api/labs CRUD
Record discrete biomarker readings (HbA1c, LDL, ferritin, TSH, …) with
their value, unit, reference range and sample date. The store pairs with
the annual blood-panel reminder, which until now had nowhere to record
the result it reminds the user to obtain.

The list/create/update/delete endpoints narrow userId from the session,
build the write data field-by-field, soft-delete via deletedAt, and
encrypt the optional note at rest. A neutral three-state reference-range
classifier (in-range / below / above / unknown) is computed
server-side so every surface renders the same calm verdict.

findLatestLabResultForAnalytes exposes the read point the blood-panel
reminder consults to mark itself satisfied, without coupling the two.
2026-06-14 23:39:16 +02:00
Marc-André Bombeck 4dcaee5ec5 merge(reminders): measurement/Vorsorge reminders with auto-resolve and neutral cards
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/index.ts
2026-06-14 23:36:57 +02:00
Marc-André Bombeck 946a9f792a docs(api): document the preventive-care reminder routes
Register the /api/measurement-reminders surface in the OpenAPI table and
regenerate the committed spec so the iOS client consumes the
MeasurementReminderDTO contract from a single source.
2026-06-14 23:33:43 +02:00
Marc-André Bombeck c6045482c3 feat(reminders): preventive-care surface — wann, was, wo
Add the Vorsorge page and section: an upcoming-reminders list sorted by
server-computed next-due, with a create form (measurement-linked or
free-text, cadence, notify hour, location) and a per-card Erledigt and
delete action. Cards stay neutral-coloured regardless of due state — the
status reads through a discreet badge only, never an alarming tint. Copy
ships in all six locales.
2026-06-14 23:33:37 +02:00
Marc-André Bombeck 8058d81590 feat(notifications): MEASUREMENT_REMINDER event type and client-managed gate
Register the MEASUREMENT_REMINDER event type (default on at the channel
layer; the real gate is the per-reminder enabled flag) and add the
notificationPrefs.measurementReminder.clientManaged sub-object plus its
cron-side resolver, mirroring the medication and cycle client-managed
precedent. The event rides the generic APNs category path and stays
non-time-sensitive, so it delivers as a plain banner with no client
change.
2026-06-14 23:33:30 +02:00
Marc-André Bombeck 383963d3b4 feat(reminders): every-15-min dispatcher for preventive-care reminders
Add the measurement-reminder cron, mirroring the mood-reminder shape: a
pure due-predicate (past nextDueAt AND inside the reminder's local
notify-hour) and a tick runner that dispatches through the existing
notification cascade. Auto-resolve runs in the cron, not on the ingest
hot path: a matching reading of the reminder's type advances
lastSatisfiedAt and skips the nudge (BP on the SYS sentinel; free-text
reminders resolve only on a manual satisfy). Dedup is the nextDueAt
advance past now, so no ledger table is needed. The queue is registered in
the all-queues list, scheduled, and bound to its worker; a registration
guard test pins that wiring.
2026-06-14 23:33:22 +02:00
Marc-André Bombeck 0a4f00f850 feat(reminders): CRUD + manual satisfy routes for preventive-care reminders
Add /api/measurement-reminders (list, create) and /{id} (get, patch,
delete) plus /{id}/satisfy. Every route wraps in apiHandler, parses with
Zod, narrows userId from the session, and builds the data object
field-by-field. Create and patch recompute the server-authoritative
nextDueAt; satisfy stamps lastSatisfiedAt and re-anchors the next-due.
Delete soft-deletes for tombstone parity.
2026-06-14 23:33:13 +02:00
Marc-André Bombeck 988ba1196e feat(reminders): server-authoritative next-due engine for preventive-care reminders
Add the Vorsorge (measurement) reminder cadence layer: a thin adapter that
maps a reminder onto the canonical recurrence engine so a rolling
"every N days" or an RFC-5545 rrule cadence is driven by the same code the
medication next-due line uses. nextDueAt is computed and stored on the
server, so web and iPhone read identical numbers. Covers the DTO serializer
and the create/update validation schemas (exactly one of intervalDays or
rrule; BP auto-resolves on the SYS sentinel).
2026-06-14 23:33:05 +02:00
Marc-André Bombeck b3d2753897 merge(import): CSV measurement import, backdate hint, JSON-import dedup + instant bounds 2026-06-14 23:29:24 +02:00
Marc-André Bombeck f0a14ac0b4 feat(measurements): surface that the entry timestamp is editable for backdating
The add form already defaults the timestamp to now and accepts any earlier
instant — the plausibility bound and the picker's max both block the future —
but many users never notice they can change it. Add a one-line hint under the
date/time field so logging a past reading is discoverable, and pin the
manual-create contract with a guard asserting the single POST schema strips an
externalId (the stats:-prefix overwrite stays batch-scoped).
2026-06-14 23:28:36 +02:00
Marc-André Bombeck fbdcb31202 feat(import): add CSV measurement import with per-row status and dry-run
A self-hoster migrating from a spreadsheet or a meter export overwhelmingly
has CSV, which neither the JSON nor the Apple Health import covers. Add
`POST /api/import/csv` reusing the existing write loop and rollup re-fold.

The documented header is `type,value,unit,measuredAt[,glucoseContext,notes,
externalId]`, order-independent. measuredAt must carry an explicit ISO-8601
offset — a row without one is skipped rather than guessed — and is bounded by
the shared entry-instant validator. Glucose accepts mmol/L (converted to
canonical mg/dL) and weight accepts lb (converted to kg); any other
non-canonical unit is skipped, never mis-stored. An optional externalId
column makes a re-upload idempotent; without it a re-upload duplicates.

The response carries a per-row status envelope so a 2000-row file with three
bad rows imports 1997 and names the three. `?dryRun=1` validates and previews
the outcome without writing. The parse, validation, and unit conversion live
in a pure module so they are unit-tested without a database.

Settings -> Export & Import gains a third CSV card mirroring the JSON and
Apple Health cards, with upload, paste, preview, and a downloadable example.
The new route is documented in the OpenAPI contract.
2026-06-14 23:28:22 +02:00
Marc-André Bombeck 82e2d7dfb1 fix(import): bound JSON measurement timestamps and dedup on externalId
The generic JSON import validated measurement `measuredAt` with a bare
`z.string().datetime()` — no ceiling — so a crafted payload could forward-date
a reading, the gap the single-POST plausibility bound already closed. Route
the measurement and mood instant fields through the shared
`validateEntryInstant` (no future beyond a 5-minute skew, no instant before
1900).

Measurement rows were also always created with a NULL externalId, which is
distinct in the `(userId, type, source, externalId)` unique key, so a
re-import duplicated the whole set. Give measurements the same optional
externalId upsert mood already has: present -> idempotent upsert, absent ->
first-write-wins create.
2026-06-14 23:28:08 +02:00
Marc-André Bombeck 1c3316112c chore(migrations): renumber onboarding-goals to 0164 for monotonic order 2026-06-14 23:22:52 +02:00
Marc-André Bombeck 1e28f9cfb8 merge(onboarding): persist goals to seed the dashboard, add anamnesis step 2026-06-14 23:22:42 +02:00
Marc-André Bombeck c8cd5aa226 feat(onboarding): add an optional anamnesis step on baseline
A collapsible "About your health" card on the baseline step captures
the genuinely-new baseline the app can act on — chronic conditions and
allergies — without re-asking the height/sex/birthdate already gathered
above. Both write through the existing encrypted self-context path
(PUT /api/coach/about-me), so there is no new model and no new
contract; the form preserves any existing free-text about-me on save
and only sends a field the user actually typed.

The card is collapsed by default with a clear optional framing, so the
step stays clear and the progress bar keeps its five dots; every field
is editable later in Settings. Copy is warm and medically grounded —
context, not a clinical form — with keys across all six locales. Also
re-truths the goals body copy now that the selection genuinely surfaces
on the dashboard.
2026-06-14 23:21:58 +02:00
Marc-André Bombeck 5f601426a9 feat(onboarding): persist goal selection and seed the dashboard
Wire the goals step to actually keep its promise. The picker now posts
the chosen slugs on the step-2 submit; the step endpoint validates them
against the closed slug enum (422 on an unknown slug), persists them to
User.onboardingGoals field-by-field, and on completion seeds
dashboardWidgetsJson from the selection — promoting the goal-mapped
tiles to the top and forcing them visible.

The seed is one-time, gated on a null dashboardWidgetsJson via the
updateMany WHERE clause, so a user who already arranged tiles is never
clobbered. An empty or general-wellness-only selection leaves the
column untouched (default layout). The resulting layout is what both
the web dashboard and the iOS widgets contract read — no client
recompute. Skip posts an explicit empty array (deliberate no
preference).
2026-06-14 23:21:48 +02:00
Marc-André Bombeck 5b756f01f2 feat(onboarding): add User.onboardingGoals for goal persistence
The goals step let the user toggle goal slugs but both Skip and Next
discarded the selection — the picker was decorative. Add the
onboarding_goals text array the earlier dead code already referenced so
the selection can persist and seed dashboard tiles + reminder
suggestions. Not a clinical target store; targets stay fully derived
from height/age/gender in /api/insights/targets. Empty array = no
preference; purely-additive migration with an empty-array default, no
backfill.
2026-06-14 23:21:39 +02:00
Marc-André Bombeck 9ef35107f0 merge(integrations): per-user Polar and Oura OAuth credentials 2026-06-14 23:21:07 +02:00
Marc-André Bombeck 80f3cd8780 chore(migrations): renumber sleep-timeline backfill to 0163 for monotonic order 2026-06-14 23:20:54 +02:00
Marc-André Bombeck ed50d10556 merge(sleep): reconstruct WHOOP per-stage timeline, fix Withings/Fitbit stamping 2026-06-14 23:20:53 +02:00
Marc-André Bombeck 38c7604ce4 feat(integrations): per-user Polar and Oura OAuth credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.

- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
  encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
  to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
  input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
  Oura cards; the status read exposes hasOwnCredentials for the saved
  placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
  with unit tests; add the new strings across all six locales.
2026-06-14 23:19:47 +02:00
Marc-André Bombeck ddfed17fa3 feat(sleep): backfill existing WHOOP + Withings sleep rows after the stamp fix
The WHOOP timeline reconstruction and the Withings END-stamp change the
stored measuredAt (and, for WHOOP, the externalId), so existing rows
keep the old broken shape until re-synced. An in-place update collides
with the (userId, type, measuredAt, source, sleepStage) unique index, so
the backfill deletes the affected SLEEP_DURATION rows for the source and
re-syncs from scratch; the re-sync re-folds the rollup tier in its tail.

Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP /
Fitbit backfills: discovery enqueues one job per (user, provider) where
sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs
and stamps the marker, and the pass is idempotent across reboots. The
queue is registered in allQueues, wired to a handler, and enqueued at
boot.
2026-06-14 23:16:33 +02:00
Marc-André Bombeck 7ad0b9c4f4 fix(sleep): stamp Withings sleep segments at the END, not the START
Withings sleep sync stamped each segment's measuredAt with its startdate,
but every reader treats measuredAt as the segment END and resolves the
span as start = end − duration. Each night was therefore rendered one
segment-length earlier than reality. Stamp the enddate so the span lands
at its true clock time.
2026-06-14 23:16:23 +02:00
Marc-André Bombeck bd467d0c4d fix(sleep): emit Fitbit sleep as per-segment rows, not a stage collapse
The Google sleep payload carries a real per-stage segment series, but the
mapper summed each stage's segments into one row stamped on the stage's
last end. The hypnogram then reconstructed all of a stage's time as a
single block ending at the night's right edge — the same stacked-bar
artefact as WHOOP, despite the source carrying true timing.

Emit one row per segment with measuredAt at that segment's end and an
indexed externalId so the several segments of one stage stay distinct
under the dedup key. The timeline is measured (real onsets), so these
rows are not flagged reconstructed.
2026-06-14 23:16:16 +02:00
Marc-André Bombeck bc12633569 fix(sleep): reconstruct an ordered WHOOP hypnogram timeline
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.

Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.

The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
2026-06-14 23:16:09 +02:00
Marc-André Bombeck 111fa7a323 merge(schema): per-user Polar/Oura credentials, measurement reminders, lab results, LA push token 2026-06-14 23:06:28 +02:00
Marc-André Bombeck 355fa9a670 feat(schema): add per-user Polar/Oura credentials, measurement reminders, lab results
Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and
non-destructive.

- users: Polar and Oura BYO-app client id/secret, encrypted at app level and
  stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1.
  Each self-hoster registers their own Polar AccessLink / Oura Cloud app and
  pastes the client id/secret into Settings before the OAuth connect flow.
- devices: live_activity_push_token for the iOS Live Activity update/end APNs
  channel. The client owns the ActivityKit lifecycle; the server only stores
  the most recent push token so the worker can address the update.
- measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a
  rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives
  the server-authoritative next_due_at. The completion target is an optional
  measurement_type (auto-resolved when a matching reading lands) or a free-text
  label that resolves on a manual check-off. Soft-deleted.
- lab_results: minimal structured lab store — one analyte per row, optional
  panel grouping and reference range, optional encrypted note — so a returned
  blood panel has somewhere to land alongside the Vorsorge reminder.

No enum change: the Polar/Oura/Nightscout measurement sources already landed in
migration 0160.
2026-06-14 23:04:48 +02:00
Marc-André Bombeck 82be867b83 merge(docs): self-hosting notification channels and Web Push guide 2026-06-14 23:03:54 +02:00
Marc-André Bombeck 8b5b35bbdd docs(self-hosting): document the notification channels and Web Push setup 2026-06-14 23:03:28 +02:00
Marc-André Bombeck 2d1c02ce7b merge(sleep): side-by-side sleep-debt and rhythm cards when both present 2026-06-14 23:03:06 +02:00
Marc-André Bombeck 3594b549d9 feat(sleep): place sleep-debt and rhythm cards side by side when both present
When both the sleep-debt and chronotype cards carry a settled readout, render
them two-up on large screens instead of stacked; they are compact and read
better paired. A lone data-bearing card — or either card in its learning /
partial state — keeps full width so it never leaves a half-width orphan. The
grid collapses to one column below the lg breakpoint and uses items-start so a
toggled-open chronotype card does not stretch its neighbour.
2026-06-14 23:02:37 +02:00
Marc-André Bombeck 94fd837d48 Merge pull request #333 from MBombeck/release/v1.17.0
v1.17.0 — clinical depth for glucose and sleep, three new sources
2026-06-14 21:52:18 +02:00
Marc-André Bombeck 1d3ee1d93c test(integrations): expect six integration pills and drop unused field maps
The mobile settings test asserted three integration pills; Polar, Oura and
Nightscout bring the list to six. Remove the unused Oura and Polar field-map
documentation constants and the unused advanced-indices interface the dead-code
gate flagged.
2026-06-14 21:46:20 +02:00
Marc-André Bombeck 3f5b1a66a1 chore(release): v1.17.0 — clinical depth for glucose and sleep, three new sources
Add the blood-glucose clinical panel with Nightscout, sleep debt and chronotype,
Polar and Oura over OAuth, reorder-aware medication supply and an invite
deep-link. Every new metric is computed once on the server and read the same way
on the dashboard, the coach, the doctor-report and the companion app, each behind
a still-learning gate. The coach now reasons from the new signals, mood and cycle
entry is one sectioned sheet, and previously-hidden wearable data surfaces.
2026-06-14 21:29:45 +02:00
Marc-André Bombeck 713fa3e926 feat(coach): feed native strain and teach the glucose panel
Surface WHOOP's native day strain to the coach on its own 0-21 scale, distinct
from the computed strain score, and instruct the model to prefer the device
number — the gold-standard signal a WHOOP user connected was previously invisible
to the coach. Document the glucose clinical block in the prompt so the model reads
time-in-range, GMI, estimated A1C and variability correctly and never asserts them
while still learning or off spot readings, and extend the non-English unit
vocabulary to cover glucose. Centralize the glucose panel window and run the
independent derived reads concurrently.
2026-06-14 21:27:52 +02:00
Marc-André Bombeck 5f85d20f02 feat(coach): feed native day strain, document glucose panel + center its window
Add WHOOP-native DAY_STRAIN (0-21) to the Coach snapshot as a dedicated
dayStrain block, kept distinct from the computed STRAIN_SCORE (0-100) and
preferred native-over-computed, mirroring recovery resolution. Document the
glucose.clinical panel (TIR/GMI/eA1C/CV) plus the stillLearning and
isSpotEstimate gates in the system prompt, and the native dayStrain scale in
the derived paragraph. Extend the DE and FR/ES/IT/PL evidence-block unit lists
with mg/dL, mmol/L and min so a non-English coach can cite glucose and duration
values. Export GLUCOSE_PANEL_WINDOW_DAYS as the single source for the 30-day
clinical-panel window and import it across the analytics route, dashboard
snapshot, and Coach snapshot. Compute the derived metrics concurrently.
2026-06-14 21:27:12 +02:00
Marc-André Bombeck a198ce0958 fix(onboarding): show the real integrations and set honest expectations
Bring the source step up to date — present the six shipped integrations instead
of a single vendor and a stale coming-soon badge, and signpost the import path
for existing history. Stop promising that the goals and source choices configure
the dashboard, which they do not yet; frame them honestly as helping the user
decide what to track. Set the calibrating expectation on the done screen so empty
tiles read as learning, not broken, and lead forward with connect and log CTAs.
Count any connected integration toward the setup checklist, not Withings alone.
2026-06-14 21:26:41 +02:00
Marc-André Bombeck 1ddc8a0c47 Surface shipped data sources in onboarding and tell the truth
Rewrite the source step to list the live integrations (Apple Health,
WHOOP, Oura, Polar, Nightscout, Fitbit) alongside Withings and manual
entry, each linking to where it is connected; drop the stale Apple
Health coming-soon badge. Add an import affordance for existing history.

Reword the goals and source copy so it no longer promises automatic
dashboard or reminder configuration that does not happen yet. Add a
calibration line to the done screen setting the expectation that some
insights sharpen over the first weeks, and lead the success screen
forward with connect-a-device and log-first-measurement actions.

Generalize the dashboard checklist data-source item so any connected
integration satisfies it, not Withings alone.
2026-06-14 21:26:09 +02:00
Marc-André Bombeck 812b217470 refactor(integrations): collapse the remaining duplication and drop a dead column
Derive the recovery source rank from the one priority ladder instead of a second
hand-kept map, re-point the WHOOP and Fitbit HTTP classifiers at the shared
status classifier, and lift the near-identical poll-cohort job handlers onto one
factory. Drop the Polar refresh-token column, which was only ever written null
since Polar tokens do not expire. Behaviour is unchanged across the recovery,
classifier, queue and route suites.
2026-06-14 21:24:28 +02:00
Marc-André Bombeck 9675d6189d Note glucoseClinical is insights-only, PDF deferred 2026-06-14 21:23:56 +02:00
Marc-André Bombeck 2d5b15c067 Drop unused polar refresh-token column 2026-06-14 21:23:56 +02:00
Marc-André Bombeck ccf4d6b2ad Extract poll-cohort job handler factory 2026-06-14 21:23:55 +02:00
Marc-André Bombeck cf2c83236b Reuse shared HTTP classifier for WHOOP and Fitbit 2026-06-14 21:23:55 +02:00
Marc-André Bombeck 2da7ea0881 Derive recovery rank from the source-priority ladder 2026-06-14 21:23:55 +02:00
Marc-André Bombeck 67c04d06fd fix(insights): surface ring and strap HRV and rank the new sources
Light up the HRV tab when a device writes RMSSD instead of SDNN, so an Oura,
Polar or WHOOP user finally sees the nightly HRV the app already stored — the
chart swaps to the RMSSD series with a measure label rather than silently
merging the two measures. Add Oura and Polar to the steps, active-energy and
distance source ladders below the phone aggregate, and lift the optical-HR
wearables above a hand-typed pulse so the canonical reading is the device's.
2026-06-14 21:23:20 +02:00
Marc-André Bombeck bd9f03805c feat(insights): surface ring/strap HRV and rank wearable activity sources 2026-06-14 21:22:41 +02:00
Marc-André Bombeck 46f9dfa47f fix(integrations): make the new cards match the existing ones and link to their data
Give the Polar, Oura and Nightscout cards the same parked-state resume banner
and test-connection button the other integrations carry, distinct brand icons,
a connected-state link to where their data appears, and the correct status-key
invalidation so the pill refreshes after a disconnect. Fix the sleep empty-state
link that pointed at a non-existent settings slug.
2026-06-14 21:15:53 +02:00
Marc-André Bombeck 11344e739c chore(deps): bump dependencies and CI actions
Update prettier, undici, lucide-react, react/react-dom (patch within the pinned
19.2 line) and zod-openapi to 6 (the OpenAPI output is byte-identical), plus the
docker/metadata and dependabot/fetch-metadata actions.
2026-06-14 21:14:14 +02:00
Marc-André Bombeck 633a451c16 feat(integrations): match parked, test, and data-link treatment on new cards 2026-06-14 21:13:09 +02:00
Marc-André Bombeck ed1766c0dc chore(deps): bump zod-openapi from 5.4.6 to 6.0.0
The v6 major leaves the createDocument API and the .meta() registry
annotations unchanged for this codebase; the generated
docs/api/openapi.yaml stays byte-identical and openapi:check passes
with no registry adjustment.
2026-06-14 21:12:20 +02:00
Marc-André Bombeck 158a79797f chore(deps): bump react and react-dom from 19.2.5 to 19.2.7
Patch bump within the pinned 19.2.x line. @types/react resolves to
19.2.17 under the existing ^19 range.
2026-06-14 21:11:57 +02:00
Marc-André Bombeck 2b240c5798 chore(deps): bump prettier, undici, lucide-react and two CI actions
- prettier 3.8.3 -> 3.8.4 (dev)
- undici 8.3.0 -> 8.4.1
- lucide-react 1.17.0 -> 1.18.0
- docker/metadata-action 6.0.0 -> 6.1.0
- dependabot/fetch-metadata 2.5.0 -> 3.1.0
2026-06-14 21:11:44 +02:00
Marc-André Bombeck e5fad221a0 feat(integrations): add test-connection endpoints for Nightscout, Polar, Oura 2026-06-14 21:11:09 +02:00
Marc-André Bombeck 95739369e1 fix(insights): point sleep empty-state CTA at a real settings slug 2026-06-14 21:11:05 +02:00
Marc-André Bombeck 95f5257572 refactor(integrations): share the HTTP status and error classifier
Lift the byte-identical Polar and Oura response classifiers onto one shared
classifyHttpStatus over the integration classification union, give the vendor
API errors a common base carrying the vendor label, and funnel the duplicated
failure-kind adapters through a single toFailureKind. Behaviour is unchanged —
the per-vendor names, signatures and the Oura invalid-grant lift are preserved.
2026-06-14 21:10:06 +02:00
Marc-André Bombeck 1b1713dd12 fix(insights): align the sleep cards with the design system
Drop the bespoke vertical-rhythm override on the sleep-debt and chronotype
cards so they inherit the standard card spacing the glucose panel uses, give the
sleep-rhythm section a quiet error notice instead of an endless skeleton on a
failed read, and move the card accents onto the semantic info token.
2026-06-14 21:10:06 +02:00
Marc-André Bombeck a3704f1633 Lift shared HTTP-status classifier for OAuth integrations
Polar and Oura response classifiers were byte-identical copies of the
status-driven verdict, the typed API error, and the invalid_grant probe.
Extract classifyHttpStatus, an IntegrationApiError base carrying the vendor
label, and a vendor-scoped isOAuthInvalidGrant into a shared module; Polar
and Oura now re-export under their existing names and types so call sites
and instanceof checks are unchanged.

Collapse the triplicated classifyXFailure mapping (Polar, Oura, Nightscout)
onto one toFailureKind helper. Behaviour is identical: same classifications,
same failure kinds, same error types.
2026-06-14 21:08:45 +02:00
Marc-André Bombeck 4807c3afd8 Align sleep rhythm cards with the house Card rhythm and states
Drop the compact gap/padding override on the sleep-debt and chronotype
cards so they inherit the standard Card rhythm, matching the glucose
panel. Switch the card icons to the semantic text-info token. Branch
SleepRhythmSection on a query error to a quiet inline notice instead of
an endless skeleton, keeping loading and settled states distinct.
2026-06-14 21:08:32 +02:00
Marc-André Bombeck 235e509333 fix(insights): point the briefing steps link at the dedicated page
Every other briefing destination links to its dedicated insight sub-page; the
step row alone pointed at the generic readings view. Route it to /insights/steps
so the briefing, the tab strip and the dashboard all open the same step page.
2026-06-14 21:05:37 +02:00
Marc-André Bombeck d84c73af0d fix(v1.17.0): reconcile QA findings across glucose, nightscout and contracts
Derive the glucose spot-estimate flag from reading density so a continuous
stream is no longer mislabeled a spot estimate, redact the Nightscout token
from a recorded sync error, rate-limit the Polar and Oura disconnect routes to
match the others, declare the two blood-pressure window fields the dashboard
snapshot already emits, make the reorder-lead-days migration re-runnable, and
guard the Polar and Oura sync queues against a silent drop.
2026-06-14 21:00:36 +02:00
Marc-André Bombeck ef36a6a81f Cover Polar and Oura sync queues in the dead-queue guard
Only the Nightscout sync queue was asserted in allQueues; the two OAuth-
wearable polls shipped alongside it carry the same v1.4.37 dead-queue risk.
Assert POLAR_SYNC_QUEUE and OURA_SYNC_QUEUE membership, cron, and handler
wiring so a future refactor can't silently drop one.
2026-06-14 20:59:58 +02:00
Marc-André Bombeck cf76e6f5ea Make migration 0161 re-runnable with IF NOT EXISTS
The reorder_lead_days column add used a bare ADD COLUMN; match 0160's
idempotent guard so a re-run is safe. Schema unchanged, SQL-only.
2026-06-14 20:58:49 +02:00
Marc-André Bombeck c2d6772b03 Rate-limit the Polar and Oura disconnect routes
Both disconnect routes ran unbounded, unlike nightscout/disconnect and the
connect routes. Add the same per-user 20/60s checkRateLimit guard after
requireAuth so the surface returns 429 on bucket exhaustion.
2026-06-14 20:58:37 +02:00
Marc-André Bombeck d58f0dc699 Redact Nightscout token from recorded sync error
The entries URL carries the access token as ?token=<secret>; a SafeFetchError
embeds the full target URL in its message, and the sync feeds err.message into
recordSyncFailure, which the status endpoint returns as lastError. Strip token
and api-secret query values to =REDACTED before the message reaches the ledger,
so the secret can never surface in the status response.
2026-06-14 20:56:44 +02:00
Marc-André Bombeck 1735205096 Add bpInTargetCount90/SpanDays90 to snapshot extras OpenAPI
buildExtras emits bpInTargetCount90 + bpInTargetSpanDays90, but the
registered extras schema (additionalProperties: false) omitted them, so a
strict iOS decoder would reject the dashboard snapshot. Declare both as
nullable integers to match the runtime shape and regenerate the spec.
2026-06-14 20:54:49 +02:00
Marc-André Bombeck c268ace58c Derive glucose isSpotEstimate from reading density
Hard-coding isSpotEstimate: true mislabels a dense Nightscout CGM stream
(~288 readings/day) as a spot estimate and undersells the data in the
clinical panel and coach. Compute readings-per-day over the covered span
and treat the series as continuous below the spot caveat once it clears a
~hourly bar (24 readings/day); sparse spot fingersticks keep the caveat.
The panel only renders the spot-reading line when isSpotEstimate, and the
OpenAPI field relaxes from a literal-true to a boolean.
2026-06-14 20:54:32 +02:00
Marc-André Bombeck c5a7e06e35 feat(medications): warn on supply with reorder lead time in mind
Move the low-stock trigger from a bare runway threshold to the larger of that
threshold and the reorder lead time plus one dose interval, so a weekly
medication is flagged before its last dose rather than after — early enough to
reorder. The change only moves the alert earlier; no one's threshold shrinks.
Surface concrete dates (runs out on, reorder by) and a running-low vs last-dose
state through the existing badge and channel, never the card colour. A reorder
lead time is configurable globally with a per-medication override, and the glp1
card flag now runs the same runway decision as the daily check instead of a
stale dose count.
2026-06-14 20:40:21 +02:00
Marc-André Bombeck 08aa655be4 feat(medications): make low-stock alert reorder-lead-time aware
The low-stock alert fired when the projected runway dropped below the
user threshold (default 7 days). For a sparse cadence — a weekly
injection's 7-day floor is barely one dose-interval — the warning landed
with about one dose left, too late to reorder before the supply ran out.

Add an optional per-medication reorder lead time plus a user-level
default (0–60 days, default 10) and widen the alert trigger to
max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning
lands before the last dose for any cadence. Because the trigger is a
max() over the user floor it never shrinks anyone's current threshold; a
daily med with no lead keeps its prior boundary.

The push body and the card notice surface concrete dates — supply runs
out around <date>, reorder by <date> — with a last-dose informational
state and the days/units fallback when supply data is thin. Retire the
stale fixed 4-dose count threshold on the GLP-1 details endpoint and
route its card lowStock flag through the same runway evaluation so the
card and the daily cron agree. Surface the lead default in the low-stock
settings card with progressive disclosure; a single medication can
override it.

Additive schema: nullable Medication.reorderLeadDays (migration 0161, no
backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI
regenerated.
2026-06-14 20:39:06 +02:00
Marc-André Bombeck 4788c81689 merge: forward the v1.16.17 fixes into the v1.17.0 line
Carry the restored sleep timeline bar and the dashboard refresh-after-intake
fixes onto the release branch so the v1.17.0 image ships with them.
2026-06-14 20:38:23 +02:00
Marc-André Bombeck 6382256c7d Merge pull request #332 from MBombeck/fix/v1.16.17-hypnogram-bar
v1.16.17 — restore the sleep timeline bar, clear the dashboard after a dose
2026-06-14 20:29:42 +02:00
Marc-André Bombeck 870ee01826 chore(release): v1.16.17 — restore the sleep timeline, clear the dashboard after a dose
Two daily-use fixes: the last-night hypnogram bar collapsed to its breakdown
for nights whose stages share an end instant, and the dashboard kept prompting
for a dose until a full reload after it was recorded. No breaking changes.
2026-06-14 20:21:31 +02:00
Marc-André Bombeck 59307760c5 fix(medications): refresh the dashboard the moment an intake is recorded
The intake write invalidates the dashboard snapshot, but the default active
refetch type only refetches mounted queries — and the snapshot is unmounted
while the user is on the medication card or detail page. On navigating back it
remounts under refetchOnMount:false, so the pre-write cache was served and the
due prompt lingered until a hard reload. Force the inactive snapshot to refetch
after every intake write so the dashboard clears as soon as the dose is taken.
2026-06-14 20:21:31 +02:00
Marc-André Bombeck 6a99f72384 fix(sleep): restore the last-night hypnogram timeline bar
The staged bar is gated on the night carrying more than one distinct stage
boundary. The guard read the segment END instants, so an ordinary multi-stage
night whose segments share a right edge — the shape a summary-derived writer
reconstructs to — collapsed to the breakdown footer with no bar. Gate on the
distinct START instants instead, so a night with timed stages keeps its
timeline and only a session recorded on a single instant falls back to the
breakdown. Covers the chart-vs-breakdown branch the test suite did not.
2026-06-14 20:21:19 +02:00
Marc-André Bombeck 6fba9568ab feat(integrations): add Polar and Oura over OAuth
Connect Polar and Oura through full OAuth, mirroring the existing wearable
house-pattern: a signed, expiring, provider-bound state doubles as an httpOnly
cookie, tokens are encrypted on the user, and an hourly job pulls sleep, heart
rate, respiratory and activity into canonical measurements under each source,
idempotently. Polar Nightly Recharge and Oura readiness feed the recovery
series, which now ranks WHOOP, then Oura, then Polar over the computed proxy.
Settings cards, localized copy and the credential env contract are wired for
both; the providers are optional and configured all-or-none.
2026-06-14 20:13:03 +02:00
Marc-André Bombeck 88139fb981 feat(integrations): add Oura Cloud v2 OAuth sync
Connect an Oura account over the Cloud v2 API: an authorization-code OAuth
flow (shared app from OURA_CLIENT_ID / OURA_CLIENT_SECRET) on the same signed
state machinery as Polar. Unlike Polar, Oura issues refresh tokens; the sync
refreshes reactively on a 401, rotating and re-encrypting both tokens, and a
403 stays a hard reauth rather than a refresh.

An hourly poll-cohort job maps daily readiness to RECOVERY_SCORE, sleep to
per-stage durations plus efficiency / HRV / resting heart rate / respiratory
rate, and daily activity to steps and active energy — all tagged source OURA,
idempotent on (userId, type, source, externalId). Readiness flows through the
canonical recovery ladder above the computed proxy. Settings gains a connect /
disconnect card. Tokens are never logged or returned; every egress goes
through safeFetch.
2026-06-14 20:10:35 +02:00
Marc-André Bombeck 31abd307fd feat(integrations): add Polar AccessLink OAuth sync
Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.

An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
2026-06-14 20:08:50 +02:00
Marc-André Bombeck 52bf985734 feat(invite): open invites through a deep-linkable path
Emit invite links as a /invite/<token> path the iOS app intercepts as a
universal link to start a prefilled registration, with a browser fallback that
redirects to the register flow once the token shape checks out. The path is a
thin, side-effect-free redirect — no lookup, no logging, no enumeration oracle
— and carries the same no-store, no-index, no-referrer hardening as the other
secret-bearing path since the token rides in the URL.
2026-06-14 19:36:01 +02:00
Marc-André Bombeck c88ec7269e feat(coach): let the coach reference sleep debt and chronotype
Add a sleep-rhythm block to the coach snapshot, built from the same assembler
the sleep page and the dashboard use over a pinned window, so the coach reasons
from the same sleep-debt and chronotype values the rest of the app shows rather
than a second computation. The prompt documents the new fields and keeps the
learning-gate honesty — a chronotype band is offered only once it is calibrated.
2026-06-14 19:35:42 +02:00
Marc-André Bombeck 03bde8d40c Harden invite token-in-path against cache and index leaks
Extend the /c/ edge defence (no-store, noindex, no-referrer) to the
/invite/<hlv_token> surface — the invite secret rides in the path, so no
CDN may cache it and no crawler may index the URL.
2026-06-14 19:34:14 +02:00
Marc-André Bombeck d3ea8d4470 feat(coach): thread sleep-debt and chronotype into the snapshot
Surface the server-authoritative sleep-rhythm read in the coach snapshot
so a reply can reference cumulative sleep debt and the chronotype band the
Sleep page and dashboard already show. The values come from the same
assembler those surfaces use over a fixed trailing-42-day window, never a
recompute, so the coach can only quote what the page renders.

Hold the learning gate: the chronotype band and social jetlag are emitted
only once the signal is ready; a calibrating chronotype is surfaced as such
and never asserted as a band. Document the new fields and minute units in
the system prompt.
2026-06-14 19:34:14 +02:00
Marc-André Bombeck c1eb618dcc Add invite universal-link landing for app deep-link
Encode the admin invite QR as a clean /invite/<hlv_token> universal-link
that the iOS app intercepts via the AASA wildcard matcher. Add the
matching web route as a thin, shape-validated redirect onto the existing
register flow, and open the path on the public allowlist so an
unauthenticated scanner reaches it. A malformed token segment is
indistinguishable from no invite — no database hit, no enumeration
oracle. The token is never logged.
2026-06-14 19:32:19 +02:00
Marc-André Bombeck e561f8d893 feat(glucose): add the clinical glucose panel
Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.
2026-06-14 19:21:25 +02:00
Marc-André Bombeck 17e0b2434e Pin coach glucose panel to the fixed 30-day clinical window 2026-06-14 19:19:36 +02:00
Marc-André Bombeck a970ba8ab0 feat(sleep): surface sleep debt and chronotype
Assemble a sleep-rhythm read on the canonical per-night reconstruction — the
same midpoints and asleep totals the sleep score uses — that reports a rolling
sleep debt against the age-based need and an MCTQ chronotype with its mid-sleep,
social jetlag and band. A shared assembler caps the chronotype window so the
dashboard and the dedicated read return identical values, and both withhold a
confident reading behind a learning state until enough nights accrue. The sleep
page gains a debt headline and a chronotype card with the deeper detail behind
an advanced disclosure; the sleep score is left untouched so debt reframes the
sufficiency gap without double-counting it.
2026-06-14 19:12:01 +02:00
Marc-André Bombeck 4b9f31f053 Add glucose clinical panel with advanced-index disclosure 2026-06-14 19:11:57 +02:00
Marc-André Bombeck d221dc4da0 feat(nightscout): pull CGM glucose from a self-hosted instance
Add a Nightscout integration that pulls sensor glucose readings from a user's
own Nightscout instance and stores them as immutable canonical mg/dL blood
glucose, giving the clinical glucose panel the continuous density it needs.
Egress runs through the safe-fetch wrapper with the public-host pin on by
default and off only behind an explicit private-network opt-in, so a home or
LAN instance works without weakening the floor for everyone else. Readings are
first-write-wins by Nightscout entry id, an hourly job keeps recent history in
sync, and a settings card carries the URL, token and the private-network toggle.
2026-06-14 19:10:47 +02:00
Marc-André Bombeck 1ce1c786b6 Cap chronotype window in the shared assembler
Make computeSleepRhythmFromNights own the chronotype look-back so the dashboard
summary (a year of nights) and GET /api/sleep/rhythm (six weeks) return the
same band, mid-sleep, and social-jetlag values rather than averaging different
samples. Derive the free/work day-type from the local wake-day digits so the
classification holds in far-east timezones, and read the shared sleep-rhythm
DTO type into the client hook instead of a hand-mirrored copy.
2026-06-14 19:10:44 +02:00
Marc-André Bombeck d33fed60d4 refactor(integrations): single Nightscout entry-to-measurement mapper
Route the sync write through mapSgvEntryToMeasurement so the
entry-to-measurement shape has one source of truth instead of an
inline copy plus an unused parallel mapper that could drift.
2026-06-14 19:09:52 +02:00
Marc-André Bombeck f5d0b2f696 feat(integrations): add Nightscout CGM glucose sync
Pull continuous glucose readings from a self-hosted Nightscout instance
and store them as immutable BLOOD_GLUCOSE mg/dL measurements
(source=NIGHTSCOUT), giving the glucose panel real CGM density.

- client over safeFetch: public-host pin by default, relaxed only on the
  per-user private-network opt-in; redirect pinned manual + timeout
- credentials encrypted at rest on User; URL is the connect marker
- sync maps SGV entries first-write-wins on (userId,type,source,externalId)
- connect/disconnect/status routes: apiHandler + requireAuth + rate limit;
  connect validates by a live test fetch with clear errors
- hourly poll-cohort job registered in the all-queues registry
- settings card with URL, token, and the private-network toggle
- i18n across all six locales
2026-06-14 19:06:56 +02:00
Marc-André Bombeck 3be7c3788b Serve glucose clinical panel from one engine to all surfaces 2026-06-14 19:03:34 +02:00
Marc-André Bombeck d1ba01548f Surface sleep-debt and chronotype on the sleep page
Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.

Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
2026-06-14 19:03:11 +02:00
Marc-André Bombeck 8b73acfa9c feat(entry): make mood and cycle add a sectioned single sheet
Redesign both add flows as one sheet that is fast by default and deep on
demand: the mood face and a quick row of recently used tags stay open, the
period buttons and flow stay open for cycle, and everything else moves into
collapsible sections that each show a count of what is set inside them. A
shared SheetSection primitive carries the collapse, the summary slot and the
reduced-motion behaviour. Tag, factor and day-log logic is moved, not
rewritten, and the submit path is unchanged.
2026-06-14 18:59:06 +02:00
Marc-André Bombeck 5b67dc636d feat(cycle): sectioned log-day sheet with summary-badge disclosure 2026-06-14 18:57:38 +02:00
Marc-André Bombeck 3dfc3fe515 Add J-index and LBGI/HBGI to glucose clinical metrics 2026-06-14 18:55:13 +02:00
Marc-André Bombeck 9e1e285fe4 feat(mood): sectioned add sheet with quick-tag row and disclosure 2026-06-14 18:50:11 +02:00
Marc-André Bombeck 4884f019b7 feat(sources): add Nightscout, Polar and Oura as measurement sources
Introduce the schema foundation the glucose and wearable integrations build on:
the NIGHTSCOUT, POLAR and OURA measurement sources, encrypted credential columns
for each (Nightscout carries a base URL, an API token and a private-host opt-in
flag; Polar and Oura carry OAuth tokens), and their integration keys. The
recovery source ladder now ranks WHOOP, then Oura, then Polar over the computed
proxy. Additive migration only — new enum values and nullable columns, no
backfill. Nightscout is glucose-only and stays off the wearable priority ladders.
2026-06-14 18:48:02 +02:00
Marc-André Bombeck 524626d2bd feat(sources): add Nightscout, Polar, Oura schema foundation
Extend MeasurementSource with NIGHTSCOUT, POLAR, OURA and add the
per-user encrypted credential columns each source needs: a Nightscout
base URL + token plus a private-host opt-in flag, and Polar/Oura OAuth
tokens mirroring the WHOOP columns. Migration 0160 is additive only —
new enum values, nullable columns, one boolean default false.

Register the three sources across the read enum, the integration-status
key set, and the source-priority ladders.
2026-06-14 18:47:14 +02:00
Marc-André Bombeck 079be9d5af feat(ui): add collapsible SheetSection with summary badge slot 2026-06-14 18:44:51 +02:00
Marc-André Bombeck 9f8f4167e4 feat(sleep): add sleep-debt and MCTQ chronotype on the unified night record
Compose two pure, server-authoritative modules on the canonical per-night
reconstruction rather than a second pass: a rolling sleep-debt that sums each
night's deficit against the existing sleep need, drains as nights age out, and
never mints negative debt; and an MCTQ chronotype from free-day mid-sleep with
the sleep-debt correction and the social-jetlag gap, classified into a band
through the shared estimator. Both withhold a confident reading behind a
learning gate until enough nights accrue.
2026-06-14 18:35:44 +02:00
Marc-André Bombeck 7c72c3ed6c feat(glucose): add the clinical-metrics core (TIR, GMI, CV)
Pure, server-authoritative glucose analytics over canonical mg/dL readings:
time-in-range on the Battelino consensus bands, the glucose management
indicator and estimated A1C from mean glucose, and the coefficient of
variation with the Monnier instability threshold, pinned to a configurable
~14-day window. A learning gate withholds a clinical assertion until enough
readings over a long enough span accrue, and every result is marked a
spot-reading estimate rather than a CGM profile, so a client renders the same
honest figure the rest of the app shows.
2026-06-14 18:35:44 +02:00
Marc-André Bombeck edaa345f4e feat(insights): add sleep-debt and MCTQ chronotype math 2026-06-14 18:32:40 +02:00
Marc-André Bombeck 15c0a63c22 feat(glucose): add clinical-metrics core for TIR, GMI, eA1C and CV 2026-06-14 18:30:55 +02:00
Marc-André Bombeck dd8ffffd91 Merge pull request #330 from MBombeck/release/v1.16.16
v1.16.16 — one engine for sleep, recovery and glucose
2026-06-14 18:11:46 +02:00
dependabot[bot] ac96cb9743 chore(deps-dev): bump tailwindcss from 4.3.0 to 4.3.1 (#325)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 16:01:13 +00:00
Marc-André Bombeck 04b09e2905 chore(release): v1.16.16 — one engine for sleep, recovery and glucose
Read sleep, recovery and blood glucose from a single source on every surface —
dashboard, coach, doctor-report, CSV and FHIR exports, and the companion app —
so the displayed figure agrees wherever it appears. Sleep nights reconstruct
once with source de-duplication; WHOOP-native recovery is canonical and resolves
to one row per night; glucose converts to the user's unit at the point it is
served. Hardens the consent receipt path and deepens WHOOP sync with per-resource
cursors, targeted refresh and full backfill. Several displayed numbers become
correct-but-different — flagged in the changelog.
2026-06-14 17:58:56 +02:00
Marc-André Bombeck 5b88401935 fix(doctor-report): resolve recovery by night in the user timezone
Pass the report's timezone into the canonical recovery resolution so the PDF
buckets a WHOOP-native row and its computed proxy onto the same physiological
night — the proxy is stamped the day that ended while WHOOP stamps the wake
morning — instead of a raw calendar day. Aligns the printed recovery summary
with the tile, the coach, and the companion feed.
2026-06-14 17:57:21 +02:00
Marc-André Bombeck 5831143e26 fix(insights): collapse recovery by physiological night, not calendar day
WHOOP stamps a recovery on the wake morning while the computed proxy files the
same night under the day that ended, a systematic off-by-one that left both
rows alive as two competing recovery days once the native series was read.
Resolve recovery by a source-aware wake-day key in the user's timezone — the
computed proxy's day is shifted forward to meet WHOOP's wake morning — so one
night collapses to one canonical row without re-dating the stored stamp and
without a backfill. Thread the same resolution through the wellness reader, the
coach nudge trend, and the coach snapshot, which had each read recovery raw.
2026-06-14 17:51:24 +02:00
Marc-André Bombeck 4eee7c1de5 fix(insights): collapse WHOOP and computed recovery to one canonical night
The native WHOOP recovery and the computed proxy stamp the same night on
opposite clocks: WHOOP files the wake-morning instant (day D), the proxy files
noon-UTC of the day that ended (day D-1). Bucketing raw measuredAt by calendar
day leaves both alive as two competing recovery days.

Bucket each source by the user's local wake-day instead — shifting the proxy's
day-that-ended stamp forward one day to meet WHOOP's wake-morning stamp — so a
single night collapses to one canonical row while distinct nights stay
separate. Resolve the coach-nudge recovery trend and the coach-snapshot dedup
through the same path so no surface blends the two series or drops a genuine
native value on a numeric tie.
2026-06-14 17:49:45 +02:00
Marc-André Bombeck 901faaa096 fix(glucose): carry the unit into the coach and align stat rounding
Convert the glucose figures the coach snapshot builds to the user's unit and
carry the unit into the prompt, so the coach speaks mmol/L to a mmol/L user
instead of a bare mg/dL number — the one glucose surface the unit work had not
reached. Aggregate the series stats over raw values and convert each figure
once, matching the detail page and the FHIR export so a multi-reading mean no
longer differs in the last decimal between surfaces. Drop the fixed day count
from the median label, which the windowed median value cannot honour.
2026-06-14 17:49:10 +02:00
Marc-André Bombeck 9065092ba0 fix(insights): drop unhonored day-count from glucose median label 2026-06-14 17:48:18 +02:00
Marc-André Bombeck 69e72c7fcc feat(coach): carry glucose in the user's display unit 2026-06-14 17:45:55 +02:00
Marc-André Bombeck 55f4406247 fix(glucose): compute series stats over raw mg/dL then convert once 2026-06-14 17:42:33 +02:00
Marc-André Bombeck 7f7418a759 fix(api): document iOS-consumed routes, harden consent dedup and mint
Add OpenAPI registry entries for the two endpoints the iOS client now depends
on — the measurement series read it charts from and the provider endpoint it
gates coach visibility on — so the contract source of truth covers their
response shapes and CI catches a future drift. Give the consent active-receipt
dedup a deterministic id tie-break so the unique-index build cannot abort on an
identical-timestamp pair, and map a concurrent first-grant unique violation in
createReceipt to the winning receipt instead of a 500.
2026-06-14 17:42:27 +02:00
Marc-André Bombeck 32d275b4aa Harden consent receipts and document iOS series + ai-provider contract
Document GET /api/measurements/series and GET /api/user/ai-provider in the
OpenAPI registry — both are consumed by the iOS client (charts and Coach
visibility gating) but were absent from the spec.

Break migration 0159's active-receipt dedup on (created_at, id) so an exact
created_at tie still leaves one survivor, keeping the unique index build from
aborting 23505 on affected tenants.

Catch P2002 in createReceipt: two concurrent first grants both reach create,
the loser re-reads the winning active receipt and returns it instead of a 500.
2026-06-14 17:42:00 +02:00
Marc-André Bombeck a201e49edd fix(insights): keep a synthesised in-bed denominator for the sleep score
Restore the efficiency denominator the pre-unification score derived when a
night carries asleep and awake time but no explicit in-bed record — a common
Apple Health shape. The canonical reconstruction returns a null in-bed figure
for those nights, which dropped the efficiency sub-score and silently
reweighted the composite. Fall back to asleep plus awake minutes when no real
in-bed record exists, keep the recorded figure when present, and leave the
denominator null only when neither signal exists, so the multi-source dedup
is retained without shifting historical scores for that class of night.
2026-06-14 17:40:59 +02:00
Marc-André Bombeck c80450dfac fix(sleep): synthesise efficiency denominator when no IN_BED row
Nights with AWAKE rows but no IN_BED row (a common Apple Health shape)
lost their efficiency sub-score after the canonical reconstructSleepNights
migration, silently reweighting the composite and shifting historical Sleep
Scores. Restore the synthesised asleep + awake fallback on top of the deduped
per-night totals: keep the real IN_BED figure when present, synthesise when
only asleep + awake exist, stay null when neither.
2026-06-14 17:40:29 +02:00
Marc-André Bombeck 555240cd28 fix(consent): close the receipt mint race, rate-limit, and share the back link
Mint the web AI-consent receipt inside a serializable transaction with an
in-transaction recheck, backed by a partial unique index over one active
receipt per user and kind, so two concurrent mounts can no longer leave two
active grants; a duplicate maps to a no-op mint. Supersede an active row
against the server clock rather than the client-supplied timestamp so a
backdated grant cannot invert the audit chain, and revoke through a single
atomic update. Rate-limit the consent endpoints per user. Extract the
hand-rolled sub-page back link into one component and adopt it across the
four pages that duplicated it.
2026-06-14 17:26:36 +02:00
Marc-André Bombeck db770738bf Harden consent supersede against backdated grants and dup rows
Two follow-ups to the active-receipt uniqueness work:

- Migration 0159 now revokes all-but-the-newest active row per
  (user_id, kind) before building the partial unique index. Without the
  pre-flight dedup the CREATE UNIQUE INDEX would abort on any tenant that
  already carries duplicate active grants — exactly the tenants the fix
  targets — and fail the deploy.
- createReceipt stamps the superseded row's revoked_at with a server
  clock instead of the client-supplied signedAt. A backdated signedAt
  would otherwise mark the prior row revoked before its own signed_at,
  inverting the audit chain. The new row keeps the client signedAt.
2026-06-14 17:25:39 +02:00
Marc-André Bombeck cbde0d573f feat(whoop): per-resource cursors, by-id refresh, and full-sync backfill
Give each WHOOP collection its own incremental cursor in a resource_cursors
map so a slow or rejecting collection no longer drags the sync window forward
for its siblings; legacy connections fall back to the shared last-synced
timestamp. The update webhook now carries the record id and triggers a
targeted single-record refresh instead of re-walking the collection, and a
full sync anchors at a fixed deep start so a backfill covers the span an
incremental tick skips. Cursor advances are a single atomic update guarded by
GREATEST so the independent resource queues cannot race each other backward.

Ingest row shape, source tagging, and upsert keys are unchanged.
2026-06-14 17:25:36 +02:00
Marc-André Bombeck e27bd8b9bf fix(insights): unify sleep reconstruction and make WHOOP recovery canonical
Route the derived Sleep Score through the canonical night reconstruction
instead of its own UTC-day grouping, so a night logged by more than one
source is deduplicated to a single writer rather than summed. The score and
the readiness that folds it now read true per-night totals; multi-source
nights drop to their real figure. Emitted minutes and the night stage map
round to integers.

Resolve recovery to one canonical series — WHOOP-native when present, the
computed proxy as fallback — in the wellness reader, the doctor report, and
the coach snapshot, and stop presenting the proxy and the native score as two
numbers. A WHOOP-connected account's recovery now reads the native value on
the tile, the chart, and the printed report alike.
2026-06-14 17:24:59 +02:00
Marc-André Bombeck 7206615213 Make WHOOP resource-cursor advance a single atomic upsert
The four WHOOP resources are independent pg-boss queues that run concurrently
for the same user, so the read-modify-write that merged one cursor key into the
map and wrote it back could let a sibling's write clobber a just-advanced
cursor. Replace it with a single jsonb_set UPDATE that merges in place under the
row lock, and GREATEST so the shared last-synced instant only moves forward.
The resource key is parameter-bound and asserted against the closed resource set
before it reaches the JSON path.
2026-06-14 17:23:55 +02:00
Marc-André Bombeck eba01b37ce Make WHOOP-native recovery the canonical read
RECOVERY_SCORE is written by two sources for the same day: the WHOOP-native
percentage from the device sync and the COMPUTED proxy from the nightly
readiness job. The wellness reader hard-filtered to source COMPUTED, silently
dropping every ingested native row, and the doctor PDF blended both sources
into one min/avg/max. The COMPUTED proxy also equals the readiness blend
verbatim, so the Coach prompt carried the same number under both READINESS and
RECOVERY_SCORE.

Add resolveCanonicalRecovery: per day, keep the WHOOP row when present, else
the COMPUTED proxy. Drop the COMPUTED-only filter for RECOVERY in the wellness
reader and resolve to the canonical row; STRESS and STRAIN stay COMPUTED-only.
Resolve the doctor PDF wellness summary the same way so the report reads the
native value, not a proxy/native blend. Dedup the Coach snapshot: omit the
recovery line when it mirrors readiness (the no-native case), keep it when a
WHOOP-native value differs. Document the WHOOP-over-COMPUTED resolution on the
sync delta feed, which already mirrors each row with its source so the client
resolves one canonical series rather than charting two.

A WHOOP-connected user's recovery tile, chart, and PDF flip from the proxy to
the native value.
2026-06-14 17:23:52 +02:00
Marc-André Bombeck 39fa1ae0e1 feat(glucose): convert units at serialization for a single unit contract
Glucose stays stored canonical mg/dL; conversion now happens at the
serialization boundary through the one convertGlucose/resolveGlucoseUnit
helper rather than ad hoc at render time. The series DTO reports the user's
unit and converts its points and stats; the CSV export converts and emits a
unit column to match the FHIR export; the blood-glucose page and stat strip
honour the preference; the median label declares its trailing-90-day window.
A mmol/L user now reads the same converted value on every surface and in
both exports instead of a mg/dL CSV against a converted FHIR document.
2026-06-14 17:23:37 +02:00
Marc-André Bombeck e2f37bbe40 Extract shared BackLink and adopt on detail pages
The workout, values, score, and medication detail pages each hand-rolled
the same ghost Button + Link + ArrowLeft back-nav block. Fold it into a
single BackLink (href + label + optional data-slot) so the affordance
reads identically — same hit area, icon, and left-bleed alignment — on
every page and lives in one place. Each page passes its existing label
translation, so no new i18n keys are introduced and the markup stays
byte-identical.
2026-06-14 17:21:45 +02:00
Marc-André Bombeck cc34cbd03d Rate-limit the AI-consent endpoints per user
POST /api/consent/ai, POST /api/consent/ai/web, and GET/DELETE
/api/consent/ai/latest had no rate limit, so a scripted loop or a
stolen session could pin the receipts table. Add a shared per-user
bucket (20/min, keyed by user id) mirroring the coach-chat ceiling,
checked right after requireAuth, returning 429 with the X-RateLimit
headers attached. The web heal endpoint runs on every AI-settings
mount, so the bucket is generous enough to never throttle normal use.
2026-06-14 17:21:38 +02:00
Marc-André Bombeck 6a173a89dd Enforce one active consent receipt per kind atomically
Two concurrent web-grant heals could each pass the read-then-create gap
and insert a duplicate active ai_full receipt; the revoke path then had
to chase one row at a time. Wrap the web-grant mint in a Serializable
transaction with an in-transaction re-check, and add migration 0159 — a
partial unique index over (user_id, kind) WHERE revoked_at IS NULL — as
the structural backstop. createReceipt now supersedes any active row of
the same kind before appending, and revokeLatest flips revoked_at via a
single atomic updateMany, so the append-only audit chain is preserved
while at most one active row ever exists.
2026-06-14 17:21:30 +02:00
Marc-André Bombeck 340e117e0c docs(glucose): correct conversion comment and scope claim
Fix the inline mmol/L example (100 mg/dL converts to 5.5, not 5.6) and narrow
the series unit docstring to the surfaces actually converted at this layer.
2026-06-14 17:21:29 +02:00
Marc-André Bombeck 2cb84c17e9 Add per-resource cursors, fetch-by-id, and full backfill to WHOOP sync
Replace the single shared last-synced cursor with a per-resource cursor map
on the WHOOP connection. Recovery, sleep, workout, and cycle each advance only
their own cursor on a successful fetch+upsert, so a slow or 403-ing collection
never drags the incremental window forward for its siblings and its own
late-arriving records stay inside the overlap. Legacy connections fall back to
the shared last-synced instant, so no historical state is lost.

Add single-record fetch-by-id client routes and per-resource by-id syncs. The
update webhook now carries the resource id through the queue payload, so the
worker resolves and upserts that one record immediately rather than re-walking
the whole collection on the next overlap tick.

Anchor a full sync at a fixed deep-history point instead of returning no lower
bound, so a backfill covers the multi-year span an incremental tick would skip
and a stale cursor cannot silently narrow it.
2026-06-14 17:19:09 +02:00
Marc-André Bombeck 25944ed57a feat(glucose): read glucose in one unit across series, CSV, and detail page
Glucose is stored canonical mg/dL but the series DTO, CSV export, and detail
page emitted raw mg/dL while FHIR already converted to the user preference.

Convert at serialization through the single convertGlucose helper:
- series route sets unit from the user's glucoseUnit and converts each point
- CSV export converts BLOOD_GLUCOSE rows and emits the unit in the unit column
- the detail page + stat strip honour the preference via the chart value scale
- declare the trailing-90-day window in the glucose median label across locales

mmol/L-preference users now see the blood-glucose page, stat strip, iOS series,
and CSV in mmol/L; mg/dL users are unchanged.
2026-06-14 17:16:48 +02:00
Marc-André Bombeck b4b9d8d9d7 Unify sleep-night reconstruction onto the canonical engine
Replace the divergent UTC-day night reconstructor in the Sleep Score with a
thin adapter over reconstructSleepNights, the shared engine the dashboard,
hypnogram, and doctor surfaces already read. The old path bucketed rows by UTC
calendar day and never deduplicated writers, so a multi-source night (WHOOP +
Apple Health for the same sleep) summed both writers and roughly doubled the
total. The adapter inherits session-clustering, local-wake-day keying, and
multi-source writer dedup, then derives the score-only fields the canonical
night does not carry: REM and Deep minutes from the stage map, the stage-
breakdown flag from any granular stage, and the midpoint from the asleep span.

Select source and deviceType in the Sleep Score read so the writer-dedup
ladder fires, and thread the user source priority through so the chosen writer
matches every other sleep surface. Round emitted asleep and in-bed minutes to
whole numbers, and round the per-stage map in the sleep-night DTO, matching the
integer contract the native client decodes.

A multi-source user's sleep score and the readiness score that folds it drop
once the double-count is removed, reflecting the true single-writer night.
2026-06-14 17:14:14 +02:00
Marc-André Bombeck 04b61569f1 feat(ai): report effective coach availability from the provider endpoint
GET /api/user/ai-provider now surfaces whether any provider can serve the
user — a personal key, a self-hosted local model, or the operator's
admin-managed key — via aiAvailable plus a managedBy origin. Previously a
server- or admin-managed provider read as a null provider, so a client with
no personal key resolved the coach as unconfigured and hid it even though
the coach worked server-side. Availability mirrors the real resolution order
and exposes no admin secret (the managed key ciphertext is reduced to a
presence boolean).
2026-06-14 17:13:47 +02:00
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 73583beaf2 test(rollups): pin rollup mean parity with live AVG on uneven days
Merge the rollup/live averaging parity guard. The audit confirmed the
rollup tier already computes mean-of-rows (per-bucket AVG over raw rows,
coarser tiers re-aggregate raw rather than folding DAY) and the live
fallback mirrors it via count-weighted recomposition. This test pins the
invariant so a future change cannot silently reintroduce a
mean-of-daily-means divergence on a coverage flip.
2026-06-14 17:08:57 +02:00
Marc-André Bombeck 11f2728e7e test(rollups): pin rollup mean parity with live AVG on uneven days 2026-06-14 17:08:31 +02:00
Marc-André Bombeck a2260d5bb8 Merge pull request #322 from MBombeck/release/v1.16.15
release/v1.16.15 — an honest calendar that learns before it predicts
2026-06-14 16:01:05 +02:00
Marc-André Bombeck 8511b0bed7 chore(release): v1.16.15 — an honest calendar that learns before it predicts 2026-06-14 15:53:01 +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 fd00eb48d0 test(cycle): cover cervix signs + secondary-symptom across the wire
Integration coverage for the new fields: a cervix observation round-trips
POST → GET on the day-log route; the cycle-prefs GET defaults
secondarySymptom to MUCUS and a PATCH to CERVIX persists; and the
backup/restore path preserves both the cervix day-log signs and the
profile's secondary-symptom choice.
2026-06-14 15:36:14 +02:00
Marc-André Bombeck e832222402 feat(cycle): surface the cervix secondary-symptom choice in the UI
- Advanced cycle settings gains a "secondary fertility sign" selector
  (cervical mucus default | cervix observation). It sits below the priors
  in the advanced settings so it never forces itself on a casual user.
- The log-day sheet shows the cervix entry — position / firmness / opening
  chips — only when the user picked cervix observation; otherwise the
  cervical-mucus picker stays as before. Progressive disclosure: exactly
  one secondary sign is offered at a time so the sheet never overwhelms.
- Web client types, the cyclePrefs patch, and the day-log payload builders
  carry the new fields; copy added across all six locales.
2026-06-14 15:33:11 +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 63f398042d Merge pull request #321 from MBombeck/release/v1.16.14
release/v1.16.14 — one number everywhere across score, coach, and doctor-report
2026-06-14 14:34:18 +02:00
Marc-André Bombeck f6b46dafd4 test(doctor-report): give the section-toggle fixture a medication schedule
The dose-ledger adherence builder only tallies scheduled medications — a
medication with no schedule is PRN/as-needed and carries no expected dose,
so it is excluded to keep a fabricated 100 % off the clinical report. The
shared fixture seeded a schedule-less medication, which now yields an
empty compliance map; seed a window so the default-sections test keeps a
compliance entry.
2026-06-14 14:27:04 +02:00
Marc-André Bombeck 5e36664f7d test(blood-pressure): pin the in-target headline and BP pillar to the 90-day window
The BD-Zielbereich headline, the Health-Score BP pillar and the coach
number now all read the same trailing-90-day in-target window. Re-derive
the integration expectations from the fixtures: the analytics headline is
the independent 90-day figure (distinct from the 30-day and all-time
windows), and the BP pillar grades from the 90-day window when readings
predate the trailing month rather than dropping out.
2026-06-14 14:26:58 +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 ae1373637f chore(release): v1.16.14 — one number everywhere across score, coach, and doctor-report
Coherence release: blood-pressure in-target unified on a labeled, thin-data-gated
90-day window across tile, health-score pillar and coach; the dashboard ring and
insights card built from one shared health-score input builder; the coach and the
doctor-report PDF/FHIR adherence drawn from the same dose ledger as the detail page;
and a shared entry-timestamp plausibility guard on measurement and mood. Several
displayed figures become correct-but-different (BP headline window, adherence).
2026-06-14 14:02:45 +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 e783d9a46a Merge pull request #320 from MBombeck/release/v1.16.13
release/v1.16.13 — installable offline, era-correct dose edits, steadier headers
2026-06-14 11:47:31 +02:00
Marc-André Bombeck 4c1c3f70e2 test(mood): align integration fixtures with the curated default catalog
The v1.16.13 catalog curation deactivated the default tags happy,
excited, hydrated, music and the factor_sleep_quality RATED factor.
Integration tests that run the real migrations against testcontainers
Postgres still hard-coded the pre-curation catalog and failed.

Swap the placeable built-in tag from happy to grateful (a long-standing
active feelings tag) and the seeded RATED factor from factor_sleep_quality
to factor_sadness (still active, same 1..5 inverse scale). Assertions and
intent are unchanged — only the fixtures move onto the live catalog.
2026-06-14 11:31:52 +02:00
Marc-André Bombeck 5aefcd59eb chore(release): v1.16.13 — installable offline, era-correct dose edits, steadier headers
App-wide service-worker registration (installable + offline, authenticated
pages kept out of the cache, logout wipe), era-correct historical dose-edit
attribution, CSV intake stock consumption, WHOOP late-workout ingest, web AI
consent-receipt minting, the layout partial-PUT section merge, the curated
mood-tag catalog, and a sweep of mobile header fixes.
2026-06-14 11:06:10 +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 458cb91b92 test(pwa): tolerate the sw-version rewrite marker in the fallback assertion
The app-wide service-worker work routes the fallback literal through a
`@sw-version-fallback` block-comment anchor that the prebuild generator
keys on, placed between the `||` and the literal. The pre-existing
fallback-shape assertion predates the marker and matched only the inline
form, so it failed on the marker comment. Widen the pattern to allow the
optional anchor while still pinning that a reachable "v" literal exists.
2026-06-14 10:57:03 +02:00
Marc-André BombeckandClaude Opus 4.8 f846b0681d fix(demo): hide the Coach FAB in demo mode
The Coach floating button was mounted unconditionally, so a demo
visitor could tap it and send a message. /api/insights/chat is not in
the proxy's demo-mutation allowlist, so the send returned a raw 403.
Demo is read-only by design, so the FAB has no job there — gate its
mount on !demoMode and pin the contract in the hoist test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:44 +02:00
Marc-André BombeckandClaude Opus 4.8 b7b160d4fa fix(headers): keep list-page header actions on one row on mobile
The medications header used flex-wrap, so its action group (take-all
+ customize wrench + Add) dropped to a second row below the title on
narrow viewports. Drop the wrap and let the title block shrink: title
div gets min-w-0, subtitle truncates, action group keeps shrink-0 —
the wrench and Add stay top-right inline with the heading.

The mood, measurements and dashboard headers shared the same latent
root cause (title div missing min-w-0, subtitle/greeting not
truncating, action group not shrink-0). Apply the same minimal guard
so none of them can wrap or compress the actions either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:39 +02:00
Marc-André BombeckandClaude Opus 4.8 4111124f24 fix(nav): keep long More-menu tile labels inside their tile
The More hub grid tile label was a flex child without min-w-0, so
long compound labels (e.g. "Benachrichtigungen") painted past the
tile's rounded border instead of wrapping. Give the span min-w-0
and tightened leading so it wraps cleanly within the tile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:33 +02:00
Marc-André BombeckandClaude Opus 4.8 3ad1a8ab25 fix(insights): right-align the hero Coach action on mobile
The overview hero's inline "Ask the Coach" button sat in a
flex-wrap row with no justify-end, so it rendered left under the
hero copy. Pin it to the right edge of the hero's left column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:28 +02:00
Marc-André Bombeck 3ad0649ff6 feat(pwa): add manifest shortcuts and a 192 maskable icon
Add three home-screen shortcuts (Add measurement, Today, Medications)
deep-linking into the app, each tagged `source=shortcut`. Add a 192×192
maskable icon alongside the existing 512 so adaptive launchers mask the
right size instead of downscaling the 512 or clipping the un-masked 192
"any" icon on circular masks.

No screenshots: the repo ships no real screenshot assets and fabricating
them is worse than the richer-install-dialog gain.
2026-06-14 10:49:00 +02:00
Marc-André Bombeck 60f587c5a7 fix(pwa): re-anchor the sw.js fallback version on every build
`public/sw.js` carries a `|| "..."` fallback literal used for `CACHE_VERSION`
when `importScripts('/sw-version.js')` is unavailable. The literal had drifted
to `v1.4.43`, four-plus minors behind `package.json`; in any environment where
the generated file is missing this keyed every cache to the stale version and
turned the activate-step eviction into a no-op — the exact bug the generator
was written to kill.

Extend the prebuild generator to rewrite the fallback literal in `sw.js` to the
current `package.json` version on every build, anchored on a
`/* @sw-version-fallback */` marker (the build fails loudly if the marker is
gone). The literal and the generated `sw-version.js` can no longer diverge.
The SW test now reads the fallback straight from the source so it tracks the
literal instead of hard-coding it.
2026-06-14 10:47:50 +02:00
Marc-André Bombeck 083410473c refactor(pwa): defer push subscription to the app-wide registration
The Web-Push opt-in registered its own `/sw.js` before subscribing. With
the app-wide registrar now installing the worker for every user, that
second `register` call is a redundant source of truth at the same scope.
Replace it with `navigator.serviceWorker.ready`, which resolves with the
active registration once the global worker is controlling the page.
2026-06-14 10:46:23 +02:00
Marc-André Bombeck 0c4b6ab8ba fix(pwa): wipe the page cache on logout
Now that the service worker is registered app-wide, cached navigation
HTML survives a logout. The everyday routes carry no server-rendered PII,
but on a shared device a clean session boundary should leave nothing in
CacheStorage. On a successful logout, best-effort delete the
`healthlog-pages-*` caches. The static cache (hashed chunks, icons) is
left intact so the next login does not re-download the shell. The wipe
never blocks the redirect to the login page.
2026-06-14 10:46:01 +02:00
Marc-André Bombeck 2a180fec95 fix(pwa): keep no-store and clinician-share pages out of the page cache
With the service worker now registered for every user, the network-first
page cache sees every authenticated navigation. The everyday app routes
are client-fetch-only shells with no server-rendered PII, but the
`/c/[token]` clinician-share view renders health values straight into the
HTML and emits `no-store`. The page cache ignored the header and stored
it, so a revoked or expired share could still render back offline.

Gate `cache.put` in `networkFirst` on a new `isCacheableNavigation`
check: skip any response carrying `Cache-Control: no-store` (the
principled rule for any present or future server RSC that renders user
data) and skip the `/c/*` path outright as belt-and-braces. Ordinary
navigations still cache unchanged.
2026-06-14 10:45:39 +02:00
Marc-André Bombeck b4c6c2cc88 feat(pwa): register the service worker app-wide
Until now `/sw.js` registered only behind the Web-Push opt-in, so any
user who never enabled notifications ran with no service worker — the
offline shell cache, the precached app shell, and the network-first page
cache were all inert. Add a production-gated `ServiceWorkerRegistrar`
that installs the worker for everyone, deferred to the `load` event
wrapped in `requestIdleCallback` so install/precache stays off the
first-paint critical path. Mount it beside `VersionPoller` in the root
providers.

The registrar only installs. `VersionPoller` remains the sole owner of
the update→reload path (poll `/api/version`, then unregister + cache-wipe
+ reload on a server-ahead deploy); no `updatefound`/`controllerchange`
handling lives here so the two cannot double-fire. `updateViaCache:
"none"` forces a network byte-check of `sw.js` on every update check so a
cached worker can never mask a new deploy.
2026-06-14 10:44:45 +02:00
Marc-André Bombeck b59f8118a9 fix(sleep): round night minute totals to whole numbers
`GET /api/sleep/night` summed second-precision stage rows into asleep /
awake totals and serialised them unrounded (e.g. 433.4999), which also
violated the OpenAPI integer contract. Round the present minute values;
`inBedMinutes` / `awakeMinutes` stay null when the night never saw that
stage.
2026-06-14 10:40:08 +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 c21338df45 fix(medications): decrement stock on CSV intake import
The CSV intake import recorded taken doses but never consumed inventory,
the only intake seam that skipped stock consumption, so runway and
days-left overstated for any imported history. Import now calls
`consumeForIntake` per freshly created event, matching the web-take,
status-toggle and bulk paths: the consumption stamp keeps it exactly-once,
the existing duplicate-key skip means a re-import never re-creates the row,
and the hook no-ops for as-needed / no-inventory medications.
2026-06-14 10:35:03 +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 bb59018ac9 fix(mood): deactivate the sleep-quality factor in the curated catalog
The default-tag curation deactivated the redundant emotion and day-type
tags but left factor_sleep_quality active. A manual 1-5 sleep rating
duplicates the measured sleep-tracking surface, so the factor leaves the
seeded set alongside the rest, with work and sadness remaining the RATED
factors. Reference-data only; historical entries keep resolving since the
read joins the catalog by id, not is_active.
2026-06-14 10:29:22 +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 7b0688b37e feat(mood): curate default tag catalog
Deactivate the redundant default tags happy, excited, hydrated
("Wasser getrunken") and music; re-activate overtime in the work
category (0126 had retired it); add a new default work tag praise
("Lob" / "Praise", Lucide ThumbsUp) with i18n across all six locales.

factor_sleep_quality stays a RATED factor: its removal was premised on
a dedicated sleep-quality slider that does not exist in the capture
surface, so dropping it would lose the signal with no replacement.

Reference-data only — is_active flips plus an idempotent keyed upsert,
no schema change, no per-user data touched; historical tag links keep
resolving their labels.
2026-06-14 10:14:02 +02:00
Marc-André Bombeck 588a3a4a8d Merge pull request #319 from MBombeck/release/v1.16.12
release: v1.16.12 — split doses, steady cards, and a read that stays warm
2026-06-13 16:39:00 +02:00
Marc-André Bombeck 4c5dbf00ec test(medications): anchor inventory first-use date relative to now
The markAsFirstUseAt case hardcoded 2026-05-14, whose 30-day in-use window
lapses at noon UTC on 2026-06-13 — past that the expected state flips to
EXPIRED and the assertion fails. Anchor first-use to one day ago so the
window is always open relative to the run.
2026-06-13 16:08:53 +02:00
Marc-André Bombeck 9cf609bae2 chore(release): v1.16.12 — split doses, steady cards, and a read that stays warm 2026-06-13 16:08:09 +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 5ce3e81972 Merge pull request #318 from MBombeck/release/v1.16.11
Release v1.16.11 — the supply speaks up, tags find their groups, and nothing asks twice
2026-06-13 01:07:59 +02:00
Marc-André Bombeck 15c77597fc fix(e2e,insights): the coach specs follow the drawer, and the status cards stop tearing hydration
The coach launcher deliberately opens the side drawer in place now —
the spec expected the old navigation and waited for a URL that never
comes; it asserts the drawer (bottom-sheet on the phone viewport) and
the nudge-dot lifecycle instead. The metric and slug status cards
rendered locale-dependent content in the hydration pass, which React
flagged as a mismatch (#418) on the dashboard and the mood page — they
ride the established mounted gate now, pinning the server branch for
the first paint. The midnight flake in the intake bucket tests is
pinned to a fixed clock: the fixtures minted UTC days while the route
buckets Berlin days, so runs between midnight and two failed.
2026-06-13 01:02:14 +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 3189848ace chore(release): v1.16.11 — the supply speaks up, tags find their groups, and nothing asks twice 2026-06-12 23:49:06 +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 f3dda21917 docs: the README states the demo credentials beside the live-demo link 2026-06-12 22:51:28 +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 e9f776937d chore(deps): bump react-hook-form from 7.77.0 to 7.78.0 2026-06-12 22:17:11 +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 c150983dc0 fix(medications): the card's dose state lives on the next-intake row
The standalone status line above the schedule lines is gone — it
reserved a blank row on every settled card while the overdue tier rode
the async compliance read, which read as a hole between the header and
the schedule. Take-now, late and the overdue escalation now render as
the right-aligned value of the next-intake row, colour and glyph
carrying the urgency; the taken-early guard simply suppresses the
prompt, with the last-intake line below already naming the recent
dose.
2026-06-12 22:09:54 +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 778c3a53a4 feat(medications): take every due dose in one confirmed action
When two or more medications are due, the page header offers a single
confirm dialog listing them with dose and window. The takes loop the
per-medication intake route rather than the sync bulk surface — slot
attribution, inventory consumption and idempotency stay byte-identical
to individual taps, and a failed medication stays due while the rest
land. (#316)
2026-06-12 22:02:47 +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 45e391c60a feat(medications): the table's stock column projects its runway
Under the dose count the column now shows roughly how many days the
remaining supply covers — the same coarse schedule-derived estimate the
detail overview renders, so the refill question is answered at a
glance. (#316)
2026-06-12 21:44:25 +02:00
Marc-André Bombeck 57eafabb9d feat(medications): packaging economics editable from the supply tab
Units-per-dose and the default pack size were settable only through the
wizard's dose step; a manufacturer switch with a different blister size
happens while looking at the supply, so the supply tab now carries a
packaging row with an edit dialog that writes sparsely onto the
medication.
2026-06-12 21:08:37 +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 159de9b78f docs: refresh the README dashboard screenshot
The current dashboard (demo account) replaces the pre-v1.16 capture;
the JPEG also weighs a tenth of the old PNG.
2026-06-12 20:59:16 +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 d1f6488b38 fix(medications): the supply tab gets a delete affordance and honest control styling
Containers can be removed from the supply tab — the endpoint existed,
the UI never offered it; a stamped container deleted later was already
refund-tolerant. The adjust trigger wears the outline button vocabulary
instead of reading as dead text, and the state badge moves inline next
to the dose figures as plain meta text instead of posing as a button in
the action cluster.
2026-06-12 20:36:11 +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 4493eb2cac feat(medications,coach): the take-now prompt joins the schedule grammar and the coach opens in place
An open take-window renders as the right-aligned value of the next-
intake row — same label/value columns as every other card line —
instead of a standalone line above the slot; the escalation tiers
(late, very late, taken-early) keep the top line. The coach launcher
opens the side drawer on the page you are on instead of navigating to
the chat page, and grows to the standard FAB size.
2026-06-12 20:27:59 +02:00
Marc-André Bombeck 8b0fbfb522 fix(medications): clipped table corners, one customize glyph, the view choice moves home
The table wrapper clips its children now — the sticky name column's
opaque cell background squared off the left rounded corners. The
dashboard, insights and medications headers share a wrench as their
customize glyph, leaving the slider icon to the target and overlay
controls that already wear it. The header view toggle retires; the
cards-or-table choice lives in the medications settings section.
2026-06-12 20:23:43 +02:00
Marc-André Bombeck 853ff61520 Merge pull request #317 from MBombeck/release/v1.16.10
Release v1.16.10 — the stock follows every dose, and the list takes your order
2026-06-12 20:04:27 +02:00
Marc-André Bombeck 9df6799a04 chore(release): stamp 1.16.10 into the OpenAPI info block
The generator reads the package version; the bump landed after the
last regeneration, so CI's drift gate caught the stale stamp.
2026-06-12 19:56:21 +02:00
Marc-André Bombeck f4f3b75166 chore(release): v1.16.10 — the stock follows every dose, and the list takes your order 2026-06-12 19:47:49 +02:00
Marc-André Bombeck 02642a964e feat(medications): list customization moves into its own settings section
/settings/medications now hosts the view preference (cards or table)
and the manual-order editor inline, following the dashboard and
insights pattern. The page header's gear icon links there instead of
opening a dialog; the quick view toggle stays next to the add button.
The order editor keeps its grouped active/inactive semantics — a saved
order can never interleave the groups.
2026-06-12 19:35:23 +02:00
Marc-André Bombeck 5142823b7e fix(ui): the filter pills open their menu on screen again
The pill trigger paints its own label spans and mounts no SelectValue,
and Radix's item-aligned placement silently skips all positioning when
the value node is absent — the open menu laid out below the
height-locked app shell, fully off-viewport, which reads as a dead
control. The mood and type/source pills on the mood and measurements
lists were affected since the unified filter rail landed in v1.16.1.
Popper placement has no value-node dependency and anchors the menu
under the pill like the date-range popover next to it.
2026-06-12 19:35:11 +02:00
Marc-André Bombeck 07fe14c6ae feat(medications): the order editor opens behind the shared customize glyph
The header slot left of the add button now carries the same Settings2
icon the dashboard and insights headers use for their customize entry
points, so all three pages signal layout customization identically.
2026-06-12 19:13:00 +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 b765febc97 style(insights): the wellness rings land on the Dracula palette
Every dark-theme arc's light stop now IS the matching palette colour —
readiness green, recovery cyan, sleep purple, stress orange, strain
pink — instead of the indigo/teal/violet neighbours that sat next to
the palette. The Alucard light set keeps its deeper hand-tuned tones
but follows the same five hue families.
2026-06-12 19:10:52 +02:00
Marc-André Bombeck baec710138 fix(coach): the launcher stops blinking out on chart hovers and sits evenly in the corner
The chart-tooltip auto-hide made the button vanish on every hover
tooltip anywhere on the page, which read as a glitch; a tooltip under
the cursor never reaches the lower-right corner anyway. The observer
chain behind it goes with it. The right inset now mirrors the desktop
bottom inset.
2026-06-12 19:07: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 dacdcccfd2 Merge pull request #315 from MBombeck/release/v1.16.9
Release v1.16.9 — the day opens with a verdict, and every dose path tells the truth
2026-06-12 12:10:38 +02:00
Marc-André Bombeck 7e498760f5 test(medications): anchor bulk-intake fixtures to relative past instants
The intake plausibility bound rejects a future takenAt by design, so
the fixed calendar dates and the today-evening slot in the bulk suite
rotted into rejections depending on the run date and hour.
2026-06-12 12:05:24 +02:00
Marc-André Bombeck 12fce6fc14 chore(release): v1.16.9 — the day opens with a verdict, and every dose path tells the truth 2026-06-12 11:23:00 +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
Marc-André Bombeck ad33fa46ed feat(dashboard): snapshot carries the day's dose state, score and verdict inputs
The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
2026-06-12 09:02:58 +02:00
Marc-André Bombeck 2718930e8d fix(whoop): trim pasted credentials and honour the derived redirect fallback
A trailing space from the portal's copy button reached WHOOP verbatim
and answered as an unknown client; the credential schema trims both
fields. The redirect override fell through nullish coalescing, so the
empty string the compose whitelist materialises for an unset var was
sent as the literal redirect target — empty now derives the callback
from the app URL.
2026-06-12 08:34:10 +02:00
Marc-André Bombeck fd3226bc39 Merge pull request #314 from MBombeck/release/v1.16.8
Release v1.16.8 — texts that follow your data, charts that never strand
2026-06-11 23:34:36 +02:00
Marc-André Bombeck 6bfba2f187 chore: drop the orphaned locale-prefix helper
The cross-locale status-cache eviction is gone, taking the prefix
builder's last caller with it.
2026-06-11 23:29:09 +02:00
Marc-André Bombeck 8fdf8597a2 chore(release): v1.16.8 — texts that follow your data, charts that never strand 2026-06-11 23:21:40 +02:00
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00
Marc-André Bombeck 95f21bfa4d fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and
accessibility tree, its focus ring carries an offset, its icon is dark
on the gradient, an unread nudge is announced once via a live region,
and it sits correctly above the fold on tablet widths and yields to
the onboarding tour. Chart error cards announce themselves, share the
exact height variables of the skeleton band, carry per-chart retry
labels, and a failed chunk renders inside the regular card shell. The
header icon cluster spaces its extended hit areas apart, the tab strip
keeps horizontal paint room for focus rings, the streak row reserves
its slot in every card state, the selection bar clears the bottom nav
on landscape phones, and the version poller keeps an in-memory reload
guard for browsers that block session storage. Mood notes render as
plain paragraphs with a toggle that appears only when text is actually
clamped, the remember control reveals on hover and hands focus to its
confirmation, and the hypnogram names its source and paints only the
lanes the night contains.
2026-06-11 23:20:37 +02:00
Marc-André Bombeck a2a995e429 fix(insights,medications): close the staleness escape hatches and harden the review findings
Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
2026-06-11 22:49:42 +02:00
Marc-André Bombeck 3d95ace218 feat(ui): an always-there coach button and a tidied insights chrome
A round coach button sits fixed bottom-right on every authenticated
page, above the bottom nav and safe-area inset, with a small dot when a
nudge is unread — replacing the bubble that only existed while a nudge
was pending. The per-chart target-adjust link is gone (the page-level
control remains), the duplicated customize entry on insights sub-pages
collapses onto the tab strip, and the full-width show-all-values foot
button becomes an icon beside the target control. The tab strip's
scroll container keeps four pixels of paint room so pill borders and
focus rings stop clipping at fractional zoom, and the mood tag
breakdown bars adopt the muted opacity the other mood charts use.
2026-06-11 22:12:06 +02:00
Marc-André Bombeck 657f2323a3 fix(sleep,mood): pick the richest writer per night and show the whole note
Sleep nights collapse per writer instead of per source: several
HealthKit apps land behind the same source value, so a phone's coarse
awake and in-bed blocks could blend into or fully constitute a night
whose stages another writer had recorded. The winning writer per
session is the one with the most distinct granular stages, then the
user's source ladder, and its rows are kept whole. The device type is
threaded through every sleep read including the stage stacked bar, and
the sync overlap for late-arriving nights widens from one day to seven
so a night scored after the cursor passed is fetched on the next tick —
assembly is read-time, so affected nights heal without manual repair.
Mood notes expand to their full text in the list on desktop and touch,
replacing the hover-only tooltip, and the mood CSV export carries a
note column.
2026-06-11 21:52:52 +02:00
Marc-André Bombeck a664395021 fix(coach): make remembering reliable and survivable
A remember action under every persisted chat message adopts its content
into the matching self-context field — allergy statements land in
allergies, diagnosis wording in conditions, everything else in the
coach focus — reusing the adopt endpoint's dedupe and encryption, with
the result visible and editable where the self-context lives. The
deterministic fact patterns accept closed filler-word and possessive
variants that previously defeated them, and stored facts survive the
snapshot budget squeeze instead of being the first block shed on
data-rich accounts.
2026-06-11 21:52:51 +02:00
Marc-André Bombeck 242d2514ee perf(dashboard,insights): reserve the cold-paint footprint and cache the slow overview reads
The dashboard reserves chart-row skeleton cells from the saved layout
before the snapshot resolves, so a cold load paints close to its final
footprint instead of growing by a screen when data lands; the tile
silhouettes count only tile-capable widgets with blood pressure as two,
the greeting line keeps its box through name personalisation, and the
getting-started checklist moves below the charts where its arrival
shifts nothing. On the insights overview the targets build runs its
eight reads in parallel with a one-year floor on the glucose scan
instead of strictly in sequence over an unbounded history, the derived
batch gains a server cache where it previously recomputed sixteen
metrics on every read, both serve stale-while-revalidate, and the
health-score column reserves its exact footprint while the analytics
payload is pending.
2026-06-11 21:52:36 +02:00
Marc-André Bombeck b5e799c2d6 fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
2026-06-11 21:17:27 +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 23f7c74c04 perf(insights): regenerate texts only when the underlying data changed
A sha256 fingerprint of the compacted feature snapshot is stored beside
every generated text; nightly, forced and ingest-triggered regeneration
all skip the model call when the fingerprint matches and refresh the
timestamp instead. Warms cover only the user's resolved locale and the
blanket per-status eviction after a comprehensive generation is gone —
a lazily-read second locale regenerates on its own miss path. The
ingest debounce widens from thirty minutes to six hours and no longer
deletes cache rows, the visit-triggered warm re-checks freshness at job
start, backs off for an hour after a failure and is capped at two
forced runs per user per day, and its dedup window now outlives the
client poll horizon. An active day drops from hundreds of model calls
to a handful tied to actual data change.
2026-06-11 21:17:05 +02:00
Marc-André Bombeck 9fa4962314 Merge pull request #313 from MBombeck/release/v1.16.7
Release v1.16.7 — insights answer instantly, every evening
2026-06-11 19:52:02 +02:00
Marc-André Bombeck 11a061c4a1 chore(release): v1.16.7 — insights answer instantly, every evening 2026-06-11 19:46:09 +02:00
Marc-André Bombeck 03453aaed2 fix(insights,cache): surface revalidation to clients and evict on interactive writes
Stale-served insights responses now carry a revalidating flag; the
advisor and comprehensive queries poll on a bounded 25 s cadence until
the background warm lands, so an open session converges instead of
pinning yesterday's briefing for an hour. Interactive measurement
writes (single create, edit, delete, bulk-delete, restore) hard-evict
the analytics cache so a manually entered value is reflected on the
next read; background batch sync keeps the mark-stale path. The
visit-triggered force warm collapses to one job per user instead of
one per locale, and the umami script proxy failure now annotates a
named action with the failure reason instead of a bare flag.
2026-06-11 19:45:32 +02:00
Marc-André Bombeck 78fe82e348 perf(insights,api): always answer instantly and regenerate in the background
The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
2026-06-11 19:05:49 +02:00
Marc-André Bombeck ceeb818f41 Merge pull request #312 from MBombeck/release/v1.16.6
Release v1.16.6 — corrected eras, an honest weekly status and a faster first paint
2026-06-11 16:31:58 +02:00
Marc-André Bombeck 01a100f50b chore(knip): cosign is an external binary, not a dependency 2026-06-11 16:24:38 +02:00
Marc-André Bombeck 390a362d7a chore(release): v1.16.6 — corrected eras, an honest weekly status and a faster first paint 2026-06-11 16:15:30 +02:00
Marc-André Bombeck 6cca9b83aa fix(dashboard): hand the preloaded snapshot over as a promise, not a cache write
Prefetching the snapshot into the query cache before hydration made the
server-rendered skeleton disagree with the first client render. The
early kickoff stays, but the response now commits through the mounted
query itself, so the speedup keeps none of the mismatch. The dead
nudge-resolver leftover goes with it.
2026-06-11 16:15:24 +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 a4b3d2fcd0 chore(release): v1.16.5 — a guided coach dialog and a schedule that shows its past 2026-06-11 12:08:46 +02:00
Marc-André Bombeck 44f344bf52 fix(medications): a manual era cannot swallow tracked live history
The manual-era boundary anchors on the medication's creation instead of
the current moment, so a backfilled era can only describe time before
the app was tracking.
2026-06-11 12:08:41 +02:00
Marc-André Bombeck 5639da4333 feat(medications,admin): the schedule's history is visible and invites count their uses
The schedule tab shows its eras as a collapsible timeline — current
plan since its date, predecessors with their times and span — and a
past era can be added manually for old history, with validation against
overlaps and the live plan. Invite creation chooses one to fifty uses
and the redemption list scrolls.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck a62e00cfcd feat(coach): a guided dialog for follow-ups and nudges that know their owner
Open follow-up questions now run as a guided in-chat sequence — a quiet
card offers to start, the coach asks one question at a time with
visible progress, answers can be adopted into the matching self-context
field, skipping keeps a question pending and a closing summary says
what was taken over. Proactive nudges learn three new deterministic
signals — weight drifting from its target band, a run of short nights,
an active user falling silent — address the focus the user asked to
watch, and gain per-group toggles and a frequency preference.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck f13aa684f9 fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck 599b7e8c99 Merge pull request #310 from MBombeck/release/v1.16.4
Release v1.16.4 — half the bundle, a typed client, and stock you can record
2026-06-11 11:08:17 +02:00
Marc-André Bombeck 5778dd0be0 fix(release): ssr-safe add-param, noon-anchored compliance days, localized wizard assertion
The measurements page consumed its add parameter during render, which
crashed server rendering now that every route renders dynamically with
real search params. Compliance day buckets anchored to the current
wall-clock minus twelve hours, so before local noon the current day was
missing from every bucket series — they anchor on local noon now. The
wizard spec accepts the localized start date its summary deliberately
shows, and the generated contract picks up the version bump.
2026-06-11 11:01:22 +02:00
Marc-André Bombeck d56cf70d7c chore(release): v1.16.4 — half the bundle, a typed client, and stock you can record 2026-06-11 03:30:51 +02:00
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00
Marc-André Bombeck 25f41f88fe feat(ux,settings): undo where it was missing and settings that behave one way
Deleting a measurement or mood entry can be undone from the toast via
new restore endpoints, dialogs submit on enter with busy states, silent
mutations gain error toasts, and the settings area finishes its
consolidation: provider and integration cards live in focused
subcomponents, every setting persists server-side at exactly one home,
and the save conventions are written down. The remaining raw fetches in
settings move onto the shared client.
2026-06-11 02:19:28 +02:00
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +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 ce3b4c70c8 chore(ops): signed images, a secret gate before commit, and a monthly restore drill
Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck 410c62c292 refactor(jobs,dashboard): a thin worker registry and a composed dashboard
The reminder worker becomes a registry over twelve queue modules cut
along the existing test structure, and the dashboard page sheds its
quick-entry sheets, header and range logic into components — same
behaviour, pinned by the existing suites.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck 35e8f076a2 refactor(openapi,queries): feature-located registries behind thin indexes
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
2026-06-11 00:10:00 +02:00
Marc-André Bombeck fdf48e4890 docs: ship a current dashboard hero in the repository
The readme hero moves from an external attachment URL to a fresh
in-repo screenshot of the v1.16 dashboard.
2026-06-10 23:33:23 +02:00
Marc-André Bombeck 963831d4ec Merge pull request #309 from MBombeck/release/v1.16.3
Release v1.16.3 — schedules remember their history
2026-06-10 22:48:49 +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 e9084410e2 chore(release): v1.16.3 — schedules remember their history 2026-06-10 22:22:05 +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
1205 changed files with 114501 additions and 21883 deletions

No files matched your search

+69 -1
View File
@@ -50,17 +50,80 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://admin.polaraccesslink.com and set its redirect URI
# to `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Both vars together enable the
# Polar connect button; set neither to leave the integration disabled. Polar
# access tokens do not expire and carry no refresh token. POLAR_REDIRECT_URI
# overrides the derived callback when the public URL differs.
# POLAR_CLIENT_ID=""
# POLAR_CLIENT_SECRET=""
# POLAR_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
# -----------------------------------------------------------------------------
# Web Push needs NO Apple account and no paid service -- just a VAPID keypair.
# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
# keys" mints and stores the pair for you (private key encrypted at rest). Use
# these env vars only if you prefer env-config over the DB. The loader reads
# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are
# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env
# route under compose, add them there too or the values never reach the
# container (see docs/self-hosting/getting-started.md Troubleshooting).
# VAPID_PUBLIC_KEY=""
# VAPID_PRIVATE_KEY=""
# VAPID_SUBJECT="mailto:you@example.com"
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
# -----------------------------------------------------------------------------
# Only the native iOS app uses APNs, and it needs a paid Apple Developer
# Program membership. Web Push (above) needs NO Apple account -- it covers
# every browser and the installed PWA on iPhone. Leave this whole block unset
# unless you ship the native app. Key-source precedence when more than one is
# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.
# APNS_KEY_ID=""
# APNS_TEAM_ID=""
# APNS_BUNDLE_ID=""
# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest
# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
# APNS_KEY_FILE=""
# -----------------------------------------------------------------------------
# SMTP -- email notification channel (optional, all-or-none)
# -----------------------------------------------------------------------------
# Operator SMTP transport for the email channel. SMTP_HOST + SMTP_PORT +
# SMTP_FROM together enable it; SMTP_USER/SMTP_PASS are optional (omit for an
# unauthenticated relay). SMTP_SECURE=true uses implicit TLS (port 465);
# unset/false uses STARTTLS (port 587). Per-user recipient address is set in
# Settings -> Notifications, not here. Missing the core trio disables the
# channel silently. Listed in docker-compose.yml's environment whitelist so
# these reach the container.
# SMTP_HOST=""
# SMTP_PORT="587"
# SMTP_FROM="HealthLog <noreply@example.com>"
# SMTP_USER=""
# SMTP_PASS=""
# SMTP_SECURE="false"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
@@ -68,6 +131,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# -----------------------------------------------------------------------------
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
# vouch for.
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+37 -2
View File
@@ -106,7 +106,7 @@ jobs:
- name: Compute image labels
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_REF }}
labels: |
@@ -268,6 +268,12 @@ jobs:
permissions:
contents: read
packages: write
# v1.16.4 — keyless image signing. The job requests an OIDC
# token from GitHub's issuer; cosign exchanges it for a
# short-lived Fulcio certificate bound to this workflow identity
# and records the signature in the Rekor transparency log. No
# long-lived signing key exists anywhere in this pipeline.
id-token: write
steps:
- name: Download digest artifacts
@@ -295,7 +301,7 @@ jobs:
- name: Compute image tags & labels
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_REF }}
tags: |
@@ -333,6 +339,35 @@ jobs:
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
# one signature, and the digest is the only stable identity — every
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
# compromise could repoint. Operators verify with:
#
# cosign verify \
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# ghcr.io/mbombeck/healthlog:<tag>
#
# See docs/ops/deploy.md ("Verify the image signature") for the
# digest-pinned deploy recommendation that pairs with this.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
run: |
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ]; then
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
exit 1
fi
echo "Signing ${IMAGE_REF}@${digest}"
cosign sign --yes "${IMAGE_REF}@${digest}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
# the new image. Replaces the former manual SSH + force-pull recipe
# on the deployment host.
+344
View File
@@ -2,6 +2,350 @@
## [Unreleased]
## [1.17.1] — 2026-06-15 — preventive care, lab results, and more of the data you already have
This release closes the loop from tracking to acting. Preventive-care reminders tell you when to measure or check what, structured lab results give your bloodwork a home, and a new recovery view surfaces signals that were already being collected but never shown. Sleep timelines now read in real clock times, marking a dose on one device clears its reminder on the others within seconds, and self-hosters gain a generic webhook channel, email, one-tap Web Push setup and a proper notifications guide. Every new number is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app. No breaking changes.
### Added
- **Preventive-care reminders.** Set a reminder to measure your blood pressure on a cadence or to schedule an annual blood panel, with a clear next-due date and where to do it. A matching measurement marks it done on its own; the rest you tick off. Reminders reach you over every notification channel, and the cards stay calm — status is a quiet badge, never an alarming colour.
- **Structured lab results.** Record bloodwork and biomarkers with their reference range, see each one trend over time, and carry them into the doctor-report PDF and the FHIR export. An out-of-range value is shown plainly, not in red.
- **A recovery view, and sleep quality in depth.** A new recovery page gathers strain, training load and autonomic-charge readings, and the sleep page gains an efficiency, performance and sleep-score block — metrics a connected device was already sending that nothing surfaced before. Each appears only once it has data and stays calm until it has enough to be sure.
- **Import and backdated entry.** Import a CSV of past measurements with a previewed, per-row result and unit conversion, and log an entry with a past date and time — the cold-start escape hatches for bringing existing history in.
- **Onboarding that does what it says.** A short, skippable health-baseline step, and the goals you pick now actually seed your dashboard.
- **Polar and Oura credentials in the browser.** Both connect with your own developer-app credentials entered in settings, like the other integrations — no environment file needed.
- **More ways to be notified.** A generic webhook channel reaches a self-hosted notifier or a chat service, an email channel sends over your own SMTP server, and an operator can see delivery health across every channel. Web Push keys can be generated in one click from the admin panel.
### Changed
- **Sleep reads in real clock times.** Per-stage rows now carry their own start and end, so the "last night" timeline lays out across the night instead of stacking — measured where the device reports stage timing, and an honestly-labelled reconstruction where it only reports stage totals. Nights logged by more than one source resolve to a single total.
- **A dose taken on one device clears the others in seconds.** Marking a dose sends a silent sync to your other devices, so a lock-screen reminder ends without waiting for the next app open.
- **More of a connected device's data flows in.** Readiness contributors, body-temperature deviation, blood-oxygen, a sleep score and autonomic-charge and training-load now come through where the source provides them. Body weight is never taken from a wearable strap.
- **One product across every screen.** Desktop and mobile navigation now tell the same story, the coach has a single home, the layout and reminder settings each gather under one hub, and every integration card links to its setup guide.
### Fixed
- Sleep nights from some sources were stamped at the wrong instant and sat shifted earlier in the day; corrected, with a one-time backfill that re-syncs affected nights.
- The doctor-report sleep figure now matches the dashboard and the companion app, reading the same reconstructed per-night total as every other surface.
- Polish across the new surfaces: consistent loading and empty states, design-token colours, responsive grids, and a calm confirmation for regenerating Web Push keys.
### Security
- The webhook channel pins a public host and refuses a private or loopback address unless explicitly allowed; webhook secrets, SMTP credentials and the Web Push private key are kept out of any recorded error; the key-generation and delivery-health endpoints are reachable only from an authenticated admin session, never a token.
### Self-hosting
- A notifications guide spells out that no Apple account is needed — Web Push, Telegram, ntfy, the new webhook and email all work without one — alongside a backup-and-restore callout and a clearer note on which variables must be whitelisted to reach the container.
### Operator note
- Migrations 0162–0166 are additive. A boot-time backfill re-syncs sleep nights from the affected sources to the corrected timeline once; it is idempotent and bounded.
## [1.17.0] — 2026-06-14 — clinical depth for glucose and sleep, and three new sources
This release builds new depth on the coherent foundation laid over the v1.16 line. Blood glucose gains a clinical panel, sleep gains a debt and chronotype reading, and three new data sources — Nightscout, Polar and Oura — join Withings, WHOOP, Fitbit and Apple Health. Every new metric is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app, and each holds back a confident reading behind a calm "still learning" state until it has enough data. No breaking changes.
### Added
- **Blood-glucose clinical panel.** Beneath the glucose chart, a panel reads time-in-range on the consensus bands, the glucose management indicator and an estimated A1C, and the coefficient of variation with an instability flag — with the J-index and the low/high blood-glucose risk indices behind an advanced view. It is honest about its limits: a panel built from spot readings says so, and it waits until enough readings over enough days have accrued before asserting anything.
- **Nightscout.** Connect a self-hosted Nightscout instance and its continuous glucose readings flow in — the density that makes the clinical panel meaningful. Public instances work by default; a per-connection toggle allows a private or home-network instance.
- **Sleep debt and chronotype.** The sleep page now shows a rolling sleep debt against your age-based need and an MCTQ chronotype with your mid-sleep, social jetlag and a type band — the deeper detail behind an advanced view, and the type held back until enough free-day nights are recorded.
- **Polar and Oura.** Connect either over OAuth to bring in sleep, heart rate, respiratory rate, activity and recovery. Recovery now reads from the strongest connected source — WHOOP, then Oura, then Polar, then the computed proxy.
- **Reorder-aware medication supply.** The low-stock warning now accounts for how long a refill takes, so a weekly medication is flagged before its last dose rather than after, with a concrete "runs out on … — reorder by …" date. A reorder lead time is configurable globally and per medication.
- **Invite deep-link.** Invites open through a link the companion app can intercept to start a prefilled registration, with a browser fallback.
### Changed
- **The coach reasons from the new signals.** Glucose time-in-range, sleep debt and chronotype, and a connected device's native recovery and strain now reach the coach, which prefers the device's gold-standard number over a computed proxy and respects each metric's still-learning state.
- **Mood and cycle entry is one sectioned sheet.** A quick row of your recent tags stays open, the rest folds into collapsible sections with a count of what's set inside — fast by default, deep on demand.
- **Wearable data you already had now surfaces.** Heart-rate variability from a ring or strap appears on the HRV view, and Oura and Polar steps, energy and pulse are ranked as real sources.
- **Onboarding tells the truth.** The setup flow now presents the sources that actually shipped, signposts importing existing history, and sets the expectation that some insights sharpen over the first week or two — instead of promising a configuration that did not happen.
### Fixed
- The sleep "last night" timeline bar returns for nights whose stages share an end instant, and the dashboard clears a recorded dose without a reload (carried forward from v1.16.17).
### Security
- Outbound Nightscout calls pin a public host by default with an explicit private opt-in, the stored token is kept out of any recorded error, and the new OAuth connections verify a signed, expiring state before exchanging a code.
### Operator note
- On the first nightly low-stock check after deploy, a medication already flagged low re-notifies once as the warning threshold moves to its lead-time-aware value. Expected and bounded.
## [1.16.17] — 2026-06-14 — the sleep timeline returns, and the dashboard clears after a dose
Two fixes to behaviour that surfaced in daily use. No breaking changes.
### Fixed
- **The "Last night" sleep timeline bar returns.** The staged hypnogram — which lays the night out across deep, core, REM and awake by clock time — had collapsed to just the numbers for nights whose stages share an end instant, leaving the breakdown without its bar. The bar now appears whenever the night carries timed stages; only a session recorded on a single instant falls back to the breakdown alone.
- **The dashboard clears a dose the moment it is recorded.** After recording an intake from the medication card or detail page, the dashboard kept prompting for that dose until a full page reload, because the home view was refreshed only while it was on screen. It now refreshes as soon as the dose is recorded, with no reload.
## [1.16.16] — 2026-06-14 — one engine for sleep, recovery and glucose
This release continues the work of making one number mean one thing everywhere. Sleep, recovery and blood glucose are now read from a single source on every surface — the dashboard, the coach, the doctor-report, the CSV and FHIR exports, and the companion app — so the figure you act on is the same wherever you look. Several displayed numbers become correct-but-different; each is called out below. No breaking changes.
### Added
- **Per-stage sleep on the night feed.** The sleep-night feed now rounds every duration to whole minutes and carries the per-stage breakdown (deep, core, REM, awake), so the companion app renders the same night the web does.
### Changed
- **Sleep is reconstructed once, from one engine.** The sleep score now reads each night through the same reconstruction the rest of the app uses, with source de-duplication: a night recorded by more than one source (for example a watch and a ring) is counted once instead of summed. For multi-source nights the score — and the readiness that builds on it — drops to its true figure rather than an inflated one.
- **WHOOP recovery is the recovery you see.** When a WHOOP-native recovery exists it is now the single canonical value on the tile, the chart, the doctor-report, and the coach; the computed proxy is the fallback when there is no native score. A connected account no longer sees the proxy and the native value as two competing numbers, and the night each belongs to is resolved consistently, so one night reads as one recovery. A WHOOP user's recovery figure can change to the native one.
- **Blood glucose speaks your unit everywhere.** Glucose is converted once, at the point it is served, through a single helper — so the series the companion app charts, the CSV export, the dashboard tile, the detail page, and the coach all read the unit you chose. The CSV export now matches the FHIR document instead of emitting raw mg/dL against a converted clinical export. If you read in mmol/L, these surfaces now show the converted value (for example 100 mg/dL as 5.5 mmol/L); mg/dL readers see no change. The blood-glucose median no longer claims a fixed window its value does not hold.
- **The coach reads the canonical numbers.** The coach now quotes recovery from the same resolved series as every other surface rather than a blend of sources, and states blood glucose in your unit instead of a bare mg/dL number.
- **WHOOP sync keeps a cursor per data type.** Recovery, sleep, workouts and cycles each track their own sync position, so a single slow or rejecting collection no longer holds the others back; a change notification refreshes just the affected record, and a full sync can backfill the deep history an incremental tick skips.
### Fixed
- **The coach appears when a shared model is configured.** A self-hoster who runs the coach from a server- or operator-managed model — with no personal key — now sees the coach instead of an "unconfigured" state, because the provider endpoint reports whether the coach can actually answer.
- **A consent receipt cannot double-mint.** Two requests arriving together can no longer leave two active consent grants; the active receipt is enforced unique and minted atomically, and a concurrent first grant resolves to the winning receipt instead of an error.
### Security
- **Consent endpoints are rate-limited.** The consent grant, read and revoke routes share a per-user limit, so the receipt path can no longer be driven in a tight loop.
## [1.16.15] — 2026-06-14 — an honest calendar that learns before it predicts
A focused release on the cycle calendar: it leans on observed data, names its
uncertainty plainly, and reads your charts the way the Sensiplan method does.
No breaking changes.
### Added
- **Mark a reading as disturbed.** A temperature taken after a fever, a late night, or any off day can be flagged "disturbed" and is dropped from the evaluation, so a single skewed point no longer drags the curve.
- **A positive ovulation test anchors the prediction.** When you log a positive test, it refines the predicted ovulation day — it sharpens the estimate but never overrides a temperature shift you have already confirmed.
- **Choose your secondary fertility sign.** Advanced cycle settings now let you track either cervical mucus (the default) or cervix observation alongside temperature, so the symptothermal evaluation follows the sign you actually record.
- **Warmer phase descriptions.** Each cycle phase gained a second paragraph of plain-language context, so the calendar explains what is happening, not just which phase you are in.
### Changed
- **The calendar learns before it predicts.** Below three logged cycles it shows a calm "still learning your cycle" state instead of a confident fertile-window or ovulation guess — and the same gate now applies on the API, so every client reads the same honest signal rather than painting a window the rest of the app holds back.
- **Temperature evaluation no longer under-detects ovulation.** The symptothermal reading gained the two Sensiplan exception rules — the slow rise and the single fall-back day, each confirmed on a fourth reading — so a real shift is recognised in the patterns that the simple rule used to miss.
- **The mucus peak settles before it is trusted.** A mucus peak is now confirmed only after three drier days follow it, so a stray late entry no longer shifts the peak backward.
### Fixed
- **Longer cycles are no longer clipped.** Cycles up to roughly sixty days are evaluated in full instead of being cut short, so a naturally long cycle reads correctly.
## [1.16.14] — 2026-06-14 — one number everywhere: the score, the coach, and the doctor-report agree
### Changed
- **Blood-pressure in-target speaks one window everywhere.** The in-target tile, the health-score blood-pressure pillar, and the coach now read a single 90-day window — labeled on the tile, so it is no longer hidden. The tile shows its real span ("· 23 T") until 90 days of history have accrued, and holds back a percentage ("sammelt noch Daten") until it has at least a handful of readings, so a single measurement can never imply a confident share. The all-time view stays on the detail page. The headline figure can differ from the previous unlabeled 30-day number — it is now the correct, consistent one.
### Fixed
- **The dashboard ring and the insights card show the same health score.** Both assemble the score from one shared input builder over identical windows, so the same account can no longer see two different scores or a pillar present on one surface and absent on the other; the ring's day-delta now also reflects blood-pressure movement.
- **The coach quotes the adherence the card shows.** Medication compliance routes through the same authority as every other surface instead of the coach's own per-day tally, so it can no longer reason from a number you never see — it matters most on irregular, rolling and as-needed schedules.
- **The doctor-report matches the app.** The adherence in the PDF/FHIR export is computed from the same dose history — slot attribution, cadence and cross-source dedup honoured — as the detail page, instead of a raw intake-row count, so the figure a clinician reads on paper agrees with the screen.
- **Measurement and mood entries reject implausible timestamps.** A future date beyond a small clock-skew allowance, or anything before 1900, is refused — matching the medication-intake guard — so a stray future-dated reading can no longer distort the recent-window metrics.
## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line
### Added
- **The app installs and works offline.** The service worker now registers for every visitor, not only after notifications are switched on, so the app installs to the home screen, its shell and assets are cached, and a lost connection shows an offline page instead of a dead tab. Authenticated pages are never cached — their data always loads live — the share-by-link page is kept out of the cache entirely, and signing out clears the page cache. The running app still heals itself across deploys, and the cached-version marker can no longer drift stale between releases.
### Changed
- **The default mood tags are curated.** The redundant `happy`, `excited`, water and music defaults step aside, `overtime` returns to the work picker, a new `praise` ("Lob") tag joins it, and the manual sleep-quality rating retires — a measured night already covers it. Tags already recorded against any of these keep resolving their labels; nothing on existing entries changes.
- **Page headers hold one line on a phone.** The medications header no longer drops its actions to a second row, the insights "Ask the coach" action sits to the right where it belongs, and a long label in the more-menu stays inside its tile. The mood, measurements and dashboard headers get the same treatment so none of them can wrap either.
### Fixed
- **A historical dose edit credits the schedule that was live then.** Editing a past intake on a medication whose schedule has since changed now attributes it to the dosing era valid at the dose's own time rather than today's schedule — the same era the history and compliance already read it under.
- **A CSV intake import moves the tracked stock.** It was the one path that recorded taken doses without decrementing the supply, so the runway read high; it now consumes exactly like every other path, once per dose, and a re-import never drains the shelf twice.
- **A workout that reaches WHOOP late is no longer lost.** Workout ingest used a one-hour overlap window while recovery and sleep used a far wider one, so a phone that synced to the WHOOP cloud more than an hour after a session left the workout permanently outside the window. It now shares the wider window; the idempotent upsert keeps the widening from duplicating anything.
- **Sleep night totals read as whole minutes** instead of a long fraction, matching the contract the clients decode.
- **A tiles-only layout save no longer wipes section customization.** Reordering the navigation tiles without sending the section layout keeps the stored sections instead of resetting them to defaults, and the same holds the other way around.
- **The web AI surfaces honour consent again.** The web client now records the consent receipt the server requires before any model call, so insights and the coach stop falling back to the empty no-provider state for people who have granted it, and the status reads "consent missing" honestly when it is genuinely absent.
- **The coach button in the public demo no longer errors** — it steps aside there instead of offering a send the demo blocks.
## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm
### Added
- **A dose can consume a fraction of a unit.** Split-pill medications now declare ½, ⅓ or ¼ of a tablet per dose — the wizard offers a curated set of fractions alongside the whole numbers, the most error-resistant input. The supply tracks the fractional remainder (30 tablets dosed at a half each reads 29.5 after one dose), the days-left projection follows, and undoing a dose refunds exactly what it consumed. Existing whole-number doses are untouched; the inventory unit counts widen to decimals across the API contract. (#316)
### Changed
- **The medication cards hold one shape.** The low-supply notice moves onto the adherence row beside the streak — a slot every card already reserves — so a card that is low on stock no longer 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 dashboard and insights stay warm between visits.** The analytics and derived-insight reads keep a served-stale snapshot for an hour rather than ten minutes, so returning after a normal break paints instantly and refreshes in the background instead of paying the full cold rebuild — which a visit spaced more than ten minutes apart hit almost every time.
### Fixed
- **The medications list refreshes when you return to it.** Navigating back to the page — or reopening the supply tab — now refetches the list, compliance and stock instead of serving a client cache that could be minutes stale, so a dose logged on another device shows up without a manual reload.
## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice
### Added
- **The app says so before the pack runs out.** A daily pass projects every tracked supply against its schedule and notifies on the configured channels when the runway falls below a per-account threshold — once per crossing, re-armed by a refill, re-announced when the threshold changes. The threshold lives beside the reminder settings (1–60 days, default 7, or off), the push deep-links straight into the supply tab, the table's stock column carries the projection as "≈ X Tage", and the card states the remaining runway once it crosses the threshold — only then.
- **Mood tags get groups, icons and a home of their own.** Custom groups with encrypted labels, a searchable 75-icon picker, and a manage page under settings; tags archive without touching the entries that carry them — editing an old entry can no longer drop its archived tags either — the picker offers inline creation right where a tag is missing, custom labels finally render in the picker AND across insights, and the arrangement persists per account. The whole surface ships in the public API contract.
- **Every due dose in one confirmed action.** When two or more doses are due — including a dose escalated past its catch-up window — a button on the medications list collects them into one confirm dialog and records each through the same path as the card buttons: slot attribution, stock consumption and every dependent surface included; failures are counted, not hidden.
- **A medication can declare itself as-needed.** No schedules, never due, never reminded, excluded from every compliance rate — but intakes log normally, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice; switching it onto a fixed schedule later never repaints the schedule-less stretch as missed doses, and the doctor report lists it without inventing a rate. (#316)
- **Units-per-dose and the default pack size are editable from the supply tab** — a manufacturer switch with a different blister size no longer requires walking back through the wizard.
### Changed
- **One wrench marks every customize entry point.** Dashboard, insights and medications share the glyph, the table corners clip to their container again, and the card/table view choice moves into its own settings section.
- **The coach opens in place.** The button brings the side drawer over the page you were reading instead of navigating away, and grew to a comfortable tap size.
- **The card's dose state lives on the next-intake row.** Take-now, late and the overdue escalation speak from the line that names the dose — with their time beside them, on mobile too — instead of a separately reserved status line that left a hole on every settled card.
- A source ladder saved before a newer integration existed now lists and ranks the later-added sources everywhere — settings, charts, rollups and the sleep picker — instead of silently pinning them below every ranked one.
- WHOOP's self-reported profile weight is no longer ingested as a measurement; the stale entry stops resurfacing as the newest weight, and existing accounts heal on the next sync.
### Fixed
- **The hypnogram keeps its timeline when a summary writer joins the night.** A stage summary stamping all its rows on one instant no longer evicts a per-segment night; when only summaries exist, the card shows the stage breakdown instead of a fake skyline.
- **A dose taken on the dashboard updates the dashboard.** The snapshot band refetches with every intake path instead of waiting out its polling interval.
- The supply tab can delete a container, its controls carry the outline they always implied, each container states its condition inline next to its figures, and the table's stock warning follows the user's runway threshold instead of a leftover four-dose constant.
- Every wide event carries the recent event-loop lag, and a stalled loop emits its own warning event — the instance-level signal per-request durations cannot carry.
## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order
### Added
- **A medication can declare how many units one dose consumes.** Two 2 mg tablets for a 4 mg dose register as one dose taken and decrement two units, the supply dialog accepts quantities in doses or units with live conversion, containers carry a type (pen, ampoule, blister pack, inhaler, bottle) and hold up to 1000 units — a 200-dose inhaler canister finally fits. (#316)
- **The medication list has a table view and an order of your choosing.** A toggle beside the add button switches between cards and a compact, per-column sortable table; both views share a manual order, and the whole arrangement lives in its own settings section behind the same gear icon the dashboard and insights pages use. The choice persists per account. (#316)
### Changed
- **The daily-overview band is opt-in now.** New accounts and accounts that never chose it start without the band and keep the plain greeting; it switches on under dashboard settings.
- **Expired containers count as available nowhere.** Every surface — overview row, supply tab, list, table, GLP-1 card and the runway estimate — agrees on the same figure and shows expired stock separately instead of mixing it in.
- The wellness rings sit exactly on the theme palette now — readiness green, recovery cyan, sleep purple, stress orange, strain pink — instead of neighbouring tones, and the light theme follows the same five hue families.
- The inventory endpoints speak one wire dialect: request fields renamed to `unitsTotal` / `unitsRemaining` to match the responses they always returned.
### Fixed
- **Every taken dose now moves the tracked stock.** Only one of the six intake paths decremented the supply before — web, the status toggle, the phone sync, the external API and the Telegram button all consume now, the next container opens automatically, and undoing, skipping or deleting a dose refunds exactly what it consumed. Each dose carries its consumption stamp, so a double tap racing a sync replay decrements once, and a re-posted history never drains the shelf. Doses recorded before this release stay untouched — the count starts moving with the next dose, or correct a container once under the supply tab. (#316)
- **A dashboard full of readings no longer claims "not enough data".** While the score warms up the band says it is being computed, and a first reading of a new data type — a fresh wearable metric, a first glucose value — can no longer knock the score out, because the gate checks only the types the score actually reads.
- **The score stops flickering on busy accounts.** The pre-aggregation rebuild deleted its window before inserting the replacement without a transaction — a concurrent sync write could abort the insert and leave whole types empty until the next pass. Delete and insert run atomically now, and the boot-time backfill shares the fold horizon, so an account with years of history stops re-backfilling on every deploy.
- **The filter pills on the mood and measurements lists open their menu on screen again.** The menu opened off-viewport below the page since v1.16.1 — selecting a mood, type or source read as a dead control.
- **The coach button stops blinking out on chart hovers.** It no longer hides whenever a chart tooltip is open anywhere on the page, and it sits evenly in the corner.
- The pre-item GLP-1 supply ledger reads again: accounts that tracked pens through the older ledger see their count on the card and in the coach until they register containers, which then take over.
## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth
### Added
- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, 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. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header.
### Fixed
- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed.
- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh.
- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions.
- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed.
- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one.
- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL.
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
### Added
- **The coach is one tap away on every page.** A round button sits bottom right, stays put while you scroll, shows a quiet dot when a nudge is waiting, and steps aside for the selection bar and the welcome tour.
- **Tell the coach to remember something.** A remember action under your own chat messages stores the statement in the matching self-context field — allergies land under allergies, diagnoses under conditions — visible and editable in the settings, and fed into every future conversation. Statements phrased casually are recognised again, and remembered facts no longer drop out of the prompt on data-rich accounts.
- **Mood notes in full.** Notes expand to their complete text right in the list — the toggle only appears when something is actually clipped — and the CSV export carries a note column.
- **The hypnogram names its source** and paints only the stages the night actually contains.
### Changed
- **Insight texts regenerate when your data changes, not on a timer.** Every generated text stores a fingerprint of the data it describes; nightly runs, page visits and sync bursts skip the model call when nothing moved. A notable new reading is narrated the same morning, a manual regenerate refreshes the cards along with the briefing, and texts can no longer claim freshness over readings that crossed a staleness boundary. An active day drops from hundreds of model calls to a handful.
- **Medication cards arrive in one piece.** All cards share a single compliance request instead of one each, the heavy date math runs about five times faster, and the status line, adherence bars, streak and cycle line keep reserved slots — nothing shifts when data lands. A dose logged from the phone app is reflected immediately.
- **A cold start paints the page you'll actually get.** The chart row reserves its space before any data arrives, the tile silhouettes match the real layout, and the health-score card holds its column while the numbers compute. The insights overview runs its reads in parallel and serves repeat visits from cache — the targets and derived endpoints answered in well over a second before.
- **Sleep nights pick the richest recorder.** Several apps writing through the same health platform no longer blend into one night — the writer with actual stage data wins, time in bed unions every writer's window, and nights that reach the sleep service late are fetched up to seven days back, so a wrong night heals itself on the next sync.
### Fixed
- Charts no longer strand on a skeleton after a deploy: the update check re-arms per version, a failed chart chunk retries and then degrades to a single card with a reload button, stalled requests time out and retry, and a failed data read shows an error with a retry instead of "no data in this period".
- Screen readers hear chart failures and coach nudges, the floating button leaves the tab order when it hides, focus survives the remember confirmation, and the icon cluster on insight pages no longer has overlapping touch areas.
- The duplicated customize entry and the per-chart target link are gone, show-all-values became an icon beside the target control, pill borders stop clipping in the tab strip, and the mood tag bars adopt the muted palette of the charts above them.
- Changing a target range, profile detail or source priority is reflected in the analytics on the next read, AI consent is honoured before any stored text is reused, and CSV exports neutralise spreadsheet formula prefixes in free-text cells.
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
### Changed
- **Insights always answer instantly.** A stale briefing is served immediately with a regeneration kicked off in the background — the response carries a revalidating flag and the open page polls briefly until the fresh content lands, instead of blocking the visit on up to ten seconds of inline generation.
- **The nightly pre-warm no longer skips evening readers.** The staleness threshold drops from twenty hours to one — previously an evening visit stamped the cache fresh, the night run skipped it, and it expired exactly the next evening, so the people who read insights after work were the ones who always hit cold generation. The visit-triggered warm fills both locale families and collapses to one job per user.
- **The session resolves faster.** The profile read inside `/api/auth/me` runs in parallel with the cookie refresh, the bug-report status answer comes from a ten-minute cache without a write per request, and the medications page is prefetched the moment its nav entry is touched.
- **The insights route ships less JavaScript.** The schema validator loads lazily and the mood charts split out of the main chunk, trimming the cold download that dominated an uncached first visit.
### Fixed
- A manually entered measurement is reflected in the analytics aggregates on the next read — interactive writes evict the cache instead of marking it stale, while background sync keeps the cheaper path.
- The analytics script proxy degrades to a silent no-op instead of failing every page when the upstream is unreachable, and records the failure reason where the operator can find it.
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
### Added
- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation.
- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works.
### Changed
- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree.
- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question.
- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner.
### Fixed
- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due.
- The adherence bars keep a fixed geometry in every state instead of growing with their status text.
- The remaining hydration warnings on the dashboard, insights and AI settings are gone.
## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past
### Added
- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over.
- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history.
- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch.
- **Invites mint up to fifty uses** with a scrolling redemption list.
### Changed
- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call.
### Fixed
- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact.
## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record
### Added
- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama.
- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history.
- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast.
- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page.
- **Answered coach follow-ups can flow into your self-context** with one tap.
### Changed
- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second.
- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes.
- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links.
- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts.
### Fixed
- Server and first client render now agree everywhere query state used to leak into the chrome.
- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong.
- A language switch updates pending status notes and only flips once the new language is actually loaded.
## [1.16.3] — 2026-06-10 — schedules remember their history
### Fixed
- **Changing a schedule no longer rewrites the past.** Every material cadence change now archives the outgoing schedule as a dated revision, and history, compliance and write attribution evaluate each period against the schedule that was valid at the time — a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistently dated old anchors behind an explicit flag.
- **The welcome tour can no longer strand its buttons below the fold.** The target scrolls into view first, the popover flips above it when space runs out, and a centred sheet takes over on any viewport when neither fits.
- The avatar menu drops its about entry; the section lives in settings.
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
### Added
+2 -2
View File
@@ -22,13 +22,13 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2316" height="1207" alt="HealthLog dashboard: health metric tiles, trend charts, and medication overview" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
## Why HealthLog
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
Try the [live demo](https://demo.healthlog.dev) to see a working install.
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
## Highlights
+62 -10
View File
@@ -4,15 +4,22 @@ services:
# For horizontal scaling switch to the web/worker split below
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
app:
# Tracks the GHCR `:latest` multi-arch image. `pull_policy: always`
# is the load-bearing line — without it, Docker keeps a stale
# `:latest` digest cached on the host and skips the registry round-
# trip on `compose up`, which is what kept Coolify re-deploying the
# previous image after every tag push. With pull_policy=always the
# compose-up always re-checks the registry digest and pulls when
# GHCR has a fresher manifest. The small bandwidth cost on every
# `compose up` is the price of digest-correctness.
image: ghcr.io/mbombeck/healthlog:latest
# Tracks the GHCR `:latest` multi-arch image by default.
# `pull_policy: always` is the load-bearing line — without it,
# Docker keeps a stale `:latest` digest cached on the host and
# skips the registry round-trip on `compose up`, which is what kept
# Coolify re-deploying the previous image after every tag push.
# With pull_policy=always the compose-up always re-checks the
# registry digest and pulls when GHCR has a fresher manifest.
#
# HEALTHLOG_IMAGE_REF pins the image deterministically without
# editing this file: set it in the compose `.env` (or the Coolify
# env vars) to `:vX.Y.Z` for a tag or `@sha256:<digest>` for an
# immutable digest reference (see docs/ops/deploy.md, "Digest
# pinning"; scripts/promote-digest.ts resolves + verifies the
# digest after a release). Unset, everything behaves exactly as
# before — `:latest` plus pull_policy.
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
pull_policy: always
container_name: healthlog-app
# The app runs as the unprivileged `nextjs` user and never needs a
@@ -70,6 +77,18 @@ services:
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
# env). Both pairs together enable each connect button; an unset pair
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
# here so operator values in .env reach the container (the `environment:`
# block is a whitelist).
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
@@ -77,6 +96,10 @@ services:
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
@@ -84,6 +107,35 @@ services:
# container (the `environment:` block is a whitelist). See
# docs/ops/tls-cert-pin.md.
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
# container (the `environment:` block is a whitelist — vars not listed
# never propagate from .env, even with `${VAR}` substitution at
# compose-up). Per-user recipient is stored in the DB, not here.
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-}"
SMTP_FROM: "${SMTP_FROM:-}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_SECURE: "${SMTP_SECURE:-}"
# v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
# these in the DB (the supported path); these env vars are the fallback the
# loader reads when the DB pair is unset. Listed here so the documented env
# path reaches the container under the bundled compose. See
# docs/self-hosting/notifications.md.
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
# APNs for a self-hosted native iOS build (optional, env-only — no admin UI
# for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
# APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
# PWA users need none of this (Web Push covers them). See
# docs/self-hosting/notifications.md.
APNS_KEY_B64: "${APNS_KEY_B64:-}"
APNS_KEY_ID: "${APNS_KEY_ID:-}"
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
depends_on:
db:
condition: service_healthy
@@ -104,7 +156,7 @@ services:
# See docs/self-hosting/scaling.md for the full guide.
#
# app-worker:
# image: ghcr.io/mbombeck/healthlog:latest
# image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
# build:
# context: .
# dockerfile: Dockerfile
+4765 -102
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+27
View File
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects — re-encrypt or accept that
pre-rotation backups are only readable with the retired key.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill needs no IAM grant beyond the uploader's existing
`GetObject` + `ListBucket`.
+127 -1
View File
@@ -70,7 +70,7 @@ roll back:
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
@@ -90,3 +90,129 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
`amd64` and `arm64`) is signed keyless via Sigstore in the
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
short-lived Fulcio certificate bound to the workflow identity, and the
signature lands in the Rekor transparency log. There is no long-lived
signing key to leak or rotate.
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
```sh
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
ghcr.io/mbombeck/healthlog:1.16.4
```
A passing verification proves the image was built by this repository's
release workflow on a `v*` tag — not by anyone who merely obtained push
access to the GHCR package.
### Prefer digest-pinned deploys
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
compromise or an accidental re-push can repoint them. The digest is the
image's only immutable identity, and it is what the signature actually
covers. For any deploy where supply-chain integrity matters more than
convenience:
The compose file carries the hook for this: the `app` service image is
`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the
day-to-day `:latest` + `pull_policy: always` flow stays the documented
default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose
`.env` (or the deployment platform's env vars) to switch the reference
without touching the file:
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
### Promote a release to a pinned digest
`scripts/promote-digest.ts` automates the whole promotion after
`docker-publish.yml` has pushed a tag:
```sh
pnpm dlx tsx scripts/promote-digest.ts v1.16.5
```
It resolves the multi-arch index digest from the GHCR registry API
(anonymous pull token, no docker daemon), verifies the keyless cosign
signature against the workflow identity (fails closed — no cosign, no
promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…`
value.
For a Coolify-managed deployment it can also apply the pin. Two facts
about Coolify shape the mechanism (verified against the Coolify
OpenAPI spec and a live instance):
- For a git-based "Docker Compose" application, Coolify re-reads the
compose file from the repository checkout on every deploy.
`docker_compose_raw` is its stored copy of that file, and the
application-update API (`PATCH /api/v1/applications/{uuid}`) does not
accept the field — editing the stored compose is neither possible
via API nor durable.
- Application **env vars** are durable, exposed via the documented
`/api/v1/applications/{uuid}/envs` endpoints, and interpolated into
the compose at deploy time.
So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With
`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the
environment the script creates/updates the var itself (add
`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it
prints the equivalent curl commands:
```sh
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
-H "Authorization: Bearer <token>"
```
After the deploy settles, confirm `/api/version` reports the released
`version` + `buildSha` on every target (see above).
**Rollback:** re-run the script with the previous tag (or PATCH the env
var back to the previous digest / to empty for `:latest`) and redeploy.
Digest pinning trades the automatic `:latest` pickup for determinism —
a pinned instance only moves when the var moves, so the promotion step
becomes part of the release routine, not an optional extra.
Digest pinning is the recommended hardening for operators with a
threat model that includes the registry; `:latest` remains the
low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
Two modes:
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
+29
View File
@@ -115,3 +115,32 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
The script is idempotent: a second `--fix` run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
zero-findings run), non-zero on bad arguments or a fatal error.
## Era backfill (`--backfill-eras`, v1.16.3)
Schedule edits made before v1.16.3 replaced the schedule rows without
archiving the old state, so history reads past days against the current
times. Section 5 of the script infers the lost era from the recorded
slot anchors:
- A medication qualifies when its anchor-shaped rows (user-tz `HH:mm` on
a 5-minute grid) deviate from the **current** `times_of_day` for at
least 7 consecutive recorded days before the current times first
appear, and it has **no** existing `medication_schedule_revisions`
row (re-runs are idempotent).
- The proposal is one revision row: `valid_from` = the first deviating
row's instant, `valid_until` = local midnight of the first day on the
current times, payload = one `FREQ=DAILY` schedule carrying the
observed old times.
- A `starts_on` that postdates the first recorded row is flagged and
pulled back to that row's instant.
The default run only **reports** the proposals. Apply them explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
```
Review the dry-run table first — the inference is a heuristic. A wrongly
created revision can be deleted from `medication_schedule_revisions`
without side effects (the read paths fall back to live-only minting).
+49
View File
@@ -147,6 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
| Next step | File |
| --------- | ---- |
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` |
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
@@ -174,3 +175,51 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
`localhost`, and restart `app` after the change.
- **An env var in `.env` seems ignored / a feature stays "off".** The
compose `environment:` block is a **whitelist** — a variable set in
`.env` only reaches the container if `docker-compose.yml` lists it
under `environment:`. Vars not listed never propagate, even with
`${VAR}` substitution at compose-up. If a setting looks configured but
is silently dead, check the var is in that block (add it if you run a
custom compose); the supported alternative for most secrets (VAPID,
GitHub token, GlitchTip DSN) is the **admin panel**, which stores them
in the database and sidesteps the whitelist entirely.
- **A new image doesn't pick up / `:latest` looks stale.** `docker-compose.yml`
sets `pull_policy: always`, which is load-bearing: without it Docker
re-uses the cached `:latest` digest and silently skips the registry
round-trip on `compose up`. If you pin a custom compose, keep
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Backup and restore
Your database is the only stateful piece — back it up, and back up the
key that decrypts it.
**Local snapshot (the common single-box case).** Dump the `db` service
with `pg_dump`:
```bash
# The bundled db service uses user `healthlog` and database `healthlog`.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-$(date +%F).sql.gz
```
Restore into a fresh database the same way:
```bash
gunzip -c healthlog-2026-01-01.sql.gz \
| docker compose exec -T db psql -U healthlog healthlog
```
> **Back up your `ENCRYPTION_KEYS` / `ENCRYPTION_KEY` alongside the dump.**
> HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database
> dump **without the encryption key is unrecoverable** — the encrypted
> data cannot be read back. Store the key (and any retired keys still in
> the rotation map) somewhere separate from the dump but equally durable.
> Losing the key means losing the encrypted data, full stop.
For off-host encrypted backups to S3/R2/B2 see
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
encryption key safely (keep the old key until zero rows remain on it) see
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
+212
View File
@@ -0,0 +1,212 @@
# Notifications — push channels for a self-host
HealthLog delivers reminders (medication doses, mood check-ins, personal
records) and a few operational alerts through a small cascade of push
channels. The single message to take away: **you do not need an Apple
Developer account or APNs to get push.** Three of the four channels are
free, work in the browser or in a messaging app you already have, and
cover every platform a self-hoster runs. APNs is only for the optional
native iOS app.
When an event fires, the dispatcher fans it out to every channel a user
has enabled, in a fixed order — APNs first, then Telegram, then ntfy,
then Web Push as the universal fallback
(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and
independent: a user can enable one, two, or all four, and a failure on
one never blocks the others.
## TL;DR — which channel needs what
| Channel | What it needs | Cost | Where it works |
| --- | --- | --- | --- |
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
Web Push is the recommended default — it needs no third-party account,
just a keypair you generate yourself once.
## Web Push (recommended default)
Web Push delivers notifications straight to a browser — desktop or
mobile — even when the HealthLog tab is closed, as long as the browser
or installed PWA is alive. It is the broadest-reach channel and the one
to set up first.
### 1. Generate and store a VAPID keypair
VAPID is the signing scheme that lets a push service trust your server.
You also choose a **subject** — a `mailto:` address the push service can
contact about your sender, e.g. `mailto:you@example.com`.
The server loads VAPID config from the database first and falls back to
environment variables (`src/lib/notifications/vapid-config.ts`). The admin
panel is the easiest path because it survives without touching `.env`.
**Admin panel — Generate keys (easiest).** Sign in as the admin user,
open `/admin`, and find the **Web Push VAPID** card
(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate
keys**: the server mints a fresh keypair, stores the private key encrypted
at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills
in the public key. Edit the subject to your real `mailto:` address and
save. The card shows a "configured" badge once all three fields are set —
no shell, no copy-paste.
> If a keypair already exists, **Generate keys** asks you to confirm before
> replacing it. Regenerating invalidates every existing browser
> subscription, so each device has to re-subscribe afterwards. Only
> regenerate when you mean to.
**Admin panel — paste an existing pair.** If you already have a keypair
(e.g. copied from another deployment), paste the public key, the private
key, and the subject into the same card and save instead of generating.
**CLI — generate a pair yourself.** You can also mint one with the bundled
`web-push` CLI and paste the result into the card or your `.env`:
```bash
npx web-push generate-vapid-keys
```
That prints a **public key** and a **private key** (both Base64URL).
### 2. Store the keys via environment variables (alternative)
If you would rather keep
secrets out of the database, set them in `.env` / the compose
`environment:` block instead:
```env
VAPID_PUBLIC_KEY="BElx...(public key)"
VAPID_PRIVATE_KEY="...(private key)"
VAPID_SUBJECT="mailto:you@example.com"
```
The database values win when both are present. The loader also accepts a
few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the
private/subject equivalents) so a config copied from another deployment
keeps working.
### 3. Subscribe a device
Each user enables Web Push for their own browser from
`/settings/notifications` — the **Browser Push** card has a Subscribe
button, and a Test button to fire a one-off push once subscribed. The
browser prompts for notification permission on first subscribe; once
granted, that browser receives every enabled event.
### iPhone caveat — Add to Home Screen first
Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and
only for a site that has been **installed to the Home Screen as a PWA**.
A regular Safari tab cannot subscribe. So on iPhone:
1. Open your HealthLog instance in Safari.
2. Tap the Share button, then **Add to Home Screen**.
3. Launch HealthLog from the new Home Screen icon (not from the Safari
tab).
4. Open `/settings/notifications` inside that installed app and tap
Subscribe; accept the permission prompt.
Android and desktop browsers have no such step — Subscribe and grant the
permission, and push works.
## Telegram
Telegram delivers reminders as bot messages, with inline action buttons
on medication reminders (Taken / snooze / skip). It is a good fit when
you already live in Telegram or want push without running a browser.
1. In Telegram, open **@BotFather**, create a bot, and copy the **bot
token** (it looks like `123456:ABC-DEF...`).
2. Send `/start` to your new bot so it can message you.
3. Find your **chat ID** — send a message to **@userinfobot**, or read it
from the Bot API.
4. In HealthLog open `/settings/notifications`, fill the Telegram card's
**Bot Token** and **Chat ID**, toggle it on, and save.
One bot can serve every user on the instance; each user pairs their own
chat ID. The token is encrypted at rest.
## ntfy
[ntfy](https://ntfy.sh) is the most self-hoster-native option: a tiny
pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
HealthLog publishes to that topic.
1. Pick a hard-to-guess topic name (the topic *is* the access control on
public `ntfy.sh` — anyone who knows the string can read it, so treat
it like a secret).
2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/<topic>`.
3. In HealthLog open `/settings/notifications`, set the ntfy card's
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated as a public host and dialled through the egress
guard (`src/lib/notifications/senders/ntfy.ts`).
## APNs — native iOS app only
Apple Push Notification service is used by **only** the native SwiftUI
iOS app (TestFlight / App Store build). PWA and browser users never touch
it. Setting it up requires a paid **Apple Developer Program** membership.
You do **not** need APNs to give iPhone users push: the installed PWA
gets every reminder over Web Push (see the iPhone caveat above). APNs adds
one Apple-platform exclusive the PWA cannot offer — the lock-screen Live
Activity — plus the tighter native delivery path. Reminders themselves
arrive on both.
If you do run the native app and want server-driven push, set the APNs
manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers
plus exactly one key source:
| Variable | Purpose |
| --- | --- |
| `APNS_KEY_ID` | The `.p8` key's Key ID |
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
| `APNS_BUNDLE_ID` | The app's bundle identifier |
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
| `APNS_KEY` | OR the raw PEM body inline |
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
APNs is **all-or-none**: either set the three IDs plus one key source, or
leave the whole block unset. A partial config is rejected so the channel
never silently half-works (`scripts/check-env.ts`). When more than one key
source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` >
`APNS_KEY_FILE`.
Two gotchas worth knowing before you blame a stale token:
- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file
pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body,
base64-encoded) sidesteps that entirely and is the recommended source.
- **Scope the key "Sandbox & Production".** In the Apple Developer
Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails
on production (TestFlight / App Store) device tokens with
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
> its own local medication reminders and ask the server to stop sending
> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
> a web/PWA-only self-hoster never needs it and there is no toggle for it
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Which channel should I pick?
| Your setup | Recommended channel |
| --- | --- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. |
| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
Mixing channels is fine and common — e.g. Web Push on the laptop and
ntfy on the phone. Each user configures their own set from
`/settings/notifications`.
+32 -3
View File
@@ -149,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
measurements to export) and for action parameters that are not
persisted at all (export options).
#### 2.2.1 Settings persistence patterns
Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist** — a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success/error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save** — the card edits a multi-field payload (or a list
the server PUTs whole). All controls — including `<Switch>` rows —
mutate a local draft only; a visible Save button flushes the draft
in one request and the card surfaces its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
A `<Switch>` inside a draft+Save card is legal **only** when the card
shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress) — label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
+2 -2
View File
@@ -116,7 +116,7 @@ test.describe("axe-core authenticated surfaces", () => {
}
// The keyboard-only "skip to content" link is fixed at the top-left
// corner of every authenticated page. Marc reported it intercepting
// corner of every authenticated page. The maintainer reported it intercepting
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
// with `-translate-y-full` so the element sits offscreen until
// focused. Regression guard: with no keyboard focus, the logo's own
@@ -127,7 +127,7 @@ test.describe("axe-core authenticated surfaces", () => {
}) => {
// The desktop sidebar (and its logo) is `hidden md:flex` — the
// mobile profile (Pixel 5) hides it by design. The skip-link
// regression Marc originally reported in v1.4.14 was a desktop-
// regression the maintainer originally reported in v1.4.14 was a desktop-
// only artifact (mobile uses the topbar logo, which is a
// different DOM node). Skip the mobile project so we don't
// flake on a contract that doesn't apply there.
+8 -8
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but Marc still saw a
* hide breakpoints + tighter card padding, but the maintainer still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
* mobile card-list at <md, mirroring `<UserManagementSection>`.
*
@@ -23,7 +23,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
}) => {
// Mock the admin tokens endpoint with a moderately wide payload —
// a token with multiple permissions, a long name, and timestamps.
// This is the realistic worst case Marc hit on prod.
// This is the realistic worst case the maintainer hit on prod.
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
@@ -38,7 +38,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
{
id: "tok2",
@@ -48,7 +48,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: "2026-08-01T00:00:00Z",
createdAt: "2026-04-15T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
],
error: null,
@@ -76,7 +76,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
// visible "scrollbar" Marc reported for the third time as the
// visible "scrollbar" the maintainer reported for the third time as the
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
// permanently exceeds clientWidth (~360 CSS px). Adding the
@@ -120,7 +120,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
// `no-scrollbar` fix from v1.4.18, Marc still reported a painted
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
// scrollbar. The card body itself must not contribute any
// horizontal-overflow culprit: every element inside the api-tokens
// section must satisfy `scrollWidth <= clientWidth + 1`. Pads with
@@ -149,7 +149,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
revoked: false,
user: {
id: "u1",
username: "marc-the-very-long-username-that-overflows",
username: "user-the-very-long-username-that-overflows",
},
},
],
@@ -166,7 +166,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
// Walk every element inside the mobile card-list and assert no
// child has a wider scrollWidth than its container. This is the
// Pixel-5 worst-case Marc kept hitting.
// Pixel-5 worst-case the maintainer kept hitting.
//
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
+8
View File
@@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
}),
);
// Hydration gate — the provider query is fired by the same client
// boundary that renders the select, so its response proves React
// has hydrated the card and attached the select's change handler.
// `selectOption` on the SSR-painted element any earlier flips the
// native value without React noticing, and the config form below
// never switches (CI failure at slow hydration).
const cardHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await cardHydrated;
// 1. Dropdown surfaces on the page.
const select = page.getByTestId("ai-active-provider-select");
+1 -1
View File
@@ -9,7 +9,7 @@ import {
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient fill,
* The maintainer reverted v1.4.16's always-on chart overlays (gradient fill,
* personal-baseline line, target-zone shading) and asked for per-chart
* switches that the user can flip on or off. This spec exercises the
* end-to-end behaviour against the seeded test user:
+252
View File
@@ -0,0 +1,252 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Guided clarifying-questions flow on the full-page Coach route
* (v1.16.5 — V2 of the v1.16.0 composer chips).
*
* Contracts under test:
*
* 1. With pending self-context questions, the entry card
* (`data-slot="coach-guided-offer"`) renders above the composer
* and offers start / later / don't-ask-again.
* 2. Starting the flow mounts the first deterministic question
* bubble in the thread (`data-slot="coach-guided-question"`,
* `data-state="current"`) with the progress line and the
* skip / later / dismiss actions.
* 3. Skip advances to the next question without any server write.
* 4. "Later" mid-sequence ends the flow without a summary when
* nothing was answered; the entry card stays hidden for the
* session.
* 5. The entry-card "later" hides the offer without touching the
* thread.
*
* 6. v1.16.6 — answering a question streams a contextual Coach
* reaction (mocked SSE) BEFORE the next question appears; the
* sequence advances only once the adopt offer settles.
*
* The questions API is mocked; the answer test also mocks the chat SSE
* POST (the reaction content itself is the model's business, only the
* ordering contract is pinned here).
*/
test.describe("Coach guided clarifying questions", () => {
test.use({ storageState: STORAGE_STATE_PATH });
const QUESTIONS = [
"Guided e2e question one?",
"Guided e2e question two?",
"Guided e2e question three?",
];
const REACTION = "Thanks for sharing — that helps me read your numbers.";
test.beforeEach(async ({ page }) => {
// Empty conversation list so the page surface paints the empty
// thread + history rail without hitting the database.
// Wide pattern on purpose: the list GET may fire with or without a
// cursor query. Neither the detail GET nor the SSE POST fires in
// this spec (no conversation is opened, no message is sent).
await page.route("**/api/insights/chat*", (route) => {
// v1.16.6 — the answer test POSTs the guided answer through the
// normal chat pipeline; serve a minimal SSE reaction. GETs keep
// the empty conversation list.
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
`data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` +
`data: ${JSON.stringify({
type: "done",
conversationId: "conv-guided-e2e",
messageId: "msg-guided-e2e",
})}\n\n`,
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { conversations: [], nextCursor: null },
error: null,
}),
});
});
// Detail refetch after the SSE `done` frame.
await page.route("**/api/insights/chat/conv-guided-e2e", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "conv-guided-e2e",
title: "Guided e2e",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 2,
summary: null,
messages: [
{
id: "msg-user-e2e",
role: "user",
content: "Yes, hypertension since 2019.",
createdAt: new Date().toISOString(),
metricSource: null,
providerType: null,
promptVersion: null,
},
{
id: "msg-guided-e2e",
role: "assistant",
content: REACTION,
createdAt: new Date().toISOString(),
metricSource: null,
providerType: "mock",
promptVersion: null,
},
],
},
error: null,
}),
}),
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
// Pending questions: GET serves the snapshot, DELETE (dismiss one
// or all) empties it.
await page.route("**/api/coach/about-me/questions*", (route) => {
const isDelete = route.request().method() === "DELETE";
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { questions: isDelete ? [] : QUESTIONS },
error: null,
}),
});
});
});
test("offer card starts the guided sequence; skip and later walk it", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
// 1. Entry card with the three choices.
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await expect(
offer.locator('[data-slot="coach-guided-offer-start"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-later"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-dismiss"]'),
).toBeVisible();
// 2. Start → first question bubble (typing reveal lasts <1s).
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
await expect(offer).toHaveCount(0);
const question = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(question).toBeVisible({ timeout: 10_000 });
await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("1");
// 3. Skip → second question, original numbering.
await question.locator('[data-slot="coach-guided-skip"]').click();
await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
// 4. Later mid-sequence → flow ends, nothing answered → no summary,
// no question bubble, and the offer stays hidden for the session.
await question.locator('[data-slot="coach-guided-later"]').click();
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-summary"]'),
).toHaveCount(0);
await expect(offer).toHaveCount(0);
});
test("entry-card later hides the offer without starting the flow", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-later"]').click();
await expect(offer).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
});
test("an answer streams a Coach reaction before the next question", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
const current = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 });
// Answer through the composer — the message rides the normal chat
// pipeline (mocked SSE above).
const textarea = page.locator('[data-slot="coach-input-textarea"]');
await textarea.fill("Yes, hypertension since 2019.");
await page.locator('[data-slot="coach-input-send"]').click();
// Order contract: the streamed Coach reaction appears, the adopt
// offer follows, and NO next question is current yet.
await expect(page.getByText(REACTION).first()).toBeVisible({
timeout: 10_000,
});
const adopt = page.locator('[data-slot="coach-self-context-adopt"]');
await expect(adopt).toBeVisible({ timeout: 10_000 });
await expect(current).toHaveCount(0);
// Settling the offer (decline via ✕) releases the next question.
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
current.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* The DE label surface (Marc's primary locale) is the assertion
* The DE label surface (the maintainer's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
test.describe("medication wizard — daily", () => {
+3 -1
View File
@@ -78,7 +78,9 @@ test.describe("medication wizard — one-shot", () => {
await expectStep(page, 5, 5);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Einmaldosis/i);
await expect(summary).toContainText(/2026-10-15/);
// The summary renders the date through the locale-aware formatter
// (DE UI → "15.10.2026"), not the ISO value the input carries.
await expect(summary).toContainText(/15\.10\.2026/);
await clickSave(page);
+1 -1
View File
@@ -23,7 +23,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* Walks the 8-step recurring path with "Flexibel ab letzter
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
* surfaces Marc's literal "Counter startet neu" copy directly below
* surfaces the maintainer's literal "Counter startet neu" copy directly below
* the row label — the assertion below pins the phrase so a future
* copy edit can't silently drop the rolling explainer.
*/
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* #316 follow-up — does the medications list refetch when you navigate
* back to the page?
*
* Reporter: "TanStack Query is not fetching data on the medications page
* or other areas when the data is stale, even when I navigate between
* pages." In Next.js App Router a route-level page UNMOUNTS on navigation
* away and remounts on return, so the query observer is recreated — but
* the global `staleTime: 5min` makes `refetchOnMount: true` skip the
* fetch while the cached data is still "fresh", so the page shows a
* potentially stale list with no network round-trip.
*
* This spec counts `/api/medications` requests across a client-side
* round-trip (medications → dashboard → medications). The DESIRED
* (post-fix) behaviour is a fresh fetch on return; pre-fix the count
* stays at 1, which is the reproduction.
*
* Desktop-only: the assertion is about query behaviour, and the sidebar
* nav links it clicks live behind a drawer on the mobile project.
*/
test.use({ storageState: STORAGE_STATE_PATH });
test("medications list refetches on client-side navigation back to the page", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.includes("mobile"),
"sidebar nav links are drawer-gated on mobile; query behaviour is project-independent",
);
let medsRequests = 0;
page.on("request", (req) => {
try {
const u = new URL(req.url());
if (u.pathname === "/api/medications") medsRequests += 1;
} catch {
/* non-URL request — ignore */
}
});
// Initial load — the list query mounts and fetches once.
await page.goto("/medications");
await page.waitForResponse(
(r) => new URL(r.url()).pathname === "/api/medications",
);
expect(medsRequests).toBe(1);
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
// remounts the medications page. A full page.goto() would reset the
// client cache and always refetch, so we click the sidebar links.
await page.locator('a[href="/"]').first().click();
await page.waitForURL("**/");
await page.locator('a[href="/medications"]').first().click();
await page.waitForURL("**/medications");
// Give a returning refetch time to fire.
await page.waitForTimeout(1500);
// Desired: the return mounts a fresh observer that refetches. Pre-fix
// the 5-min staleTime masks it and this stays at 1 (the bug).
expect(medsRequests).toBeGreaterThanOrEqual(2);
});
+1 -2
View File
@@ -143,9 +143,8 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
const pills = page.locator('[data-testid="integration-status-pill"]');
// Three integration cards now (Withings, WHOOP, Google Health) → three
// pills.
await expect(pills).toHaveCount(3, { timeout: 10_000 });
await expect(pills).toHaveCount(6, { timeout: 10_000 });
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
+1 -1
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
* exactly: a single link with no sub-item expansion in the global
* sidebar. Marc reported in v1.4.15 that the sidebar was auto-expanding
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
* the sidebar — both unwanted UX. The in-shell `<AdminShell>` already
* renders its own per-section nav inside the page itself.
+10
View File
@@ -144,6 +144,16 @@ export function buildMockSnapshot(
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
},
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
+77 -26
View File
@@ -5,21 +5,23 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
* Contracts under test (v1.16.1):
* Contracts under test (v1.16.8):
*
* 1. The floating bubble (`data-slot="coach-nudge-bubble"`) renders
* ONLY while an unread Coach-initiated nudge exists
* (`GET /api/insights/coach/nudge-status`). By default no bubble
* mounts on any viewport; the everyday entry point is the inline
* launch the sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`, visible across breakpoints).
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
* launcher on every authenticated page. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
* unread the FAB renders without the dot. The inline launch the
* sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
*
* 2. Clicking the inline launch opens the Coach drawer — as a
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
*
* 3. With an unread nudge the bubble mounts and clicking it
* navigates to the full-page chat at `/insights/coach`.
* 3. With an unread nudge the FAB carries the dot; clicking the FAB
* opens the Coach drawer in place (v1.16.11 — no navigation, the
* page underneath stays) and the dot clears.
*
* 4. The drawer's "Conversations" button no longer opens an in-panel
* tray; it hands off to the full-page route `/insights/coach`.
@@ -124,7 +126,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Pixel 5`, async ({
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
@@ -134,18 +136,20 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// The header launch icon is the everyday entry point — visible
// on mobile too since the layout bubble is nudge-gated.
// The header launch icon stays the contextual entry point.
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
// No unread nudge → no floating bubble in the DOM.
// The FAB is a permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
});
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Desktop Chrome`, async ({
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
page,
}, testInfo) => {
test.skip(
@@ -158,8 +162,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
});
}
@@ -172,7 +178,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer bottom-sheet branch is mobile-only",
);
// Hydration gate — the status query is fired by the sub-page
// boundary itself, so its response proves React has hydrated the
// page and attached the launch icon's click handler. Clicking the
// SSR-painted icon any earlier is silently lost (CI flake).
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -191,7 +205,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer side-sheet branch is desktop-only",
);
// Hydration gate — see the bottom-sheet branch above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -202,10 +221,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("an unread Coach nudge mounts the floating bubble and clicking it opens the full-page chat", async ({
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({
page,
}) => {
// The bubble contract is viewport-independent — the test runs on
}, testInfo) => {
// The FAB contract is viewport-independent — the test runs on
// both projects. Override the default nudge mock with an unread
// one (later registration wins).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
@@ -219,16 +238,43 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
}),
);
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached (the button paints from SSR HTML long before that).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const bubble = page.locator('[data-slot="coach-nudge-bubble"]');
await expect(bubble).toBeVisible({ timeout: 10_000 });
await bubble.click();
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(fab).toHaveAttribute("data-unread", "true", {
timeout: 10_000,
});
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
page.locator('[data-slot="coach-fab-unread"]'),
).toBeVisible();
await fab.click();
// v1.16.11 — the FAB opens the side drawer in place instead of
// navigating to `/insights/coach`; the page underneath stays.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute(
"data-variant",
testInfo.project.name === "chromium-mobile"
? "bottom-sheet"
: "side-sheet",
);
expect(new URL(page.url()).pathname).not.toContain("/insights/coach");
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
@@ -242,7 +288,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"exercises the desktop drawer layout",
);
// Hydration gate — see the bottom-sheet test above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -0,0 +1,133 @@
/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
*
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
* (message via `readError`, `status`, error-side `meta`). A raw
* `fetch("/api/…")` in a component re-implements that contract by hand
* — the exact drift this wrapper retired — so this rule flags it at
* authoring time and in CI.
*
* Complements `healthlog/safe-fetch-required`: that rule covers
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
* same-origin relative paths; this rule covers exactly those same-origin
* `/api/...` paths inside the client surface.
*
* Allowed:
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
* distinct identifiers, never match.
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` / `self.fetch(` member forms, where the first
* argument is a string literal or template literal whose head starts
* with `/api/`. Variable-sourced or absolute URLs are the
* safe-fetch-required rule's territory and stay out of scope here.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
// Files exempt from the rule — the wrapper internals and the test
// surface. Posix-style suffix matches against the absolute filename via
// `String#includes`, mirroring the safe-fetch-required convention.
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// Enforce across the client-facing source roots. `src/lib/` stays out:
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component.
const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const apiFetchRequiredRule = {
meta: {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
},
schema: [],
messages: {
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isApiPath(arg) {
if (!arg) return false;
const isApiHead = (head) => head.startsWith("/api/");
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isApiHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isApiHead(arg.quasis[0].value.cooked ?? "");
}
return false;
}
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
},
};
module.exports = apiFetchRequiredRule;
+3
View File
@@ -4,16 +4,19 @@
*
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
*/
"use strict";
const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
module.exports = {
rules: {
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
"safe-fetch-required": safeFetchRequired,
"api-fetch-required": apiFetchRequired,
},
};
+8 -5
View File
@@ -22,7 +22,7 @@
* test-guard substitute holds the "guarded surface" line until a
* future wave extends both the guard and this whitelist together.
*
* @see src/lib/query-keys.ts — factory home (do not lint as offender)
* @see src/lib/query-keys/ — factory home (do not lint as offender)
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
*/
@@ -35,14 +35,16 @@
// exempt for now. Path matches use `String#includes` semantics against
// the absolute filename — keep entries as posix-style suffix paths.
const FACTORY_HOME_FILES = [
// The factory definition file is allowed to declare literal keys —
// that's the whole point of the factory.
"src/lib/query-keys.ts",
// The factory's own unit-test file constructs literal keys to assert
// the factory output shape.
"src/lib/__tests__/query-keys.test.ts",
];
// The factory definition files are allowed to declare literal keys —
// that's the whole point of the factory. The factory is split into
// per-feature files under this directory (barrel at index.ts).
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
// Directories under which any `queryKey: [ … ]` literal is an error.
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
const GUARDED_DIRECTORIES = [
@@ -81,6 +83,7 @@ function isGuarded(filename) {
for (const home of FACTORY_HOME_FILES) {
if (filename.endsWith(home)) return false;
}
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
for (const dir of GUARDED_DIRECTORIES) {
if (filename.includes(`/${dir}/`)) return true;
}
@@ -96,7 +99,7 @@ const queryKeyFactoryRule = {
type: "problem",
docs: {
description:
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys.ts` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
@@ -39,6 +39,10 @@
const EXEMPT_FILES = [
"src/lib/safe-fetch.ts",
"src/lib/safe-fetch-dispatcher.ts",
// The same-origin `/api/...` envelope wrapper: its `fetch` calls are
// relative-path by contract (the api-fetch-required rule pins every
// client call site onto it), so the egress defences don't apply.
"src/lib/api/api-fetch.ts",
];
// Only enforce inside the application source roots. Scripts, config, and
+6
View File
@@ -38,6 +38,12 @@ const eslintConfig = defineConfig([
// requirePublicHost connect-time pin). The wrapper internals and
// test files are exempt; see the rule file for the allowlist.
"healthlog/safe-fetch-required": "error",
// v1.16.4 — same-origin `/api/...` calls in the client surface
// (src/components + src/app) must route through the typed apiFetch
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
// envelope unwrap, one ApiError shape. Wrapper internals and test
// files are exempt; see the rule file for the allowlist.
"healthlog/api-fetch-required": "error",
},
},
]);
+10 -4
View File
@@ -68,11 +68,17 @@
"docker",
"docker-compose",
"pg_isready",
"tsx"
"tsx",
"cosign"
],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
"src/components/ui/**": ["exports", "types"],
"src/lib/validations/**": ["types"]
"src/components/ui/**": [
"exports",
"types"
],
"src/lib/validations/**": [
"types"
]
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
# `prepare` script in package.json; no manual step needed.
#
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
# staged hunks against the repo's `.gitleaks.toml` before every commit,
# so a secret-shaped string never reaches the object store (where only
# a history rewrite can remove it again).
#
# Requirements: the `gitleaks` binary must be on PATH — the command
# below fails with install pointers when it is missing rather than
# silently skipping the scan.
#
# Opt-out (one commit): LEFTHOOK=0 git commit ...
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
# Opt-out (permanent): pnpm lefthook uninstall
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
pre-commit:
commands:
gitleaks:
run: |
if ! command -v gitleaks >/dev/null 2>&1; then
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
echo "Install it first, e.g.:" >&2
echo " brew install gitleaks # macOS" >&2
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
exit 1
fi
gitleaks protect --staged --redact --no-banner
+894 -47
View File
File diff suppressed because it is too large. Load diff
+895 -48
View File
File diff suppressed because it is too large. Load diff
+894 -47
View File
File diff suppressed because it is too large. Load diff
+894 -47
View File
File diff suppressed because it is too large. Load diff
+894 -47
View File
File diff suppressed because it is too large. Load diff
+895 -48
View File
File diff suppressed because it is too large. Load diff
+12 -8
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.1",
"version": "1.17.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -24,6 +24,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"build": "next build",
@@ -68,7 +69,7 @@
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.17.0",
"lucide-react": "^1.18.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next-themes": "^0.4.6",
@@ -76,20 +77,21 @@
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"nodemailer": "^9.0.0",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-hook-form": "^7.77.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "^7.78.0",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"undici": "^8.3.0",
"undici": "^8.4.1",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zod-openapi": "^5.4.6",
"zod-openapi": "^6.0.0",
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
@@ -99,6 +101,7 @@
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
"@types/node": "^25.9.2",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
@@ -109,9 +112,10 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
"prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
"tailwindcss": "^4",
+856 -727
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
-- v1.16.3 — schedule-revision archive (effective dating).
--
-- A medication whose schedule times are edited used to lose its history: the
-- wholesale replace (deleteMany + create) left only the CURRENT rows, so
-- bands for PAST days were minted from the new times — the entire old era
-- read "unscheduled" and the new times' past slots read "missed".
--
-- This table archives each SUPERSEDED schedule state as one revision row
-- covering `[valid_from, valid_until)`. The LIVE rows stay in
-- `medication_schedules`; a revision never describes the current state, so
-- `valid_until` is NOT NULL by construction. `payload` holds the full
-- snapshot of the replaced schedule rows (timesOfDay, windows, cadence
-- fields, doseWindows, label, dose, reminderGraceMinutes) — enough to
-- rebuild the canonical schedule list for era minting.
--
-- `starts_on` stays the global floor: the first era begins at
-- max(medication.created_at, starts_on).
--
-- Additive + non-destructive: a new empty table; no existing row changes.
CREATE TABLE "medication_schedule_revisions" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"valid_from" TIMESTAMPTZ(6) NOT NULL,
"valid_until" TIMESTAMPTZ(6) NOT NULL,
"payload" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_schedule_revisions_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "medication_schedule_revisions_medication_id_valid_from_idx"
ON "medication_schedule_revisions"("medication_id", "valid_from");
ALTER TABLE "medication_schedule_revisions"
ADD CONSTRAINT "medication_schedule_revisions_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,10 @@
-- v1.16.4 — per-intake dose override.
--
-- A user who takes half a tablet (or a doubled dose on a titration day)
-- could only record the take as-is; the ledger then implied the scheduled
-- dose was consumed. `dose_taken` records the free-text dose actually
-- consumed for THIS event when it deviates from (or simply documents) the
-- medication / schedule dose. NULL = no override recorded — every legacy
-- row and every take logged without touching the dose field stays NULL,
-- and read paths fall back to the medication's configured dose.
ALTER TABLE "medication_intake_events" ADD COLUMN "dose_taken" TEXT;
@@ -0,0 +1,19 @@
-- v1.16.5 — schedule-revision provenance.
--
-- The Zeitplan tab now lets a user append a PRE-tracking era by hand
-- ("the medication dosed at 07:00/19:00 from March to June, before I
-- edited it here"). Those rows are user-entered, not write-path
-- archives, so they must stay deletable without touching the archive
-- the schedule-replace path mints.
--
-- `source` records the provenance:
-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable
-- history; never deletable through the API).
-- 'MANUAL' — entered through POST
-- /api/medications/{id}/schedule-revisions (deletable by
-- the owner).
--
-- Additive + non-destructive: every existing row is a write-path
-- archive, so the default backfills correctly.
ALTER TABLE "medication_schedule_revisions"
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED';
@@ -0,0 +1,14 @@
-- v1.16.6 — audit trail for corrected schedule archives.
--
-- The Zeitplan tab now lets the owner CORRECT a recorded era. A MANUAL
-- era is edited in place; an ARCHIVED era (minted by the write path)
-- stays immutable — the correction is written as a new MANUAL row and
-- this column on the original points at it. Every era consumer (the
-- splitter, the list endpoint, live-boundary reads) skips superseded
-- rows, so the correction takes the original's place while the
-- original remains as the audit record.
--
-- Additive + non-destructive: NULL means "active era", which is what
-- every existing row is.
ALTER TABLE "medication_schedule_revisions"
ADD COLUMN "superseded_by_revision_id" TEXT;
@@ -0,0 +1,15 @@
-- v1.16.8 — content-hash gate for insight regeneration.
--
-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the
-- compacted feature snapshot the cached comprehensive insight was
-- generated from. Nightly, forced, and lazy regenerations compare the
-- fresh snapshot's hash against it and skip the provider call
-- (refreshing only `insights_cached_at`) when nothing the prompt sees
-- has changed.
--
-- `insights_warm_failed_at` records the last failed/timed-out forced
-- warm of the comprehensive insight, so the forced path can back off
-- for an hour instead of re-attempting a broken provider chain on
-- every page-open. Additive + non-destructive.
ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT;
ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3);
@@ -0,0 +1,36 @@
-- v1.16.10 — multi-unit inventory consumption.
--
-- `units_per_dose` (medications): how many inventory UNITS one dose
-- consumes (2 × 2 mg tablets for a 4 mg dose). Default 1 keeps every
-- existing medication on the one-unit-per-dose behaviour.
--
-- `container_type` (medication_inventory_items): what kind of physical
-- container the item is (pen / ampoule / blister pack / inhaler /
-- bottle). Display-level only. Backfilled PEN for items whose
-- medication is an INJECTION (the pre-v1.16.10 surface was pen-only),
-- OTHER for everything else.
--
-- `inventory_consumption` (medication_intake_events): the per-event
-- consumption stamp `[{itemId, units}]` — the exactly-once gate for
-- the consume hook and the refund ledger for the restore hook. NULL =
-- never consumed (pending / skipped / pre-v1.16.10 rows).
--
-- The Prisma fields `dosesTotal` / `dosesRemaining` are renamed to
-- `unitsTotal` / `unitsRemaining` at the client level only; the DB
-- columns keep their names via @map — zero data migration.
CREATE TYPE "medication_container_type" AS ENUM ('PEN', 'AMPOULE', 'BLISTER', 'INHALER', 'BOTTLE', 'OTHER');
ALTER TABLE "medications" ADD COLUMN "units_per_dose" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "medication_inventory_items" ADD COLUMN "container_type" "medication_container_type" NOT NULL DEFAULT 'OTHER';
ALTER TABLE "medication_intake_events" ADD COLUMN "inventory_consumption" JSONB;
-- Backfill: every existing item was registered through the pen-framed
-- surface; items on injection medications are pens.
UPDATE "medication_inventory_items" i
SET "container_type" = 'PEN'
FROM "medications" m
WHERE i."medication_id" = m."id"
AND m."delivery_form" = 'INJECTION';
@@ -0,0 +1,11 @@
-- v1.16.10 — medications list presentation preference.
--
-- `medication_list_layout_json` (users): the per-user card/table view
-- choice for /medications plus the user-defined manual medication order
-- shared by both views. Follows the per-surface-column convention of
-- `dashboard_widgets_json` / `insights_layout_json`. NULL = defaults
-- (cards, alphabetical order); the GET endpoint never lazy-writes.
--
-- Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB;
@@ -0,0 +1,11 @@
-- v1.16.11 — low-stock notification stamp (medications).
--
-- The daily low-stock pass notifies once per threshold crossing:
-- `low_stock_notified_at` records the dispatch instant,
-- `low_stock_notified_threshold_days` the user threshold (days of
-- remaining runway) it was written against. The pass clears both when
-- the projected runway rises back to / above the threshold (refill),
-- and treats a differing stamped threshold as re-armed. NULL = armed.
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_at" TIMESTAMP(3);
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_threshold_days" INTEGER;
@@ -0,0 +1,29 @@
-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout.
--
-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags):
-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category,
-- set = a user's custom group carrying a `customcat:<uuid>` key) and
-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's
-- free-text label; NULL for seeded rows, which resolve `label_key`
-- against the locale).
-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob
-- (group order + per-group tag placement), display-only, following the
-- `medication_list_layout_json` per-surface-column convention.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by
-- duplicate_object, CREATE INDEX IF NOT EXISTS.
-- ─── 1. mood_tag_categories: owner + encrypted custom label ──────────
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT;
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT;
DO $$ BEGIN
ALTER TABLE "mood_tag_categories"
ADD CONSTRAINT "mood_tag_categories_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id");
-- ─── 2. users: per-user mood-tag presentation blob ───────────────────
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB;
@@ -0,0 +1,9 @@
-- v1.16.11 (#316) — as-needed (PRN) medications.
--
-- `as_needed = true` marks a medication taken only when the user needs
-- it: no fixed schedule (zero medication_schedules rows — enforced at
-- the route layer), never due, never reminded, excluded from
-- compliance, active indefinitely. Intakes still log, inventory still
-- consumes. Additive: every existing row backfills `false`.
ALTER TABLE "medications" ADD COLUMN "as_needed" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,15 @@
-- v1.16.12 (#316) — fractional dosing.
--
-- A dose can consume a FRACTION of a unit (½ / ⅓ / ¼ of a tablet for a
-- split pill), so the unit↔dose factor and the per-container stock must
-- hold non-integer values: 30 tablets dosed at ½ each reads 29.5 after
-- one dose. Widen the three integer unit columns to NUMERIC; every
-- existing integer value casts losslessly. Additive and reversible in
-- value terms (all current rows are whole numbers).
ALTER TABLE "medications"
ALTER COLUMN "units_per_dose" TYPE numeric(10,4) USING "units_per_dose"::numeric(10,4);
ALTER TABLE "medication_inventory_items"
ALTER COLUMN "doses_total" TYPE numeric(12,4) USING "doses_total"::numeric(12,4),
ALTER COLUMN "doses_remaining" TYPE numeric(12,4) USING "doses_remaining"::numeric(12,4);
@@ -0,0 +1,43 @@
-- v1.16.13 — operator mood-tag catalog curation.
--
-- Reference-data only on top of 0101 / 0118 / 0119 / 0126. No schema
-- change, no per-user data touched, no destructive delete — every change
-- is an `is_active` flip or an idempotent upsert keyed by `key`, so a
-- re-run is a no-op and historical `mood_entry_tag_links` rows keep
-- resolving their labels (the insights read joins the catalog row by id
-- and does NOT filter `is_active`).
--
-- Operator walked the capture sheet and asked for:
-- - deactivate the redundant default tags `happy`, `excited`,
-- `hydrated` ("Wasser getrunken"), `music`, and the
-- `factor_sleep_quality` RATED factor;
-- - keep `overtime` active — 0126 had deactivated it as a low-signal
-- day-type tag, but the operator wants it back in the work picker;
-- - add a new default tag `praise` ("Lob" / "Praise") under the work
-- category, Lucide `ThumbsUp` (already in the iOS Lucide→SF table).
--
-- factor_sleep_quality is deactivated alongside the rest: a manual 1–5
-- sleep rating is redundant with the measured sleep-tracking surface, so
-- it leaves the seeded factor set — work / sadness stay as the RATED
-- factors from 0126. Historical entries keep resolving (read joins by id,
-- not `is_active`).
-- ─── Deactivate the redundant default tags + sleep-quality factor ─────
UPDATE "mood_tags"
SET "is_active" = false
WHERE "key" IN ('happy', 'excited', 'hydrated', 'music', 'factor_sleep_quality');
-- ─── Re-activate overtime in the work category ───────────────────────
UPDATE "mood_tags"
SET "is_active" = true
WHERE "key" = 'overtime';
-- ─── New default tag: praise (work) ──────────────────────────────────
-- Sort after the existing work tags (productive 0, overtime 1, day_off 2,
-- travel 3, sick_day 4). ThumbsUp keeps the iOS render native (no app
-- update needed). Idempotent upsert by `key`, mirroring 0101 / 0118.
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_praise', 'mtc_work', 'praise', 'mood.tag.praise', 'ThumbsUp', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
@@ -0,0 +1,10 @@
-- v1.17.0 — disturbed-temperature exclusion for the symptothermal engine.
--
-- A fever / illness / alcohol / late or short-sleep BBT reading can fake or
-- mask a temperature rise. `temperature_excluded = true` marks a day's basal
-- body temperature as a disturbed value: the Sensiplan cover-line baseline and
-- the shift evaluation both skip it, so a spike neither fabricates nor masks a
-- rise (the cover line is drawn over the last six *unbracketed* values).
-- Additive: every existing row backfills `false`.
ALTER TABLE "cycle_day_logs" ADD COLUMN "temperature_excluded" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,29 @@
-- v1.17.0 — symptothermal SECONDARY-symptom choice: cervical mucus (default)
-- OR cervix observation, mirroring the Sensiplan/NFP double-check where the
-- temperature primary sign pairs with one chosen second indicator.
--
-- `cycle_profiles.secondary_symptom` picks which second sign the engine uses
-- (MUCUS default | CERVIX); the casual user keeps temperature + mucus and never
-- sees the choice (it lives behind the advanced cycle settings).
--
-- `cycle_day_logs` gains the three standard Sensiplan cervix signs — position
-- (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — used as the
-- secondary indicator when secondary_symptom = CERVIX. Fertile = HIGH+SOFT+OPEN;
-- infertile (closure) = LOW+FIRM+CLOSED.
--
-- Additive + back-compatible: every existing profile backfills MUCUS (the prior
-- behaviour) and every existing day-log row keeps NULL cervix signs. iOS
-- inherits the fields without a migration of its own.
CREATE TYPE "secondary_symptom" AS ENUM ('MUCUS', 'CERVIX');
CREATE TYPE "cervix_position" AS ENUM ('LOW', 'HIGH');
CREATE TYPE "cervix_firmness" AS ENUM ('FIRM', 'SOFT');
CREATE TYPE "cervix_opening" AS ENUM ('CLOSED', 'OPEN');
ALTER TABLE "cycle_profiles"
ADD COLUMN "secondary_symptom" "secondary_symptom" NOT NULL DEFAULT 'MUCUS';
ALTER TABLE "cycle_day_logs"
ADD COLUMN "cervix_position" "cervix_position",
ADD COLUMN "cervix_firmness" "cervix_firmness",
ADD COLUMN "cervix_opening" "cervix_opening";
@@ -0,0 +1,18 @@
-- v1.16.16 (iOS #17) — per-resource WHOOP sync cursors.
--
-- WHOOP's collection endpoints filter on a record's OWN time range, not on
-- when it reached the cloud, and each resource (recovery / sleep / workout /
-- cycle) re-scores and arrives on its own latency. A single shared
-- `last_synced_at` cursor advanced by the fastest resource then drags the
-- incremental `start` forward for a slow or failing resource too, so its
-- late-arriving records fall permanently before the window and are never
-- ingested. A 403/timeout on ONE collection likewise must not poison the
-- cursor every sibling reads.
--
-- `resource_cursors` is a small JSON map `{ "recovery": "<iso>", ... }` of the
-- per-resource last-synced instant. Each resource advances only its own key on
-- a successful fetch+upsert; a stalled resource leaves its key untouched and
-- keeps re-fetching from where it left off without holding back the others.
-- Null = legacy connection — the readers fall back to the shared
-- `last_synced_at` so no historical state is lost.
ALTER TABLE "whoop_connections" ADD COLUMN "resource_cursors" JSONB;
@@ -0,0 +1,47 @@
-- v1.16.16 — at most one active consent receipt per (user, kind).
--
-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt`
-- non-atomically. Two concurrent web mounts could each see "no active
-- receipt" and both insert an active `ai_full` row, leaving duplicate
-- active grants the revoke path then has to chase one at a time.
--
-- The application wraps the mint in a transaction with a re-check, but the
-- structural guarantee lives here: a partial unique index over
-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a
-- non-NULL `revoked_at` and fall outside the predicate, so the append-only
-- grant/revoke history is unaffected — only two *active* rows for the same
-- (user, kind) collide.
-- Pre-flight dedup. The bug this migration closes already produces duplicate
-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows
-- and would abort (23505) on any such pair, failing the whole deploy on
-- exactly the tenants the fix targets. Revoke all-but-the-newest active row
-- per (user_id, kind) first so the index can build. The newest active row
-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so
-- the surviving active grant matches current read behaviour; the superseded
-- duplicates get a `revoked_at` marker and stay in the audit history.
--
-- The sibling predicate breaks on `(created_at, id)`, not `created_at` alone:
-- two active rows with an EXACT `created_at` tie would otherwise leave each
-- without a strictly-greater sibling, so neither is revoked and the
-- `CREATE UNIQUE INDEX` below aborts (23505), failing the deploy on exactly
-- the tenants this migration heals. The `id` tie-break is total (PK), so each
-- duplicate set keeps precisely one survivor — the row with the greatest
-- `(created_at, id)`, preserving the "newest survives" intent.
UPDATE "consent_receipts" c
SET "revoked_at" = now()
WHERE "revoked_at" IS NULL
AND EXISTS (
SELECT 1 FROM "consent_receipts" n
WHERE n."user_id" = c."user_id"
AND n."kind" = c."kind"
AND n."revoked_at" IS NULL
AND (
n."created_at" > c."created_at"
OR (n."created_at" = c."created_at" AND n."id" > c."id")
)
);
CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key"
ON "consent_receipts" ("user_id", "kind")
WHERE "revoked_at" IS NULL;
@@ -0,0 +1,50 @@
-- v1.17.0 — schema foundation for three new measurement sources: Nightscout
-- glucose (F1) and Polar + Oura OAuth wearables (F4).
--
-- Purely-additive: three enum-extensions on `measurement_source` plus eight
-- new nullable columns on `users` (encrypted credentials + one boolean flag).
-- No backfill, no existing row touched, no new tables. The feature waves add
-- their own connection / oauth-state tables later; this step only unblocks
-- them from colliding on the enum + the credential surface.
--
-- 1. `measurement_source` += `NIGHTSCOUT`, `POLAR`, `OURA` for the
-- server-side native ingest of each source.
-- 2. `users` += Nightscout base URL + token (encrypted) + an
-- `allow_private_host` opt-in boolean (default false) that lets a
-- self-hoster point HealthLog at a private/LAN Nightscout instance past
-- the public-host SSRF floor.
-- 3. `users` += Polar OAuth credentials (access / refresh token + user id),
-- encrypted, mirroring the WHOOP credential columns.
-- 4. `users` += Oura OAuth credentials (access / refresh token), encrypted,
-- mirroring the WHOOP credential columns.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: Postgres cannot remove an enum value, so the three sources
-- stay; with no rows carrying them they are inert. The columns drop with
-- `DROP COLUMN IF EXISTS`.
-- ── 1. measurement_source — append the three server-owned sources ──────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'NIGHTSCOUT';
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'POLAR';
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'OURA';
-- ── 2. users — Nightscout credentials + private-host opt-in ────────────
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "nightscout_url_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "nightscout_token_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "nightscout_allow_private_host" BOOLEAN NOT NULL DEFAULT false;
-- ── 3. users — Polar OAuth credentials (encrypted at app level) ────────
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_access_token_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_user_id_encrypted" TEXT;
-- ── 4. users — Oura OAuth credentials (encrypted at app level) ─────────
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_access_token_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_refresh_token_encrypted" TEXT;
@@ -0,0 +1,19 @@
-- v1.17.0 — reorder lead time for the medication low-stock alert.
--
-- The low-stock alert previously fired only when the projected runway fell
-- below the user's threshold (default 7 days). For a sparse cadence (e.g. a
-- weekly injection) 7 days is barely one dose-interval, so the warning landed
-- with about one dose left — too late to reorder before the supply ran out.
--
-- `medications.reorder_lead_days` carries an OPTIONAL per-medication reorder
-- lead time (days between placing an order and the new supply arriving). The
-- alert engine adds it, plus one dose-interval, on top of the user threshold
-- so the warning lands before the LAST dose for any cadence. NULL = inherit
-- the per-user `notificationPrefs.medication.reorderLeadDays` default.
--
-- Additive + back-compatible: the column is nullable with no backfill; every
-- existing row keeps NULL and inherits the global default. iOS picks the field
-- up without a migration of its own.
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "reorder_lead_days" INTEGER;
@@ -0,0 +1,118 @@
-- v1.17.1 — schema foundation for per-user Polar/Oura BYO-app credentials,
-- preventive-care (Vorsorge) measurement reminders, a minimal structured lab
-- store, and the iOS Live Activity push-token channel (#22).
--
-- Purely-additive: four new nullable columns on `users` (encrypted client
-- credentials), one new nullable column on `devices`, and two new tables. No
-- backfill, no existing row touched, no enum change (Polar/Oura/Nightscout
-- measurement sources already landed in migration 0160).
--
-- 1. `users` += Polar + Oura BYO-app client id/secret (encrypted at app
-- level, stored as TEXT), mirroring the WHOOP/Fitbit credential columns.
-- 2. `devices` += `live_activity_push_token` for the iOS Live Activity
-- update/end APNs channel (#22). NULL when no active Live Activity.
-- 3. `measurement_reminders` — preventive-care reminders. Cadence is a
-- rolling `interval_days` OR an RFC-5545 `rrule`; the recurrence engine
-- drives `next_due_at` (server-authoritative). Completion target is an
-- optional `measurement_type` (auto-resolve) or a free-text `label`.
-- Soft-deleted.
-- 4. `lab_results` — one analyte per row, optional panel grouping + optional
-- reference range, optional encrypted note. Pairs with the Vorsorge
-- "annual blood panel" reminder so a result has somewhere to land.
-- Soft-deleted.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: every column drops with `DROP COLUMN IF EXISTS`; both tables
-- drop with `DROP TABLE IF EXISTS`. No data is rewritten.
-- ── 1. users — Polar + Oura BYO-app client credentials (encrypted) ─────
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_client_secret_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_client_secret_encrypted" TEXT;
-- ── 2. devices — iOS Live Activity push token (#22) ────────────────────
ALTER TABLE "devices"
ADD COLUMN IF NOT EXISTS "live_activity_push_token" TEXT;
-- ── 3. measurement_reminders — preventive-care (Vorsorge) reminders ────
CREATE TABLE IF NOT EXISTS "measurement_reminders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"measurement_type" "measurement_type",
"interval_days" INTEGER,
"rrule" TEXT,
"anchor_date" TIMESTAMP(3),
"notify_hour" INTEGER NOT NULL DEFAULT 9,
"location" TEXT,
"next_due_at" TIMESTAMP(3),
"last_satisfied_at" TIMESTAMP(3),
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "measurement_reminders_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_deleted_at_idx"
ON "measurement_reminders" ("user_id", "deleted_at");
CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_next_due_at_idx"
ON "measurement_reminders" ("user_id", "next_due_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'measurement_reminders_user_id_fkey'
) THEN
ALTER TABLE "measurement_reminders"
ADD CONSTRAINT "measurement_reminders_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── 4. lab_results — minimal structured lab store ──────────────────────
CREATE TABLE IF NOT EXISTS "lab_results" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"panel" TEXT,
"analyte" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"reference_low" DOUBLE PRECISION,
"reference_high" DOUBLE PRECISION,
"taken_at" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL DEFAULT 'MANUAL',
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "lab_results_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "lab_results_user_id_analyte_taken_at_idx"
ON "lab_results" ("user_id", "analyte", "taken_at");
CREATE INDEX IF NOT EXISTS "lab_results_user_id_deleted_at_idx"
ON "lab_results" ("user_id", "deleted_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'lab_results_user_id_fkey'
) THEN
ALTER TABLE "lab_results"
ADD CONSTRAINT "lab_results_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,20 @@
-- v1.17.1 — one-shot sleep-timeline backfill markers.
--
-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset
-- timestamps), and the pre-fix mapper stamped all five stage totals on the one
-- sleep-END instant. The hypnogram then reconstructed every stage as a span
-- touching the night's right edge — the stacked-bar artefact with no clock
-- times. The fix reconstructs an ordered, contiguous per-segment timeline.
--
-- Withings stamped each sleep segment with its START while every reader treats
-- `measured_at` as the segment END, shifting each night one segment-length
-- earlier. The fix stamps the END.
--
-- Both fixes change the stored row shape / instant, so existing rows must be
-- re-synced. The boot-time backfill (per connection) deletes the affected
-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup
-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops
-- the connection. Null = backfill not yet run; the marker makes the one-shot
-- idempotent across reboots.
ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
@@ -0,0 +1,15 @@
-- v1.17.1 — persist the onboarding goal selection.
--
-- The GoalsChipPicker step let the user toggle goal slugs, but both
-- Skip and Next discarded the selection — the picker was decorative.
-- This adds the `onboarding_goals` text array the dead code already
-- referenced, so the selection can seed dashboard tile pinning and
-- reminder suggestions.
--
-- Purely-additive: one new column on `users`, NOT NULL with a `'{}'`
-- (empty array) default, so every existing row keeps "no preference"
-- without a backfill pass. The column is a personalization seed, not a
-- clinical target store.
ALTER TABLE "users"
ADD COLUMN "onboarding_goals" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,20 @@
-- v1.17.1 — Oura coverage completion (additive).
--
-- Two new `measurement_type` enum values so the Oura sync can store signals it
-- already fetches (or now fetches) but had no home for:
--
-- 1. `SLEEP_SCORE` — Oura's headline 0–100 Sleep Score from the
-- `daily_sleep` collection (distinct from the per-stage `sleep` detail).
-- Kept apart from the WHOOP `SLEEP_PERFORMANCE` / `SLEEP_EFFICIENCY`
-- sub-scores so two vendors' headline numbers never share a bucket.
-- 2. `BODY_TEMPERATURE_DEVIATION` — Oura's nightly body-temperature deviation
-- from the user's personal baseline (`daily_readiness.temperature_deviation`),
-- a SIGNED °C offset (illness / luteal-phase / stress signal). It is NOT an
-- absolute reading, so it cannot share the BODY_TEMPERATURE /
-- SKIN_TEMPERATURE / WRIST_TEMPERATURE absolute-temperature buckets.
--
-- Purely-additive: two enum extensions, no backfill, no existing row touched,
-- no new tables or columns. Both stored as `source = OURA`.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE_DEVIATION';
@@ -0,0 +1,20 @@
-- v1.17.1 — Polar pull-completeness: two native Polar signals get their own
-- MeasurementType so they stop being read off the wire and dropped at the mapper.
--
-- 1. `ANS_CHARGE` — Polar Nightly Recharge `ans_charge`, the HRV-based
-- autonomic-nervous-system charge. The primary differentiated recovery
-- component, distinct from the 1–6 recovery band already mapped to
-- RECOVERY_SCORE.
-- 2. `CARDIO_LOAD` — Polar Training Load Pro `cardio_load`, the device-native
-- cardiovascular-strain figure (Polar's WHOOP-day-strain analogue). Kept
-- distinct from DAY_STRAIN (WHOOP 0–21) and the COMPUTED STRAIN_SCORE so
-- each native value keeps its own bucket and scale.
--
-- Purely additive: two enum-extensions on `measurement_type`. No backfill, no
-- column change, no existing row touched. Idempotent (`IF NOT EXISTS`); reruns
-- are safe. Forward-only — Postgres cannot remove an enum value, and with no
-- rows carrying them before the app ships they are inert until the Polar sync
-- writes them.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ANS_CHARGE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_LOAD';
+475 -6
View File
@@ -60,6 +60,17 @@ model User {
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
// cached comprehensive text was generated from. Nightly / forced / lazy
// regenerations compare the fresh snapshot's hash against this and skip
// the provider call (refreshing only `insightsCachedAt`) when nothing
// the prompt sees has changed.
insightsSnapshotHash String? @map("insights_snapshot_hash")
// v1.16.8 — last time a forced full warm's comprehensive step failed or
// timed out. The forced warm path backs off for an hour after a failure
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -98,6 +109,45 @@ model User {
fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted")
fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted")
// v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app
// level). A self-hoster points HealthLog at their own Nightscout instance:
// the base URL + the API token / secret are stored encrypted. By default
// the base URL must resolve to a public host (the SSRF floor); a self-hoster
// running Nightscout on a private/LAN network can flip
// `nightscoutAllowPrivateHost` to opt that instance past the public-host
// requirement explicitly.
nightscoutUrlEncrypted String? @map("nightscout_url_encrypted")
nightscoutTokenEncrypted String? @map("nightscout_token_encrypted")
nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host")
// v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns: the per-app authorized-user cap on
// Polar AccessLink makes a single shared app unworkable for a multi-operator
// product, so each self-hoster registers their own Polar app and the OAuth
// tokens are stored encrypted.
polarAccessTokenEncrypted String? @map("polar_access_token_encrypted")
polarUserIdEncrypted String? @map("polar_user_id_encrypted")
// v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at
// app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1
// so a self-hoster pastes their own Polar AccessLink app's client id/secret
// into Settings before the OAuth connect flow runs. Stored encrypted as a
// string via `src/lib/crypto.ts`, matching every other `*Encrypted` column.
polarClientIdEncrypted String? @map("polar_client_id_encrypted")
polarClientSecretEncrypted String? @map("polar_client_secret_encrypted")
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
// encrypted.
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
// v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app
// level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a
// self-hoster pastes their own Oura Cloud app's client id/secret into Settings
// before the OAuth connect flow runs. Stored encrypted as a string via
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
@@ -126,6 +176,21 @@ model User {
// the user has explicitly saved a layout.
// Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 }
insightsLayoutJson Json? @map("insights_layout_json")
// Per-user medications list presentation — the card/table view choice
// plus the user-defined manual medication order shared by BOTH views.
// Follows the per-surface-column convention of `dashboardWidgetsJson` /
// `insightsLayoutJson`. Null = defaults (cards, alphabetical order).
// The GET endpoint never lazy-writes — the column only carries data
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
medicationListLayoutJson Json? @map("medication_list_layout_json")
// v1.17.0 — per-user mood-tag presentation: group order + per-group tag
// placement (catalogue tags "moved" into a group live here only — their
// `categoryId` never changes). Display-only, preserve-when-absent PUT,
// unknown keys dropped at read. Null = seeded defaults.
// Shape: { "groupOrder": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.7.0 unit pref — global metric/imperial display preference.
@@ -171,6 +236,15 @@ model User {
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int @default(0) @map("onboarding_step")
// v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker).
// Seeds dashboard tile pinning + reminder suggestions; NOT a clinical
// target store (targets are fully derived from height/age/gender +
// clinical refs in /api/insights/targets). Empty [] = skipped / no
// preference. The slug set is the closed enum
// `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[]
// (not a relation) mirrors `insightsExcludeMetrics` precedent — the
// set is small and fixed, never queried relationally.
onboardingGoals String[] @default([]) @map("onboarding_goals")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
@@ -383,6 +457,7 @@ model User {
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
@@ -475,6 +550,15 @@ model User {
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -690,6 +774,29 @@ enum MeasurementType {
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ──
// Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based
// autonomic-nervous-system charge component of Nightly Recharge — Polar's most
// differentiated recovery signal, distinct from the 1–6 recovery band that
// already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro
// cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct
// from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
// ── v1.17.1 — Oura coverage completion (additive) ──
// Oura's headline 0–100 Sleep Score from the `daily_sleep` collection
// (distinct from the per-stage `sleep` detail). Stored as `source = OURA`,
// unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP
// sub-scores) so two vendors' headline numbers never share a bucket.
SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline.
// Oura nightly body-temperature deviation from the user's personal baseline
// (`daily_readiness.temperature_deviation`), in °C. NOT an absolute
// temperature — a signed offset (illness / luteal-phase / stress signal),
// so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
// absolute-reading buckets. Plausible band is small and centred on 0.
BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`.
@@map("measurement_type")
}
@@ -763,6 +870,19 @@ enum MeasurementSource {
// (no client write path); rejected on batch + single POST like WITHINGS/WHOOP.
// iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
FITBIT
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
// write path); glucose rows ingest from the user's Nightscout instance.
// Rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so /api/sync/changes decodes the rows.
NIGHTSCOUT
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
POLAR
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
OURA
@@map("measurement_source")
}
@@ -1250,6 +1370,17 @@ model Medication {
/// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
/// pen = 1). NULL = inventory tracking off.
dosesPerUnit Int? @map("doses_per_unit")
/// v1.16.10 — units consumed per dose. The inventory items count UNITS
/// (tablets, ampoules, puffs); a single dose may consume several of
/// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption
/// hook multiplies by this factor; dose-level readouts divide by it.
/// Default 1 = one unit per dose (the overwhelmingly common case).
/// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a
/// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated
/// set of common fractions; the column stores their decimal value
/// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers.
/// Existing integer rows cast losslessly.
unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4)
active Boolean @default(true)
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
@@ -1272,6 +1403,16 @@ model Medication {
/// the migration backfills `oneShot = false` for every existing
/// row.
oneShot Boolean @default(false) @map("one_shot")
/// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the
/// user needs it (pain relief, rescue inhaler): no fixed schedule,
/// never due, never reminded, never counted in compliance, stays
/// active indefinitely — but intakes log, inventory consumes, and the
/// history renders. An as-needed medication carries ZERO
/// MedicationSchedule rows (the create/update routes 422 on a
/// schedules array alongside `asNeeded: true`); every "never due /
/// never reminded" surface follows structurally from the empty
/// schedule list. Mutually exclusive with `oneShot`.
asNeeded Boolean @default(false) @map("as_needed")
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
/// so the injection-site picker can surface for any INJECTION dose.
/// A one-time injection is `oneShot = true` + `deliveryForm =
@@ -1312,6 +1453,23 @@ model Medication {
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
/// ATC code, never instead of the free-text `.text` anchor.
rxNormCode String? @map("rxnorm_code")
/// v1.16.11 — low-stock notification stamp. Set when the daily
/// low-stock pass dispatched a notification for the current
/// threshold crossing; cleared (re-armed) when the projected runway
/// rises back to / above the user's threshold (refill). NULL = armed.
lowStockNotifiedAt DateTime? @map("low_stock_notified_at")
/// v1.16.11 — the user threshold (days) the stamp was written
/// against. A changed threshold re-arms the medication even while
/// the runway stays low, so the user hears about the new crossing.
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
/// v1.17.0 — optional per-medication reorder lead time in days (how
/// long between placing a refill order and the new supply arriving).
/// The low-stock alert adds this lead plus one dose-interval on top of
/// the user threshold so the warning lands BEFORE the last dose for any
/// cadence. NULL = inherit the per-user
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
/// backfill.
reorderLeadDays Int? @map("reorder_lead_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1335,6 +1493,12 @@ model Medication {
/// on the linked MedicationIntakeEvent rows refresh the row inline;
/// the read paths consume this instead of the per-event findMany.
complianceRollups MedicationComplianceRollup[]
/// v1.16.3 — archived schedule eras (effective dating). Superseded
/// schedule states land here when a wholesale schedule replace changes
/// cadence-relevant fields; the band minter segments historical minting
/// ranges into eras so past days read against the schedule that was
/// live THEN, not the current one.
scheduleRevisions MedicationScheduleRevision[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1431,6 +1595,60 @@ model MedicationSchedule {
@@map("medication_schedules")
}
/// v1.16.3 — schedule-revision archive (effective dating). One row per
/// SUPERSEDED schedule state of a medication: when the schedule set is
/// wholesale-replaced with materially different cadence fields, the old
/// rows are archived here as a single revision covering
/// `[validFrom, validUntil)`. The LIVE rows stay in
/// `medication_schedules` — a revision never describes the current
/// state, so `validUntil` is always set (no open-ended rows).
///
/// The band minter segments any historical minting range into eras:
/// each revision interval mints with ITS archived schedules, the
/// remainder (from the newest `validUntil` onward) mints with the live
/// rows. Era boundary rule: a slot belongs to the era its anchor lies
/// in; an on-time/late tail may reach past the boundary, but no new
/// anchor is minted beyond it.
///
/// `startsOn` stays the global floor for ALL eras: the first era begins
/// at `max(medication.createdAt, startsOn)`; a times-only edit must
/// never move `startsOn`.
model MedicationScheduleRevision {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Inclusive start of the era this archived state was live for.
/// Chains from the previous revision's `validUntil`, or from
/// `medication.createdAt` for the first revision.
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
/// Exclusive end of the era — the instant the replace happened.
validUntil DateTime @map("valid_until") @db.Timestamptz(6)
/// Full snapshot of the superseded schedule rows: an array of
/// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule,
/// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks,
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
/// to rebuild a `CanonicalSchedule` list for era minting.
payload Json
/// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace
/// write path (immutable history). 'MANUAL' = user-entered pre-tracking
/// era via POST /api/medications/{id}/schedule-revisions (deletable by
/// the owner).
source String @default("ARCHIVED")
/// v1.16.6 — audit trail for corrected archives. When the owner
/// corrects an ARCHIVED era through PATCH
/// /api/medications/{id}/schedule-revisions/{revisionId}, the route
/// mints a MANUAL replacement and points this column at it. The
/// original row stays as the audit record; every era consumer
/// (splitter, list endpoint, live-boundary reads) skips rows where
/// this is set.
supersededByRevisionId String? @map("superseded_by_revision_id")
createdAt DateTime @default(now()) @map("created_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, validFrom])
@@map("medication_schedule_revisions")
}
enum IntakeSource {
WEB
API
@@ -1523,6 +1741,20 @@ model MedicationIntakeEvent {
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
injectionSite InjectionSite? @map("injection_site")
/// v1.16.4 — per-intake dose override. Free text (mirrors
/// `Medication.dose`), populated only when the user edits the dose at
/// log time (half tablet, titration day). NULL = the configured
/// medication / schedule dose applies; read paths fall back to it.
doseTaken String? @map("dose_taken")
/// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the
/// containers this taken event decremented. NULL = never consumed
/// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly-
/// once gate for the consume hook (a stamped row never decrements
/// again, however often a sync replays it) and the refund ledger for
/// the restore hook (taken → skipped / deleted refunds per item). The
/// stamp freezes history: editing `Medication.unitsPerDose` later
/// never changes what an already-taken dose consumed.
inventoryConsumption Json? @map("inventory_consumption")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
@@ -1630,16 +1862,41 @@ enum MedicationInventoryState {
@@map("medication_inventory_state")
}
/// v1.16.10 — what kind of physical container an inventory item is.
/// Display-level only (no state-machine behaviour hangs off it); the
/// register dialog defaults it from the medication's delivery form
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
enum MedicationContainerType {
PEN
AMPOULE
BLISTER
INHALER
BOTTLE
OTHER
@@map("medication_container_type")
}
model MedicationInventoryItem {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
state MedicationInventoryState @default(ACTIVE)
/// Doses the pen / vial shipped with. Mounjaro KwikPen = 4,
/// Trulicity single-dose = 1, Byetta = 60.
dosesTotal Int @map("doses_total")
/// Remaining doses; decremented by the intake hook.
dosesRemaining Int @map("doses_remaining")
/// v1.16.10 — container kind (pen / ampoule / blister pack / …).
/// Backfilled PEN for items on INJECTION medications, OTHER otherwise.
containerType MedicationContainerType @default(OTHER) @map("container_type")
/// Units the container shipped with (v1.16.10 — renamed from
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
/// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to
/// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1.
/// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a
/// fractional remainder (30 tablets dosed at ½ each reads 29.5 after
/// one dose). Existing integer rows cast losslessly.
unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4)
/// Remaining units; decremented by the intake consumption hook
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
/// NULL until the pen is first opened. Sets the 30-day in-use
/// clock per EMA EPAR §6.3.
firstUseAt DateTime? @map("first_use_at")
@@ -1948,6 +2205,13 @@ model WithingsConnection {
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
/// sleep rows were stamped with the segment START; the reader treats
/// `measuredAt` as the END, shifting every night one segment-length earlier.
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2012,9 +2276,25 @@ model WhoopConnection {
/// a refresh token. Null = legacy connection that predates scope tracking.
scope String? @map("scope")
lastSyncedAt DateTime? @map("last_synced_at")
/// v1.16.16 (iOS #17) — per-resource sync cursors. A JSON map
/// `{ "recovery": "<iso>", "sleep": "<iso>", "workout": "<iso>",
/// "cycle": "<iso>" }` of each resource's own last-synced instant. The
/// incremental `start` for a resource is `cursor − overlap`; a resource
/// advances ONLY its own key on a successful fetch+upsert, so a slow or
/// 403-ing collection never drags the cursor forward for its siblings and
/// its own late-arriving records stay inside the window. Null / a missing
/// key falls back to the shared `lastSyncedAt` (legacy connection).
resourceCursors Json? @map("resource_cursors")
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP
/// sleep rows stamped all five stage totals on the one sleep-END instant, so
/// the hypnogram stacked them on the night's right edge. The boot-time
/// backfill deletes the legacy summary rows and re-syncs the reconstructed
/// per-segment timeline, then sets this so the discovery query drops the
/// connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// Max heart rate from the WHOOP body-measurement profile — a profile
/// constant, not a time series, so it lives here rather than as a
/// Measurement row.
@@ -2241,8 +2521,19 @@ model MoodTagCategory {
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
tags MoodTag[]
/// v1.17.0 — owner of a per-user custom group. NULL = the global seeded
/// catalogue (resolves `labelKey` against the locale). Set = a user's own
/// group, carrying a `customcat:<uuid>` key and a free-text label held in
/// `labelEncrypted` (mirrors the MoodTag custom-tag precedent).
userId String? @map("user_id")
/// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label
/// (user prose, possibly health-adjacent). NULL for seeded rows.
labelEncrypted String? @map("label_encrypted")
tags MoodTag[]
user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("mood_tag_categories")
}
@@ -2868,6 +3159,14 @@ model Device {
/// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to
/// all the user's devices and iOS dedupes locally).
medicationDelivery String? @map("medication_delivery")
/// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end
/// channel. ActivityKit issues a per-Activity push token distinct from the
/// device APNs token; the server stores the most recent one so the worker
/// can push a Live Activity content-state update or an end signal. NULL when
/// the device has no active Live Activity. The iOS client owns the
/// ActivityKit lifecycle and registers/clears this token; no server-side
/// behaviour hangs off it beyond addressing the update push.
liveActivityPushToken String? @map("live_activity_push_token")
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2883,6 +3182,104 @@ model Device {
@@map("devices")
}
// ─── Measurement / Vorsorge reminders ────────────────────────
/// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7
/// days", "annual blood panel", "log weight weekly". Reuses the existing
/// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR
/// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and
/// rides the existing notification dispatcher + `push_attempts` ledger. The
/// completion target is either a `measurementType` (auto-resolved when a
/// matching measurement lands) or a free-text `label` for non-measurement
/// Vorsorge that only resolves on a manual check-off. `nextDueAt` is
/// server-computed + stored (server-authoritative — iOS consumes the resolved
/// value, never recomputes). Soft-deleted.
model MeasurementReminder {
id String @id @default(cuid())
userId String @map("user_id")
/// User-facing copy: "Blutdruck messen", "Großes Blutbild".
label String
/// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood
/// panel, a dermatology check) with no measurement to match against; those
/// resolve only on a manual satisfy. Non-NULL = the cron advances
/// `lastSatisfiedAt` when a matching Measurement of this type lands.
measurementType MeasurementType? @map("measurement_type")
/// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence
/// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in
/// practice; both NULL is a one-shot reminder anchored on `anchorDate`.
intervalDays Int? @map("interval_days")
/// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through
/// the same recurrence engine the medication schedules use.
rrule String?
/// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt`
/// when NULL.
anchorDate DateTime? @map("anchor_date")
/// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour.
/// DST-safe windowing is applied at the cron via the existing TZ helpers.
notifyHour Int @default(9) @map("notify_hour")
/// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional.
location String?
/// Server-computed canonical next-due instant (server-authoritative).
nextDueAt DateTime? @map("next_due_at")
/// When the reminder was last fulfilled (a matching measurement or a manual
/// satisfy). Re-anchors the next-due computation.
lastSatisfiedAt DateTime? @map("last_satisfied_at")
/// Master on/off for this reminder. Distinct from soft-delete: a disabled
/// reminder is retained + editable but never fires.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@map("measurement_reminders")
}
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
/// blood panel" reminder. HealthLog has no first-class blood-biomarker type
/// beyond glucose, so a self-hoster who gets a panel back had nowhere to record
/// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per
/// row, optional panel grouping, optional reference range. The doctor-report /
/// charting wiring lands in sibling work — this model only owns the shape.
/// Soft-deleted.
model LabResult {
id String @id @default(cuid())
userId String @map("user_id")
/// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value.
panel String?
/// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
analyte String
value Float
unit String
/// Optional reference-range bounds as reported by the lab.
referenceLow Float? @map("reference_low")
referenceHigh Float? @map("reference_high")
/// When the sample was taken (UTC instant; wall-clock anchoring is the
/// reader's concern per the schema's UTC-only convention).
takenAt DateTime @map("taken_at")
/// Provenance: "MANUAL" today; import paths set their own source string.
source String @default("MANUAL")
/// Optional free-text note, AES-256-GCM encrypted at rest via
/// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention).
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
@@map("lab_results")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
@@ -3187,6 +3584,14 @@ model ConsentReceipt {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
// `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index
// over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159.
// It enforces at most one *active* receipt per (user, kind) so two
// concurrent web-grant heals can't both mint an active row. Prisma can't
// express a partial-predicate unique in the schema language, so it's
// introduced via raw SQL and intentionally omitted here. The append-only
// history (revoked rows) stays unconstrained because revoked rows carry a
// non-NULL `revoked_at` and fall outside the predicate.
@@map("consent_receipts")
}
@@ -3448,6 +3853,47 @@ enum CyclePhase {
@@map("cycle_phase")
}
/// Which symptothermal SECONDARY sign the user observes alongside the
/// temperature primary sign for the Sensiplan double-check. `MUCUS`
/// (cervical-mucus quality) is the default; `CERVIX` swaps in the cervix
/// observation (position / firmness / opening). The temperature primary
/// sign is unchanged either way — only the second indicator of the
/// double-check differs. Mirrors the `drip` app's Settings →
/// Customization → Secondary symptom choice.
enum SecondarySymptom {
MUCUS
CERVIX
@@map("secondary_symptom")
}
/// Cervix vertical position, a Sensiplan cervix sign. Fertile = `HIGH`
/// (the cervix rises around ovulation); infertile = `LOW`.
enum CervixPosition {
LOW
HIGH
@@map("cervix_position")
}
/// Cervix firmness, a Sensiplan cervix sign. Fertile = `SOFT` (the cervix
/// softens around ovulation); infertile = `FIRM`.
enum CervixFirmness {
FIRM
SOFT
@@map("cervix_firmness")
}
/// Cervix os / opening, a Sensiplan cervix sign. Fertile = `OPEN`;
/// infertile = `CLOSED`.
enum CervixOpening {
CLOSED
OPEN
@@map("cervix_opening")
}
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
@@ -3487,6 +3933,12 @@ model CycleProfile {
typicalPeriodLength Int? @map("typical_period_length")
lutealPhaseLength Int? @map("luteal_phase_length")
/// Which symptothermal SECONDARY sign the user observes for the Sensiplan
/// double-check: `MUCUS` (default) or `CERVIX`. Lives behind the advanced
/// cycle settings — the casual user keeps temperature + mucus and never
/// sees the choice. The temperature primary sign is unchanged either way.
secondarySymptom SecondarySymptom @default(MUCUS) @map("secondary_symptom")
/// When false, the app computes / stores NO predictions — pure logging
/// (the Read-Your-Body posture for users who distrust algorithmic
/// interpretation).
@@ -3582,11 +4034,28 @@ model CycleDayLog {
/// symptothermal confirmation. BBT also lands in Measurement
/// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy.
basalBodyTempC Float? @map("basal_body_temp_c")
/// Whether this day's BBT is a disturbed/unreliable reading (fever,
/// illness, alcohol, a late or short-sleep measurement) the user marked
/// excluded. When true the symptothermal engine drops the reading from the
/// cover-line baseline AND the shift evaluation, so a spike neither
/// fabricates nor masks a temperature rise (Sensiplan: the cover line is
/// drawn over the last six *unbracketed* values).
temperatureExcluded Boolean @default(false) @map("temperature_excluded")
/// Ovulation predictor-kit (OPK) reading.
ovulationTest OvulationTest? @map("ovulation_test")
/// Cervical-mucus quality.
cervicalMucus CervicalMucus? @map("cervical_mucus")
/// Cervix observation — the three standard Sensiplan cervix signs, used
/// as the symptothermal secondary indicator when
/// CycleProfile.secondarySymptom is CERVIX. All NULL = not observed.
/// The fertile combination is HIGH + SOFT + OPEN; the infertile
/// (closure) combination is LOW + FIRM + CLOSED. Queryable-plaintext like
/// the other physiology signs (mucus / BBT / OPK).
cervixPosition CervixPosition? @map("cervix_position")
cervixFirmness CervixFirmness? @map("cervix_firmness")
cervixOpening CervixOpening? @map("cervix_opening")
/// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption
/// is ON these are encrypted at the application layer (the dispatch
/// envelope), default-ON for this category.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+26
View File
@@ -22,11 +22,37 @@
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/logo-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Add measurement",
"short_name": "Add",
"url": "/measurements?add=WEIGHT&source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Today",
"short_name": "Today",
"url": "/?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Medications",
"short_name": "Meds",
"url": "/medications?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+36 -2
View File
@@ -27,8 +27,16 @@ try {
} catch {
// Fall through to the literal fallback below.
}
// The `|| "..."` fallback literal is the value used only when the
// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild`
// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs`
// rewrites it to the current `package.json` version on every prebuild — the
// marker below is the rewrite anchor — so in any shipped image the literal
// matches the active release and can never drift stale the way it did across
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) || "v1.4.43";
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.17.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
@@ -118,6 +126,32 @@ async function cacheFirst(request, cacheName) {
return response;
}
/**
* Privacy gate for the navigation page cache. Most app routes are
* client-fetch-only shells with no server-rendered PII (health JSON loads
* over `/api/*`, which is network-only here), so caching their HTML is
* safe. Two carve-outs:
*
* 1. A `Cache-Control: no-store` response opts itself out — the
* principled, future-proof rule for any current/future server RSC
* that renders user data and sets the header.
* 2. `/c/*` (the clinician-share view) renders health values + wellness
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*/
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
try {
const { pathname } = new URL(request.url);
if (pathname === "/c" || pathname.startsWith("/c/")) return false;
} catch {
// Unparseable URL — fall through; the no-store check already ran.
}
return true;
}
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
@@ -134,7 +168,7 @@ async function networkFirst(event, cacheName) {
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok) {
if (response.ok && isCacheableNavigation(request, response)) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_PAGE_ENTRIES);
@@ -43,8 +43,11 @@ describe("public/sw.js CACHE_VERSION wiring", () => {
const sw = readFileSync(SW_PATH, "utf8");
// The fallback OR-expression keeps the SW functional in dev mode
// where the build script has not run. Pin the shape so the fallback
// can never silently vanish.
expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/);
// can never silently vanish. The optional block comment is the
// `@sw-version-fallback` rewrite anchor the prebuild generator keys on.
expect(sw).toMatch(
/self\.__APP_VERSION__\s*\)?\s*\|\|\s*(?:\/\*[^]*?\*\/\s*)?"v\d/,
);
});
});
+52
View File
@@ -54,6 +54,38 @@
}
]
},
{
"name": "Polar OAuth",
"description": "Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "POLAR_CLIENT_ID",
"purpose": "Polar AccessLink OAuth client id."
},
{
"name": "POLAR_CLIENT_SECRET",
"purpose": "Polar AccessLink OAuth client secret."
}
]
},
{
"name": "Oura OAuth",
"description": "Required to enable the Oura Cloud v2 integration. App boots without these but the Oura connect button stays disabled. Register an app at https://cloud.ouraring.com/oauth/applications and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/oura/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OURA_CLIENT_ID",
"purpose": "Oura Cloud OAuth client id."
},
{
"name": "OURA_CLIENT_SECRET",
"purpose": "Oura Cloud OAuth client secret."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
@@ -105,6 +137,26 @@
}
]
},
{
"name": "SMTP (email channel)",
"description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "SMTP_HOST",
"purpose": "SMTP server hostname, e.g. smtp.example.com."
},
{
"name": "SMTP_PORT",
"purpose": "SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)."
},
{
"name": "SMTP_FROM",
"purpose": "From address for outbound mail, e.g. \"HealthLog <noreply@example.com>\"."
}
]
},
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
+3 -3
View File
@@ -4,15 +4,15 @@
* table in `src/lib/openapi/registry.ts`.
*
* v1.4.23 baseline: only the iOS-touched routes are registered (see
* `src/lib/openapi/routes.ts`). The CI step that wraps this script runs
* `src/lib/openapi/routes/`). The CI step that wraps this script runs
* with `continue-on-error: true` until the registry catches up with the
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
* complete, the gate flips to hard-fail in v1.4.24+.
*
* The output is stable across re-runs because the upstream order is
* itself deterministic: Zod preserves schema property declaration
* order and the path table is a single object literal in
* `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is
* order and the path table is assembled with a fixed spread order in
* `src/lib/openapi/routes/index.ts`. `yaml@2`'s `sortMapEntries` option is
* intentionally NOT set — when enabled, the emitter reorders keys
* alphabetically and can place an `*alias` reference before its
* `&anchor` definition, which triggers `verifyAliasOrder` and throws
+20 -1
View File
@@ -23,6 +23,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const pkgPath = join(repoRoot, "package.json");
const outPath = join(repoRoot, "public", "sw-version.js");
const swPath = join(repoRoot, "public", "sw.js");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const version = `v${pkg.version}`;
@@ -33,5 +34,23 @@ const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`;
writeFileSync(outPath, banner + body, "utf8");
// Also re-anchor the in-`sw.js` fallback literal to the current version, so
// the literal used when `/sw-version.js` is missing (dev, or a 404 on the
// generated file) can never drift stale — it bit v1.4.38.4 → v1.4.42. The
// `/* @sw-version-fallback */` marker is the rewrite anchor; the literal that
// follows it is replaced in place. If the marker is gone the SW source has
// been restructured and the build must surface that rather than silently skip
// the re-anchor.
const sw = readFileSync(swPath, "utf8");
const fallbackPattern = /(\/\* @sw-version-fallback \*\/\s*)"v[^"]*"/;
if (!fallbackPattern.test(sw)) {
process.stderr.write(
"generate-sw-version: @sw-version-fallback marker not found in public/sw.js\n",
);
process.exit(1);
}
const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
// One-line log so the build output shows the version we baked in.
process.stdout.write(`sw-version.js → ${version}\n`);
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
+242
View File
@@ -0,0 +1,242 @@
/**
* promote-digest.ts — pin a released image to its immutable digest.
*
* After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
* script turns the mutable tag into a deterministic deploy reference:
*
* 1. Resolves the multi-arch index digest for the tag straight from
* the GHCR registry API (anonymous pull token — the image is
* public; no docker daemon needed).
* 2. Verifies the keyless cosign signature the publish workflow
* attached to that digest (GitHub Actions OIDC identity). Fails
* closed: no cosign binary or a failed verification aborts the
* promotion.
* 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
* file interpolates into `image:` — and, when a Coolify API token
* is present in the environment, writes it to the application's
* env vars via the documented `/envs` endpoint. Without a token
* it prints the exact curl commands instead of guessing.
*
* Why an env var and not the compose file itself: for a git-based
* "Docker Compose" application Coolify re-reads the compose file from
* the repository checkout on every deploy (`docker_compose_raw` is its
* stored copy of that file and is not part of the application-update
* API surface), so editing the stored compose is not durable. The
* application env vars ARE durable and are interpolated into the
* compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
* resolves to the pinned digest when the var is set and to `:latest`
* when it is not.
*
* Usage (one-shot, not a recurring job):
* pnpm dlx tsx scripts/promote-digest.ts v1.16.5
*
* Env (all optional — without them the script prints commands only):
* COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
* COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
* COOLIFY_APP_UUID UUID of the HealthLog application resource
* PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
*
* See docs/ops/deploy.md ("Digest pinning") for the full runbook,
* including rollback (re-point the var at the previous digest).
*/
import { execFileSync } from "node:child_process";
const IMAGE_REPO = "mbombeck/healthlog";
const IMAGE = `ghcr.io/${IMAGE_REPO}`;
const ENV_KEY = "HEALTHLOG_IMAGE_REF";
const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
// The OCI index media types a multi-arch publish can produce. GHCR
// answers the manifest request with the digest of whichever it stores;
// accepting both keeps the script working across buildx versions.
const INDEX_ACCEPT = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
].join(", ");
function fail(message: string): never {
console.error(`\nERROR: ${message}`);
process.exit(1);
}
async function resolveDigest(tag: string): Promise<string> {
// Anonymous pull token — the package is public, so no credentials.
const tokenRes = await fetch(
`https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
);
if (!tokenRes.ok) {
fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
}
const { token } = (await tokenRes.json()) as { token?: string };
if (!token) fail("GHCR token response carried no token");
// HEAD is enough — the digest rides the Docker-Content-Digest header.
const manifestRes = await fetch(
`https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
{
method: "HEAD",
headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
},
);
if (!manifestRes.ok) {
fail(
`manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
);
}
const digest = manifestRes.headers.get("docker-content-digest");
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
}
return digest;
}
function verifyCosign(digest: string): void {
const ref = `${IMAGE}@${digest}`;
try {
execFileSync(
"cosign",
[
"verify",
"--certificate-oidc-issuer",
COSIGN_ISSUER,
"--certificate-identity-regexp",
COSIGN_IDENTITY_RE,
ref,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
if (e.code === "ENOENT") {
fail(
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
);
}
fail(
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
);
}
console.log(`cosign signature verified for ${ref}`);
}
interface CoolifyEnvRow {
uuid: string;
key: string;
value?: string;
}
async function coolifyRequest(
base: string,
token: string,
path: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
}
async function updateCoolifyEnv(
base: string,
token: string,
appUuid: string,
value: string,
): Promise<void> {
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
if (!list.ok) {
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
}
const rows = (await list.json()) as CoolifyEnvRow[];
const existing = Array.isArray(rows)
? rows.find((r) => r.key === ENV_KEY)
: undefined;
const method = existing ? "PATCH" : "POST";
const res = await coolifyRequest(
base,
token,
`/applications/${appUuid}/envs`,
{ method, body: JSON.stringify({ key: ENV_KEY, value }) },
);
if (!res.ok) {
fail(`Coolify env ${method} failed: HTTP ${res.status}`);
}
console.log(
`Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
);
}
async function triggerDeploy(
base: string,
token: string,
appUuid: string,
): Promise<void> {
const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
console.log("Deploy triggered. Verify /api/version after it settles.");
}
function printManualCommands(value: string): void {
console.log(`
No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
token and application UUID; the UUID is in the Coolify URL of the app):
# set / update the pin (POST creates, PATCH updates an existing key)
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
-d '{"key":"${ENV_KEY}","value":"${value}"}'
# redeploy with the pinned digest
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
-H "Authorization: Bearer <token>"
`);
}
async function main(): Promise<void> {
const tag = process.argv[2];
if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
}
console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
const digest = await resolveDigest(tag);
console.log(`digest: ${digest}`);
verifyCosign(digest);
const value = `@${digest}`;
console.log(`\n${ENV_KEY}=${value}`);
const token = process.env.COOLIFY_API_TOKEN;
const base = process.env.COOLIFY_API_URL;
const appUuid = process.env.COOLIFY_APP_UUID;
if (token && base && appUuid) {
await updateCoolifyEnv(base, token, appUuid, value);
if (process.env.PROMOTE_DEPLOY === "1") {
await triggerDeploy(base, token, appUuid);
} else {
console.log(
"Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
);
}
} else {
if (token || base || appUuid) {
console.log(
"Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
);
}
printManualCommands(value);
}
}
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
+266 -2
View File
@@ -38,6 +38,18 @@
* anchors — they linger as phantom open doses. Repair: tombstone
* with `--fix` (same soft-delete shape as defect 1).
*
* 5. ERA INFERENCE (v1.16.3) — medications edited BEFORE schedule
* versioning existed lost their old era: history reads against the
* current times and the old takes look off-schedule. When the
* recorded slot anchors (user-tz HH:mm on a 5-minute grid) deviate
* from the CURRENT `times_of_day` for >= 7 consecutive recorded days
* before the current times first appear, the script proposes one
* `medication_schedule_revisions` row (validFrom = first deviating
* row, validUntil = first day on the current times). It also flags a
* `starts_on` that postdates the first recorded row. Report always;
* CREATE/UPDATE only with `--backfill-eras`. Medications that
* already have a revision are skipped (idempotent).
*
* After any mutation the affected `(user, medication, day)` compliance
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
@@ -75,6 +87,9 @@
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*
* # create the proposed schedule revisions + startsOn fixes (defect 5)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
*/
import { Client, types } from "pg";
@@ -101,6 +116,8 @@ function utcParam(date: Date): string {
interface CliOptions {
fix: boolean;
tombstoneImplausible: boolean;
/** v1.16.3 — create the proposed schedule revisions / startsOn fixes. */
backfillEras: boolean;
userId: string | null;
}
@@ -108,6 +125,7 @@ function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
fix: false,
tombstoneImplausible: false,
backfillEras: false,
userId: null,
};
for (let i = 0; i < argv.length; i++) {
@@ -116,6 +134,8 @@ function parseArgs(argv: string[]): CliOptions {
opts.fix = true;
} else if (arg === "--tombstone-implausible") {
opts.tombstoneImplausible = true;
} else if (arg === "--backfill-eras") {
opts.backfillEras = true;
} else if (arg === "--user" && argv[i + 1]) {
opts.userId = argv[i + 1];
i += 1;
@@ -374,6 +394,10 @@ interface Summary {
windowsReconciled: number;
staleAnchorRows: number;
staleAnchorTombstoned: number;
eraCandidates: number;
eraRevisionsCreated: number;
startsOnCandidates: number;
startsOnAdjusted: number;
rollupsRecomputed: number;
}
@@ -429,6 +453,10 @@ async function main(): Promise<void> {
windowsReconciled: 0,
staleAnchorRows: 0,
staleAnchorTombstoned: 0,
eraCandidates: 0,
eraRevisionsCreated: 0,
startsOnCandidates: 0,
startsOnAdjusted: 0,
rollupsRecomputed: 0,
};
@@ -807,13 +835,245 @@ async function main(): Promise<void> {
}
// ─────────────────────────────────────────────────────────────────
// 5. Recompute the affected compliance rollups so scheduled/taken
// 5. Era inference (v1.16.3) — medications whose historical slot
// anchors deviate from the CURRENT times_of_day for >= 7
// consecutive recorded days before the current times appear: the
// schedule was edited pre-versioning and the old era was lost.
// Propose one MedicationScheduleRevision per medication
// (validFrom = first deviating row, validUntil = first day on the
// current times) plus a startsOn correction when startsOn
// postdates the first recorded row. Report always; CREATE only
// with --backfill-eras. Only medications with ZERO existing
// revisions are considered, so the section is idempotent.
// ─────────────────────────────────────────────────────────────────
const eraRowsRes = await client.query<{
user_id: string;
medication_id: string;
medication_name: string;
created_at: Date;
starts_on: Date | null;
scheduled_for: Date;
}>(
`
SELECT e."user_id", e."medication_id", m."name" AS medication_name,
m."created_at", m."starts_on", e."scheduled_for"
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND (e."taken_at" IS NOT NULL OR e."skipped" OR e."auto_missed")
AND NOT EXISTS (
SELECT 1 FROM "medication_schedule_revisions" r
WHERE r."medication_id" = e."medication_id"
)
${userFilter}
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
`,
userParams,
);
/** Per-medication actioned rows, chronological. */
const byMedication = new Map<string, typeof eraRowsRes.rows>();
for (const row of eraRowsRes.rows) {
const list = byMedication.get(row.medication_id) ?? [];
list.push(row);
byMedication.set(row.medication_id, list);
}
interface EraProposal {
userId: string;
medicationId: string;
medicationName: string;
timesOfDay: string[];
validFrom: Date;
validUntil: Date;
dayCount: number;
}
interface StartsOnProposal {
medicationId: string;
medicationName: string;
startsOn: Date;
firstRow: Date;
}
const eraProposals: EraProposal[] = [];
const startsOnProposals: StartsOnProposal[] = [];
for (const [medicationId, rows] of byMedication) {
const tz = await userTimezone(rows[0].user_id);
const anchors = await medicationAnchors(medicationId);
if (anchors.size === 0) continue; // PRN / unscheduled — nothing to infer.
// startsOn AFTER the first recorded row — the course began earlier
// than the configured start; propose pulling startsOn back.
const startsOn = rows[0].starts_on;
if (startsOn && startsOn.getTime() > rows[0].scheduled_for.getTime()) {
startsOnProposals.push({
medicationId,
medicationName: rows[0].medication_name,
startsOn,
firstRow: rows[0].scheduled_for,
});
}
// Walk the recorded days chronologically. Only ANCHOR-shaped rows
// count: a scheduled_for whose user-tz HH:mm sits on a 5-minute
// grid (reminder-/projector-minted slot instants land on the
// configured times; free-hand ad-hoc takes rarely do).
const dayTimes = new Map<string, Set<string>>();
const dayOrder: string[] = [];
for (const row of rows) {
const hhmm = hhmmInTz(row.scheduled_for, tz);
if (!HHMM_RE.test(hhmm)) continue;
if (Number(hhmm.slice(3)) % 5 !== 0) continue; // not anchor-shaped
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
let set = dayTimes.get(dayKey);
if (!set) {
set = new Set<string>();
dayTimes.set(dayKey, set);
dayOrder.push(dayKey);
}
set.add(hhmm);
}
// The leading run of recorded days whose anchor times ALL deviate
// from the current schedule, ended by the first day that matches
// the current times. Consecutive = consecutive RECORDED days; gaps
// without rows (skipped logging) do not break the run.
const offTimes = new Set<string>();
let runDays = 0;
let firstOffDay: string | null = null;
let switchDay: string | null = null;
for (const dayKey of dayOrder) {
const times = [...(dayTimes.get(dayKey) ?? [])];
if (times.length === 0) continue;
const allOff = times.every((t) => !anchors.has(t));
const anyOn = times.some((t) => anchors.has(t));
if (allOff) {
if (switchDay !== null) {
// Off-anchor days AFTER current times appeared — mixed
// history, not a clean era prefix. Bail for this medication.
runDays = 0;
break;
}
runDays += 1;
if (firstOffDay === null) firstOffDay = dayKey;
for (const t of times) offTimes.add(t);
} else if (anyOn && switchDay === null) {
switchDay = dayKey;
}
}
if (runDays >= 7 && firstOffDay !== null && switchDay !== null) {
const firstRow = rows.find(
(r) => dayKeyForScheduledFor(r.scheduled_for, tz) === firstOffDay,
);
const validFrom = firstRow?.scheduled_for ?? rows[0].created_at;
const validUntil = startOfDayUtcInTz(switchDay, safeTimezone(tz));
eraProposals.push({
userId: rows[0].user_id,
medicationId,
medicationName: rows[0].medication_name,
timesOfDay: [...offTimes].sort(),
validFrom,
validUntil,
dayCount: runDays,
});
}
}
summary.eraCandidates = eraProposals.length;
summary.startsOnCandidates = startsOnProposals.length;
console.log(
`\n${TAG} 5) era-inference candidates (>=7 consistent off-anchor days): ${eraProposals.length}`,
);
if (eraProposals.length > 0) {
console.table(
eraProposals.map((p) => ({
medication: p.medicationName,
oldTimes: p.timesOfDay.join(","),
validFrom: p.validFrom.toISOString(),
validUntil: p.validUntil.toISOString(),
days: p.dayCount,
})),
);
}
if (startsOnProposals.length > 0) {
console.log(
`${TAG} startsOn postdates the first recorded row: ${startsOnProposals.length}`,
);
console.table(
startsOnProposals.map((p) => ({
medication: p.medicationName,
startsOn: p.startsOn.toISOString(),
firstRow: p.firstRow.toISOString(),
})),
);
}
if (opts.backfillEras) {
const { randomUUID } = await import("node:crypto");
for (const p of eraProposals) {
const payload = JSON.stringify([
{
timesOfDay: p.timesOfDay,
windowStart: p.timesOfDay[0],
windowEnd: p.timesOfDay[p.timesOfDay.length - 1],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: null,
dose: null,
reminderGraceMinutes: null,
},
]);
await client.query(
`
INSERT INTO "medication_schedule_revisions"
("id", "medication_id", "valid_from", "valid_until", "payload")
SELECT $1, $2, $3::timestamptz, $4::timestamptz, $5::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM "medication_schedule_revisions"
WHERE "medication_id" = $2
)
`,
[
randomUUID(),
p.medicationId,
p.validFrom.toISOString(),
p.validUntil.toISOString(),
payload,
],
);
summary.eraRevisionsCreated += 1;
}
for (const p of startsOnProposals) {
const res = await client.query(
`
UPDATE "medications"
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
WHERE "id" = $1 AND "starts_on" = $3::timestamp
`,
[
p.medicationId,
utcParam(p.firstRow),
utcParam(p.startsOn),
],
);
summary.startsOnAdjusted += res.rowCount ?? 0;
}
}
// ─────────────────────────────────────────────────────────────────
// 6. Recompute the affected compliance rollups so scheduled/taken
// counts self-correct. Same DISTINCT-slot SQL the shared helper
// runs (see `recomputeComplianceDay` above).
// ─────────────────────────────────────────────────────────────────
if (opts.fix && daysToRecompute.size > 0) {
console.log(
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
`\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
);
for (const key of daysToRecompute) {
const [userId, medicationId, dayKey] = key.split("|");
@@ -850,6 +1110,10 @@ async function main(): Promise<void> {
console.log(` windows reconciled: ${summary.windowsReconciled}`);
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
console.log(` era-inference candidates: ${summary.eraCandidates}`);
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`);
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
}
+44
View File
@@ -230,6 +230,50 @@ async function main() {
results.push(r);
}
// ───── MoodTag.labelEncrypted ─────
// v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
// label (catalogue rows carry NULL and are skipped by `shouldRotate`).
const moodTags = await prisma.moodTag.findMany({
where: { labelEncrypted: { not: null } },
select: { id: true, labelEncrypted: true },
});
results.push(
await rotateField(
"MoodTag",
"labelEncrypted",
moodTags,
(t) => t.labelEncrypted,
async (id, ciphertext) => {
await prisma.moodTag.update({
where: { id },
data: { labelEncrypted: ciphertext },
});
},
),
);
// ───── MoodTagCategory.labelEncrypted ─────
// v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
// free-text label (seeded rows carry NULL and are skipped).
const moodTagCategories = await prisma.moodTagCategory.findMany({
where: { labelEncrypted: { not: null } },
select: { id: true, labelEncrypted: true },
});
results.push(
await rotateField(
"MoodTagCategory",
"labelEncrypted",
moodTagCategories,
(c) => c.labelEncrypted,
async (id, ciphertext) => {
await prisma.moodTagCategory.update({
where: { id },
data: { labelEncrypted: ciphertext },
});
},
),
);
// ───── NotificationChannel.config ─────
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
// Skipping these on rotation would leave channels permanently undecryptable
+2 -2
View File
@@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { decrypt } from "@/lib/crypto";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
const DATABASE_URL = process.env.DATABASE_URL!;
@@ -33,7 +33,7 @@ function parseTimeToMinutes(value: string): number {
return h * 60 + m;
}
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/tz/local-day
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
+1 -1
View File
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
## Conventions
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
@@ -4,7 +4,7 @@
*
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
* §1 A2) flagged the per-type `fetchMeasurementSeriesChunked` loop
* inside `/api/analytics` as the dominant cost of Marc's 74.6 s cold
* inside `/api/analytics` as the dominant cost of the maintainer's 74.6 s cold
* full-slice mount. v1.4.38.8 (commit 8a8150d2) relaxed the
* `isFullyCovered` gate inside the three downstream fast-paths so the
* walk became unreachable in the common case — but the A2 loop itself
@@ -129,7 +129,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-since-1",
username: "marc",
username: "testuser",
role: "USER" as const,
locale: "en",
timezone: "Europe/Berlin",
@@ -119,7 +119,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("passkey_only");
expect(body.data.hasPasskey).toBe(true);
@@ -132,7 +132,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: "argon2id$…",
_count: { passkeys: 1 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("email_fallback");
expect(body.data.hasPasskey).toBe(true);
@@ -145,7 +145,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("exists");
expect(body.data.hasPasskey).toBe(false);
@@ -1,7 +1,7 @@
/**
* v1.4.49.3 — call-site i18n coverage guard.
*
* Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event
* The maintainer surfaced raw `insights.relativeHoursAgo`, `notifications.event
* MoodReminder`, and `notifications.eventMoodReminderDesc` strings
* leaking into the UI in v1.4.49. Audit found 28 keys called from real
* code that never existed in any locale bundle — silent regressions
@@ -0,0 +1,79 @@
/**
* v1.16.10 — inventory write-path isolation guard.
*
* The consumption stamp on `MedicationIntakeEvent.inventoryConsumption`
* is only an exactly-once ledger as long as EVERY stock movement flows
* through `src/lib/medications/inventory/consumption.ts` (intake-driven
* consume / restore) or the dedicated inventory surfaces (the CRUD
* routes, the create-input builder, and the expire cron in
* `service.ts`). A `medicationInventoryItem` write sprinkled into some
* future intake path would bypass the stamp and re-open the
* double-decrement / stranded-stock class of bugs this release closed.
*
* This test walks every TypeScript / TSX file under `src/` (excluding
* tests + generated code) and asserts that no file outside the
* allowlist invokes a Prisma write method on `medicationInventoryItem`.
* Reads (`findFirst`, `findMany`, …) stay legal everywhere.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
const ROOT = join(__dirname, "../..");
const SRC = join(ROOT, "src");
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
/**
* Files allowed to write `medicationInventoryItem` rows:
* - the consumption module (intake-driven consume / restore);
* - the inventory persistence helpers (expire cron `updateMany`);
* - the two inventory CRUD routes (register / mutate / delete).
*/
const ALLOWED_WRITERS = new Set(
[
"src/lib/medications/inventory/consumption.ts",
"src/lib/medications/inventory/service.ts",
"src/app/api/medications/[id]/inventory/route.ts",
"src/app/api/medications/[id]/inventory/[itemId]/route.ts",
].map((p) => p.split("/").join(sep)),
);
const WRITE_PATTERN =
/medicationInventoryItem\s*\.\s*(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const stat = statSync(p);
if (stat.isDirectory()) {
walk(p, out);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(p);
}
}
return out;
}
describe("medicationInventoryItem write isolation", () => {
it("no file outside the consumption module + inventory surfaces writes inventory items", () => {
const offenders: string[] = [];
for (const file of walk(SRC)) {
const rel = relative(ROOT, file);
if (ALLOWED_WRITERS.has(rel)) continue;
const content = readFileSync(file, "utf8");
if (WRITE_PATTERN.test(content)) {
offenders.push(rel);
WRITE_PATTERN.lastIndex = 0;
}
}
expect(offenders).toEqual([]);
});
it("the allowlisted writers actually exist (allowlist stays honest)", () => {
for (const rel of ALLOWED_WRITERS) {
expect(() => statSync(join(ROOT, rel))).not.toThrow();
}
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
* (iOS #16). It is a thin shape-validated redirect onto
* `/auth/register?invite=…` and carries no session, so an unauthenticated
* visitor (scanning the admin QR) must reach it without the auth-gate
* bounce to `/auth/login`. This guard locks the allowlist entry in place
* so a future refactor of `src/proxy.ts` cannot silently re-gate it.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
const VALID_TOKEN = `hlv_${"a".repeat(64)}`;
function makeRequest(
pathname: string,
cookies: Record<string, string> = {},
): NextRequest {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
it("does not 307 an unauthenticated /invite/<token> to /auth/login", () => {
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("admits a pending-onboarding session onto the invite landing", () => {
const res = proxy(
makeRequest(`/invite/${VALID_TOKEN}`, {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("hardens the token-in-path surface against cache / index / referrer leaks", () => {
// Mirror the `/c/<token>` edge defence — the secret rides in the path,
// so no CDN may cache it and no crawler may index the URL.
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
expect(res.headers.get("Cache-Control")).toBe(
"no-store, no-cache, must-revalidate",
);
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
});
it("still redirects unauthenticated requests for protected pages", () => {
// Negative check — confirms the fixture would catch a real
// regression. `/insights` is not public.
const res = proxy(makeRequest("/insights"));
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
});
});
+48 -4
View File
@@ -24,10 +24,16 @@ const SW_SOURCE = readFileSync(
);
const ORIGIN = "https://app.example";
// `importScripts` throws in the sandbox, so the literal fallback version
// is active and the cache names are deterministic.
const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43";
const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43";
// `importScripts` throws in the sandbox, so the in-`sw.js` fallback literal
// is active and the cache names are deterministic. The fallback is
// re-anchored to `package.json` on every prebuild by
// `scripts/generate-sw-version.mjs`; read it straight from the SW source so
// this test never drifts from the literal it is exercising.
const FALLBACK_VERSION = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec(
SW_SOURCE,
)![1];
const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
class FakeCache {
map = new Map<string, Response>();
@@ -238,6 +244,44 @@ describe("sw.js — networkFirst offline fallback", () => {
});
});
describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () =>
new Response("private shell", {
headers: { "Cache-Control": "no-store" },
});
const res = await dispatchNavigationFetch(harness, "/");
expect(await res.clone().text()).toBe("private shell");
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
expect(await pages.match(`${ORIGIN}/`)).toBeUndefined();
});
it("does not cache the /c/ clinician-share view even without no-store", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () => new Response("share shell");
const res = await dispatchNavigationFetch(harness, "/c/hls_abc123");
expect(await res.clone().text()).toBe("share shell");
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined();
});
it("still caches an ordinary navigation response", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () => new Response("app shell");
const res = await dispatchNavigationFetch(harness, "/measurements");
expect(await res.clone().text()).toBe("app shell");
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
expect(await pages.match(`${ORIGIN}/measurements`)).toBeDefined();
});
});
describe("sw.js — trimCache", () => {
it("reads the key list once and deletes only the oldest excess entries", async () => {
const harness = bootServiceWorker();
@@ -9,7 +9,7 @@ import { join } from "node:path";
* thick `/api/analytics` envelope; every per-type tile waited on the
* heavy fan-out before paint. Mood and medication tiles arrived first
* (separate routes) and then every other tile arrived as one burst,
* which Marc reported as "etwas nervig" in the v1.4.39.1 post-deploy
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
* trace.
*
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
*
* - it gates on the layout's `heroVisible` flag (only the literal
* `true` renders — the hero is opt-in) AND on snapshot mode (the
* verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph lives in the hero when that renders; when
* the hero does NOT render (snapshot flag off, or hidden via the
* layout toggle) the header takes it back behind `showGreeting`,
* fed from the page's hero gate — the greeting never disappears.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
);
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
});
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
expect(matches).toHaveLength(1);
});
it("the hero sits between the header and the strip", () => {
const headerIdx = src.indexOf("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
// The greeting renders here ONLY when the hero band does not — the
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{showGreeting\s*\?/);
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
it("the page feeds showGreeting from the inverted hero gate", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
});
it("the header keeps the title + customize + add actions", () => {
expect(header).toContain('t("dashboard.title")');
expect(header).toContain("dashboard-customize-shortcut");
expect(header).toContain("dashboard-quick-add");
});
});
@@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The greeting hour only changes when the user's timezone changes
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
// local one line above the `useMemo` so the dep array stays stable.
expect(src).toMatch(
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
});
@@ -2,7 +2,15 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDashboardFirstPaintGate } from "../page";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "../page";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardLayout,
} from "@/lib/dashboard-layout";
/**
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow first
@@ -94,18 +102,105 @@ describe("resolveDashboardFirstPaintGate", () => {
});
});
describe("resolveConfiguredTileCount (v1.16.8)", () => {
it("counts only widgets that paint a strip tile, with bp counting double", () => {
// Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget are
// tile-visible AND tile-capable; medications + recentWorkouts are
// tile-visible in the stored layout but paint NO strip tile, so
// they must not inflate the silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "medications", visible: true, tileVisible: true, order: 0 },
{ id: "recentWorkouts", visible: true, tileVisible: true, order: 1 },
{ id: "achievements", visible: true, tileVisible: true, order: 2 },
{ id: "cardioRecovery", visible: true, tileVisible: true, order: 3 },
],
};
expect(resolveConfiguredTileCount(layout)).toBe(0);
});
it("falls back to `visible` for legacy entries without tileVisible", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, order: 0 },
{ id: "pulse", visible: false, order: 1 },
],
};
expect(resolveConfiguredTileCount(layout)).toBe(1);
});
});
describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
it("reserves one cell per chart-visible chart-capable widget", () => {
// Default layout chart row: weight, bp, pulse, bodyFat, mood,
// medications are `visible`; bpInTarget / achievements /
// recentWorkouts carry no chart-shaped footprint.
expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(6);
});
it("adds the BMI cell riding the weight gate when a height is known", () => {
expect(
resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT, {
hasHeightCm: true,
}),
).toBe(7);
});
it("respects a trimmed layout", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: false, tileVisible: true, order: 1 },
{ id: "steps", visible: true, tileVisible: false, order: 2 },
],
};
expect(resolveChartRowPlaceholderCount(layout)).toBe(2);
});
});
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `configuredTileCount` from the resolved layout", () => {
it("derives `configuredTileCount` through the tile-capable resolver", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
/const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/,
);
});
it("reserves the chart row while the primary query loads", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// The placeholder count keys off the layout config minus the
// already-mounted (layout-gated) cells, and only while loading.
// `mounted &&` pins the hydration render to the SSR output — the
// auth query can settle before the page boundary hydrates, and an
// extra BMI silhouette then mismatched the server HTML (React #418).
expect(src).toMatch(
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/,
);
expect(src).toMatch(/-\s*charts\.length/);
// The reserved cells render as aria-hidden ChartSkeletons in their
// own slot.
const skeletonBlock = src.match(
/chartRowPlaceholderCount\s*>\s*0\s*&&\s*\([\s\S]*?dashboard-chart-row-skeleton[\s\S]*?\)\}/,
);
expect(skeletonBlock).not.toBeNull();
expect(skeletonBlock?.[0]).toContain('aria-hidden="true"');
expect(skeletonBlock?.[0]).toContain("<ChartSkeleton");
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
// skeleton branch so a late-hydrating boundary can't disagree with
// the server HTML (React #418); see `useMounted`.
expect(src).toMatch(
/const\s+primaryLoading\s*=\s*snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading;/,
/const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/,
);
});
@@ -0,0 +1,41 @@
/**
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
*
* The guard is keyed on the running shell's build version: one reload
* attempt per BROKEN SHELL, not one per session. After a successful
* heal the reloaded page carries the new version and the guard re-arms
* by construction; the pre-v1.16.8 once-per-session key exhausted on
* multi-deploy days and stranded the user on the error page.
*/
import { describe, expect, it } from "vitest";
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
describe("shouldAttemptChunkReload", () => {
it("reloads when no attempt is recorded", () => {
expect(shouldAttemptChunkReload(null, "1.16.8")).toBe(true);
});
it("suppresses a second reload for the SAME shell version", () => {
const stored = chunkReloadGuardValue("1.16.8");
expect(shouldAttemptChunkReload(stored, "1.16.8")).toBe(false);
});
it("re-arms after a deploy — the reloaded shell carries a NEW version", () => {
const stored = chunkReloadGuardValue("1.16.8");
expect(shouldAttemptChunkReload(stored, "1.16.9")).toBe(true);
});
it("treats a legacy timestamp guard value as no attempt", () => {
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
// a guard value, so a stranded session heals on its next chunk error.
expect(shouldAttemptChunkReload("1765400000000", "1.16.8")).toBe(true);
});
it("degrades to once-per-shell-lifetime when no version is injected", () => {
expect(chunkReloadGuardValue("")).toBe("unversioned");
expect(shouldAttemptChunkReload(null, "")).toBe(true);
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
});
});
+32
View File
@@ -259,3 +259,35 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
expect(hookSrc).toContain("compareBaseline");
});
});
describe("v1.16.8 — insights overview cold paint", () => {
it("fires every overview query in parallel — none gated on another query's data", () => {
const src = load(INSIGHTS_PATH);
// The derived batch + analytics + comprehensive reads all gate on
// auth alone. Chaining any of them behind another query's resolved
// payload re-introduces the sequential reveal.
expect(src).toMatch(/useDashboardDerived\(isAuthenticated\)/);
expect(src).toMatch(/const analyticsQuery = useAnalyticsQuery\(\);/);
expect(src).toMatch(/enabled:\s*isAuthenticated/);
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
});
it("reserves the health-score column while the analytics payload is pending", () => {
const src = load(INSIGHTS_PATH);
// The hero receives the pending flag so the score card's slot holds
// its footprint from first paint (skeleton in `<HeroStrip>`).
expect(src).toMatch(
/healthScorePending=\{analyticsQuery\.isPending && !analytics\}/,
);
});
it("hero strip renders the score-card skeleton in the reserved column", () => {
const heroSrc = readFileSync(
join(ROOT, "src/components/insights/hero-strip.tsx"),
"utf8",
);
expect(heroSrc).toContain('data-slot="health-score-card-skeleton"');
// The two-column split keys on score-or-pending, not score alone.
expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/);
});
});
+1 -1
View File
@@ -17,7 +17,7 @@
* convention (cf. the SSR-string tests elsewhere).
*/
import { afterEach, describe, expect, it } from "vitest";
import { sheetBodyHasUnsavedInput } from "../page";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
const BODY_SELECTOR = '[data-slot="responsive-sheet-body"]';
const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]';
+2
View File
@@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { AboutSection } from "@/components/settings/about-section";
import { AiQualitySection } from "@/components/admin/ai-quality-section";
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
import { AssistantSection } from "@/components/admin/assistant-section";
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
@@ -104,6 +105,7 @@ export function AdminSectionRenderer({
subtitle={t("admin.section.assistant.subtitle")}
>
<AssistantSection />
<AiServerKeySection />
</SectionFrame>
);
case "coach-feedback":
@@ -0,0 +1,217 @@
/**
* v1.16.6 — admin CRUD contract for the operator-wide AI key
* (`/api/admin/ai-settings`). The endpoint predates its UI; the new
* admin "AI server key" card now drives it, so the contract gets
* pinned: admin-only access, encrypted storage, key removal, the
* HTTPS + hostname allowlist on the base URL, and the masked
* `keyPreview` in every response.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/ai-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 60_000),
} as never);
});
describe("GET /api/admin/ai-settings", () => {
it("propagates the admin gate (non-admin cannot read)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
await expect(GET()).rejects.toMatchObject({ statusCode: 403 });
});
it("returns defaults + hasKey=false when nothing is configured", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
const res = await GET();
const body = await res.json();
expect(body.data).toEqual({
hasKey: false,
keyPreview: null,
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
});
it("returns hasKey=true with a masked preview when a key is stored", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc(sk-test-12cd)",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
const res = await GET();
const body = await res.json();
expect(body.data.hasKey).toBe(true);
expect(body.data.keyPreview).toBe("...12cd");
expect(body.data.model).toBe("gpt-4o-mini");
// The plaintext key never leaks into the envelope.
expect(JSON.stringify(body)).not.toContain("sk-test-12cd");
});
});
describe("PUT /api/admin/ai-settings", () => {
function upsertEcho() {
vi.mocked(prisma.appSettings.upsert).mockImplementation(
(async (args: { update: Record<string, unknown> }) => ({
adminAiKeyEncrypted: null,
adminAiModel: "gpt-4o",
adminAiBaseUrl: "https://api.openai.com/v1",
...args.update,
})) as never,
);
}
it("propagates the admin gate (non-admin cannot write)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
statusCode: 403,
});
});
it("encrypts and stores a new key, audit-logged", async () => {
upsertEcho();
const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" }));
const body = await res.json();
expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab");
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" },
}),
);
expect(body.data.hasKey).toBe(true);
expect(body.data.keyPreview).toBe("...89ab");
expect(auditLog).toHaveBeenCalledWith(
"admin.ai-settings.update",
expect.objectContaining({ userId: "admin-1" }),
);
// The audit detail records THAT the key changed, never the key.
const details = vi.mocked(auditLog).mock.calls[0][1] as {
details: Record<string, unknown>;
};
expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab");
});
it("clears the key when apiKey is empty", async () => {
upsertEcho();
const res = await PUT(jsonReq({ apiKey: "" }));
const body = await res.json();
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({ update: { adminAiKeyEncrypted: null } }),
);
expect(body.data.hasKey).toBe(false);
expect(body.data.keyPreview).toBeNull();
});
it("updates model and baseUrl for an allowlisted https host", async () => {
upsertEcho();
const res = await PUT(
jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }),
);
const body = await res.json();
expect(body.data.model).toBe("gpt-4o-mini");
expect(body.data.baseUrl).toBe("https://openrouter.ai/v1");
});
it("rejects a plain-http base URL", async () => {
await expect(
PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })),
).rejects.toMatchObject({ statusCode: 422 });
});
it("rejects a base URL host outside the allowlist", async () => {
await expect(
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
).rejects.toMatchObject({ statusCode: 422 });
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("rejects an empty model", async () => {
await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({
statusCode: 422,
});
});
it("rejects a body with no recognised fields", async () => {
await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({
statusCode: 422,
});
});
it("returns 429 when the admin surface is rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
statusCode: 429,
});
});
});
@@ -70,19 +70,19 @@ describe("GET /api/admin/audit-log — extended filters", () => {
});
it("filters by `actor` against userId OR user.username", async () => {
await GET(req("actor=marc"));
await GET(req("actor=testuser"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// Expect an OR clause covering userId + user.username (contains)
expect(where.OR).toBeDefined();
const or = where.OR as Array<Record<string, unknown>>;
const matchesUserId = or.some((c) => c.userId === "marc");
const matchesUserId = or.some((c) => c.userId === "testuser");
const matchesUsername = or.some(
(c) =>
typeof c.user === "object" &&
c.user !== null &&
// user: { username: { contains: "marc", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("marc"),
// user: { username: { contains: "testuser", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("testuser"),
);
expect(matchesUserId).toBe(true);
expect(matchesUsername).toBe(true);
@@ -50,6 +50,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -536,6 +537,10 @@ const handler = apiHandler(
},
});
// v1.16.9 — the restore replaced every row the owner's cached
// payloads were built on; hard-evict all of that user's buckets.
invalidateUserData(ownerId);
const response: RestoreResponse = {
restored: true,
summary,
+5
View File
@@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
details: result,
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
});
@@ -60,7 +60,7 @@ import { getSession } from "@/lib/auth/session";
const ADMIN_SESSION = {
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
};
const USER_SESSION = {
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -134,6 +134,14 @@ function isChannelConfigPresent(
if (type === "NTFY") {
return typeof parsed.topic === "string" && parsed.topic.length > 0;
}
if (type === "WEBHOOK") {
return typeof parsed.url === "string" && parsed.url.length > 0;
}
if (type === "EMAIL") {
return (
typeof parsed.recipient === "string" && parsed.recipient.length > 0
);
}
return false;
} catch {
return false;
@@ -0,0 +1,155 @@
/**
* v1.17.1 — coverage for the operator-wide notification-health admin route.
*
* Pins:
* * 401 unauthenticated / 403 non-admin (requireAdmin is cookie-only),
* * the groupBy(channel, result) aggregate folds into per-channel
* ok/error/skipped/total rows,
* * auto-disabled channels (enabled=false + disabledReason not null) are
* surfaced with a count,
* * the window is clamped to 1..168 hours (default 24).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: { groupBy: vi.fn() },
notificationChannel: { groupBy: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return { ...actual, requireAdmin: vi.fn() };
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { run: <T>(_e: unknown, fn: () => Promise<T>) => fn() },
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { NextRequest } from "next/server";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" },
} as never;
function req(hours?: string): NextRequest {
const url = hours
? `http://localhost/api/admin/notifications/health?hours=${hours}`
: "http://localhost/api/admin/notifications/health";
return new NextRequest(url);
}
interface HealthEnvelope {
data: {
windowHours: number;
channels: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}>;
autoDisabledChannels: Array<{ type: string; count: number }>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/health — auth gates", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET(req());
expect(res.status).toBe(401);
});
it("returns 403 for a non-admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET(req());
expect(res.status).toBe(403);
});
});
describe("GET /api/admin/notifications/health — aggregate", () => {
beforeEach(() => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
it("folds groupBy rows into per-channel ok/error/skipped/total", async () => {
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([
{ channel: "NTFY", result: "ok", _count: { _all: 40 } },
{ channel: "NTFY", result: "error", _count: { _all: 12 } },
{ channel: "EMAIL", result: "ok", _count: { _all: 5 } },
{ channel: "EMAIL", result: "skipped", _count: { _all: 2 } },
] as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as HealthEnvelope;
const ntfy = body.data.channels.find((c) => c.channel === "NTFY");
expect(ntfy).toEqual({
channel: "NTFY",
ok: 40,
error: 12,
skipped: 0,
total: 52,
});
const email = body.data.channels.find((c) => c.channel === "EMAIL");
expect(email).toEqual({
channel: "EMAIL",
ok: 5,
error: 0,
skipped: 2,
total: 7,
});
// Sorted alphabetically.
expect(body.data.channels.map((c) => c.channel)).toEqual(["EMAIL", "NTFY"]);
});
it("surfaces auto-disabled channels with a count", async () => {
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([
{ type: "WEBHOOK", _count: { _all: 3 } },
] as never);
const res = await GET(req());
const body = (await res.json()) as HealthEnvelope;
expect(body.data.autoDisabledChannels).toEqual([
{ type: "WEBHOOK", count: 3 },
]);
});
it("defaults the window to 24h and clamps out-of-range values", async () => {
let res = await GET(req("999"));
let body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("0"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("48"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(48);
});
});
@@ -0,0 +1,103 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
export const dynamic = "force-dynamic";
/**
* v1.17.1 — operator-wide notification delivery health.
*
* The per-self diagnostic (`/api/admin/notifications/diagnostic`) only shows
* the calling admin's own account. On a multi-user instance the operator
* cannot answer "are anyone's pushes failing?" without DB shell. This route
* does a fleet-wide `groupBy(channel, result)` over the trailing window of the
* `push_attempts` ledger, plus a list of channels currently auto-disabled
* (across ALL users), so the operator can spot a flapping upstream that
* silently disabled a channel after five failures.
*
* Admin-only (cookie auth, never Bearer) by construction via `requireAdmin()`.
* The aggregate is anonymous: it counts channel×result, it never names a user.
*/
interface ChannelHealthRow {
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}
interface DisabledChannelRow {
type: string;
count: number;
}
interface HealthPayload {
windowHours: number;
since: string;
channels: ChannelHealthRow[];
autoDisabledChannels: DisabledChannelRow[];
}
export const GET = apiHandler(async (request) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.health" } });
// Window is operator-tunable via ?hours=, clamped 1..168 (7 days). The
// `(channel, result, created_at)`-friendly index on push_attempts keeps the
// groupBy bounded regardless of instance lifetime volume.
const url = new URL(request.url);
const hoursRaw = Number(url.searchParams.get("hours"));
const windowHours =
Number.isFinite(hoursRaw) && hoursRaw >= 1 && hoursRaw <= 168
? Math.floor(hoursRaw)
: 24;
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000);
const [grouped, disabled] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
where: { createdAt: { gte: since } },
_count: { _all: true },
}),
// Channels disabled with an automated reason. A manual disable leaves
// `disabledReason` NULL (see schema comment), so a non-null reason on a
// disabled channel is the auto-disable signal we surface to the operator.
prisma.notificationChannel.groupBy({
by: ["type"],
where: { enabled: false, disabledReason: { not: null } },
_count: { _all: true },
}),
]);
const byChannel = new Map<string, ChannelHealthRow>();
for (const row of grouped) {
const existing =
byChannel.get(row.channel) ??
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
const count = row._count._all;
if (row.result === "ok") existing.ok += count;
else if (row.result === "error") existing.error += count;
else if (row.result === "skipped") existing.skipped += count;
existing.total += count;
byChannel.set(row.channel, existing);
}
const channels = Array.from(byChannel.values()).sort((a, b) =>
a.channel.localeCompare(b.channel),
);
const autoDisabledChannels: DisabledChannelRow[] = disabled
.map((d) => ({ type: d.type, count: d._count._all }))
.sort((a, b) => a.type.localeCompare(b.type));
const payload: HealthPayload = {
windowHours,
since: since.toISOString(),
channels,
autoDisabledChannels,
};
return apiSuccess(payload);
});
Loaded 100 of 1205 files, more files were not shown because too many files have changed in this diff. Show more