Default documentDate at store time. A store-only upload no longer lands
with a NULL filing date: when the client omits one, default it to the
upload day so the column is always set on a stored row. The library list
sorts, groups, and range-filters on the same effective date the UI shows,
so undated uploads no longer mis-sort under nulls-last, break date-group
headers, or fall through the range filter. The date stays user-editable
and may be cleared back to NULL via PATCH.
Block re-extraction once any fact is APPROVED. A partially-confirmed
document stays EXTRACTED, which the CONFIRMED guard does not catch;
re-extracting would delete the approved facts (severing the committed
record's provenance) and re-stage them as PENDING, letting a second
approval duplicate the committed lab / condition / medication. The
extract route now refuses with 409 (documents.inbound.alreadyPartlyConfirmed)
when approved facts exist; the user must finish reviewing or discard first.
RFC 5987-encode the original-download filename. The Content-Disposition
now carries both an ASCII filename= fallback and a filename*=UTF-8'' value
so a non-ASCII name survives intact; the stored set is inline-safe by
construction, so the dead attachment branch is removed.
Add a light per-user rate-limit backstop to the original-read, PATCH, and
DELETE routes (generous; self-DoS only — the routes are owner-scoped).
Lean the list counts: tally facts for the whole page in one grouped count
instead of materialising fact-id rows, and exclude REJECTED facts from
factCount. Extend the documentDate index with deleted_at to cover the
list scan. Document the deliberate plaintext-title PHI tradeoff at the
column and the store site.
Evolve the inbound-documents screen into a real document library on top of
the store-first backend.
- Upload stays store-only and always works: the form now files a document
with an optional title, category, and document date in one step. Extraction
is a separate, explicit action.
- List becomes an organized library: documents grouped by date, with a title
(filename fallback), category and status badges, and pending/fact counts. A
toolbar adds title/filename search, a category filter, and a sort control
(document date / date added / title, asc/desc). Keyset pagination loads more
via the returned cursor through useInfiniteQuery.
- Detail panel: view or download the original, edit metadata (rename,
recategorise, set the document date), optionally run extraction, then the
existing review-then-confirm flow. When no scan provider is configured,
extraction surfaces a calm inline note instead of a hard error; the stored
document is untouched. Discard stays.
- Add a parameterised list key to the query-key factory so each filter/sort
combination keeps its own cache bucket while staying under the documents
prefix for invalidation.
- Rename the surface to "Documents" across all six locales (title, subtitle,
module label/description) and add the new toolbar, edit, extract, and
date-group strings. Route, model, and module key stay internal.
Add component and unit coverage for the grouped list, the detail/review
surface, the metadata edit form, the provider-unsupported note, and the
search-param/grouping/error-classification helpers.
Invert the inbound-documents surface from an OCR inbox into a document
library: a file can be stored without any AI provider, and extraction
becomes an opt-in action on top.
- Migration 0222: add `title` + `document_date` columns, a
`(user_id, document_date)` index, the richer kind taxonomy
(lab/imaging/prescription/insurance/vaccination), and a STORED status
made the column default. The status enum is recreated so the new value
can be set as the default within the migration transaction; kind values
use ADD VALUE. Nullable columns, no backfill.
- POST /api/documents/inbound is now store-only: bounded multipart read,
magic-byte MIME sniff, AES-256-GCM at rest, persisted as STORED with no
provider/consent/budget call and no egress.
- POST /api/documents/inbound/[id]/extract carries the relocated
extraction gauntlet (resolve provider, consent, rate-limit, budget, run,
reconcile, stage facts) against an already-stored row; absent a provider
it 422s the enhancement only and leaves the stored document intact.
- PATCH /api/documents/inbound/[id] edits title/kind/documentDate
field-by-field, owner-scoped by session userId.
- GET /api/documents/inbound gains search, kind filter, documentDate
range, sort, and keyset pagination via the parameter-bound query builder.
- Extend the validation schemas + DTO, regenerate the OpenAPI contract,
and add route tests for the store/extract/edit/list paths.
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 layered self-description standard already carries the canonical
intended-purpose line, the Coach standing note, the emergency note and
the data-posture note. The medication, insights and labs surfaces each
repeated a further context-specific disclaimer footer that added noise
without adding meaning.
Remove the three rendered footers and retire the now-unused
`insights` / `labs` / `medications` disclaimer variants from the shared
component (type, key map and doc). Drop the orphaned selfDescription
keys from all six locale bundles so call-site and reverse i18n coverage
stay in parity.
The root QueryClient is created once and survives every client-side
navigation, so logout then login on the same browser left the previous
account's non-user-scoped health-data queries in memory and the next
account read them before any refetch landed. Wipe the entire in-memory
cache on logout, on session expiry, and at the start of a fresh login,
alongside the existing IndexedDB and service-worker cache wipe, so no
session ever inherits another account's data.
Remove the standalone amber disclaimer card from the inbound-documents
review screen. The non-diagnostic essence now reads as the plain muted
subtitle line under the page title: upload a clinical document and review
the extracted details. Drop the now-unused disclaimer.title /
disclaimer.body keys across all six locales and the AlertTriangle /
CardDescription imports left orphaned by the removal.
Move the PHQ-9 / GAD-7 screener off `/insights/mental-wellbeing` to a
top-level `/mental-wellbeing` route so it no longer borrows the Insights
tab strip and layout shell. The page now uses the standard module-page
frame at the wider content width the other module pages use.
Replace the standing screening disclaimer block and the opt-in/private
line in the header with an info icon after the page heading whose
hover/focus tooltip carries the screening-not-a-diagnosis note. Drop the
now-orphaned `disclaimer` / `optInNote` strings across all six locales
and add the `infoTooltip` string in their place.
The questionnaire, scoring, severity result, PHQ-9 item-9 crisis-resource
handling, focus-on-submit, and 44px touch targets are unchanged. Repoint
the module nav entry and the registry comments at the new route.
The authenticated shell keeps a single <main id="main-content"> mounted
across every route change, so its scroll offset persisted between routes:
scrolling down in one section (e.g. Settings) and navigating to another
opened the next view already scrolled.
Reset the scroll container to the top on each pathname change, with a
window.scrollTo fallback for the body-scrolled shells. Back/forward
navigation skips the reset so the browser's own scroll restoration stands:
popstate arms a one-shot skip cleared on a macrotask so it can never stick.
Bring the lab biomarker detail in line with the Insights metric pages:
- Lead with the static catalog description directly beneath the heading,
the explainer-caption position the metric pages use.
- Drop the early big-value + reference-badge block that did not match the
assessment-card styling.
- Split numeric and qualitative markers: numeric keeps the stat strip and
chart, qualitative shows only the latest result text (no strip, no chart,
no range badge). The assessment card now renders for any marker with
readings and self-suppresses for qualitative-only markers.
- Move the full reading history off the detail page onto a dedicated
/labs/[biomarkerId]/values route, reached by a top-right list control,
matching the metric pages' show-all-values affordance.
The inbound-document store keeps the uploaded doctor letter encrypted at
rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way
to read the original back — the decrypt helper had been dropped as unused.
- Restore decryptDocumentFromBytes in the document store, fail-closed on a
bad / missing key id.
- Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed
query), module-gated, returns the decrypted bytes with the stored MIME
type. A PDF / image is served inline so the browser can render it; other
types download as an attachment. The filename is sanitised before it
reaches the Content-Disposition header. The bytes are PHI — never logged,
never served cross-user, and a decrypt error fails closed (500) rather than
returning ciphertext. Cache-Control is private, no-store.
- Surface a 'View original' action in the document review screen.
- Document the route in the OpenAPI registry and regenerate the spec.
- Cover the route with a test: owner-scoped success, attachment fallback,
404 for another user's document, and fail-closed on decrypt error.
i18n: viewOriginal across all six locales.
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.
The new-chat hero carried a standing self-description line. Remove that
instance; the greeting and composer stay. The MedicalDisclaimer component
and its insights/labs/medications placements are unchanged.
Grip strength and waist circumference resolved against a single sex-aware
bracket for every adult age. Replace each with age x sex norm bands:
- Grip strength: age x sex bands from Dodds et al. 2014 (PLOS ONE
9(12):e113637) British normative data — band low is the 10th centile
floored at the EWGSOP2 probable-sarcopenia cut-off (men 27 kg, women
16 kg), band high is the 50th-centile median. Strength peaks in the 30s
and declines from midlife, so the bands step down with age.
- Waist circumference: WHO increased-risk threshold (men 94 cm, women
80 cm) held flat through midlife and raised for older adults toward the
WHO substantially-increased-risk line, since the WHO cut-points derive
from cohorts aged 20-74. The standard threshold is never lowered.
The norms resolver gains an age-independent clinical cut-off row per sex,
used only as the no-age fallback so a profile with a known sex but no date
of birth keeps the sex cut-off rather than the coarse registry anchor. The
signal registry structure is unchanged — only the resolver tables move.
- 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.
The screener POST hardcoded source=WEB and ignored any client-supplied
externalId, so the native client could not tag provenance and an outbox
replay beyond the 24h idempotency-key window minted duplicate
administrations plus duplicate trend points.
Accept an optional closed source enum (WEB|IOS, default WEB) and an optional
bounded externalId, and persist both. Deduplicate on (userId, externalId)
when present: the route returns the existing administration on replay, and a
partial unique index over (user_id, external_id) WHERE external_id IS NOT
NULL (migration 0221) backstops a concurrent insert race (P2002 resolved to
the winner). The derived *_SCORE measurement keeps its stable per-assessment
externalId, so dedup at the assessment level prevents duplicate trend points.
Item answers stay encrypted and out of AI/MCP/export. Regenerate OpenAPI.
HealthKit emits HKQuantityTypeIdentifierWaistCircumference as a length
quantity in metres, but the mapping table had no entry for it, so a batch
upload of that identifier was silently dropped (mapAppleHealthEntry returned
null). Add the bidirectional mapping with metre-to-cm scaling so the round
trip lands on the canonical WAIST_CIRCUMFERENCE measurement (cm), matching
getUnitForType and the WHO display bands. Extend the mapping test.
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.
Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).
Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.
Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).
MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
The existing i18n guards prove key-shape parity but pass an English string
copied verbatim into another locale — the exact class of bug the v1.25
audit found (illness journal, PHQ-9/GAD-7 screener). Add a heuristic that
flags any es/fr/it/pl value byte-identical to en for a multi-word string,
with an explicit allowlist for the legitimately-identical keys (clinical
abbreviations, units, brands, OAuth labels) and auto-skips for URLs and
mask placeholders.
The detail page fetched a single limit=500 window and sorted client-side,
silently truncating any marker that crossed 500 readings. Switch to an
offset-paginated useInfiniteQuery: it loads a page at a time, a 'Load more'
control reveals the rest, and the chart and stat strip render the
accumulated set so loading more extends the trend.
The nightly discovery fan-out called isModuleEnabled per candidate user —
N serial DB reads on a large instance. Resolve the opt-in gate in-memory
from one findMany plus a single operator-availability read instead. The
per-day backfill upserts ran one awaited statement at a time (up to 730
for a full backfill); batch each location group's upserts into a single
transaction round-trip while keeping the idempotent semantics.
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 inbound-document list and review panels used centered spinners; swap
them for the app's Skeleton pattern so the resolved content lands without
a layout jump. The low-confidence review badge moves from destructive red
to a calm amber, matching the surface's non-alarming posture. Approve /
reject / edit / save controls grow to the 44px touch floor, the upload
file input gains an accessible label, and each document row exposes
aria-expanded.
The sleep-debt computed-engine InfoHint only rendered in the still-learning
state — but the resolved full-debt card is the common case and where the
figure most needs the 'this is our own estimate, not a wearable's native
number' context. Show it there too. Separately, swap the period-narrative
loading shell's hand-rolled muted divs for the shared Skeleton primitive
so its shimmer matches every other loader.
The top-right Coach hand-off icon button and the info-hint trigger sat
below the WCAG 2.5.5 44px minimum on touch. Grow both hit boxes to 44px
on touch while keeping the visible footprint unchanged — the Coach button
is a fill-less ghost, and the info-hint offsets its padding with an equal
negative margin so inline layout is untouched.
The illness journal copy (load error, delete confirmation, edit/menu
labels, parent links, and the vital-sign labels) shipped in English in
every locale; translate it into de/es/fr/it/pl. Also add the new strings
the v1.25 surfaces introduce — the biomarker 'load more readings' action,
the environment city-search zero-result message, and an accessible label
for the document upload file input — across all six locales.