Commit Graph
860 Commits
Author SHA1 Message Date
Marc-André Bombeck ba8804cce0 merge: coach-cadence suggestions — SUGGEST-REMINDER sentinel, suggestion SSE frame, one-tap reminder 2026-06-16 21:19:36 +02:00
Marc-André Bombeck 12f4bc24e8 feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a
measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol,
structured glucose). The server resolves the cadence from a closed catalog
and emits an additive 'suggestion' SSE frame the chat renders as a one-tap
action card; accepting it creates a MeasurementReminder with origin COACH
through the same engine the Vorsorge surface uses (server-authoritative
schedule, finite course window self-expires via endsOn).

Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a
master opt-out under Coach settings, and an explicit 'I measure enough'
stop path, all in the coach-prefs blob. Triple dedup at suggest, render,
and create so a cadence is never double-suggested or double-created, and
suggestions for a disabled module never surface. RHR/HRV stay passive --
the Coach surfaces the baseline, it never nudges to 'go measure' them.

The reminder DTO now echoes origin and endsOn for the iOS client.
2026-06-16 21:17:51 +02:00
Marc-André Bombeck 6ee0060c8a merge: illness/condition journal P2 MVP — born-gated module, episode CRUD, symptom timeline 2026-06-16 21:17:09 +02:00
Marc-André Bombeck cc32e2adf7 merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill
# Conflicts:
#	src/app/api/labs/route.ts
2026-06-16 21:17:09 +02:00
Marc-André Bombeck b079431a8d feat(illness): add the condition-journal MVP behind a born-gated module
Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.

- Register `illness` as a born-gated (opt-in / default-off) module. The
  gate resolver flips the disabled-allowlist default to off for born-gated
  keys, so the surface stays dark until the account opts in from the
  Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
  module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
  delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
  route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
  userId from auth, builds data field-by-field, and gates the module.
  Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
  functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
  via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
  the day-log upsert, the gate enforcement, and the born-gated resolver.

The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
2026-06-16 21:09:35 +02:00
Marc-André Bombeck 90e0f2191c feat(labs): OpenAPI contract, i18n, and module-gate inventory
Add the Biomarker + restore routes to the OpenAPI registry and carry
biomarkerId on the LabResult schema (additive; iOS resolves the unit +
range from the linked marker, never recomputes). Regenerate the YAML.
Add the labs.* / labs.catalog.* keys across en/de/es/fr/it/pl. List the
biomarker catalog tree + restore route on the module-gate inventory's
data-layer exemptions (the module gates the surfaces, not the row store).
2026-06-16 21:06:16 +02:00
Marc-André Bombeck e4722fb9a1 feat(labs): backfill job linking legacy readings to the catalog
Add a one-shot pg-boss job that heals the per-row free-text divergence:
for each user it groups un-linked live readings by lower(analyte),
mints (or reuses) one Biomarker per group from the most-recent report's
spelling/unit/range, and stamps biomarkerId on every row. Registered in
allQueues with a boot-time discovery enqueue; idempotent across reboots
(a completed pass drops the user from the discovery set).
2026-06-16 21:05:58 +02:00
Marc-André Bombeck e9037a0d92 feat(labs): biomarker catalog with server-resolved reference ranges
Add a user-scoped Biomarker catalog so a marker is defined once — name,
unit, lower/upper bound, optional encrypted context, panel — and every
later reading just links it. New /api/biomarkers CRUD plus a structured
/api/labs create path: a biomarkerId resolves the unit + reference range
server-side, so the list and detail DTOs carry the resolved values and
the client never recomputes the range. Lab reads include the linked
marker and fall back to the legacy free-text fields for unlinked rows.

Add a lab-result restore endpoint for the delete-Undo affordance, a
shared serialiser, a static common-panel seed list, and tests for
catalog validation, structured-entry resolution, and the context codec.
2026-06-16 21:05:51 +02:00
Marc-André Bombeck a0d864db8c feat(vorsorge): widen the linkable measurement set to the body-comp family
The reminder type allow-list exposed 7 of the full MeasurementType enum.
Widen it to the ~15 active-measurement set: vitals (weight, BP, pulse,
glucose, SpO2, temperature) plus the body-composition family a scale
produces (fat mass, fat-free mass, muscle, lean body mass, bone mass,
body water, visceral fat, BMI). One step on the scale satisfies every
linked reminder at once. Passive scores, cumulative activity, and device
events stay excluded — nudging someone to 'go measure' a passive sample
makes no sense.

