The sleep page rendered an assessment under the duration/architecture charts
and a second one under the sleep-quality scores. The quality assessment mostly
never generated — most accounts carry no wearable sleep score — so it sat at the
foot of the page as a duplicate empty card or a redundant connect-a-provider
prompt.
Drop the quality-block assessment so the duration card is the page's single
assessment; the quality block keeps its score tiles. Remove the now-unused
grounded-note fallback and its deterministic grading helper, and the orphaned
quality-assessment message keys across every locale.
The per-metric assessment card carried the Coach hand-off as a bottom-right
text button, present on only some pages. Pin it to the card header's top-right
as a single icon button (no text label) with an accessible name and a tooltip,
and wire every assessment surface to it: the bespoke slug pages
(weight, BMI, pulse, blood pressure, mood), the generic metric scaffold, the
medication-compliance card, and the biomarker detail page already on the shared
card. Each opens the Coach with a localised, auto-sent opener narrowed to that
metric's snapshot source.
The button self-gates on the Coach triple (provider mounted, operator flag,
per-user opt-out) and the card only mounts it on the populated branch, so it
never advertises a control that cannot work. The auto-sent opener now
interpolates the localised measurement name rather than a raw token.
Drive the biomarker resource route against a real Postgres: PUT persists a
new reference window and rejects an inverted one, and DELETE drops the marker
together with its readings while leaving another user's marker and readings
intact.
Add coverage for the new biomarker-assessment seam and the marker detail
surface:
- the generator serves today's cache, re-stamps unchanged readings without an
LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
assessment card
- the biomarker resource route drops the marker with its readings in one
userId-narrowed transaction and rejects an inverted target range
Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.
The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.
The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
Add a control beside the marker actions that opens a focused sheet for
the reference low and high only, so a user can tune the band without the
full marker editor. Persists through the existing biomarker PUT and
invalidates the detail, catalog, and result reads.
The per-marker catalog description used to render on every labs overview
row, both in the list and the card layout. It reads better as a caption
beneath the detail-page heading, mirroring the metric pages' explainer
line. Resolve the catalog slug from the marker name and fall back to the
user's own context note.
Hold the submit handler in a ref updated from an effect and dispatch
the auto-send through a microtask, so the first-turn send no longer
calls setState synchronously inside the effect.
Deleting a biomarker now removes its recorded values in the same
userId-narrowed transaction instead of unlinking them via the SetNull
FK. Removing a marker is a deliberate "drop this and its history"
action, so leaving orphaned, unlinked readings behind was surprising.
Update the delete-confirmation copy in all six locales to state that
the values are removed too.
Thread an autoSend flag through the coach-launch flow so a card's
"ask the coach about this" hand-off dispatches its seeded prompt as
the conversation's first turn instead of only seeding the composer.
The flag rides the launch context, the layout mount, the drawer, and
the shared conversation surface, where a ref-guarded mount effect fires
the send exactly once for a fresh thread.
Localize the previously hardcoded English assessment opener in the
metric status card behind insights.coach.assessmentPrompt with a
{metric} placeholder, and localize the resting-pulse coach prefill.
Both keys land in all six locales.
Convert the per-biomarker reading history from a static reverse-chronological
list to a shadcn Table with clickable sort headers. Reuse the shared
useTableSort hook and SortableHead chrome already backing the measurement and
mood lists; the sample-date column opens descending, value/range/note open
ascending. The sort runs client-side over the in-memory reading feed.
Lift the byte-identical sortBy/sortDir/toggleSort triple the measurement
and mood lists each carried inline into a shared useTableSort hook. Both
lists now compose their page-reset and selection-clear side effects around
the hook's toggle rather than re-implementing the direction math, and the
date columns keep their descending-first default via descColumns.
Surface the generation-failure signal in the daily briefing card. When a
held briefing is shown but the last refresh failed, the footer states it
plainly and offers a retry that runs the existing explicit regenerate —
no warm-on-mount. When there is no briefing to fall back on, the empty
state reads "couldn't generate" with the same retry instead of the
generic "no briefing yet". The hint is suppressed when no AI provider is
configured, where that hint already owns the surface and a retry is
futile. New copy lands in all six locales.
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.
Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:
- runStatusCompletion is the single chokepoint every status / reference
surface funnels through (per-metric cards, the batched assessment, the
derived assessments, the period narratives), so resolving it there
applies the setting to all of them at once — on both the upstream
call's own timeout and the outer wall-clock cap that previously clipped
a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
route thread it onto the main generation, the JSON retry, the grounding
retry, and the paragraph re-roll.
On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
A render failure on `/c/<token>` previously fell through to the full-app
error screen, which carries app chrome the public share view never wants.
Add a route-scoped, chrome-free error boundary that shows a localised,
stack-free message and a single retry. Add the matching copy across all
six locales.
The passphrase gate is a client component, but the page passed it a `t`
translator function. A function cannot cross the server→client boundary,
so a fresh (passphrase-protected) link threw a generic server-components
render error instead of painting the unlock form. Pre-resolve the gate
copy on the server into a plain, serializable `strings` object and pass
that instead. Locale resolution stays server-authoritative.
The page test mocked the gate away and only checked the token prop, so
the regression slipped through. Tighten it to assert the gate receives a
serializable contract: no prop value is a function, and the copy arrives
as a pre-resolved plain string object.
Move DOCTOR_REPORT_TYPE_LABEL_KEYS into a jsPDF-free module so leaf
consumers can import the map without dragging jspdf + jspdf-autotable
into their graph. The PDF core re-exports the map, keeping a single
source of truth and its existing import surface intact. Point the
clinician share view at the new module so the public share route no
longer pulls the PDF renderer into its server graph.
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.
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:
- Paused medications. A doctor-ordered pause flipped `active` off and stamped
`pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
and every paused day became a missed dose after resume — collapsing
adherence across the Health Score, the BP gate, the Coach snapshot and the
report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
migration 0211), close the open era on resume, thread the eras through the
compliance context and drop slots inside a paused interval from the
denominator. Every compliance call site now fetches and passes the eras.
- Health Score eras. The Health-Score fast path was the only compliance
caller that skipped the shared schedule select and schedule revisions, so
after a schedule edit it scored past days against the current schedule
while every other surface used the historical era. Switch it to the shared
select and thread revisions (and pause eras) like the canonical path.
- Insight bucketing timezone. The per-metric status series bucketed every
user in Europe/Berlin, so a near-midnight reading landed in the wrong day
and the relative-day phrasing was off for non-Berlin self-hosters. Thread
the resolved user timezone and key off `userDayKey`.
- Correlation significance. User-facing correlations surfaced from five
paired points with strength read straight off the coefficient. Route them
through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
so small-N coincidences no longer render as confident findings.
- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
daily low-percentile proxy) so the label matches the input.
A sustained fever (three calendar-consecutive days at or above the fever
red-flag floor) or sustained low SpO2 was already classified "seek care",
but the only escalation path ran inside a GET on the episode correlation
route — no push fired unless the user opened that tab, and no cron drove
it. The proactive on-write safety check covered only blood pressure and
glucose; the two signals with the strongest safety case had the weakest
delivery.
Add sustained-run temperature and SpO2 checks to the on-write
`runSafetyFloorCheck`, wired from both measurement-ingest seams. The run
length and clinical floors are shared with the retrospective detector (no
duplicated thresholds), day bucketing honours the user's timezone, and
escalation reuses the existing red-flag notification with its push-attempt
dedup so a febrile user logging repeatedly is alerted at most once per
reason per day.
`estimateDailyDoseCount` only special-cased FREQ=MONTHLY and FREQ=YEARLY.
A modern FREQ=WEEKLY rrule (the encoding the create path stores, with an
empty daysOfWeek) fell through to the legacy branch at seven days per week,
so a once-weekly injection counted as one dose per day. The detail card's
"lasts about N days" and the daily low-stock job — which share this math —
fired reorder alerts roughly seven times too early (fourteen for bi-weekly).
Parse FREQ=WEEKLY honouring INTERVAL and BYDAY count:
perDay += (times * bydayCount) / (7 * interval).
The two cumulative drains soft-deleted per-sample rows and wrote a daily
total but never recomputed the affected DAY rollup bucket. The rollup tier
reads only `deleted_at IS NULL`, so the stored `sumValue` kept the
pre-drain double-counted total indefinitely — dashboard and every rolled-up
week/month/year step or energy figure stayed wrong and never self-healed,
because the backfill only re-flags days with no DAY row and the
daily-series read-swap only re-folds on a day-count change.
Call `recomputeBucketsForMeasurement` after each per-day write, mirroring
the daily-mean drain that already does this. Added per-drain (not hoisted)
so the mean drain, which recomputes inside its own writer, is not
double-recomputed.
Date-only daily scalars from Oura and Polar were stored at UTC midnight
(`${day}T00:00:00.000Z`). On read these rows re-bucket through
`userDayKey(measuredAt, userTz)`, so for any west-of-UTC user the day
shifted back one: a June 1 SpO2 or readiness row filed under May 31 and
fell out of alignment with the HR/sleep rows that carry true instants.
Extend the existing `canonicalDailyTimestamp` helper to accept an optional
timezone and anchor a date-only day at local noon (noon UTC when no zone
is supplied) — a full twelve hours inside the calendar day, so it
round-trips through `userDayKey` to the same day for every zone within
plus/minus twelve hours. Route every Oura day-keyed mapper (via the shared
`dayAnchor` fallback) and all five Polar daily mappers through it; real
wake/measurement instants stay untouched. Fitbit already anchored at noon
and is now consistent; Whoop and the Withings cursor anchors were verified
not to be measurement anchors.
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.
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.
Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
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.
Subtract both the top and bottom safe-area insets from the full-page
Coach height so the composer no longer slides behind the bottom-nav on a
notched phone, and pad the Coach drawer composer and the ResponsiveSheet
bottom-sheet footer for the home-indicator band.
Raise the cycle and illness day-log severity controls to a 44px touch
floor, add hit-slop to the custom-symptom remove button, give the shared
day-log chips a min height, and let the mood-tag action cluster wrap so a
360px row no longer clips the tag name.
The 60s poller used to unregister service workers, wipe CacheStorage,
and call location.reload() the moment it saw a version bump, with no
prompt — firing mid-form, mid-message, or mid-wizard and discarding
whatever the user had typed. Keep detecting the new build, but now show
a non-blocking, dismissible toast with a Reload action; the eviction and
reload run only when the user chooses to reload. Show the toast once per
detected version.
Wrap the shared HealthChart plus the mood, medication-compliance, and
scatter-correlation charts in role="img" with a concise aria-label that
states the metric, value range, latest reading, and trend direction, so
VoiceOver and TalkBack announce something for every chart cell and the
~50 insight pages behind the shared component.
Add the same role="img" summary to the mood and compliance heatmaps,
route their tooltip dates through the locale- and timezone-aware
formatter instead of a hardcoded German format, and clamp the pinned
tooltip to the viewport so it no longer overflows the right edge on a
narrow phone.
Chart and heatmap text-alternative summaries, the version-update
toast, the touch-help trigger label, list load-error messages, and
the target-editor validation hints — across all six locales.
The confirm:false preview echoed the would-be record without running the
type / range / instant validation, so a preview could show a value the
commit then refused. Run the same shared validation in the preview and
surface wouldFail with the error + reason the commit would return, so a
preview and its commit never disagree.
Also note at the REST read-method allowlist that the MCP-audience guard
assumes GET/HEAD/OPTIONS are side-effect-free — a future side-effecting
GET must revisit that audience binding.
Add three grounded read tools, each a thin façade over an existing
server-authoritative engine (no new analytics, userId from the session):
- get_medication_schedule: next-due / overdue per active medication via
the canonical recurrence engine (computeDisplayDue), with as-needed
flagged and no scheduled due time.
- get_integration_status: per-provider sync health (connected, last sync,
reauth-required/failing) from the integration-status ledger, so a user
can see why their data is stale. No secrets or tokens.
- get_preventive_care: the user's already-configured Vorsorge reminders
with their persisted next-due dates and an overdue flag; it surfaces
configured reminders only and invents no screening logic.
Each declares an output schema, read-only annotations, and {present:false}
on absence. Page the search tool across the full assembled result set
(the prior 200-row scan made later items unreachable even through the
cursor) and give each result a per-item deep link where an id exists.
The doctor-visit summariser was duplicated and had drifted: the
healthlog://report/doctor-visit resource omitted mood, the glucose
panel, the wellness composites, and illness episodes that the
doctor_visit_summary prompt already carried. Extract one shared reducer
(doctor-visit-summary.ts) using the richer superset and have both
surfaces call it, so they ground identically and a future change reaches
both wires at once. Both keep the grounded latest+aggregate shape and
omit absent sections honestly.
The MCP measurement and blood-pressure write tools accepted a
client-supplied measuredAt with no temporal bound and persisted it
verbatim, so a far-future or ancient instant could poison "latest"
reads and rollup buckets. Bound it through the shared entry-instant
validator both manual capture paths use — no future beyond a 5-minute
clock-skew tolerance, nothing before 1900 — inside the write cores, and
return the structured out_of_range refusal rather than throwing.
Extract the type/range/instant checks into shared pre-write validators so
the same verdict can be reused by the confirm-gate preview.
Document the MCP server: an operator guide for enabling the
off-by-default module and connecting Claude.ai / ChatGPT / Claude
Desktop, a capabilities reference for the tool / resource / prompt
catalogue and the grounding + write model, and a skill-author guide for
building connectors against it. Link all three from the docs indexes.