Commit Graph
2153 Commits
Author SHA1 Message Date
Marc-André Bombeck 2b5974cf4a fix(coach): enforce the module gate on coach chat and list routes
The streaming POST and the conversation-list GET enforced only the
legacy operator assistant flag (requireAssistantSurface("coach")),
missing the per-user disableCoach opt-out and the operator
module-availability layer. Both handlers now run the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the account has Coach turned off — even with a
valid Bearer token. The legacy assistant-flag check stays in place.
2026-06-16 01:24:20 +02:00
Marc-André Bombeck 00d16ea275 chore(openapi): regenerate after the reconcile merge module-map ordering 2026-06-16 01:01:08 +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é Bombeck 5e6e010e2f merge(reconcile): r1-modulehub 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é Bombeck ba7a72f484 fix(labs): confirm destructive deletes on lab results and Vorsorge reminders
The single-row delete on lab results and on preventive-care reminders
fired immediately on click, unlike the measurement and mood lists which
gate the same action behind a confirm dialog. Route both through the
shared DeleteButton so a stray tap can no longer drop a reading or a
reminder, and add the per-surface confirm copy across all locales.
2026-06-16 00:45:19 +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 f435142db5 feat(profile): carry the resolved module map on the /api/user/profile alias
The native client reads /api/user/profile, not /api/auth/me, so the
v1.18.0 module enable/disable map never reached it. Resolve the same
`resolveModuleMap` projection /api/auth/me returns and include it on the
alias response, overlapped with the profile-row read. Extend the route
test to assert the map is present and server-authoritative.
2026-06-16 00:35:59 +02:00
Marc-André Bombeck 7c8d82f565 test(modules): pin every module-gated route with a gate-inventory invariant
Walk every per-domain route tree that serves a toggleable module and
classify each route.ts as module-gated, delegated (cycle/coach),
builder-gated (doctor-report/FHIR), or explicitly exempt with a reason.
A new ungated module route trips the guard by name.

Close four route-level gate gaps the inventory surfaced: the mood
analysis reads (/api/mood/insights, /api/mood/analytics) and the sleep
view reads (/api/sleep/night, /api/sleep/rhythm) now gate on their
module instead of staying reachable over a Bearer token while the module
is off. Pin the gate to enabled in the three pre-existing route suites.
2026-06-15 23:38:37 +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 9c5e7a0186 feat(settings): gather Coach settings into a dedicated entry
Move the Coach preference cards (disable toggle, preferences, durable-fact
memory) out of the AI / Assistant section into their own Coach entry,
module-gated on the coach module. The AI entry keeps its name and owns
provider / model / BYOK configuration plus the "about me" context (which
the daily briefing reads too, so it is not Coach-only).

The proactive Coach-nudge card stays in Notifications: it is a reminder
TYPE, already coach-module-gated there alongside the other reminder types,
and belongs with the reminder family rather than the Coach preferences.
2026-06-15 23:28:22 +02:00
Marc-André Bombeck fce9ca8f1a feat(settings): gather medication settings into a Medikamente entry
Promote the Medikamente section from a hidden Layout-hub child to its own
standalone nav entry (always shown — medications is a CORE domain) and
gather the injection-site exclusions card here from the account profile,
so every medication-specific preference lives on one screen. The Layout
hub keeps only the dashboard + insights arrangement editors.

