2098 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
v1.17.1
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