Commit Graph
2479 Commits
Author SHA1 Message Date
Marc-André Bombeck 8f442c3d6c feat(documents): keyboard-first vault timeline
Roving tabindex over the card grid: arrows move (grid-aware by measured
column count), Home/End jump, Enter opens, Space selects, Delete removes
with the undo toast; focus rides the virtualizer window (scroll-to-row,
bounded retry focus). The container carries list semantics with a
translated label, and the transient upload queue stays a polite live
region. Selection state is conveyed by the per-card checkbox, which the
focus ring reveals for keyboard users.

Refs: document-vault plan W3-T3
2026-07-07 13:24:07 +02:00
Marc-André Bombeck aef2003d4c feat(documents): multi-select bulk actions on the vault timeline
Selection grows file-manager gestures — shift-click ranges (additive,
anchored on the last plain toggle) and press-and-hold on touch — and a
floating toolbar appears with the selected count and the bulk verbs:
change type, link condition, delete, clear. All verbs drive the bulk
endpoint in <=100-id chunks with merged per-id results; partial failures
surface as one counted toast, and a bulk delete gets one aggregate undo
that bulk-restores the succeeded rows (both sides no-op-safe by
contract). Escape clears the selection unless a dialog owns the key.

Refs: document-vault plan W3-T2
2026-07-07 13:21:16 +02:00
Marc-André Bombeck a8a733fc4b feat(documents): page-wide drop and clipboard paste feed the upload queue
Dragging files anywhere over /documents raises a dimmed full-page drop
overlay (Escape dismisses); dropping or pasting files/screenshots feeds
the same upload queue as the picker, pre-linked to the active condition
filter. The overlay is decorative (aria-hidden) — intake announces
through an sr-only live region. Copy translated in all six locales.

Refs: document-vault plan W3-T1
2026-07-07 13:15:54 +02:00
Marc-André Bombeck e9b8995486 fix(documents): drop the dead route-level CSP on the serve path
The proxy's serve-route carve-out owns the response CSP (default-src
'none'; frame-ancestors 'self'; no sandbox — Chromium force-downloads
sandboxed PDFs), and middleware headers overwrite route headers, so the
route-level sandbox value never reached a client. Remove it and point
both the route and its header-matrix test at the proxy pin.

Refs: document-vault plan W1-T10 posture reconciliation
2026-07-07 13:12:47 +02:00
Marc-André Bombeck f9ca7fef79 fix(proxy): allow same-origin framing of the document serve route
The blanket X-Frame-Options: DENY + frame-ancestors 'none' refused the
detail preview's same-origin <iframe>, and the middleware page CSP
clobbered whatever the serve route set — the inline PDF preview rendered
as a permanent skeleton. Carve out exactly the /original path:
SAMEORIGIN framing, frame-ancestors 'self', and a document CSP of
default-src 'none'. Deliberately NO sandbox directive and no sandbox
attribute on the preview iframe — Chromium force-downloads PDFs in
sandboxed documents instead of rendering them (verified against a real
Chromium channel; headless test builds ship no PDF viewer at all). The
load-bearing boundaries for user-uploaded bytes are unchanged:
magic-byte classification at upload, true Content-Type + nosniff at
serve, HTML/SVG denied entirely, attachment-only posture for every
non-passive format. Tests pin the carve-out and that every neighbouring
path keeps the DENY posture.

Plan-Task: W2-T3 (preview integration)
2026-07-07 12:57:12 +02:00
Marc-André Bombeck 2b7a6544d6 feat(documents): retire the v1.25 extraction-review surface
The parked inbound-documents view (upload form + staged-fact review UI)
and its helpers leave the tree; /documents renders only the vault. The
extraction routes stay dark server-side with no UI entry points, per the
release plan. Orphaned i18n keys left with the locale rewrite in the
previous commit; the reverse-coverage guard pins the result.

Plan-Task: W2-T7
2026-07-07 12:26:07 +02:00
Marc-André Bombeck b8009d2e73 feat(documents): the vault surface
The page becomes one wide, calm filing-cabinet view: PageHeader with an
Upload action, a drop-target upload zone whose accept list and per-file
cap come from the usage endpoint (quota bar only above 80 %), a sticky
filter rail (200 ms debounced search focused by '/', nine type chips,
condition chips from linked episodes, a year segmenter, one-tap clear),
and the virtualized timeline below. Filter state lives in the URL so
every view is shareable and back-button-safe; uploads filtered to an
episode arrive pre-linked. Hovering a card prefetches detail metadata —
never the blob. Designed states: a skeleton grid that matches the loaded
layout, a teaching empty state, a distinct no-matches state, and a
retryable error card. /documents joins the shell's wide-container list;
the shell stays the single owner of the page frame. Locale bundles gain
the vault vocabulary in all six languages and drop the retired
extraction-review strings.