The low-stock card stays whole in Notifications: its runway config and the
low-stock reminder toggle are one tightly-coupled control writing a single
prefs row, so splitting only the config out would break the card.
2026-06-15 23:23:50 +02:00
Marc-André Bombeck 16bd43c08f feat(settings): move mood settings into a dedicated Stimmung entry
Promote the mood-tag management surface from a hidden Layout-hub child to
its own standalone settings nav entry, shown only when the mood module is
enabled. The shell now module-gates per-submodule entries off the resolved
`useAuth().user.modules` map (fail-open: a missing key reads as enabled).
The Layout hub keeps only genuine layout/personalisation editors.
2026-06-15 23:18:54 +02:00
Marc-André Bombeck 59fea72d8b feat(settings): give the health record its own Gesundheitsakte entry
Lift the full health-record export (PDF + FHIR R4 + zip package) out of
Export & Import into a dedicated top-level Gesundheitsakte section. Export
& Import keeps the generic CSV/JSON data-out and import paths only. The
new section is always available (not module-gated).
2026-06-15 23:14:28 +02:00
Marc-André Bombeck 604778f166 feat(settings): make Benachrichtigungen the single module-gated reminder-types home
Consolidate every reminder TYPE onto the Notifications screen and drop the
redundant stacked breadcrumb for one clean heading. Each reminder-type card
is shown only when its module is enabled, read from the resolved
useAuth().user.modules map (mood card on mood, Coach nudge on coach); the
gate fails open so a stale /me payload never blanks a card. Low-stock maps
to the medications CORE domain and Vorsorge (preventive care) is not a
toggleable module, so both stay shown — the Vorsorge editor embeds in place.
The delivery channels keep a single concise pointer to Integrations rather
than a duplicated section. Reword the heading copy and drop the now-unused
settings.sections.reminders.* keys across all six locales.
2026-06-15 23:06:45 +02:00
Marc-André Bombeck fccdcb378e refactor(settings): remove the redundant Erinnerungen hub page
The standalone /settings/reminders page was a link-only hub that merely
deep-linked into the canonical editors — duplicating the Notifications
entry. Drop the section, its slug, and its placeholder/route wiring, and
301-redirect /settings/reminders to /settings/notifications. Reminder
types consolidate onto the Notifications home in a follow-up.
2026-06-15 23:03:52 +02:00
Marc-André Bombeck 7b80a38ea2 refactor(settings): fold Sources into Integrationen as a sub-tab
Source priority decides which connection wins when two report the same
metric, so it belongs with the integrations rather than as a standalone
settings page. Sources renders as the third Integrations sub-tab
(Connections / Channels / Sources); the standalone `sources` slug is
removed from the shell nav, slug registry, and route dispatch, and
`/settings/sources` 301-redirects to `/settings/integrations`.
2026-06-15 22:58:44 +02:00
Marc-André Bombeck d3d8731209 refactor(settings): move notification channels into Integrationen
Channels (Telegram, ntfy, Web Push, Webhook, Email) and the live
per-channel status surface are delivery providers, not notification
preferences, so they now sit under Integrations next to the connected
services. Integrations gains a Connections / Channels sub-tab split;
Notifications keeps the reminder-type content (mood, low-stock, coach
nudge) and cross-links to the relocated channels.
2026-06-15 22:54:19 +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 3cae7beda8 test(settings): cover the Module hub
Render every toggleable and core switch, confirm a disabled module reads
unchecked while an absent key stays default-on, assert the core domains
are locked, and verify a toggle PATCHes only the flipped key as a
disabled-allowlist entry and invalidates the auth/me query.
2026-06-15 22:37:34 +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 dceac65ab8 test(modules): stub the module gate in doctor-report and export suites
The v1.18.0 per-user module gate reads the database to resolve a user's module map. Three pre-existing aggregator/route test suites — the doctor-report PDF route, the health-record export route, and the export soft-delete filter — exercise paths that now call resolveModuleMap / requireModuleEnabled but never mocked the gate, so its DB read threw and the route returned 500. Stub the gate to all-modules-enabled (an empty map is default-on) in each file, re-asserting the return values in the mock-resetting beforeEach, mirroring the snapshot suite's existing pattern.
2026-06-15 22:04:07 +02:00
Marc-André Bombeck 6bde45ce0a merge(modules): gate achievements behind the achievements module flag
# Conflicts:
#	src/hooks/use-auth.ts
2026-06-15 21:57:43 +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 70402ea95a feat(modules): gate achievements behind the achievements module flag
When an account turns the achievements module off the whole gamification
surface disappears. The route enforces the gate after auth and returns the
403 module.disabled envelope, so no badge evaluation, unlock persistence,
or payload is produced. The achievements page, the dashboard recent-unlocks
tile, and the unlock notifier read the resolved module map from
GET /api/auth/me and render nothing / disable their query when the module
is off. Default-on: an absent key reads as enabled, a stale /me payload
coerces to an empty map so no surface vanishes on a partial-deploy
rollback.
2026-06-15 21:25:40 +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 1bb2a661d2 merge(modules): gate insights status routes behind module flags 2026-06-15 21:13:45 +02:00
Marc-André Bombeck fb13759f45 feat(modules): gate insights status routes behind module flags
The derived-score, metric-status and mood-status insight routes serve a mix
of core-vital and toggleable-module signals. Map the module-owned metric ids
(sleep/recovery/glucose) to their module key and refuse with the shared 403
module.disabled envelope when the account has that module disabled; mood-status
gates wholesale on the mood module, mirroring the cycle gate. Metrics absent
from the map are core vitals and stay open.
2026-06-15 21:13:16 +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