Commit Graph
2113 Commits
Author SHA1 Message Date
Marc-André Bombeck 74bda80abb feat(modules): exclude disabled-module sections from doctor report and FHIR export
Resolve the per-user module map at report-build start and drop a disabled
module's section/resources from the aggregated payload, so the PDF and the
FHIR bundle reflect only the modules the user keeps. The module gate wins
over the per-report section toggle: a module turned off cannot be shared
even if its report toggle is on.

Map report sections / FHIR resource groups to module keys: mood, sleep,
cycle, labs via the existing section toggles; glucose panel + raw glucose
series; recovery + stress wellness scores; workout activity series + strain
score. Core clinical sections (weight, BP, pulse, medications) carry no
module key and stay unconditional. FHIR resources inherit the exclusion
because every builder derives from the gated aggregator output.
2026-06-15 21:18:33 +02:00
Marc-André Bombeck 76d24dd684 merge(nav): Coach standalone page, Recovery+Workouts as Insights pills 2026-06-15 21:06:50 +02:00
Marc-André Bombeck f0a030f1ce refactor(nav): drop Workouts from the left nav (already an Insights tab)
Workouts already surfaces as an Insights tab-strip pill (the workouts
slug, gated on a logged workout row), so a parallel left-nav home was
redundant. Removing it from NAV_DESTINATIONS lets the Insights nav home
read active on /insights/workouts like any other /insights/* sub-route.
2026-06-15 21:05:24 +02:00
Marc-André Bombeck 0467f15b6c merge(modules): hide dashboard tiles for disabled modules 2026-06-15 21:05:14 +02:00
Marc-André Bombeck 1e02af998b feat(modules): hide dashboard tiles for disabled modules
Resolve the per-user module map in the dashboard snapshot builder and
strip every toggleable module's tile + data before the payload leaves
the server, so a disabled module never reaches the client.

- mood / sleep / glucose / achievements / workouts widgets are forced
  invisible on both the web layout and the iOS catalogue when their
  module is off; the persisted layout is untouched.
- sleep / glucose summary types drop out of the slim slice, so the
  derived metricStates and the client data-floor gates also drop them.
- the glucose clinical panel and per-context block are cleared when
  glucose is off; the mood block is emptied when mood is off.
- the Daily Briefing falls back to its disabled state when the insights
  module is off (alongside the existing operator flag + coach opt-out);
  raw vitals stay untouched — insights is the narrative layer.

Core widgets (weight, BP, pulse, body-fat, medications, BP-in-target)
carry no module mapping and always render.
2026-06-15 21:04:40 +02:00
Marc-André Bombeck 640793a3e8 refactor(nav): surface Recovery as an Insights tab instead of a left-nav item
Recovery is a composite wearable surface (WHOOP / Polar / Oura recovery,
strain, ANS charge, cardio load), not a top-level tracking destination.
It moves off the left nav and onto the Insights tab strip as a pill at
/insights/recovery. The pill is always present (like Overview) since the
page self-gates every block and falls back to a calm empty note rather
than carrying its own availability signal. Adds the insights.navRecovery
label across all six locales.
2026-06-15 21:03:48 +02:00
Marc-André Bombeck 910775e912 merge(api): document coach conversation endpoints for iOS 2026-06-15 21:01:38 +02:00
Marc-André Bombeck 4e78c2cfb2 docs(api): document coach conversation endpoints
Register the four /api/insights/chat surfaces in the OpenAPI contract:
list (cursor-paginated), streaming send (SSE), detail (messages decrypted
server-side), and delete. Adds CoachConversation / CoachConversationsPage /
CoachConversationDetail / CoachMessage / CoachProvenance / CoachChatRequest
schemas and regenerates docs/api/openapi.yaml. Additive; the endpoints
themselves are unchanged.
2026-06-15 21:00:57 +02:00
Marc-André Bombeck aa27708ebc refactor(nav): move Coach to a standalone /coach page
The Coach lived under /insights/coach, rendering inside the Insights
layout's tab strip. It is the product's headline surface, not a metric
sub-page, so it now lives at the top-level /coach in the standard page
chrome without the Insights tab strip. The nav model points at /coach,
every in-app link and the proactive-nudge deep link follow, and a 301
redirect keeps /insights/coach (bookmarks, cached PWA navigation, older
push payloads) resolving.
2026-06-15 21:00:46 +02:00
Marc-André Bombeck 2629976096 merge(modules): module enable/disable foundation — registry, gate, migration 0167, /api/auth/me projection 2026-06-15 20:55:36 +02:00
Marc-André Bombeck fa53631c55 feat(api): surface and update per-user module preferences
GET /api/auth/me now returns a resolved modules map for every toggleable
module (cycle/coach reflect their delegated state). New PATCH/GET
/api/auth/me/modules read and update the preferences: the body is strict
over the toggleable key set so a core-domain or unknown key is a 422, the
merge is field-by-field (no mass assignment), and userId is narrowed from
the session. Documented in the OpenAPI registry and yaml.

The disable-coach gate-inventory allowlist records the module gate's
intentional coach delegation read.
2026-06-15 20:54:02 +02:00
Marc-André Bombeck 25f85730d4 fix(reminders): match Vorsorge page width to peer pages
The Vorsorge page wrapped its content in a centered max-w-2xl container
nested inside the shared app shell, rendering it noticeably narrower than
every peer surface. Drop the width cap and centering so the top-level
container is a bare space-y-6 div, matching measurements, labs,
medications, cycle, and mood.
2026-06-15 20:53:54 +02:00
Marc-André Bombeck d1089ba232 feat(modules): add the canonical module registry and enforcement gate
registry.ts is the single source of truth for the toggleable secondary
domains (cycle, mood, sleep, glucose, workouts, recovery, labs,
achievements, coach, insights, doctorReport) plus the always-on core
domain denylist. Each entry carries a stable key, label/description i18n
keys, a category, and an optional delegatesTo marker.

gate.ts is the one enforcement point: isModuleEnabled / requireModuleEnabled
(403 module.disabled envelope) plus resolveModuleMap for the /api/auth/me
projection. No double source of truth — cycle delegates to isCycleEnabled
and coach delegates to disableCoach AND the operator assistant master
flag; every other module resolves from the disabled allowlist. Default-on,
fail-open per key, per-request memoised reads.

insights gates only the AI-analysis surfaces (briefing, status cards,
correlation narration, score explainer) — never the raw measurement
charts, which are core and render regardless.
2026-06-15 20:53:54 +02:00
Marc-André Bombeck 6116d92ff5 feat(modules): add module_preferences_json disabled-allowlist column
Additive, nullable JSONB on users carrying a default-on disabled
allowlist for the optional (secondary) domains. NULL / empty / a key
set to true all read as enabled; a key set to false disables that
module. No backfill — every existing account reads all-on.

Core domains (weight, blood pressure, pulse, medications) are never
written here and have no toggle. cycle and coach keep their existing
source of truth and the column does not own them.
2026-06-15 20:53:46 +02:00
Marc-André Bombeck 56c92e0cfe fix(nav): keep More-hub tile labels inside their tiles
The mobile "More" overflow sheet renders a two-column grid of icon-plus-label
tiles. The label span carried min-w-0 but no break handling, so a long
single-word label such as the German "Benachrichtigungen" — which has no
natural break opportunity — pushed past its grid track and overflowed the
tile box.

Add break-words to the label so an unbroken long word breaks inside the tile;
combined with the existing min-w-0 shrink and the auto-height tile (min-h-14),
the second line is absorbed cleanly. The fix is layout-level, so every label
in all six locales fits without special-casing the longest one.
2026-06-15 20:34:33 +02:00
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