Plan-Task: W2-T1, W2-T4 (zone), W2-T5 (prefetch), W2-T6
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 4014d77a21 feat(documents): detail sheet with inline preview and undo-able delete
ResponsiveSheet (bottom sheet on phones, dialog on desktop): inline-class
documents preview against the owner-scoped serve route — skeleton first,
internal scroll only; attachment-class documents get no fake preview,
just glyph, filename, size, and a prominent Download. Title edits
click-to-edit and commit on Enter/blur, kind and filing date save on
change, condition links PATCH as a replace-set with chips navigating to
the episode page. Delete soft-deletes behind an Undo toast; a refused
restore translates the purged / duplicate reasons. Mutation errors stay
inline. No share affordance — deliberately.

Plan-Task: W2-T3
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 53d0d8cc33 feat(documents): virtualized month timeline and document cards
The timeline windows a flat item list (month labels + chunked card rows)
with @tanstack/react-virtual over the shell's scroll container — single
scroll owner, bounded DOM at four-digit corpus sizes. Columns are
measured by a ResizeObserver so the row model and the painted grid never
disagree. Cards are compact-density: kind glyph, foreground title, one
muted meta line, condition pills, a download-only badge for attachment-
class formats, and a hover/focus selection checkbox above the whole-card
click target. In-flight and failed uploads share the same footprint with
a live progress ring and translated failure copy.

Plan-Task: W2-T2, W2-T4 (upload cards)
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 5536749985 feat(documents): client upload queue with progress and duplicate handling
One multipart XHR POST per file (real upload-progress events, no new
dependency), concurrency capped at 3 to match the server's memory
envelope, an Idempotency-Key per file so a retried blip cannot
double-store, and a canvas downscale to 3000 px longest edge for raster
photos only — PDFs, Office files, and GIFs upload verbatim. Entries
appear synchronously before any network work; an over-limit file fails
locally against the configured cap while the batch continues; a
duplicate resolves to the existing row and hands its id to the timeline
for a brief highlight.

Plan-Task: W2-T4 (manager half)
2026-07-07 12:25:45 +02:00
Marc-André Bombeck 37e9d933e2 feat(documents): vault list contract on the client
URL-backed facets (q / kind / episode / year) parse and serialise through
one pure helper pair so deep links round-trip byte-stable; the API search
builder pins the timeline sort. Month bucketing + row chunking feed the
virtualized grid as a flat item list. The upload-response parser owns the
duplicate contract: HTTP 200 with envelope-level meta.duplicate returns
the existing row, every 413/415 reason maps to a translated-copy class,
and proxy-stripped bodies fall back to the HTTP status. Query keys move
to the vault filter shape and gain the usage read.

Plan-Task: W2-T1 (URL state), W2-T5 (keys)
2026-07-07 12:25:45 +02:00
Marc-André Bombeck 8747c69156 docs(api): document-vault contract in the OpenAPI spec
The documents route table picks up every delta: conditionLinks +
servingClass on the document DTO (render/download by servingClass, never
by MIME guess; unknown kind values decode as OTHER), the broadened upload
accept-set with the 413/415 error contract and the 200+meta.duplicate
dedupe outcome, episodeIds on upload and PATCH (replace-set), the kind[] /
episodeId / year list facets, the class-aware /original posture, and the
new /restore, /bulk, and /usage endpoints. Regenerated docs/api/openapi.yaml
committed alongside; openapi:check is clean.

Task: W1-T14.
2026-07-07 11:06:21 +02:00
Marc-André Bombeck ac88ce1a2d feat(modules): re-enable the inboundDocuments module
inboundDocuments leaves CODE_DISABLED_MODULE_KEYS (parked there since
v1.25.3). The normal two-layer resolution applies again: the module ships
dark (opt-in, per-user default off) and an operator false still disables
it server-wide; nothing becomes user-visible without an explicit opt-in.
The gate tests now pin the re-enabled behaviour instead of the hard-off
short-circuit.

Task: W1-T2.
2026-07-07 11:06:21 +02:00
Marc-André Bombeck 155b74ccf4 feat(admin): expose the two document-vault limits
The admin settings GET/PUT carry documentMaxFileBytes (1 MiB – 100 MiB;
the hard ceiling exists because single-shot GCM + bounded in-memory reads
are load-bearing past it, and the upload-time resolver clamps again as
defence-in-depth) and documentQuotaBytes (10 MiB – 1 TiB instance
default). The admin user PUT accepts a nullable per-user quota override;
null clears it back to the instance default. Cookie-only requireAdmin()
guards both surfaces as before — Bearer tokens cannot reach them.

Task: W1-T13.
2026-07-07 11:06:04 +02:00
Marc-André Bombeck de9e528614 feat(documents): daily purge for tombstoned documents past the undo grace
Delete in the vault is undo-able: DELETE tombstones, the restore endpoint
clears the tombstone, and this pg-boss job (daily 04:10 Europe/Berlin,
single-flight like every other cleanup) hard-deletes tombstones older than
30 days — returning the TOAST space the encrypted blob holds. Until then a
tombstoned row keeps counting against the quota, so deleted bytes are
never invisible backup weight. Staged facts cascade via the FK;
restore-after-purge answers 409 at the API.