App-layer only: the column is the full enum, so no migration. Group the
dropdown (Vitals / Body composition) and add the labels across all six
locales.
2026-06-16 20:59:06 +02:00
Marc-André Bombeck 7b57c1d3d8 feat(vorsorge): resolve reminders on ingest, not only on the cron
Add a reminder-satisfy pg-boss queue (mirrors pr-detection) that every
measurement and lab ingest path enqueues — manual create, batch, and the
Withings / Polar / Oura / Nightscout / Fitbit / WHOOP syncs. A reminder
now auto-resolves the moment a matching reading lands instead of waiting
up to 15 minutes. The 15-minute cron stays as the idempotent safety net;
the forward-only guard lets the eventful hook and the cron converge
without double-stamping.

The worker honours the module toggle (a reminder for a disabled module
produces no engine activity) and runs the same satisfaction primitives
the cron uses.
2026-06-16 20:58:56 +02:00
Marc-André Bombeck f92247760c feat(vorsorge): one satisfaction primitive + lab auto-resolve
Extract a single satisfyReminder() that every path uses to mark a
reminder's cadence fulfilled: stamp lastSatisfiedAt (forward-only) and
recompute the server-authoritative nextDueAt. The cron auto-resolve and
the manual satisfy route now route through it instead of duplicating the
reschedule.

