A rising score curve on the check-in page itself reads as a verdict at
the moment someone is taking stock of how they feel. The page keeps the
calm dated list of past check-ins (severity badge, score, the discreet
crisis-resource re-surface); the score trend stays available through the
PHQ9_SCORE / GAD7_SCORE measurement rows on the Insights / Measurements
surfaces. The per-card trend sheet and its chart component are removed,
along with the now-unused chart i18n keys in all six locales.
- assessments render in full; the three-line clamp and its show-more
toggle are gone
- AI prose (assessment, briefing, explainers, usual-range note) reads
in the regular foreground; muted stays reserved for meta lines
- the usual-range note becomes a proper card with the shared TileHeader
(icon + title, labelled in all six locales)
- the metric-page heading + explainer indent to the cards' text edge
- the health-score card drops its Ask-the-Coach corner button
The prose renderer knew only blank-line paragraphs, so a reply that
enumerated options read as dash-prefixed text lines and a bold marker
surfaced as literal asterisks. ProseBlocks now groups consecutive
"- " lines into a real list and renders **bold** spans as strong
text — pure string splitting over the trusted string, fail-closed on
anything unclosed or unknown, still no markdown library. The Coach
streaming renderer settles completed paragraphs through the same
block renderer.
Reading matchMedia (or localStorage) inside the initial render branches
the hydration render away from the SSR markup — the sidebar is only
CSS-hidden below md and still hydrates its DOM there, so the phone
viewport tripped React #418 in the dashboard console guard. The
collapsed state now gates on the mounted probe like the module filter
above it: SSR and hydration paint the expanded shell, the stored pref
or tablet default applies right after. useIsMobile learns the lg cut
for the tablet probe.
Without min-w-0 the shell's flex column adopted the measurements
table's intrinsic min width, widening main past the viewport and
putting the whole page on a horizontal scrollbar at tablet widths.
Wide children scroll inside their own overflow-x-auto containers.
A 256 px expanded sidebar next to a 768 px viewport leaves the content
a 512 px column — an iPad held upright rendered every page squeezed.
With no stored preference, md–lg widths now start collapsed to the
icon rail; a manual toggle persists and wins on every later visit.
Reconciles the v1.26.1 Fitbit setup-guide fix with the Google Health
release: google-health.md stays the full runbook (with a pointer to the
classic fitbit.md guide), the doc-parity guard now asserts the Google
Health page against the google-health client's endpoints and scopes,
and the changelog carries both sections.
Wire the settings card to the live connection so it reflects reality
after a connect.
- Emit a google-health entry on the consolidated integration-status
envelope (configured, connected, last sync, backfill, and the
needs-reconnect flag). Without it the card read an undefined status and
showed a permanently disconnected connection even after a successful
OAuth grant, hiding the sync, disconnect, and reconnect controls.
- Add the resume and test-connection routes the card calls, mirroring the
classic Fitbit ones.
- Use the shared refresh icon on the reconnect action.
- Drop the experimental-scope opt-in from the runbook and describe ECG
and irregular-rhythm as a planned addition rather than a current toggle.
Surface the Google Health integration to the operator and document the
setup honestly.
- A settings card matching the other integration cards: the BYO Google
client id and secret form, connect and disconnect, a manual sync, and
a distinct reconnect prompt when the connection needs fresh consent.
- Register the card in the connections panel and extend the integration
key, the docs-link provider, and the query-key factory.
- Translations for the card copy across all six locales. The copy states
plainly that this reads Fitbit, Pixel Watch, and Fitbit Air through the
user's Google account, that it succeeds the Fitbit connection Google
retires in September 2026, that the operator registers their own Google
client, that stress and readiness are not available, and that a
periodic reconnect may be needed.
- Rewrite the Google Health runbook into the real setup: a Google Cloud
project, the Health API, an OAuth consent screen kept in Testing, a
Web Server client, the callback redirect, the four Restricted scopes,
the hundred-test-user ceiling, and the reconnect caveat.
- Regenerate the OpenAPI contract for the new measurement source.
The Fitbit integration connects through the classic Fitbit Web API, so a
self-hoster registers a Fitbit developer app at dev.fitbit.com — but the
setup guide walked users through creating a Google Cloud OAuth client,
whose client id the Fitbit sign-in rejects (unauthorized_client). Rewrite
the guide for the dev.fitbit.com flow (app type, HTTPS callback, scopes,
PKCE), leave the old page as a pointer, and add a test that keeps the
documented scopes and endpoints in sync with the client so the guide
cannot drift from the code again. Flags the September 2026 sunset of the
classic Fitbit Web API.
Route the notifications main view and the documents header through the
canonical page-header vocabulary the rest of the release adopted, and
align the admin demote-role label to the standard Nutzer term.
Add the healthlog/no-raw-palette-color rule so UI code routes color
through the semantic tokens instead of Tailwind palette shades, and
sweep the remaining offenders (password-strength scale, injection-site
accents, redundant dark overrides). Add guard tests for width stability,
the single page-header vocabulary, and motion-reduce coverage on
indefinite animations.
Swap the remaining native date inputs (allergy onset, environment
travel/backfill ranges, inbound-document dates) for the app DateField
so they no longer render in the browser's locale order. Route the
medication caution amber and the document info banner through the
warning/info tokens so they adapt to the theme.
The per-metric reference bands, chart palettes, and status tints across
the insights pages and shared charts carried hardcoded hex that glared
on the light theme. Route them through the semantic and chart CSS
variables; dark rendering stays byte-identical, light gets the muted set.
Add a PageHeader primitive (one H1 vocabulary, optional description,
back-link, and action slots) and route the hand-rolled headers through
it, so every module reads with the same title/subtitle rhythm and no
icon beside the page title. Mental Wellbeing gains a visible header.
Add a QueryErrorCard and wire the list surfaces so a failed read shows
a retry card instead of falling through to the empty state, and swap
their full-page spinners for skeletons that hold the layout.
The admin tree hand-rolled its card surfaces and stepped padding at sm:
instead of md:, so it read denser than every other route on a tablet.
Move the sections onto SettingsCard/SettingsCardHeader and the mini-rows
onto ListRow; no behaviour, data, or copy changes.
The per-dose "custom window" switch could never be turned on. Switching it on
seeded the range from the default band and emitted immediately, but the
serialiser drops a range equal to the default band, so the persisted value
round-tripped straight back to empty, the controlled `checked` recomputed
false, and the switch snapped off. The explicit range fields only appear once
the switch is on, so there was no way to edit the range into a non-default one
first — a deadlock.
Track the opened-switch intent in local editor state, decoupled from
serialisation. A toggled-on row renders its default band immediately and the
switch stays on; an explicit range persists only once the user edits it away
from the default. Toggling off drops any stored range back to the default
derivation.
The card header carried the dose on its own muted line under the name and the
category as an outline badge below that. Surface the dose as a tag sitting next
to the category badge instead — `[7,5 mg] [Sonstiges]` — so a blood-pressure
tablet and a GLP-1 injection present their strength the same way, in one tight
badge row directly under the name. Both cards share this header, so the generic
and GLP-1 variants pick it up together. State badges keep their own row below,
so the narrow-viewport two-line contract is unchanged.
The chronic / pre-existing conditions the Coach watches lived under the
personal-context "About me" panel, but they belong with the rest of the
medical history. Move the conditions and the "what should the Coach
watch" focus into Settings -> Anamnese, next to allergies and family
history, and make them editable inline there.
The store is unchanged: both fields are still part of the
/api/coach/about-me payload, read and written through the same query key,
so the Coach and every other consumer keep reading the same data. The
About-me panel keeps the free-text note and the allergies line and omits
the two conditions fields on save, leaving them untouched.
Clicking a PHQ-9 / GAD-7 card body now opens that instrument's history
in a detail sheet, mirroring how a medication card opens its detail. The
card body is the navigation target; the bottom-pinned Start button stays
a separate action and keeps its canonical typography and spacing.
The detail reuses the existing history chart and dated list, pinned to
the chosen instrument: the PHQ-9 / GAD-7 toggle and the outer card chrome
drop away since the sheet owns the title. The combined history card on the
landing is unchanged.
The resolved per-user module map rides the client-only `/api/auth/me`
query, so it is still undefined on SSR and the first client paint. The
nav model's module gate fails open (`modules?.[key] !== false`), so every
module-gated entry — cycle, mood, labs, coach, insights … — rendered for
one frame before the query resolved and filtered it out. A user without a
module saw its entry flash into the sidebar and the mobile bar, then
vanish.
Gate the module-dependent filtering behind `useMounted()` in both bars.
SSR and the first client paint now render only the core (non-module-gated)
destinations; the real module map applies once mounted. Before mount a
gated entry is treated as hidden — fail-closed — so SSR and first paint are
identical (no hydration mismatch) and a disabled module can never flicker
in. The pure nav-model helpers keep their fail-open default for callers
that pass a settled map; the bars thread the mounted flag in.
Rework the sidebar and bottom-nav SSR tests to assert the fail-closed
first-paint contract (core-only, gated entries absent regardless of the
map); the resolved post-mount set stays covered by the pure-helper tests
in nav-model.test.ts and by e2e.
The medication and GLP-1 cards print a per-schedule dose override as a
trailing accent ("— 5 mg"), straight from the stored string, so the same
raw unit token the main dose carried still showed through there. Run it
through formatDose so the per-schedule accent localises like the rest.
The date field opened the browser's native date picker, which rendered by
the browser and kept its intrinsic width on mobile. Replace it with the
shadcn calendar in a popover: a fully app-controlled react-day-picker
calendar that looks the same everywhere and never overflows a phone-width
sheet. The field keeps its preference-formatted, typeable display and its
yyyy-MM-dd value contract, so the date-time field, the time field and
every form that uses them are unchanged. The calendar's month and weekday
labels follow the UI language.
A short settings section over-scrolled into a dark empty band below the
last card: the content column's min-height reserve was tuned without the
AuthShell wrapper's pt-6/pb-20 in the budget, so it pushed the column past
the viewport. Move the floor onto the grid (top bar + wrapper padding =
10.5rem) and make the content column the grid's 1fr row, so it fills the
viewport exactly on a short section — no over-scroll — and grows normally
on a long one. The sticky sidebar and the stable scrollbar gutter are
unchanged, so switching sections stays width-stable.
Appearance ("Darstellung") stacked every module's view and ordering
settings on one long page. Split it into a hub that lists the modules as
navigable rows, each opening its own /settings/layout/<module> subpage
with a back link — the per-module settings move verbatim, no restyling.
The module routes now redirect to those subpages instead of an in-page
anchor, and the in-app cogs link straight to them.
The Input and Textarea primitives already opted config and secret fields
out of 1Password and LastPass autofill (data-1p-ignore / data-lpignore)
whenever autocomplete was off, but Bitwarden had no opt-out hint anywhere,
so it kept offering to fill and save API keys, tokens and integration
URLs. Emit data-bwignore alongside the existing hints, which covers every
field flowing through the primitives. Pin explicit autocomplete on the
change-password dialog so genuine credentials still autofill, and add the
ignore hints to the export passphrase and the few new-password admin
config fields that opt in directly.
The custom-metrics add button hid its label below sm and carried no
accessible name, so on mobile it read as a bare "+". Add an aria-label to
both the header and empty-state add buttons, and separate the section from
the built-in measurement list above it with a top border so it reads as
its own block.
A long IPv6 in the login overview forced the table to scroll sideways and
the full address was awkward to lift out. Cap the cell, ellipsize the
address, and make it a button that copies the full value to the clipboard
with a transient checkmark and a toast — keyboard-activatable, with an
accessible label. The empty case stays plain text.
The admin system snapshot hardcoded "ipwho.is" for the offline-geo-off
state, so an operator who points IP_GEO_LOOKUP_URL at another provider
still read the wrong host. Resolve the actual provider host from the env
and surface it through /api/version, so the snapshot reads
"Online (ip-api.com)" — the host it really queries.
A medication's dose is stored as free text the wizard composes from an
amount and a unit token ("1 pieces", "2 tablets"), and the card rendered
that token verbatim glued onto the name — so the unit showed in English
on every locale and the title read "Test ABC 1 pieces".
Translate the dose at display time: a formatDose helper splits the stored
string and runs a known unit token through the existing unit messages, so
"1 pieces" reads "1 Stück" / "1 piece(s)" per locale while a custom
free-text dose passes through untouched. The card header now carries the
name alone on its title line with the dose on a muted line beneath it,
above the category badge; the detail view, table and intake dialogs
localise the same way. Vorsorge and lab rows pass an empty dose so they
render no extra line.
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.
The measurement add form opened with a horizontal type row (label beside
the select) while every field below it stacked label-above-control, so
the form read with two competing rhythms. Introduce a small FieldGroup
primitive — the long-standing space-y-2 label-above-control idiom in one
place — and route the measurement, mood and baseline forms through it so
they share one vertical rhythm. The type row now stacks like the rest.
Output stays byte-identical for the already-vertical groups; only the
type row changes, by design.
The mental-wellbeing instrument card carried a font-medium title and a
gap-4 body, drifting from the medication card it should mirror; bring its
title to text-lg font-semibold and its body to the shared space-y-3.5
rhythm.
Several insight grids left a lone trailing tile stranded at half width
with an empty column beside it. Gate each multi-column grid on having
more than one item so a single tile spans the full row, and give the
fixed five-card mood-correlation grid a trailing-card span so it no
longer orphans on the two-up breakpoint.
Add a little bottom breathing room to the device-score tile so its
rolling-average line no longer reads flush against the card edge.
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.
A native date input keeps its intrinsic width on mobile WebKit and won't
shrink, so the `date + fixed-width time` row overran a phone-width sheet —
the time field and its clock icon spilled past the screen edge and every
add form (measurements, mood, medication, …) scrolled sideways. Stack the
two halves on narrow viewports and only place them side-by-side from the
`sm` breakpoint up; each half then takes the full sheet width and nothing
overflows.
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.
Add a user-defined custom-metric store alongside the built-in measurements
on the Measurements surface. A user defines an arbitrary metric (name, unit,
optional target window, decimals, description), logs values, and charts them.
The store is a separate generic catalog + values pair, isolated from the
closed measurement-type system: not synced, not in FHIR, not in insights —
log and chart only. Names and units are plaintext, mirroring the biomarker
catalog. The inline list lives beneath the measurement list (no new nav
entry); tapping a metric opens a detail page with the trend chart, target
band, stat strip, and an editable value history.
Migration 0224 adds custom_metrics and custom_metric_entries.
The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.
Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.
A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.
In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
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 screener trend now reads like every other trend: a monotone line over
the primary colour with the dashboard's soft gradient area fill and matching
margins. The surface-only severity-band backgrounds are gone (the band still
shows in the tooltip), so it no longer looks different from the dashboard.
- The landing fills the content width like the other tracking surfaces instead
of a narrow max-w-3xl column.
- The voluntary-self-test disclaimer line and the visible page heading + its
tooltip are removed; the title stays as an sr-only heading for the outline.
- The instrument card's last-test line adopts the medication-card grammar:
label left, the relative day (today / yesterday / date) right-aligned, no
severity badge.
In the three-column blood-pressure entry grid the "Systolic (mmHg)" /
"Diastolic (mmHg)" / "Pulse (bpm)" labels wrapped onto a second line in
narrow columns. Render the localised label as the metric name (which
truncates if it must) plus the unit as a compact muted affix that never
wraps, so each field reads on a single line while keeping its unit.
The native datetime-local / time picker renders its clock and AM/PM
toggle by the browser UI language, not the page lang or the app's
time-format preference — so an English-language browser showed AM/PM
even with 24-hour selected. Replace the native time control with an
app-controlled TimeField (text entry + a dependency-free popover of
hour/minute, plus AM/PM in 12-hour mode) whose hour cycle always tracks
the preference. DateTimeField now composes the native calendar (date)
with TimeField (time); the committed value stays a local
yyyy-MM-ddTHH:mm string, and min/max clamp the recombined value.
Also thread the time-format preference into the doctor-report PDF so its
generated-at timestamp honours it instead of the locale default.
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.