Task: W1-T12.
2026-07-07 11:06:04 +02:00
Marc-André Bombeck 972c5fbf48 feat(crypto): rotate the document blob column in bounded, codec-aware batches
The registry entry for InboundDocument.contentEncrypted gains a codecField
marker: the column now holds two layouts (base64v1 string codec | binary2
binary codec), recorded per row, and its rows are up to cap-sized blobs.
scan/rotate walk such a column with id-cursor pagination (25 rows per
page — never an unbounded blob findMany) and re-encrypt each row under its
OWN codec; an unknown codec value is counted as an error and the row left
untouched, same fail-closed posture as a missing key. The walk is
idempotent, so an interrupted run resumes on re-invocation with the
already-active rows skipped.

Both rotation paths share the logic: the admin-triggered pg-boss job via
rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now
calls the shared batched walk for this column. The registry guard test
keeps schema, registry, and script in lock-step as before.

Unit coverage over a mocked 500-row corpus asserts the batch boundedness
(take + advancing cursor per page), both-codec rotation, per-row
fail-closed on unknown codecs, and interrupted-run resume.

Task: W1-T11.
2026-07-07 11:05:43 +02:00
Marc-André Bombeck d9bbdb037a feat(documents): restore, bulk, usage endpoints and class-aware serving
POST [id]/restore clears the soft-delete tombstone inside the 30-day undo
grace; it answers 409 once the tombstone is purged (the undo window is
over) and 409 with meta.reason=duplicateExists when an identical live
document would collide with the live-dedupe index. Restoring an
already-live row is a no-op success, so a late undo toast never errors.

POST /bulk applies one action (setKind / linkEpisode / unlinkEpisode /
delete / restore) to up to 100 owner-scoped documents and reports per-id
outcomes — a partial failure never aborts the batch. GET /usage returns
usedBytes (tombstone-inclusive), the effective quota, the per-file cap,
and the accepted-extension list for picker accept attributes.

The serve route now derives its posture from the serving class: inline
types keep their true Content-Type and gain Content-Security-Policy:
sandbox as defence-in-depth for the PDF viewer context; attachment types
ALWAYS go out as application/octet-stream + Content-Disposition:
attachment. nosniff and private,no-store on both; RFC 5987 filename
handling unchanged; decrypt dispatches on the row codec and stays
fail-closed. Header matrix pinned per class in the route tests.

Tasks: W1-T8, W1-T9, W1-T10.
2026-07-07 11:05:29 +02:00
Marc-André Bombeck b76873ee1f feat(documents): rebuild upload and list on the vault contract
POST /api/documents/inbound now runs the policy layer end to end: bounded
read aborts at the admin-set cap (413 + meta.reason=fileTooLarge with the
limit), magic-byte classification (415 + unsupportedType), per-user quota
checked inside the insert transaction over every non-purged row —
tombstones keep counting, so deleted bytes are never invisible weight
(413 + quotaExceeded with the figures), sha256 dedupe (a same-bytes
re-upload answers 200 + meta.duplicate with the existing live row; the
partial unique index closes the race), an honoured Idempotency-Key, the
binary2 codec on write, and optional episodeIds[] pre-linking narrowed to
the caller's live episodes.

GET stops fetching the encrypted blob per row (omit: contentEncrypted —
a 50-row page dragged up to 50 × cap ciphertext before), learns kind[] /
episodeId / year facets on top of q/from/to, and returns conditionLinks +
servingClass on every DTO via one grouped link query (no N+1). PATCH gains
replace-set episodeIds with the same 404-shaped refusal for a foreign
episode. The extract path decrypts through the codec dispatcher.

The document-kind labels gain REFERRAL in all six locales.

Tasks: W1-T5, W1-T6, W1-T7.
2026-07-07 11:05:12 +02:00
Marc-André Bombeck bd64b78e5d feat(documents): upload policy layer and binary content codec
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.
2026-07-07 11:04:51 +02:00
Marc-André Bombeck 9ea22b8a98 chore: drop an unused registry-keys export 2026-07-07 00:45:23 +02:00
Marc-André Bombeck 52d09d17c7 feat(insights): inject guideline band placement into generic metric assessments
Thread the interpretation block into the generic per-metric assessment
path (the ~40 HealthKit metric cards). For a metric the knowledge base
covers, the prompt now carries the band table plus the server-computed
position of the current value, so the assessment names where the value
sits on the guideline scale and judges the trend by that position — the
visceral-fat 2.7 case now reads as "comfortably in the healthy band"
rather than reciting how many times it was logged.

The block is absent for uncovered metrics and for a sex-split band with
no known sex, so those cards fall back to exactly today's personal-relative
behaviour. Band edges carry no strip risk here — the generic assessment
path runs no post-generation numeric grounding gate.
2026-07-07 00:36:43 +02:00
Marc-André Bombeck 3ce396081f feat(insights): anti-recitation and interpretation-depth prompt contracts
Add two shared cross-surface contracts and compose them into the
assessment cards (base-system) and the comprehensive overview + daily
briefing (insight-generator):

