NotificationStatusCard read useAuth().isAuthenticated, which is false
during SSR and true on the client, so the card rendered null on the
server and a loading card on first client paint — a tree-shape change
React reported as a hydration mismatch on every authenticated load of
the integrations page, regenerating the subtree and dropping the
channel controls' handlers.
Gate the card behind the shared useMounted() hydration probe so the
server and the first client paint agree (both render null); the real
status loads as an ordinary post-hydration update. Fold the settings
shell's own inline copy of the probe onto the same hook.
Hand-rolled section headers had drifted into a dozen variants — some
without an icon, some with a text-sm description instead of the shared
text-xs, the data-reset title once even tinted red. Route the remaining
ad-hoc headers through the SettingsCardHeader primitive so icon sizing,
title weight and description size live in one place.
Give the integrations connections group (the data sources) its own
labelled header for parity with the delivery-channels group below it.
A coverage test now globs every settings *-section.tsx / *-card.tsx and
fails on any hand-rolled <h2 text-lg> outside the primitive, with a short
allowlist for the collapsible-toggle security cards and the layout
section pending its hub rebuild.
The settings section navigation filtered its entries by the resolved
`user.modules` map directly in render. That map resolves at different
points on the server-rendered pass and the hydrating client pass, so the
two renders emitted a different number of nav entries — a hydration
mismatch that made React discard and regenerate the tree, leaving every
control on the page non-interactive (a disclosure that wouldn't open, an
export button that did nothing).
Emit the full fail-open list on both the server pass and the first client
paint (gated behind a `useSyncExternalStore` hydration probe), then apply
the module filter once, after hydration. Server and first client render
now always match, so the nav — and the rest of the settings page — stays
interactive.
The authenticated shell's <main> scroll container already reserves the
scrollbar gutter; the settings content column is not a scroll container, so a
gutter rule on it has no effect. Remove it (and its misleading comment) rather
than imply a fix that does nothing.
Collapse the duplicated settings navigation so each surface lives in
one place:
- Appearance ("Darstellung") stops being a link hub and renders the
dashboard, insights, and every tracking module's settings inline as
stacked, anchored sections. Each module section keeps its module
gate, so a disabled module's section does not render. The standalone
Medikamente / Stimmung / Labor / Krankheit / Vorsorge nav entries are
gone; their routes 301-redirect to /settings/layout#<anchor>.
- Move the delivery channels out of Notifications into Integrations,
where the other connected services live. Notifications keeps only the
reminder types. /settings/channels now redirects to
/settings/integrations#channels; the per-channel anchors still resolve.
- Fold the clinician share-link surface into the health-record section
as a labelled Sharing group. /settings/sharing redirects to
/settings/gesundheitsakte#sharing.
- Drop the duplicated active-sessions and security-activity cards from
Account; they remain under Data & Privacy.
- Reserve the scrollbar gutter on the settings content column so a
section with a vertical scrollbar and one without render at the same
width.
Move-only: every card renders through its existing component unchanged.
The H24 / H12 preference only reached makeFormatters().time. Surfaces that
build their own Intl.DateTimeFormat — the sleep hypnogram axis, the workout
list and detail headers — omitted the hour cycle, so an en-locale (or AUTO)
user saw AM/PM even with H24 selected. The security activity feed and session
list rendered raw toLocaleString(), bypassing both the locale and the
preference entirely.
Export hourCycleOptions from format-locale as the single source for the
H12 / H24 / AUTO Intl options and spread it into the self-built formatters,
preserving each surface's locale and timezone. Route the security cards
through useFormatters().dateTime. Drop the date-time field's local copy of
the helper.
Guards: the helper contract, the en-US hour/minute call-site pattern (no
AM/PM under H24, AM/PM under H12), and formatUpdatedLabel fed an H24
formatter for the briefing freshness label.
Two surgical, additive, reversible changes to the flat settings navigation.
Notifications becomes one surface with two sequenced, labelled groups:
Reminders ("what you receive") first, then Delivery channels ("where it
lands") = the existing channels panel, unchanged. The standalone Channels
sidebar entry and slug are dropped; /settings/channels 301-redirects to
/settings/notifications#channels (mirroring the reminders redirect). The
per-channel anchors keep resolving.
The mis-titled "Dashboard" area (slug kept as layout, so no route churn) is
renamed Appearance / Darstellung and widened from two links to six: dashboard
arrangement, insights overview and pills, plus deep-links to the view/sort/
order cards of medications, labs, the illness journal, and checkups. Each
module keeps its view cards on its own page; the hub only indexes them. The
checkups view card gains a vorsorge-view anchor so the deep-link lands.
i18n updated across all six locales; settings and notifications tests track
the new structure.
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.
Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.
Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.
Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
The inbound-documents surface is withdrawn from this release and returns
later with more depth. Park its module key (`inboundDocuments`) in a new
`CODE_DISABLED_MODULE_KEYS` list so it is hard-off for every account ahead
of both the operator-availability and the per-user opt-in layer.
The gate short-circuits a code-disabled key to disabled, so the resolved
module map reports it off everywhere: the nav entry stays born-gated and
hidden, the `/documents` page redirects, and every `/api/documents/inbound/*`
route returns the 403 module-disabled envelope. The Modules hub and the
operator-availability panel drop the row, and the per-user PATCH surface
refuses to write the key (a clean 422 instead of an inert preference).
The whole implementation stays intact — routes, the inbound-documents view,
the registry entry with its opt-in marker, the validation schemas, and the
migration are all preserved. Re-enabling the module is a one-line removal
from the list once the rebuild lands.
Regenerate the OpenAPI contract: the module-preferences PATCH body no
longer advertises the parked key.
Revert the nine-group consolidation and the card relocations back to the
flat sidebar: sessions, trusted devices, and login activity return to
Privacy, and account deletion returns inline under Advanced. Drop the
/settings/layout redirect and the now-unused group and sign-in cross-link
i18n keys across all six locales.
Keep the collapse-by-default sessions and activity cards, the medical-history
cross-link from "About me", and the chronic-conditions consolidation.
Follow-ups on the v1.25.1 settings group consolidation.
- Sign-in security is no longer split across two top-level groups. Active
sessions, trusted devices, and the login-activity feed move into Account
> Security alongside the second-factor and passkey controls, so every
"who can sign in as me" control sits in one place. The Data & Privacy
pane keeps the data / retention / encryption story and cross-links to
the security home. Sessions and activity are no longer duplicated on the
Profile tab.
- First-child sub-tabs no longer repeat their group name: the section
titles become "Profile & preferences", "Connections", and
"Privacy & retention" across all six locales.
- Account deletion moves out of Advanced into the Account group as a
standalone card; research mode and the data reset stay in Advanced.
- Disambiguate the Spanish labels for the health record vs medical
history (they both read "Historial médico"); medical history becomes
"Antecedentes médicos".
- The section heading carries its id on a single breakpoint instance to
clear a duplicate-id-aria violation; the focus hook keeps targeting
both instances and picks the visible one.
- The in-page sub-tab strip scrolls its active tab into view on a narrow
viewport, matching the group strip.
- Redirect /settings/layout to the Display landing now that nothing links
to the old personalisation hub.
- Note in code that delivery channels deliberately stay under
Integrations (a channel is a delivery provider).
Login only wiped the in-memory query cache; the IndexedDB query snapshot
and the service-worker data/page caches from the previous account stayed
on disk. Logout and the 401/expiry redirect already drop all three. Run
the same offline-cache wipe on a successful login too, before the new
user's dashboard prefetch warms, so a fresh sign-in on a shared browser
inherits nothing from the prior session. The per-account restore guard
still stands; this removes the stale data at the source.
Settings shell: move focus to the section heading when the active section
changes within the mounted shell, so keyboard and screen-reader users are
placed in the new content instead of being left on the nav link they
activated. The first mount is skipped to avoid an unexpected focus move on
a deep link. Raise the inactive sub-tab text from muted-foreground to
foreground/70 so it clears the WCAG AA contrast floor over the tinted
sub-tab strip.
The settings left rail rendered one entry per section — 26 visible — which
read as a long flat list on desktop and a 26-chip scroll strip on mobile.
Group the sections into nine top-level entries and surface each group's
children as in-page sub-tabs, keeping the rail one level deep and the second
level inside the page.
Every section route stays live at its own URL. The rail is now driven by a
SETTINGS_GROUPS model; a reverse index maps each child slug onto its group so
deep links and the per-page settings links keep resolving and highlight the
right group, with the active child marked on the sub-tab strip. This
generalises the existing hidden-child highlight mechanism rather than moving
routes, so bookmarks and the contract surface are untouched.
Groups: Account (profile, sign-in), Tracking (areas, the per-domain pages,
targets), Display (dashboard, insights), Integrations (connections, channels,
source priority), Notifications, AI & Coach, API & Access, Data & Privacy
(export, health record, sharing, privacy, advanced), About. Per-domain
settings keep their view and data together inside one sub-tab — only the nav
groups, never a single concept.
Rename the Display group title away from "Dashboard" so it no longer collides
with its own Dashboard child. Add the nine group labels and the sub-section
nav label across all six locales. Drop the now-unused hub back-link.
The chronic-conditions and Coach-focus self-context read as foundational
medical history, so the Anamnese section now surfaces them as a read-only
shared view next to allergies and family history, with a link to their single
editing home in personal context. Personal context links back to the medical
history. The card is coach-gated and reads through the existing
/api/coach/about-me query key, so nothing is moved or duplicated and there is
no schema change.
Both account-security lists now render behind a disclosure control and open
only on click. The trigger carries aria-expanded and aria-controls and is
keyboard-operable; the region is gated with hidden. State is UI-only and not
persisted.
- Reword two LOINC verification comments in the signals registry so they
no longer trip the forbidden-marker grep, keeping the meaning intact.
- Resolve the dead-code gate: gate the seeded-question opener on the
per-user ambient-suggestions opt-out (wiring the previously orphaned
resolveCoachAmbientSuggestionsEnabled, server-authoritative), drop the
unread decryptDocumentFromBytes helper and its now-unused import, and
remove the unused SignalKind type alias.
- Delegate undici's fetch to the global stub in the provider-chain
integration test so the egress-pinned client HTTP is mocked again.
- Defer the JSON example-download cleanup a tick so revoking the blob URL
no longer races the browser download start (stabilises the e2e).
- Sync the service-worker version fallback to v1.25.0.
Wrap the home and travel city-search inputs in a form so Enter runs the
search instead of doing nothing, and show a 'no matching places' line when
a geocode returns nothing rather than leaving the user without feedback.
Validate the home timezone against the runtime IANA set (a bogus zone
would silently mis-key every stored environment day). Grow the result-row
and travel-remove controls to the 44px touch floor.
The mental-wellbeing screener surface shipped built but unreachable: no nav
entry, no module key, ungated. Add a `mentalHealth` module key (opt-in,
default OFF — a depression/anxiety self-assessment is at least as sensitive
as mood) to the registry, the module-hub icon map, and the exhaustive module
maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated
on the module so it appears only once the account opts in, and redirect the
page home on a direct hit when the module is off.
Item answers stay encrypted and off the AI Coach / MCP regardless of the
toggle; this only controls whether the surface is visible and reachable.
Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.
- New shared `MedicalDisclaimer` component renders the copy as plain
React text (no markdown renderer, per the XSS posture). Variants:
intendedPurpose, coach, insights, labs, medications, emergency,
dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
intended-purpose line, the per-surface notes, the emergency note, and
an explicit data-posture statement (self-hosted, data stays on your
instance, encrypted at rest, BYOK / local model, no third-party
analytics).
- Apply once per surface, layered not plastered: a calm standing line on
the Coach new-chat hero, one framing line in the Insights layout shell
(covers the briefing and trends), a labs note (ranges vary, only a
clinician interprets), a neutral medications note, and the data-posture
line where the provider key is configured. Mental-wellbeing keeps its
existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
rule so the Coach answers "what are you / where does my data go"
honestly and stays non-diagnostic.
App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
Settings → Environment now reads the override list as "location periods": a place
plus a date range that covers a trip OR corrects a stretch of earlier history,
overriding the home for the days it spans. Show the home's effective-from date
and prefill the backfill start with it, so the default span matches what the
server will actually store. Strings updated across all six locales.
Add the opt-in Environment module (default off), its Settings surface (home
location search, travel overrides, backfill, with Open-Meteo attribution), the
gated API routes (overview, home, geocode, travel CRUD, backfill), the nightly
pg-boss fetch job with a lookback window plus on-demand backfill, the query
keys, six-locale strings, and the regenerated OpenAPI. The module performs an
outbound weather fetch tied to a coarse location, so it ships dark and the user
turns it on deliberately; off means no job, no row, no egress.
Add the opt-in inboundDocuments module (off by default, like the MCP
endpoint): ingesting a clinical document egresses it to the OCR/vision
provider, so the surface ships dark and the user turns it on from the
Modules hub. The born-gated /documents page carries the prominent
non-diagnostic disclaimer (we read, we don't interpret; nothing is saved
until you approve it; only your clinician can interpret these results),
uploads a document, and walks each extracted fact: source provenance and
confidence are shown, low-confidence facts are blocked from approval until
edited, and the confirm step commits the approved facts.
Rework the proactive Coach nudge so it reads like one human checking in on
another instead of a terse English alert.
Copy: the title is a name-led greeting that rotates day to day ("Guten
Morgen, …" / "Hey …"; the 05:15 frame makes a morning opener safe) and the
body is one calm observation plus a gentle invite — never an imperative.
Drop the verbatim quoting of the user's decrypted self-context sentence:
the focus is referenced only by presence, abstractly, and is no longer
decrypted at all. Remove the 80-char mid-word clamp; each channel composes
to a length budget instead.
Cadence: suppress the nudge when the user already talked to the Coach in
the last ~24 h. With the existing rolling frequency cap (never two in a
window) and the no-trigger-no-nudge rule, the surface stays anti-nag.
AI enrichment (opt-in via the coach prefs blob, default off): when a
provider is healthy the body is composed through the model under hard
guards — a per-user budget gate, a tight per-call timeout, a per-tick
ceiling so the sequential tick can't stall across the user base, and the
outbound safety screen on the output. Any error, timeout, budget refusal
or unsafe reply falls silently back to the deterministic template, which is
always the default and the fail-closed fallback.
Localized greetings, per-trigger bodies and the focus note across all six
locales; the prefs opt-in is surfaced in the notification settings.
Generalise sleep debt onto the existing per-metric source-priority picker
(a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar >
computed) instead of assuming one provider. The rhythm read resolves the active
source off the ladder and carries it on the DTO; when the active source is our
own COMPUTED engine — every user today, since no provider ships a native
sleep-debt balance — the Sleep page explains what the figure means and that it
differs from a wearable's native number. The computed model itself is unchanged
and correct (a short rolling balance of need minus sleep, surplus-credited);
only stale comments that still referenced the old 14-night window are corrected.
Add a per-user toggle that turns off the proactive ambient example suggestions
(the daily seeded opener on the Coach hero). The flag rides the existing coach
notification-prefs blob (coach.ambientSuggestions, default on) — no schema
change — and a shared hook makes it the single source every ambient suggestion
surface consults, so the seeded opener and its fetch both stand down when off.
Add a new "Medical history" (Anamnese) Settings section that hosts the two
structured-record managers — allergies and family history — each a
list + add/edit sheet + delete-with-confirm built on the shared query-key
factory and the ResponsiveSheet pattern. It sits between the illness journal
and the preventive-care reminders and is always shown (not module-gated, like
Vorsorge), since these are foundational health-profile records.
The section carries a plain non-diagnostic note ("your own records, not a
medical diagnosis"). Full i18n across all six locales (de/en/es/fr/it/pl).
Add isError branches with a retry affordance to the mood, illness, and
vorsorge lists so a failed read no longer renders as "no entries yet"
and invites duplicate re-entry, matching the labs and cycle pattern.
Make the admin backups table reachable on a phone with a card fallback,
name the admin language and timezone selects and the AI raw-data switch
for screen readers, and replace the touch-unreachable title= help on the
glucose clinical panels with a tap-capable info popover.
Route the password-strength label through the AA-tuned semantic tokens,
underline the register login link so it is not distinguished by colour
alone, give the labs load error alert semantics, and explain why the
target editor Save button is disabled with aria-invalid and a live
alert.
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read
tools + deep-value reads + a confirmed, audience-bound write surface)
with the v1.23 account-security line (MFA, known/trusted devices,
free-text note encryption).
- schema: keep every v1.23 User relation (MFA, known/trusted devices)
alongside the McpOAuthConnection relation; the MeasurementSource enum
carries the MCP value.
- api-handler: the MCP audience guard (a token scoped to
health:read/health:write is bound to /mcp and refused a REST write)
and the step-up MFA helpers coexist.
- maintenance queues: register the MCP token-cleanup sweep next to the
note-encryption backfill and key-rotation queues; shift it to 04:00
so the two daily discovery ticks do not collide.
- migrations: renumber the MCP migrations to 0206/0207 so they follow
the v1.23 chain (0199-0205).
- MCP mood writes route the note through the v1.23 at-rest cipher.
Trusted devices was mounted on both the Security section and the Privacy
dashboard. Keep it only on the Privacy dashboard, which already gathers sessions
and security activity, and drop it from the Security section. While here: the
Retention card painted an empty list while the privacy summary was loading and
on error — give it a skeleton placeholder and a one-line fallback, and align the
"What's stored" indentation with the other privacy cards.
The inline rename save/cancel controls on the security-key and passkey lists and
the passkey-nudge dismiss button sat at 32px, below the 44px touch minimum. Give
them the same min-h-11/min-w-11 phone target that collapses to 32px from sm up.
Add an xs (30rem) breakpoint and stack the recovery-code grid one-up below it so
the 11-character codes no longer crowd at 320px.
Regenerating recovery codes invalidates the current set immediately, so the
action now sits behind a confirmation dialog matching the disable-MFA pattern,
with a consequence line spelling out that the existing codes stop working. While
in the card: give the disable-step TOTP input the same digit-strip + 6-char cap
and exact-length submit guard as setup, swap the static disable-code id for a
useId value, and lift the copy-secret button to a 44px touch target on phones.
Bring the scattered data-sovereignty controls into one /settings/privacy pane.
It assembles already-shipped pieces rather than rebuilding them: the encryption-
at-rest summary and configured retention windows (read from the server so the
copy stays truthful), links to the export surfaces (including the passphrase-
encrypted option) and the account-deletion / data-reset surface, the active-
session list, trusted devices, and the security-activity log. The backup↔
deletion lag is disclosed honestly — deletion is immediate, but encrypted
off-host backups still hold the data until they roll off retention.
Add an opt-in "remember this device" at the second-factor step. On opt-in
a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie,
and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in
from the same browser still requires the password (factor 1); a matching,
unexpired trusted device lets the login skip factor 2 only.
The token is never accepted for step-up — a trusted-device login does not stamp
mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action.
Trusted devices are listed and revocable from the security surface, are revoked
wholesale when the second factor is removed or the user signs out everywhere,
and cascade away on account deletion. A recovery-code login never trusts the
browser. Migration 0204 adds the table (and the instance-wide mfa_required
policy column that the enforcement work reads).
Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.
The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
Add the user-facing account-security surface in Settings:
- Active sessions (closes#64): GET /api/auth/me/sessions lists each browser
login with a coarse device label, masked IP, resolved location, the sliding
last-active stamp, and the current-session marker. DELETE the collection
signs out everywhere else (keeps the current session, revokes other sessions
and native refresh tokens, leaves API tokens alone); DELETE one revokes a
single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
the audit log filtered to sign-ins, second-factor changes, password changes,
session revocations, exports, and deletions, with masked IPs.
OpenAPI regenerated for the new endpoints.
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.
POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
Standardise the labs marker subtitle to text-xs across both the compact list
and the card grid, and build the localized name->slug lookup map once per
locale instead of re-scanning the catalog seeds per group on every render.
Add relative to the mobile section strips in the settings and admin shells so
the active chip's offsetLeft is measured from the strip, giving an exact
horizontal scroll target.
Drop the orphaned insights.sleep.headlineCaption and headlineCaptionSuffix
keys from all six locales; nothing references them after the sleep headline
removal.