Add findSatisfyingEvent(): a typed reminder resolves from a matching
Measurement, a free-text reminder from any LabResult. This closes the
Lab<->Vorsorge loop — an annual-blood-panel reminder is now satisfied
when a lab result lands, not only on a manual check-off.
2026-06-16 20:58:46 +02:00
Marc-André Bombeck 2057cb9d2b fix(dashboard): split module maps into client-safe module to unblock build
The settings dashboard-layout client component imported WIDGET_MODULE_BY_ID
from @/lib/dashboard/snapshot, which transitively pulls the server snapshot
builder -> lib/db -> @prisma/adapter-pg -> pg -> node:dns. Turbopack tried to
bundle pg for the browser and failed (Module not found: Can't resolve 'dns'),
breaking next build and therefore e2e and both Docker arch builds.

Move the two pure data maps (WIDGET_MODULE_BY_ID, SUMMARY_TYPE_MODULE) into
@/lib/dashboard/widget-modules, which imports only the ModuleKey and
MeasurementType types. snapshot.ts re-exports both for the server call sites
and the existing tests; the client component imports from the new module.
2026-06-16 16:34:28 +02:00
Marc-André Bombeck 90c7da2a5e feat(doctor-report): add workouts + lab trends + self-context, align adherence rounding
Surface actual workout sessions and a per-analyte lab trend in the doctor
report PDF + FHIR bundle (gated on their modules), carry the user's stored
conditions/allergies through to the report and FHIR Condition/AllergyIntolerance,
and round medication adherence to the app's integer convention so the PDF, FHIR
and in-app numbers agree.
2026-06-16 08:36:08 +02:00
Marc-André Bombeck 5f9670d222 feat(doctor-report): render the clinical glucose panel in the PDF and FHIR
The server already computes the clinical glucose panel (TIR / GMI / estimated
A1C / mean / CV%) by the one literature-locked engine the insights panel and
coach consume, but nothing surfaced it in the export. Render it as a dedicated
PDF table after the vitals section and emit it as FHIR Observations
(LOINC-coded where a term exists; CV% under a survey text-only concept). Both
gate on the panel carrying readings, which the aggregator already zeroes when
the glucose module is off — so a disabled module emits no glucose panel. The
spot-reading caveat rides along when the readings are sparse. Adds clinical
glucose-panel keys across all locales and PDF + FHIR tests.
2026-06-16 03:01:40 +02:00
Marc-André Bombeck b9a3f577ca fix(doctor-report): collapse to the canonical source before per-type stats
The per-type avg/min/max blended every ingest source, so a metric carried
by two overlapping sources on the same day (WHOOP + Apple Watch resting
heart rate) averaged both readings while the dashboard and rollups already
collapse to the ladder-canonical source per day. Route the report's raw
measurements through the same picker the analytics aggregator uses
(pickCanonicalSourceRows, keyed by metricKeyForType + the user day key +
source-priority ladder) before grouping, so the PDF and FHIR numbers match
the dashboard. SLEEP_DURATION stays untouched — its per-stage rows are
resolved by the per-night reconstruction downstream. Adds a parity test.
2026-06-16 02:55:46 +02:00
Marc-André Bombeck f73bc32a72 merge(reconcile): b5-achievements audit findings 2026-06-16 02:20:45 +02:00
Marc-André Bombeck f966ae884a fix(dashboard): gate the workouts + recovery surfaces by module
The dashboard's recent-workouts and steps tiles painted regardless of
the workouts module state; AND the resolved per-account flag into both
gates (fail-open — only an explicit false hides the tile).

Extend the snapshot module maps to cover the recovery domain: the
cardioRecovery / sixMinuteWalk / stairAscentSpeed / stairDescentSpeed
widgets and their HealthKit measurement types now resolve to the
recovery module, and the per-night breathing-disturbance widget + type
resolve to sleep. A new guard test asserts every toggleable-domain
widget id carries a mapping and every mapped value is a real module
key, so a future device widget cannot leak onto a disabled account.
2026-06-16 02:05:37 +02:00
Marc-André Bombeck f72d0b0ef3 fix(achievements): skip disabled-module badges and decouple unlock writes from the cached read
Gate badge evaluation on the per-user module map: mood badges no longer
evaluate, render, or persist while the mood module is off, and the same
holds for any metric that maps to a toggleable module (sleep). Core and
account-wide badges have no owning module and always pass through.

Move unlock persistence out of the cached builder. The builder now
returns the not-yet-persisted unlocks and the handler runs the
idempotent createMany after the cache read, so a cache hit can no longer
skip the write and a concurrent miss can no longer duplicate it.

Add the parity fields the iOS DTO dropped (category, points, target,
current, isHidden) so the native client groups and renders badges in
lock-step with the web surface.
2026-06-16 02:01:47 +02:00
Marc-André Bombeck a73872435d fix(cycle): enforce the operator module kill-switch across every cycle gate
The cycle gate checked only the per-user toggle and gender, never the
operator server-wide availability layer. With the Cycle module turned off
by the operator, /api/cycle/* kept serving, the coach injected the cycle
block, the Apple-Health importer kept writing cycle rows, and the
cycle-reminder cron kept firing CYCLE_PERIOD pushes — while nav and the
doctor report already honoured the operator switch.

Add isCycleAvailableForUser, which delegates to isModuleEnabled(userId,
"cycle"): the cycle ModuleKey resolves the per-user toggle AND the
operator availability map, so there is one source of truth and no
duplicated operator logic. Route requireCycleEnabled, the coach cycle
block, and the import accumulator through it; switch the cron to the
injectable isModuleEnabled gate, matching the mood and measurement
reminder crons, and add a skippedModuleDisabled counter. Correct the
cron comment that claimed isCycleEnabled was itself the module gate.

isCycleEnabled stays the pure per-user (user-layer) resolver the module
foundation AND-s at layer one.

Tests: assert requireCycleEnabled returns a 403 cycle.disabled envelope
and the cron skips the push when the operator turns the module off.
2026-06-16 01:19:46 +02:00
Marc-André Bombeck 2425832f47 merge(reconcile): r4-logic audit findings 2026-06-16 00:59:31 +02:00
Marc-André Bombeck ba6cb98148 merge(reconcile): r3-ios audit findings 2026-06-16 00:59:31 +02:00
Marc-André Bombeck c2537e2c08 merge(reconcile): r2-labs audit findings 2026-06-16 00:59:31 +02:00
Marc-André BombeckandClaude Opus 4.8 79f2762468 fix(insights): make AI medication-compliance per-day series cadence-aware
The per-day compliance series in the medication-compliance insight snapshot
divided every event-day by the schedule count, treating each calendar day as
fully dose-due. A weekly-cadence medication (a Monday-only schedule, a
bi-weekly interval) therefore had its series read off-cadence weekdays as
misses, contradicting the canonical compliance30 value shown in the same
snapshot.

Route the per-day series through the unified dose-history ledger that already
backs compliance7 / compliance30: add dailyComplianceRatesFromLedger, which
groups the ledger's scheduled slot rows by day and scores each day with the
same taken / (taken + missed) tally — on-time-plus-late numerator, skip /
ad-hoc / upcoming exclusions — that tallyLedgerRows applies to the window
rates. Only days the cadence actually expected a dose produce a point, so a
non-due day can no longer be counted as a miss, and the per-day series and
compliance30 are computed from one ledger and cannot disagree.

The insight generator now derives both the canonical compliance results and
the ledger from a single buildMedicationComplianceBundle pass instead of two
calculateCompliance calls plus a hand-rolled per-day map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:59:04 +02:00
Marc-André Bombeck bf5deb33e2 refactor(labs): one shared reference-range classifier and range formatter
Reference-range classification + low/high range rendering was implemented three
times (validations, PDF core, lab list). Extract a single classifier and a
structure-only range formatter that takes a per-surface number formatter, so
each surface keeps its own number formatting while sharing the canonical
classification. Behaviour is unchanged per surface and now covered by one test.
2026-06-16 00:58:27 +02:00
Marc-André Bombeck 9a5e562001 fix(coach-nudge): correct the sleep-debt trigger unit so it can fire
The sleep-debt nudge compared a minutes value against an hours threshold, so
the trigger was effectively dead. Align the comparison to the unit the
sleep-debt value is produced in and pin it with a firing-case test.
2026-06-16 00:54:00 +02:00
Marc-André Bombeck 91d01d154a fix(settings): make delegated module rows read-only managed deep-links
The Module hub rendered live switches for the delegated keys cycle and coach,
but the gate resolves those from their own sources (cycle from CycleProfile,
coach from the operator flag + disableCoach), so a toggle wrote an inert entry
to modulePreferencesJson, snapped back, and still fired a success toast. Render
delegated modules as read-only rows that deep-link to their real control, strip
delegated keys from the modules PATCH schema so no inert value is stored, drop
the duplicate cycle control, and surface an operator-disabled state with the
previously orphaned hint string.
2026-06-16 00:51:16 +02:00
Marc-André BombeckandClaude Opus 4.8 3de53f549c fix(health-score): drop the mood pillar when the mood module is disabled
The Personal Health Score weighted the 0.20 mood-stability pillar
unconditionally — the fast-path read mood entries regardless of the
account's module state, so a user who turned the mood module off was
still scored (and silently penalised or rewarded) by a pillar hidden
everywhere else in the product. The dashboard already blanks the mood
tile and the coach snapshot unions mood out; the score ring and the
insights card pillar list did not.

Thread a default-on `moodEnabled` flag through the fast-path. When the
caller resolves the mood module as disabled the helper skips the mood
read entirely and leaves the series empty, so `moodStability` returns
null and the existing proportional null-redistribution re-scales the
surviving pillars to sum to 100. The analytics route resolves the
module server-side and threads the flag; the insights card hides the
Mood row via a new SSR-safe `useModuleEnabled` accessor so it never
advertises a pillar the account turned off.

Golden fixtures pin mood-on vs mood-off to different, correctly
re-weighted scores and assert the disabled read is never issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:39:55 +02:00
Marc-André Bombeck 3090b742c4 docs(openapi): document the profile module map and the 403 module.disabled envelope
Add the resolved `modules` map to ProfileResponse (shared ModuleMap
schema) so the spec matches the alias payload, and add a reusable
module-disabled 403 response documenting `meta.errorCode = module.disabled`
plus `meta.module` (the disabled key). Wire that 403 onto the module-gated
sleep night/rhythm routes, which emit it via requireModuleEnabled.
Regenerate docs/api/openapi.yaml; openapi:check is in sync.
2026-06-16 00:36:05 +02:00
Marc-André Bombeck 0fdeafc65d test(coach): point the disable-coach invariant at the relocated Coach section
The per-user disableCoach gate moved out of the AI/Assistent settings section
into its own dedicated Coach section in v1.18.0. Update the call-site allowlist
so the grep-based invariant pins the new home instead of the old one.
2026-06-15 23:31:22 +02:00
Marc-André Bombeck 4a1971c0dd feat(admin): module availability matrix
Add an operator-side toggle matrix to the admin assistant section that
flips each toggleable module on or off server-wide via PATCH
/api/admin/settings/module-availability. Mirrors the assistant-flags
matrix: one neutral toggle per module, optimistic flip with a revert on
failure, and an authMe invalidation so the resolved module map re-gates
the session immediately. New admin.modules.* labels across all six
locales; core domains never appear.
2026-06-15 22:48:05 +02:00
Marc-André Bombeck 5d55347dd0 feat(modules): add operator-level server-wide module availability
Second layer of the module enable/disable model: an operator can turn a
module off for the whole server, and an operator-disabled module is off
for every account regardless of personal preference — mirroring the
coach master flag above the per-user opt-out.

Storage is a DISABLED allowlist on AppSettings.moduleAvailabilityJson
(absent/empty/true = available, explicit false = disabled), resolved by
getOperatorModuleAvailability(). The gate now resolves every toggleable
module as operatorAvailable(key) AND userEnabled(key); cycle and coach
keep their existing delegation but are still AND-ed with operator
availability. Core domains are never disableable.

GET/PATCH /api/admin/settings/module-availability is cookie-only
(requireAdmin); a strict per-module-key schema rejects core domains and
unknown keys at the boundary.
2026-06-15 22:44:35 +02:00
Marc-André Bombeck f50f92902e feat(settings): add the Module (what-you-track) hub
Surface a dedicated settings section for enabling and disabling the
secondary tracking domains (mood, sleep, glucose, workouts, recovery,
labs, achievements, coach, insights, doctor report, cycle). Each row
carries an icon, label, and one-line description with a switch bound to
the resolved module map on /auth/me; toggling PATCHes /api/auth/me/modules
and invalidates the auth/me query so the navigation, Insights pills, and
dashboard tiles re-gate live. Core domains (weight, blood pressure, pulse,
medications) render as a read-only always-on group. The entry sits right
below Account in the settings navigation.
2026-06-15 22:36:04 +02:00
Marc-André Bombeck 8de15a0400 merge(modules): hide nav entries and Insights pills for disabled modules 2026-06-15 21:56:17 +02:00
Marc-André Bombeck 7982f90cc1 merge(modules): exclude disabled-module sections from doctor report and FHIR 2026-06-15 21:56:17 +02:00
Marc-André Bombeck 5682055fe2 feat(modules): hide nav entries and Insights pills for disabled modules
Generalise the per-destination cycle gate into a requiresModule marker
on each nav destination and read it from the account's resolved module
map (the modules field on GET /api/auth/me). Fold the bespoke cycle
boolean into requiresModule: "cycle" so there is one gating mechanism;
cycle and coach are delegated keys already reflected in the map, so
reading them here is not a re-derivation.

Tag mood, cycle, labs, coach and achievements with their module keys;
core destinations (measurements, medications, vorsorge, insights, the
dashboard) carry none and always render. The sidebar, the mobile More
hub, and the Insights tab strip all filter on the same map.

Insights pills: a metric pill belonging to a toggleable module (mood,
sleep, glucose, workouts) hides when its module is disabled, on top of
the existing data + saved-layout gates; the Recovery pill gates on the
recovery module. Overview and core-metric pills always show. Every gate
fails open — a missing key or an absent map keeps the entry, matching
the gate's default-on contract, so a stale /me payload never blanks the
nav.
2026-06-15 21:18:47 +02:00
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 3264dd2e86 merge(modules): skip reminders for disabled modules 2026-06-15 21:16:24 +02:00
Marc-André Bombeck c9c53865df merge(modules): exclude disabled-module domains from the coach snapshot 2026-06-15 21:16:14 +02:00
Marc-André Bombeck e365350dd7 feat(modules): exclude disabled-module domains from the coach snapshot
Resolve the per-user module map once at snapshot build start and fold any
disabled data-domain module into the same system-side source exclusion the
excludeMetrics flow already drives, so a disabled domain (mood, sleep, glucose,
workouts) never reaches the coach context. The recovery composites are gated
explicitly as well, since their derivedActive flag also reads the sleep signal
and would otherwise survive when only recovery is off. Cycle keeps resolving
through its single-source-of-truth gate. Tests stub resolveModuleMap to the
all-on default so existing fixtures are unaffected.
2026-06-15 21:16:00 +02:00
Marc-André Bombeck 2fc4d049d3 feat(modules): skip reminders for disabled modules
Gate each per-module reminder cron on the v1.18.0 module foundation so a
disabled module never dispatches its reminder.

- mood-reminder: skip a user whose mood module is off, after the window
  gate, with a mood_reminder.skipped_module_disabled annotation.
- measurement-reminder: skip a glucose/sleep reminder when that module is
  off and advance nextDueAt past the slot so it does not re-evaluate every
  tick; core-vital reminders (weight/BP/pulse/body-comp) and free-text
  reminders map to no module and dispatch regardless.
- cycle-reminder: already gates on isCycleEnabled, the single source the
  cycle module key delegates to; documented, no behaviour change.

Medication reminders stay ungated (medications is core). The gate is
injectable into each tick so the unit tests pin the disabled-module skip
without the gate's DB reads.
2026-06-15 21:15:44 +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 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 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 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 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 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 d7691b6cbe merge(integrations): honest Polar sleep timeline, shared reconstruction, OAuth returns, status envelope 2026-06-15 01:35:27 +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