Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
The policy module (src/lib/documents/upload-policy.ts) is the one source of
truth for what the vault accepts and how it serves: Class A (PDF/JPEG/PNG/
WebP/GIF) renders inline; Class B (Office OOXML+CFB, text/CSV/Markdown/RTF,
TIFF, HEIC/HEIF, XML/JSON) is stored verbatim and leaves the origin only as
an opaque attachment download; executables, HTML/XHTML/SVG, and generic
archives are denied. Detection is magic-bytes first — the wire Content-Type
is never trusted; containers (ZIP → OOXML, CFB → legacy Office) and
extension-only text formats gate on the filename plus a no-NUL heuristic.
resolveDocumentLimits() reads the admin-tunable per-file cap (clamped to
the hard 100 MiB ceiling — single-shot GCM and bounded in-memory reads are
load-bearing past that) and the quota (user override ?? instance default).
crypto.ts gains the binary2 codec: encryptBytes/decryptBytes do AES-256-GCM
over raw bytes with a versioned key-id header, skipping the base64 detour
(−33 % at rest, one fewer copy). Same fail-closed discipline as the string
codec. The store dispatches per row on the explicit contentCodec column:
new uploads write binary2, pre-existing base64v1 rows keep decoding
byte-identically, and an unknown codec throws instead of guessing.
Exhaustive unit coverage: every accept class from real magic-byte fixtures,
every deny case (incl. the PDF-with-HTML-head, SVG-as-image, and
ZIP-renamed-docx polyglots), header-matrix source of truth, limit clamping,
codec round-trips both ways, crossed-codec and unknown-codec fail-closed.
Tasks: W1-T3, W1-T4.
Two new self-report instruments join the mental-wellbeing engine on the
existing PHQ-9 / GAD-7 infrastructure:
- WHO-5 Well-Being Index: 5 items on the 6-point 5..0 scale over the last
two weeks; the reported total is raw-sum x 4 (0-100) per the official
WHO scoring (WHO/UCN/MSD/MHE/2024.1, CC BY-NC-SA 3.0 IGO). A total of
50 or below follows the WHO comment (below 50 = indication for further
assessment).
- Sleep Condition Indicator: 8 DSM-5-aligned items, 0-4 each with
item-specific anchors, total 0-32, higher = better sleep; a total of
16 or below maps to the probable-insomnia range of the source
publication (Espie et al., BMJ Open 2014;4:e004183, CC BY-NC,
(c) Sleepio Limited).
The instrument registry generalises rather than forking: per-item answer
ceilings, option order/label schemes, recall-stem captions, a reported-
score multiplier, score direction (higher-is-better inverts the follow-up
threshold), per-instrument attribution lines, and the validated-item
locale set are now instrument properties. Scoring stays server-
authoritative; the create route validates the per-instrument answer
ceiling and projects the total onto a COMPUTED WHO5_SCORE / SCI_SCORE
measurement row (never client-attributable), which also auto-satisfies a
planned screening reminder. Migration 0226 adds the enum values
(additive, idempotent). OpenAPI regenerated.
Neither instrument carries a self-harm item; the PHQ-9 item-9 crisis
routing is unchanged.
Add selectedScoreRings to the dashboard layout blob: a closed four-id
set (READINESS, RECOVERY_SCORE, SLEEP_SCORE, MED_COMPLIANCE) capped at
three rings, with a client-safe ring-to-module map alongside. The
resolver drops unknown ids, dedupes, and clamps; a legacy blob without
the field defaults to the single MED_COMPLIANCE ring while an explicit
empty array is respected. The widgets PUT validates the field against
the closed enum and preserves the stored value when a client omits it,
the same contract heroVisible already rides.
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.
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.
A self-hoster can set IP_GEO_ALLOW_INSECURE=true alongside an http:// URL to
use a free HTTP-only provider — the free ip-api.com endpoint is HTTP-only (its
HTTPS form needs a paid key) but its geolocation is often more accurate and it
carries the ISP. The default stays HTTPS-only with ipwho.is; plain HTTP is
refused unless the operator explicitly opts in, since the looked-up IP then
travels unencrypted over the server's own egress. Whitelisted in compose and
documented in the env example.
ipwho.is is free, needs no key, and resolves a city/country for the login
overview out of the box. It is the default online lookup again; the offline
GeoLite2 MMDB tier stays strictly optional (consulted only on an online miss,
skipped when the databases are absent). Operators whose egress ipwho.is
rejects can point IP_GEO_LOOKUP_URL at ip-api.com — the parser accepts both
response shapes, so the swap needs no code change.
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.
Add the clinical-signals wave's new measurement types and wire them through
the signal registry so FHIR, correlation eligibility, ingestion, and the
measurement-list surfaces derive from one definition each:
- MeasurementType: PHQ9_SCORE, GAD7_SCORE, GRIP_STRENGTH, PAIN_NRS,
WAIST_CIRCUMFERENCE, WAIST_TO_HEIGHT (+ AssessmentInstrument enum and the
MentalHealthAssessment model that backs the screener scores).
- Registry: the six measurement/score signals plus the longevity lab panel as
kind:"biomarker" entries; the FHIR golden fixture is regenerated in lockstep.
Grip-strength and waist-to-height carry a null LOINC (NEEDS-VERIFY) and emit a
local text concept until a stable code is confirmed.
- FHIR: add the survey observation category for the screener / pain signals.
- Labs: extend the biomarker catalogue with ApoB, Lp(a), fasting insulin, and
the omega-3 index; add LOINC codes for those plus GGT and hs-CRP; add a
computed HOMA-IR helper.
- Exhaustive maps updated to keep the enum coverage, category, list-meta, and
personal-record drift guards green.
The requirePublicHost egress paths now dispatch through undici's own
fetch, so the existing global fetch stubs in the AI, GeoIP, moodlog,
send, and ntfy suites no longer intercept them. Delegate undici's fetch
back to the installed global stub so each suite keeps asserting its
request shape and canned responses without exercising the network.
The connect-time SSRF pin returned only the first vetted address and
attached an undici@8 Agent to Node's built-in fetch. Two failures:
- A host whose first DNS record is an unreachable IPv6 (no IPv6 route on
the container / LAN / Tailscale) failed the connect instantly, because
the single-address pin discarded the working IPv4 record behind it and
Happy Eyeballs never ran.
- Node's global fetch is backed by its own pinned undici copy; handing it
an Agent built from the project's undici throws UND_ERR_INVALID_ARG on
a dispatch-handler interface mismatch.
Hand undici the full vetted survivor set and enable autoSelectFamily so
it runs RFC 8305 Happy Eyeballs across the already-vetted IPs (rebinding
stays closed: every dialable IP passed isPublicIp in the same lookup).
Drive the pinned path through undici's own fetch so the dispatcher and
the fetch engine share one undici version.
Add an end-to-end regression that drives a real localhost-bound server
through the pinned path (not a mocked fetch, which hid the coupling),
covering the IPv6-to-IPv4 fallback, streaming, timeout, and abort.
Remove the in-app water-intake logging feature: the WATER_INTAKE
measurement type, the per-user daily-goal field, the goal-ring card and
its endpoint, and the related query keys, OpenAPI route, and locale
strings. The feature is dropped from this release; the baseline-drift,
breathing-screening, and labs-changes insight cards are unaffected, and
the pre-existing TOTAL_BODY_WATER body-composition type stays intact.
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.
- Health status: surfaces what is drifting from the user's own normal by
folding the personal-band deviations (coincident-deviation engine) with
the dated, sustained level shifts (changepoint detector) at
GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
sleep-breathing-disturbance index plus device-flagged events at
GET /api/insights/breathing-screening. Mirrors the device's own
classification and states plainly that it is a screening signal, not a
diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
each shared analyte's signed delta and reference-band standing at
GET /api/insights/labs-changes. Neutral framing only.
Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.
Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.
Document GET/PATCH /api/hydration in the OpenAPI contract.
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.
- extractFeatures: read newest-first with a 6000-row cap (mirrors the
Coach snapshot) and reverse to ascending, so a 365-400 day window no
longer materialises hundreds of thousands of PULSE / glucose rows per
Coach turn and nightly briefing. Full-history extremes still come from
readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
the type-less range scans that extractFeatures and the consolidation
passes drive; the existing (userId, type, measuredAt) cannot serve them
as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
(measuredAt, id) instead of one unbounded findMany, removing the giant
single result-set allocation that drove the boot storm. Output is
identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
offsets so the heavy full-history loads no longer all drain in parallel
onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
for the weekly and off-host writers, bounding transient heap. Off-host
still includes soft-deleted rows. Covered by a serialize/restore
round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
already records auth_method and user_id, so the polling client no longer
drives a write + pool checkout on every request. Failure-path audit
stays.
- Coach conversation detail: cap the message include to the newest 200 so
a long thread no longer decrypts every message on each open; response
shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
and its count stop sweeping open-ended over the substring filters.
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.
Record the very first device a user is ever seen on silently. The
new-device sign-in alert only carries meaning once there is an
established device to contrast against, so an empty device ledger
establishes the baseline without firing a notification; alerts begin
with the second distinct device. Registration's silent first-device
behaviour is unchanged.
Also extend the destructive-action step-up to treat a registered
security key as an active second factor, matching requireFreshMfa, so a
key-only account cannot delete or reset without a fresh factor.
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.
Account deletion and the keep-account data reset now demand a recent
second-factor re-verification for accounts that have a second factor
active; single-factor accounts are unaffected and keep the typed-
confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled,
which is cookie-only by construction — a Bearer transport for an MFA-enrolled
account cannot satisfy step-up and surfaces the auth.stepup.required envelope
so the UI can launch a re-verification flow.
Add requireFreshMfa(maxAgeSeconds) and requireCookieAuth alongside
requireAdmin. Both resolve through getSession() only, so a Bearer token can
never satisfy them — the same structural boundary requireAdmin relies on, not
a softenable runtime check. requireFreshMfa passes only for a cookie session
whose Session.mfaVerifiedAt is within the window and whose user has an active
second factor; otherwise it throws StepUpRequiredError (401 +
meta.errorCode "auth.stepup.required") so the client can launch a
re-verification flow.
createSession now accepts an optional mfaVerifiedAt stamp so a session minted
behind a completed second factor records its freshness.
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.
Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.
Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.
Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.
Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.
Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.
New migration 0195 adds the connection anchor and links access tokens to it.
resolveInsightsLayout merged any default tile a saved layout did not
enumerate as hidden, so a late-added tile such as steps (introduced in
v1.12) was force-hidden on every read of an existing or iOS-synced
layout and its nav pill never appeared, even with step data present.
Appended tiles now inherit the default visibility instead, matching the
fresh-account default; the per-tile data floor still keeps an empty
metric out of the nav.
Add a CoachPlan model: durable, user-scoped if-then implementation plans
the Coach proposes and the user confirms. The background memory-refresh
pass extracts plans the user agreed to and writes them as "proposed"; only
the user-facing PATCH on /api/coach/plans/[id] activates one. The free-text
cue / action / target are encrypted at rest with the shared Bytes codec;
metric, status and dates stay plain so the injection picker ranks without a
per-row decrypt. Active plans fold into the Coach snapshot's memory block
and survive the prompt-budget drop alongside facts. List, confirm and
soft-delete routes are Coach-gated and narrow the owner from the session.
Key the dashboard-snapshot query cell by locale so a locale switch reads
freshly localised tile copy on its own cell instead of serving the prior
locale's prose until the slot goes stale. The zero-arg key stays
byte-identical, so every prefix-based invalidation keeps reaching the
locale-keyed cells; the offline-banner and preloader match on the prefix.
Migration 0194 (additive CREATE TABLE). Encryption-rotation registry and
script cover the three new Bytes columns.
Promote the static /learn guide catalog into a typed concept-to-slug
lookup the deterministic UI surfaces can consume. learnUrl is the only
sanctioned /learn URL builder and learnLinkForMetric fails closed for
an unmapped id. A test asserts every mapped slug resolves in the
catalog, so the mapping can never point at a guide that does not exist.
The hypertensive-crisis floors were defined in three modules with a
divergent diastolic value: the notification engine and the Coach acute
clause used 180/120, but the dashboard hero used 180/110. A reading like
170/112 lit the red critical-BP banner on the hero yet never tripped the
notification alarm or matched the Coach's stated acute number — one
surface said crisis, the other two stayed calm on the same row.
Promote the absolute floors into a dependency-free clinical-floors leaf
and have the hero, the safety-floor engine, the status registry's fever
band, and the illness escalation all import from it. The crisis diastolic
floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the
wider 110 hero net is dropped so the surfaces tell one story. A coverage
test pins that every consumer resolves the same constants.
Add a date-order resolver + formatter mirroring the hour-cycle path:
AUTO defers to the active locale, DMY/MDY/YMD pin the field order
through a canonical Intl locale. Thread it through makeFormatters and
expose useDateFormatPreference from the i18n context, backed by the
same localStorage mirror as the time-format preference.
The sleep, steps and glucose strip tiles shipped default-hidden even
though their modules are on by default and rich data flows in from the
sync integrations, so accounts had to hunt through Settings to see them.
Flip the three strip tiles to tileVisible:true and the vorsorge chart-row
card to visible:true, matching the VO2max precedent: each tile self-gates
on having data, so empty accounts keep a clean dashboard. Chart rows for
the three metrics stay off — those charts live on the /insights sub-pages.
totalBodyWater, boneMass and oxygenSaturation stay hidden: they carry as
catalogue ids for the iOS layout round-trip only and have no web render
path, so a flip would surface nothing. Note that at the splice point.
Update the silhouette skeleton and layout default tests for the new
counts.
The canonical rotation registry (src/lib/crypto/encrypted-columns.ts) is the
source of truth its guard test cross-checks; add EcgRecording.waveformEncrypted
there (the script already references it) and drop the redundant parallel guard.
Update the sleep/rhythm route test to the v1.19.0 rolling 5-night balance
(nightsCounted 5, debt 300) instead of the old 14-night cumulative sum.
The v1.19.0 EcgRecording.waveformEncrypted Bytes column was not added to the
key-rotation script, so an operator rotation would strand ECG waveforms on the
legacy key and dropping it would make them permanently undecryptable — the same
class as the v1.18.1 clinical-spine gap. Wire the column into the rotation and
add a schema-driven coverage test that fails CI if any encrypted Bytes column
is ever left out again.
Oura's daily_resilience collection reports a categorical level — limited,
adequate, solid, strong, exceptional. Encode it ordinally (limited=1 …
exceptional=5) into the numeric measurement value so it fits the existing
model with no new categorical column: add the RESILIENCE measurement type
(migration 0186), fetch and map daily_resilience in the Oura sync, and skip
an unknown or missing level rather than coercing it to zero.
RESILIENCE_LEVELS in the Oura client is the single source of truth for the
encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored
day overwrites in place. Extend the measurement-type label, unit, range,
category, chart-token, and PR-direction maps; add the metric label across all
six locales.
The ipwho.is default rejected server-side egress with a 403 on the free
plan, which the resolver swallowed as a clean miss, so the admin
login/audit overview rendered "—" for every session. Switch the default
online provider to ip-api.com (the parser already accepts its
status/countryCode shape) and surface a non-ok HTTP status (403/429/5xx)
as a wide-event warning instead of returning null silently, so a future
provider rejection is visible.
Whitelist TRUST_CF_CONNECTING_IP, IP_GEO_LOOKUP_URL and
IP_GEO_LOOKUP_DISABLED in the compose environment block and document them
in .env.production.example so a Cloudflare-fronted deployment can store
the real visitor IP rather than the edge address.
The 15s default cancelled the live-aggregate analytics fallback on a coverage
miss over a large account (Postgres 57014), surfacing as a 500. Sixty seconds
still bounds a runaway query well before it can starve the pool while leaving
headroom for the heaviest legitimate read.
Make the keyless ipwho.is HTTPS lookup the primary resolver for every
self-host. The bundled MaxMind GeoLite2 offline tier becomes an optional
fallback, consulted only when the online lookup misses or when egress is
disabled. A missing offline tier is now the expected baseline rather than
a gap, so the one-shot admin alert fires only when neither resolver can
produce a location.
Without a statement timeout a single stuck query or lock wait holds one of
the 20 pool slots indefinitely; enough of them permanently exhaust the pool
and every DB-backed route stops serving. Apply statement_timeout and
idle_in_transaction_session_timeout (default 15s, env-overridable, 0 to
disable) at connection establishment so every pooled session is bounded.
Correct the stale pool-sizing comment: production max_connections is 200,
not 100, and the web and worker containers share the one Postgres.
The VO2max strip tile was opt-in, so accounts that already sync a
cardio-fitness sample from Withings, Apple Health, Fitbit, or Oura saw
nothing on the dashboard. Default the strip tile on; it still self-gates
on having a VO2_MAX sample, so empty accounts keep a clean tile strip.
The chart row stays off — VO2max charts live on /insights/cardio-fitness.
Invalidate the dashboard snapshot on measurement writes and refetch on focus so
fresh and background-synced readings surface without a hard reload; expose the
no-provider state on the briefing read with a connect-a-provider hint; align the
briefing footer caption.
A measurement added in-app left the dashboard hero, score ring and tile
strip showing yesterday's value until a hard reload. The snapshot query
carries refetch-on-mount/focus off and a 120 s poll, but the measurement
mutation bundle never invalidated the snapshot key, so an in-app create /
update / delete updated every other surface and left the Startseite stale.
Add the snapshot key to the measurement-dependent bundle, mirroring the
medication fix; the manual routes already hard-evict the server snapshot
bucket, so the triggered refetch returns post-write data at once.
Background syncs (Withings, Apple Health, iOS batch) fire no client
mutation event, so the keys were never invalidated and the dashboard only
caught up on the 120 s poll or a reload. Refetch the snapshot on window
focus so returning to the tab after a sync surfaces the new readings; the
60 s staleTime keeps rapid focus toggles free and the warm server cache
keeps the focus refetch cheap.
Set the compact list as the Labs surface default; tiles in the card view now
route through MedicationCardHeader so a lab tile reads identically to a
medication or Vorsorge tile side-by-side. Add alphabetical (A-Z / Z-A) sort
options to the per-module sort choice.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Centralise per-surface AI budgets and right-size status maxTokens; add
seed support (pinned for reference surfaces, seedless for the briefing);
force JSON on Ollama and Anthropic; reroll only the daily-briefing prose
once a day for variety while findings stay stable; reuse the corrective
retry on the comprehensive path; extract shared prompt contracts as one
source of truth for grounding, tone, and the GLP-1 safety rule; reserve
the coach budget atomically before the call and reconcile burned tokens;
turn the local-AI private-host flag into a host allowlist.
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`,
opened every private/internal host at once — including the cloud
metadata endpoint and any internal panel. Replace the binary check at
all three sites (write-time validation, local client, test resolver)
with a shared `isLocalAiHostAllowed` helper: a comma-separated host list
permits only those exact hostnames, while the legacy `true` still
permits any private host for backward compatibility. The safeFetch /
isPublicUrl floor is untouched; the allowlist only widens what the
operator deliberately permits. Docs and the SSRF-pin inventory test are
updated accordingly.
The dev-only SHA-256 key padding was gated on the absence of a production
marker, so a staging or preview container with NODE_ENV unset silently
derived a weak key the operator never recorded — rotating to a real key
later orphaned those rows (decrypt is fail-closed). Gate the padding on an
explicit NODE_ENV of development or test; every other case, including unset
NODE_ENV, throws the same hard error as in production.
- add a wrench beside each module's add button linking to the module's own
settings page; rename every add button to a plain "hinzufügen"
- give each module a settings page: view (cards/list) + manual reorder; Labs
also gets a sort-order choice (by last reading) and biomarker CRUD/reorder
- collapse the Illness "abgeschlossen" list by default
- rebuild the Vorsorge card on the medication card's building blocks: a green
"Jetzt messen" action, the interval as a chip beside the name, and an
in-interval progress signal; drop the kebab "anpassen" and stray actions
- persist per-module view/order/sort client-side; add reverse-coverage-clean
strings across all six locales
The reference-range classifier assumed bands[0] was always the
normal/target band, so any metric whose first band is abnormal was
placed wrong: a healthy BMI 22 read as just outside the reference and
an underweight BMI 17 read as inside; pulse pressure had the same flaw
(Narrow is its first band). The same classifier drives the in-app
four-state explainer, so both surfaces were affected.
Mark the population-normal band explicitly with a `normal: true` flag
on every metric, add `normalBandIndex`, and place values by distance
from the marked band rather than from index 0. `headlineBandText` now
renders the marked band, so BMI grounds against 18.5-24.9 kg/m2.
Exclude daily steps from the Coach grounding: it is fed the intra-day
mean of the step rows, which averages a multi-row day down and can
misplace it below the 8,000 floor. Grounding it on a per-day sum is the
correct fix and is left as a follow-up; until then drop the line rather
than ship a wrong placement.
Extend the classifier and grounding tests for BMI 17/22/27, a pulse
pressure case, the normal-band-not-at-index-0 contract, and that steps
is not grounded.
Introduce src/lib/reference-ranges.ts: a structured, citation-backed
per-metric reference-range backbone (band + source body + year) with a
deterministic four-state placement helper, read by the UI surfaces. Each
band traces to the medical-citations registry, which gains the supporting
sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse
pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity,
FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature,
RCP NEWS2 and American Lung Association respiratory rate, visceral-fat
imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV,
Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep).
Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and
ESC 2024 appear only as displayed placement context. Enrich the metric
explainers with concise cited reference bands and a general-guidance
tooltip on every metric page: a darker-skin pulse-oximeter over-read
caveat, device-proxy honesty for readiness/recovery/strain/stress, an
HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill
the empty registry normal ranges, reconcile the steps green floor to
8,000, and tighten the body-temperature band below the fever line. Add
free pulse-pressure and mean-arterial-pressure context on the blood-
pressure detail page, plus cycle-physiology and sleep-hygiene copy across
all six locales. Cycle thresholds carry a verification marker pending an
ACOG source check.
Each reminder is now its own med-styled card reusing the medication card
primitives (MedicationCardHeader / MedicationNextLastSlot) on a neutral
surface with a single bottom-pinned action and a kebab carrying edit +
delete. The mark-done action branches on the linked measurement type:
- a free-text / self-planned exam keeps the silent satisfy ("Done");
- a measurement-linked reminder opens the real measurement-entry form
("Log value") pre-filled with its type, and on a successful write
satisfies the reminder in the same action (forward-only guard keeps
the cron a no-op).
The create/edit sheet gains a first-due date (anchorDate), a free
custom interval, and an RFC-5545 RRULE option, all already accepted by
the create/update schema. A wrench/customize affordance beside the page
Add button re-homes the per-reminder enable toggles moved off the card.
Vorsorge also joins the dashboard grid layout like medications: a new
chart-row summary card listing the next few due reminders with inline
done/capture buttons, default-off so existing dashboards are unchanged.
No migration and no API schema change; OpenAPI stays in sync.