- anti-recitation: a measurement count, a logging cadence, or page
  mechanics is not an insight and may only appear when it carries a
  consequence — stated as the consequence, not the count.
- interpretation-depth: say what a value MEANS, place it on its guideline
  scale where one is attached, judge the trend by band position, and frame
  the consequence without diagnosis; where no band exists, interpret
  against the person's own baseline and say so honestly.

The cross-surface coverage guard now asserts both contracts reach the
surfaces that enforce them. The briefing's grounding and format machinery
is untouched.
2026-07-07 00:36:34 +02:00
Marc-André Bombeck 8fc71620e8 feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline
interpretation bands derived strictly from the health knowledge base —
band edges, plain labels, valence, direction-of-good, and a source tag,
each entry citing the KB file + section it came from. A metric the KB
gives no numeric band for gets no entry, so its assessment stays
personal-relative rather than inventing a threshold.

The band position is computed server-side (`classifyBandPosition`, strict
upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock`
renders the table + placement into the assessment prompt so the model states
where a value sits and what the band means instead of reciting counts.
`interpretationBandEdges` exposes the edges for any numeric grounding gate.

Covers visceral fat, resting heart rate, blood oxygen, respiratory rate,
body temperature, sleep duration, waist (sex-split), waist-to-height,
pulse-wave velocity, and BMI. Tests pin every band edge against the
guideline value so a silent edit breaks a test.
2026-07-07 00:36:25 +02:00
Marc-André Bombeck 3653402d4a feat(google-health): report the accepted rollup shape from the structure probe
The structure probe (POST /api/integrations/google-health/test with
probe:"structure") already exercises one 7-day dailyRollUp per cumulative
type; it now also reports which documented request shape Google accepted
(days90 standard chunking vs days14 conservative fallback) as
requestShape, next to the field-name skeleton. A self-hoster can settle
the range-constraint question from the UI without server logs; no value,
timestamp, or identifier is added to the output.
2026-07-06 20:24:50 +02:00
Marc-André Bombeck de86ee26f2 fix(google-health): one broken collection no longer kills its siblings
The live 400 on fetchSteps suppressed fetchDistance / fetchFloors /
fetchActiveEnergy entirely: handleCollectionFetchError rethrew anything
that was not a per-class 403, and the rethrow escaped the rollup loop in
sync-activity, emptying the whole cumulative group after the first bad
collection.

- handleCollectionFetchError records the classified failure, warns with
  the per-collection detail, and returns instead of rethrowing; every
  call site already sits in a per-collection catch, so the sibling
  collections keep fetching.
- A per-cycle hard-fail ledger (AsyncLocalStorage, mirroring the 403
  soft-skip tracker) still fails the cycle: the run counts as a partial
  error, the watermark is not stamped, and the next tick refetches the
  window - a broken collection cannot silently advance past its gap.
- The orchestrator's per-resource warning names the resource explicitly;
  fn.name minifies to a single letter in the production bundle (the live
  log read "google-health c failed").
- Tests pin the no-rethrow contract per classification and the activity
  loop reaching all four rollup types plus VO2 max after a steps failure.
2026-07-06 20:24:40 +02:00
Marc-André Bombeck 1c3c733fab fix(google-health): build dailyRollUp ranges the way the docs demonstrate
The first live sync on v1.27.4 imported every list-based type but died on
the first cumulative chunk: fetchSteps(chunk=0,page=0) returned
HTTP 400 INVALID_ARGUMENT "Invalid argument in request.".

Re-verified the request contract against the v4 reference:

- Request body (users.dataTypes.dataPoints/dailyRollUp): range
  { start/end: CivilDateTime }, windowSizeDays, pageSize, pageToken,
  dataSourceFamily - every field the client sends is legal under that
  exact name.
- CivilDateTime (inlined on the DataPoint page): nested
  {date:{year,month,day}, time:{hours,minutes,seconds,nanos}} objects.
- range: "Required. Closed-open range of data points that will be rolled
  up. The start time must be aligned with the aggregation window. The
  maximum range for calories-in-heart-rate-zone, heart-rate,
  active-minutes and total-calories is 14 days. The maximum range for
  all other data types is 90 days." - steps / distance / active-energy /
  floors sit in the 90-day class, so the 90-day chunk constant stands
  (the in-file "14-day-max" note applies to heart-rate only).
- The worked dailyRollUp example (developers.google.com/health/endpoints)
  rolls up ONE civil day as start = {date: day, time: 00:00:00} and
  end = {date: SAME day, time: 23:59:59} - the end bound names the last
  covered day, not the next day's midnight.

The previous body sent end = {date: <next day>} with no time object, so
a maximal 90-day chunk touched 91 civil dates - the one divergence from
the documented example, and the prime suspect for the 400 (the docs do
not spell out how the cap counts days).

- buildDailyRollUpBody() pins the documented shape: explicit date+time
  on both bounds, end = last covered day at 23:59:59, windowSizeDays: 1,
  no pageSize (the documented default of 1440 covers the <=90 windows a
  chunk can produce), pageToken threaded on follow-up pages.
- fetchDailyRollUp() keeps 90-day chunking; if the very FIRST request of
  a walk is rejected with a 400 it re-walks the range once at the most
  conservative documented span (14 days, the tightest cap the docs state
  for any type) and annotates googleHealth.rollup.shape with the shape
  that succeeded, so the next live sync reports which constraint binds.
- extractGoogleApiErrorDetail() surfaces AIP-193 error.details[]
  BadRequest fieldViolations as "field: description" fragments (field
  paths and descriptions only, redacted and capped at three), so the
  next 400 names the offending request field in the logs instead of the
  bare "Invalid argument in request.".
- mapping.md documents the verified range semantics; unit tests pin the
  exact body JSON, the 90/91-day boundary, the fallback walk and the
  violation extraction.
2026-07-06 20:24:24 +02:00
Marc-André Bombeck ec586ba75c fix(dashboard): paint the dose ring in the medication hue, not green
The 'Doses today' ring pinned the ring system's green arc — a colour
that vocabulary reserves for band semantics. Every medication surface
in Insights paints --primary (the compliance progress bars, the
compliance trend line, the drug-level and dose-strength curves), so the
ring now rides a new constant 'meds' hue on that token: flat by design
(both gradient stops on --primary), never a band gradient over pending
morning doses. Contrast, computed: dark #bd93f9 — 6.78:1 card / 5.90:1
bg; light #644ac9 — 6.00:1 card / 5.60:1 bg; both clear the 3:1
graphics floor with room. The guard now windows the assertion to the
dose ring's own SVG and pins primary present, ring-green absent.
2026-07-05 22:11:26 +02:00
Marc-André Bombeck 72ad228f3e fix(ui): lift the remaining explainer prose out of muted
Residual grey-on-content sweep after the page-description lift:
multi-sentence explainer paragraphs are content and read in the
regular foreground, matching the metric sub-page explainer that set
the pattern — the labs biomarker and custom-metric detail leads, the
recovery metric and resilience explainers, and the onboarding
carousel/done-screen body copy. Muted stays reserved for meta:
timestamps, units, captions under a stat, one-sentence description
slots.
2026-07-05 22:01:15 +02:00
Marc-André Bombeck eb730bf7f3 fix(insights): one card language on the sub-page spine
A measured pass over every insights sub-page, the dashboard, and the
settings sections (computed text colour, left text edge relative to the
card edge, header→body distance, per card) surfaced the drift the eye
kept catching:

- The blood-pressure pulse-pressure/MAP explainer was a bare muted
  paragraph at the container edge, 16–24 px left of every neighbouring
  card's text. It now rides a compact Card in the regular foreground.
- The 'Usual range' read strip was a hand-rolled bg-card/60 shell with
  its own padding; rebuilt on Card + TileHeader + CardContent at the
  compact density so background, radius, icon spacing and text edge
  match the neighbouring tiles.
- The spine tiles carried three different header→body distances (6, 12
  and 24 px) via off-scale overrides (gap-1.5, md:gap-3, md:py-5,
  gap-3). Assessment, target summary, medication tiles, the mood
  stand-out card and the sleep hypnogram now share the sanctioned
  compact tier (gap-2 py-3 md:py-4).
- The hero score card and its skeleton kept their translucent identity
  but move from a static px-4 py-4 to the p-4 md:p-6 card norm, putting
  their text on the same edge as every other card.
- Two dashboard shells (recent workouts, recent unlocks) froze at p-4
  on every breakpoint; now p-4 md:p-6.
- Settings: the modules hint moves into the header's description slot
  and the injection-site grid joins the section-wide mt-4 rhythm — the
  two gaps that deviated from the settings 16 px header→content norm.
2026-07-05 22:00:53 +02:00
Marc-André Bombeck a4b204ffe4 refactor(ui): retire the raw dracula utilities for semantic tokens
Migrate every remaining *-dracula-* utility site (127 lint warnings,
193 class occurrences) onto the semantic vocabulary. The swaps are
pixel-identical in both themes — each target token aliases the same
primitive the utility read, including the light-theme AA overrides:

- green/orange/cyan/red → success/warning/info/destructive
- purple → primary (same value in both themes)
- pink → the new --brand-pink token (Coach gradient, Apple-Health
  badge, recording mic): dark #ff79c6 — 6.86:1 card / 5.97:1 bg;
  light #a3134d — 7.32:1 card / 6.83:1 bg (computed)
- yellow sites resolve to their status meaning: warn-level log icon,
  update-available pill and offline-geo hint → warning; the admin
  helpful-rate ramp → success/warning/destructive; the confidence
  meter mirrors the coverage-meter precedent (medium + low share the
  caution tint, the lit count carries the distinction)
- the confidence-badge low tier dropped dead *-dracula-comment/*
  classes (no generated utility) for border-border + bg-muted/50

Close two rule blind spots the sweep surfaced — directional borders
(border-l-dracula-red) and the parenthesised CSS-var shorthand
(bg-(--dracula-green)) — and flip healthlog/no-dracula-utility from
warn to error so the class cannot regrow. var(--dracula-*) references
in chart code stay: they are token references the light theme
overrides, and the chart palette is pinned visually.
2026-07-05 21:28:09 +02:00
Marc-André Bombeck 68b4dd08a7 feat(lint): guard the gap-based Card slots against padding overrides
Add healthlog/spacing-scale (error): pt-*/pb-* on CardHeader/CardContent
fights the gap-based Card contract — the header→body distance comes from
the parent flex gap, so slot padding stacks on top of it or is a no-op.
This is the drift class the earlier slot-padding sweep cleaned; the rule
keeps it from reaccumulating. Density belongs on the Card itself
(gap-2 py-3 md:py-4). No allowlist — an exception, should one ever be
load-bearing, carries an inline disable with its reason.

Remove the two stragglers the rule caught: pb-1 on the device-score tile
and pb-4 md:pb-6 on the intake history list, both stacking dead padding
on top of the card gap. RuleTester coverage for the modifier-prefixed,
arbitrary-value, cn()/ternary/template and member-expression forms.
2026-07-05 21:10:01 +02:00
Marc-André Bombeck e9692f0b81 fix(i18n): survive Safari's 7-day cookie cap on the chosen locale
The first-paint ladder consulted only the healthlog-locale cookie and
Accept-Language — never User.locale. The cookie is written client-side
via document.cookie, which Safari ITP caps at 7 days, so a chosen
language silently reverted to the browser language once a week.

Three seams close it:
- resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts)
  now reads User.locale on a cookie miss via a lightweight session read
  (no sliding-expiry write, no cookie mutation, fail-soft) before the
  Accept-Language fallback.
- PUT /api/auth/me/locale mirrors the accepted locale into the cookie
  via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via
  shouldEmitSecureCookie) — server-set cookies are exempt from the ITP
  cap, and the mount-time backfill hitting the route doubles as the
  weekly refresh.
- getSession re-emits the cookie when the user has a persisted locale
  but the cookie is absent (absent-only, so a mid-switch cookie is
  never reverted before the PUT lands).

Users who never chose a language stay on Accept-Language.
2026-07-05 21:01:50 +02:00
Marc-André Bombeck ce9b5bec29 fix(mental-health): drop the landing rhythm hint and lift page descriptions to foreground
The cadence sentence between the page header and the instrument cards
reads as clutter once the recall windows live in each instrument's own
description. The canonical PageHeader description also moves from the
muted tier to foreground: it is content, not meta.
2026-07-05 20:45:24 +02:00
Marc-André Bombeck 6e59fe5ce0 Merge branch 'feat/w-g-screenings' into release/v1.27.10 2026-07-05 20:36:50 +02:00
Marc-André Bombeck 8f1485fdcb feat(mental-health): rebuild the check-in page around four instrument cards
The landing slims to intro + instrument cards: the combined history card
leaves the main page entirely. Each card body now opens a per-instrument
detail sheet (the med-/Vorsorge card interaction) carrying the last
score + band, the score-over-time trend chart (restored from the
pre-v1.27.6 chart, lazy Recharts, bands surfacing discreetly in the
tooltip), the dated result history with the crisis re-surface, the Start
action, and the instrument's required attribution line — the Verlauf is
deliberately opt-in behind a click, never pushed onto the page someone
arrives at to take stock.

The wizard drives from the registry: option values in source order (the
WHO-5 leads with 5 = all of the time, the SCI with 4), shared vs
per-item anchor groups, and recall-stem captions above the WHO-5 / SCI
item fragments. WHO-5 items and anchors ship VERBATIM from the official
WHO publication in all six locales; the SCI is validated in English, so
every non-English locale presents the English items plus a localized
'validated in English' note (a self-made translation would invalidate
the instrument — the leak-guard allowlist documents this as intended).
Result view: direction-aware follow-up hints (WHO-5 <= 50 points gently
to the PHQ-9 check-in; SCI <= 16 carries the paper's neutral band
wording; PHQ-9 / GAD-7 keep their >= 10 hint). The intro states each
recall window honestly (two weeks vs past month). The Vorsorge screening
group offers all four instruments. Chrome localized in de/en/es/fr/it/pl.
2026-07-05 20:34:01 +02:00
Marc-André Bombeck 03d4496d3a feat(measurements): surface the WHO5_SCORE and SCI_SCORE trends
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
2026-07-05 20:33:44 +02:00
Marc-André Bombeck 1262333acc feat(mental-health): add the WHO-5 and SCI screening instruments
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.
2026-07-05 20:33:29 +02:00
Marc-André Bombeck f5b0ec431e feat(coach): surface plan proposals for confirmation and add the plans page
The Coach has extracted goal / if-then plan proposals since v1.21.3 —
written as status "proposed", never activated without the user — but no
surface ever showed them, so nothing could be confirmed and the active-plan
memory block stayed permanently empty. This lands the missing user half:

- Chat thread: proposal confirm cards at the tail of the open conversation
  (proposals land asynchronously via the post-turn memory-refresh worker,
  so the block slow-polls the proposed list while the thread is mounted and
  only shows proposals born in that conversation). Accept activates the
  plan — it enters the Coach's snapshot memory from the next turn on;
  decline soft-deletes the proposal so it never activates and stops
  counting against the per-user cap. A settled card collapses to a
  one-line confirmation.
- /coach/plans: the management ledger, a sibling of /coach/conversations
  reachable from the composer's + menu. Groups proposals (confirm /
  decline), standing plans (goal met / end), and the settled history
  (remove). No prose editor by design — the extractor is the only writer
  of plan text and the PATCH contract only moves the lifecycle.
- Shared use-coach-plans hook + centralised coach-plans query keys, so the
  thread cards and the page invalidate each other's reads.
- Both coach-gate invariants (operator master flag, per-user opt-out)
  register the new page; i18n keys land in all six locales.

SSR tests pin the card contract: only same-conversation proposals render,
undecryptable rows are skipped, both actions are real buttons.
2026-07-05 20:00:38 +02:00
Marc-André Bombeck acd2f29da2 feat(coach): scope filter + conversation provenance on the plans list
Groundwork for the plan confirm/manage UI. The CoachPlan pipeline has been
complete on the server since v1.21.3/v1.22 — extractor writes proposals
(status "proposed", per-user cap, de-dup, encrypted prose), the PATCH
confirms proposed -> active, buildCoachPlansBlock injects the top-6 active
plans into the snapshot, and the review sweep drives the reviewDate
lifecycle — but no surface ever listed or confirmed a proposal, so the
extraction spend had no user-visible outcome.

Two additive contract changes the UI needs:
- GET /api/coach/plans accepts ?scope=open|past|all (open = proposed +
  active + review_due, past = met + abandoned + reviewed, all = no status
  clause) so the management page reads its ledger in one round-trip.
  Mutually exclusive with ?status=; both omitted keeps today's
  non-terminal default unchanged.
- Plan items now carry sourceConversationId so the chat thread can show
  only the proposals born in the open conversation.

OpenAPI regenerated; route tests cover the scope mappings, the
status+scope 422, and the provenance passthrough.
2026-07-05 19:45:20 +02:00
Marc-André Bombeck 5a7a13c934 test(records): cover the new report-section toggles in the prefs route fixtures
The doctor-report prefs route resolves every toggle to the full
canonical shape; the fixtures predate the allergies/familyHistory
sections and asserted the seven-key shape.
2026-07-05 18:40:59 +02:00
Marc-André Bombeck 133b84fa72 feat(mental-health): ask the PHQ-9 functional follow-up as the last question
The PHQ-9's validated functional-impairment item left the web wizard
with the review step; the API kept the field but no web client captured
it. It returns as a regular question 10 of 10 in the same style as the
nine scored items: optional per the instrument (submit stays armed
without an answer, a second tap clears the selection, a short hint says
skipping is fine) and never scored into the total. The answer rides the
existing functionalDifficulty field; GAD-7 keeps its seven steps. The
validated wording returns to all six locales.
2026-07-05 18:40:59 +02:00
Marc-André Bombeck bbb05f92a0 fix(theme): keep the light-override comment clear of a comment terminator
The override block's prose mentioned an opacity-suffixed dracula
utility in a form that embedded the CSS comment terminator, cutting the
block comment short and failing the stylesheet parse. Reword the line;
values are unchanged.
2026-07-05 18:15:11 +02:00
Marc-André Bombeck e32e27d27f feat(dashboard): make the spotlight heading and the verdict sentence the links
The briefing spotlight carried a separate underlined 'open Insights'
text link that thickened the card; every signal row below already
deep-links. Drop it and make the Tagesbriefing heading itself the link
— quiet, no underline, hover lift only — targeting the briefing card
directly via a new #daily-briefing anchor on the insights section
(scroll-mt-28, the app-wide offset token).

After the broad Insights button left the hero, the scoreDrop and
briefing verdict sentences were dead ends. Link-CTA verdicts without a
button label now make the sentence itself clickable (same quiet
treatment): scoreDrop routes to /insights, the briefing sentence
deep-links to the picked finding's metric sub-page via the METRIC_HREF
map (fallback /insights). Verdicts with their own action keep their
button.
2026-07-05 18:09:10 +02:00
Marc-André Bombeck 8c22d66dd9 feat(settings): apply hero ring choices instantly and make the selection sortable
The ring toggles rode the section's draft/Save machine; combined with
the server snapshot cache and client staleness this read as 'flipping
does nothing, rings appear later'. Ring choices now bypass the draft: a
dedicated mutation PUTs the server layout copy with only
selectedScoreRings changed (an open draft's other edits stay unsent,
its ring slice is kept in sync), optimistically updates the widgets
query, rolls back on error, and invalidates the dashboard snapshot on
success. The other layout controls keep the draft/Save flow.

Selected rings are now reorderable with the same drag-handle + arrow
interaction as the widget rows below — the array order is the hero
render order, which the server already preserves. Unselected rings
stay plain switch rows and disable at the three-ring cap.
2026-07-05 18:06:51 +02:00
Marc-André Bombeck 47d638cf02 feat(dashboard): show today's dose tally on the medication hero ring
The MED_COMPLIANCE hero ring carried the pooled 7-day adherence
percentage — a mystery number next to today-focused scores. It now
answers the question the hero poses: how many of today's doses are
done. The resolver reads takenToday/scheduledToday off the snapshot's
own medsToday block (one shared builder call, no second tally); score
stays the rounded 0..100 progress for wire compatibility and a new
additive doses field carries the taken/scheduled pair. No dose
scheduled today means no ring, and the band is progress semantics —
green once everything is taken, yellow while doses remain, never red
(a pending morning dose is not an alert state).

The web ring renders the tally (1/3) with a doses label over the ring
system's constant green arc — the same green family the compliance
surfaces in Insights paint — instead of a band gradient; the derived
rings keep their exact insights hues. ScoreRing gains documented
valueText/ariaLabel overrides for tally-shaped centres. OpenAPI
regenerated; labels land in all six locales.
2026-07-05 18:05:13 +02:00
Marc-André Bombeck 9b4f3ce56d feat(records): surface structured allergies and family history in the doctor report and AI context
The structured Allergy / FamilyHistoryEntry stores reached FHIR export
only: the doctor-report PDF shipped without the two sections every
clinical intake asks for first, and the Coach knew allergies only from
the free-text answer — a penicillin allergy recorded in the structured
records UI never reached either surface.

Doctor report: two new section toggles (allergies, familyHistory; both
default ON, same stance as labs) across the prefs schema, the export
selection, the aggregator, the PDF renderer, and the export panel. The
tables render stored fields only — substance/category/kind/severity/
reaction/status and relationship/condition/age-at-onset; the reaction
decrypts fail-soft per row and free-text notes are never read, matching
the illness-journal stance. OpenAPI regenerated for the extended
sections shape.

Coach: the about-me context block now appends the structured rows as
clearly labelled entries (authoritative over the free-text answer on
conflict); the projection is a pure composer with unit tests. New
section labels land in all six locales.
2026-07-05 17:54:32 +02:00
Marc-André Bombeck 8d36e69f75 refactor(ui): migrate hand-rolled tile headers onto TileHeader
Nine insights/cycle cards hand-rolled their icon+title rows at drifting
sizes and colours: the sleep trio (debt, chronotype, average) and the
glucose clinical panel led with h-4 accent icons, the cycle module
(predictions, BBT chart, symptom patterns) coloured its header icons
text-primary against both header contracts, and the hypnogram,
correlation card, and mood correlation cards carried bespoke title rows.
All of them now compose TileHeader.

TileHeader grows the two documented gaps instead of a fourth header
language: an optional icon (text-only tile titles) and the ONE sanctioned
compact variant size="sm" (icon h-4 w-4, title text-sm) used by the dense
correlation tiles. The sleep-rhythm guard test now pins the tile-header
slot instead of the retired accent icon colour.
2026-07-05 17:42:54 +02:00
Marc-André Bombeck 9a35cfb3e9 fix(ui): route bare read-failure branches through QueryErrorCard
Eight data surfaces still rendered a read failure as a bare
text-destructive paragraph with no retry: the biomarker manager, the lab
values list, the three record managers (allergies, family history,
conditions), the illness episode detail, and the intake history table;
the lab biomarker detail carried an inline near-duplicate of the
primitive from before it existed. Replace all of them with
QueryErrorCard wired to the query's refetch, keeping each surface's own
error string. Inline role="alert" text remains the pattern for mutation
errors inside forms.
2026-07-05 16:33:10 +02:00
Marc-André Bombeck 21b65788ae fix(ui): drop stale padding-era overrides on CardHeader and CardContent
The Card has been a gap-based flex column since v1.4.33 (gap-4 md:gap-6;
CardHeader carries no bottom padding), but 29 CardHeader pb-0/1/2/2.5 and
8 CardContent pt-6 overrides from the padding-era shadcn card survived.
Under the gap model they invert their intent: pb-* renders gap PLUS
padding (looser, not tighter), pb-0 is a no-op, and pt-6 on a header-less
CardContent stacks on the Card's own py-4/6 and makes the card top-heavy
(40-48 px top vs 16-24 px bottom). Delete them all; a genuinely tighter
header-to-body distance is expressed on the Card itself (className="gap-2"),
the only knob that controls it. An ESLint guard for these slots is a
follow-up.
2026-07-05 16:29:34 +02:00
Marc-André Bombeck d3ed9b8225 fix(insights): pass theme tokens instead of raw hex on four HealthKit metric pages
grip-strength, waist, waist-to-height, and pain shipped stock hex chart
colours (#22c55e / #0ea5e9 / #8b5cf6 / #f97316) while every sibling page
passes var(--token) — the four charts ignored both themes and any token
retune. Map them to var(--success) / var(--info) / var(--chart-1) /
var(--warning) and narrow the scaffold's color prop to a `var(--…)`
template-literal type so a raw hex no longer typechecks.
2026-07-05 16:23:24 +02:00