v1.18.0 resolves the cycle module through isCycleEnabled(gender,
CycleProfile): a NULL cycleTrackingEnabled derives from gender, so the
MALE-seeded e2e account read as cycle-OFF and /cycle redirected home —
the cycle-wheel tile never mounted. Seed a CycleProfile with
cycle_tracking_enabled = true so the cycle vertical renders for the
mobile smoke spec.
The wheel tile, phase-education card, and dashboard cycle tile each set
both data-revealed and .wellness-tile-rise on the same node. The keyframe
selector is [data-revealed="true"] .wellness-tile-rise (descendant
combinator), so it never matched a same-node pair — the tiles stayed at
the rise rule's opacity: 0, invisible but still boxed. Lift data-revealed
onto a contents wrapper (the documented pattern from the cycle insight
summary card) so the entrance plays and the signature ring paints.
The inactive tab text used --muted-foreground (#9aa3b3) on the muted
(#44475a) tab strip, reading 3.6:1 — below the WCAG AA 4.5:1 floor that
axe flags on /settings/integrations. Add a --tab-inactive token (#b3bccd
dark, 4.79:1 on the strip; light mirrors the existing 4.58:1 muted tone)
and point the dark-mode inactive trigger color at it. The active trigger
keeps --foreground.
The v1.18.0 settings IA split the full health-record export panel out of
/settings/export into its own top-level /settings/gesundheitsakte section.
The export-export page keeps the CSV/JSON tiles and import cards. Realign
the panel + included-data disclosure assertions to the new route; the CSV
tile assertions stay on /settings/export.
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.
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.
The v1.18.0 IA restructure reworded the mood + medications section descriptions;
update the SSR copy assertions to the new strings (the i18n parity guards stayed
green, but these per-section copy assertions pinned the old text).
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.
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.
A widget toggle whose owning module is disabled is a dead control: the
dashboard snapshot gates that tile and chart out server-side regardless
of the switch state, so the row in Settings just confuses. Read the
resolved per-user module map from the session and drop each widget row
whose owning module is off, mapping widget id to module key through the
shared WIDGET_MODULE_BY_ID. Core widgets (no module entry) and any row
whose module key is unset stay visible; the gate fails open when the map
is absent.
Add `src/app/api/insights` to the inventory's walked trees so every
`route.ts` beneath it must classify: the 14 AI-narrative routes as
insights-module-gated, the Coach sub-tree as coach-delegated, and the
AI-settings / provider-chain / tile-layout / feedback / targets /
glp1-timeline routes as explicit infra-and-config EXEMPT entries with
documented reasons. A future ungated insights AI route now fails the
inventory BY NAME rather than leaking the surface over a Bearer token when
the account turned insights off.
Add representative insights-off → 403 module.disabled coverage to the
blood-pressure-status and correlations route tests (alongside the existing
insights-on → 200 path).
Mark the top-level `/insights` nav destination `requiresModule: "insights"`
so it drops from both bars when the account has the insights module off,
mirroring mood / labs / coach / achievements. The bottom-nav pinned
`/insights` as a fixed primary slot regardless of the module map; it now
filters that slot through the same per-user map the More hub honours
(fail-open on a missing key / unloaded /me payload). A disabled insights
module is hidden everywhere — the primary-slot filter keeps it out of the
More hub too, so it is hidden, not relocated.
Extend the nav-model + bottom-nav tests with insights on/off coverage.
Require the `insights` module (in addition to the existing assistant-surface
gate and any per-metric data-module gate) on all 14 narrative insights
routes: blood-pressure / bmi / pulse / weight / medication-compliance /
mood / metric / derived / derived-batch status, cards, correlations,
narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope
now precedes any provider or cache work when the account has insights off.
The per-metric sleep/glucose/recovery/mood gates and the
requireAssistantSurface("insightStatus"|"correlations") checks are kept;
this adds the module layer on top of them.
Route tests that now resolve the gate mock @/lib/modules/gate
default-enabled so their existing data-shape and assistant-flag assertions
ride through unchanged.
The DoctorReportDialog + its /api/doctor-report, /pdf and /availability routes
were referenced only by their own tests — the health-record (Gesundheitsakte)
export is the live surface. Remove the dead dialog, routes, their tests, and
the now-stale docs page; the shared doctor-report-data aggregator stays (the
health-record export uses it). The route-gate inventory exempt list is updated
accordingly.
The workout list + detail reads now carry requireModuleEnabled, so the
route-gate inventory's exempt allowlist must stop claiming them as
ungated data-layer surfaces. Only the iOS batch ingest stays exempt.
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.
The dashboard tour launcher now derives includeAchievements from the
per-user module map (modules?.achievements !== false), so an account
that has the achievements module turned off no longer walks the user to
a page it has hidden.
Mention the module on/off concept in the integrations tour stop, where
the tour introduces Settings and customization, so a first-time user
learns whole areas can be turned on or off.
The sleep, blood-glucose, workouts, and recovery insights pages
fetched their series and half-rendered when the backing module was
disabled — a disabled account that navigated straight to the URL saw
the surface flash before any guard. They now resolve a shared
useModulePageGuard hook (mirroring the cycle page): an unauthenticated
visitor bounces to login, a disabled-module account bounces to
/insights, and the page renders a calm loader until the gate clears.
The backing /api routes enforce the gate server-side, so this is the
UX layer, not the security boundary.
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.
The doctorReport module toggle was inert: turning it off hid nothing and the
server-side export route accepted requests regardless. Gate the live surface
on the module — the Health-record settings entry now hides client-side when
the module is off, and POST /api/export/health-record refuses with a 403
module.disabled envelope, so the doctor-report surface cannot be reached over a
Bearer token either.
GET /api/workouts and GET /api/workouts/{id} returned a disabled-
module account's workout list and detail to any valid Bearer or
cookie session. Both now require the workouts module and return the
403 module.disabled envelope when it is off.
The batch INGEST route (POST /api/workouts/batch) stays ungated by
design: synced workouts must keep landing in storage even while the
surface is hidden, so re-enabling the module reveals a complete
history rather than a gap.
The default /api/analytics slice served the per-context glucose
summaries and the TIR / GMI / eA1C clinical panel unconditionally,
leaking a disabled-glucose account's readings to a surface the
account has turned off. The route also serves core weight / BP /
pulse metrics, so a 403 on the whole payload is wrong.
Resolve the glucose module once server-side and null out
glucoseByContext + glucoseClinical when it is off; the core metrics
keep flowing. Mirrors the mood-pillar gate already in this route.
The compliance number + card already render neutral (no grade tint); only a
stale comment still described a green take-window highlight the implementation
does not do. Reword it to state status drives a discreet line/glyph, never a
card background/border tint.
mood, medications, Vorsorge, the health-record export panel and the export
section hand-rolled their card headers; route them through the shared settings
card-header primitive so every settings card header is structurally identical.
The batch derived endpoint (the one the dashboard wellness strip reads)
computed Recovery/Strain/Stress unconditionally while the single derived route
already gated them, so disabling the recovery module did not hide the rings.
Reuse the single route's DERIVED_MODULE map (now exported, with READINESS added
since it equals the computed recovery score) and drop disabled-module metrics
from the batch payload per request, without mutating the cached map.
Per-route tests assert the two-layer Coach module gate: a disabled
module (operator availability OR per-user disableCoach) yields 403
module.disabled before any Coach work runs, and an enabled module falls
through to the normal path. Covers the chat POST + list GET, about-me
GET/PUT, the questions GET/DELETE, and the adopt POST short-circuit
ahead of the locked read-modify-write.
The structured self-context endpoints — about-me GET/PUT, the adopt
POST, and the clarifying-questions GET/DELETE — read and wrote Coach
self-context with no module check. Each handler now runs the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the operator availability layer or the
per-user disableCoach opt-out has Coach turned off. The adopt route
test mocks the gate to stay focused on the locked read-modify-write.
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.
The insights cycle ring tile and the /cycle page redirect gated on
user.cycleTrackingEnabled, which /api/auth/me resolves without the
operator availability layer — so with the Cycle module disabled
server-wide the ring still rendered (and fired its calendar read) and a
direct /cycle URL still loaded.
Read user.modules.cycle instead, which the /me map already AND-s with the
operator switch, so an operator-off instance suppresses both surfaces.
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.