Commit Graph
1812 Commits
Author SHA1 Message Date
Marc-André Bombeck 23ded20306 fix(pages): replace bare auth-gate spinners with a layout-reserving skeleton
Introduce a shared PageAuthGate (header + tile skeleton) and adopt it on the
top-level pages that flashed a lone centered spinner while auth resolved:
mood, measurements, achievements, illness, mental-wellbeing, checkups, cycle,
and the labs / medications / custom-metrics / illness detail routes. The
auth-resolving frame now reserves the page geometry instead of snapping in
after a spinner.
2026-07-09 00:23:13 +02:00
Marc-André Bombeck 00140e509e docs(documents): correct extract-route gauntlet comment for new gate 2026-07-09 00:10:20 +02:00
Marc-André BombeckandMarc-André Bombeck c825d4b501 feat(documents): per-egress notice + document-AI capability probe
Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:59:56 +02:00
Marc-André Bombeck 4c158f6abc feat(integrations): per-metric-type last-value freshness signal
Freshness was tracked only per-integration, so a provider returning a
healthy 200 with an empty body for ONE metric type recorded no failure and
the whole integration stamped green — a metric dead since launch was
indistinguishable from a healthy-idle one. Compute the last-value
timestamp per (source, type) straight from the Measurement table (no
migration) and surface it per integration on the status endpoint, so the
Settings card can flag a silently-dead metric the green pill hides. The
card UI badge + per-cadence staleness classification are the deferred
remainder.
2026-07-08 23:55:35 +02:00
Marc-André Bombeck 188662311a fix(custom-metrics,medications): shared error cards, client nav, themed delete
- CustomMetricList load error uses QueryErrorCard with retry; post-create
  navigation switches from a full-page window.location.assign to router.push.
- CustomMetricDetail + medications list swap their hand-rolled error blocks
  for the shared QueryErrorCard.
- Side-effect delete now uses the themed AlertDialog confirm instead of a
  native window.confirm.
2026-07-08 23:55:19 +02:00
Marc-André BombeckandMarc-André Bombeck c1e11e284a test(documents): update vault AI route mocks for document provider order
Point route + backfill test mocks at the document-scoped resolvers and the
document-egress consent gate.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:51:50 +02:00
Marc-André BombeckandMarc-André Bombeck 8e5f5f04ae feat(documents): route vault AI surfaces through document provider order
Repoint suggest/summary/extract/index/reindex routes and the content-index
backfill job to the document-scoped resolvers (local-first, codex last) and the
pick-based document-egress consent gate. Labs OCR and Coach/insights untouched.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:49:30 +02:00
Marc-André Bombeck dbc2b15b1b fix(coach,insights,achievements): honest error states + Plans discoverability
- Achievements: a failed load renders QueryErrorCard instead of falling
  through to zeroed summary tiles and a 'none unlocked yet' empty grid.
- /coach/conversations: a load failure now shows a retry instead of a false
  'no conversations yet' empty state.
- Insights overview error uses the shared QueryErrorCard.
- Coach Plans gains a header cross-link from /coach/conversations (and back),
  a skeleton loading state, and the shared EmptyState primitive.
2026-07-08 23:44:10 +02:00
Marc-André Bombeck 329f78d295 perf(import): batch CSV measurement upserts instead of one round-trip per row
The CSV importer ran a serial prisma.upsert/create per accepted row, so
an N-row file cost N round-trips and a few-thousand-row import could
approach the request timeout. Adopt the /measurements/batch shape:
pre-fetch existing rows under the IMPORT source in one query per
dedup-key shape (externalId and natural-key paths), partition into bulk
createMany + grouped updateMany, and reconstruct the per-line
inserted/updated/duplicate envelope from the partition. In-file repeats
and re-import idempotency preserved; response contract unchanged.
2026-07-08 23:43:11 +02:00
Marc-André Bombeck 2e84498cf3 fix(notifications): repoint channel CTA, add error/loading primitives, cross-link surfaces
- The 'no channels' CTA now points at Settings → Integrations#channels
  (where channels are actually configured since v1.25.7) instead of the
  reminder-types screen, which had become a dead-end.
- A failed load renders the shared QueryErrorCard with Retry; the loading
  frame reserves a matrix skeleton instead of a lone spinner.
- The globally-disabled hint reads as a full sentence rather than the bare
  '(Disabled)' inline tag.
- The three notification surfaces (per-event matrix, reminder types,
  delivery channels) now cross-link each other.
- noChannels copy names its destination.
2026-07-08 23:39:59 +02:00
Marc-André Bombeck 2dc9947de2 fix(dashboard): link metric tiles to Insights detail + unify tile header
- Dashboard metric tiles link to their /insights/<slug> detail page
  (weight, blood pressure, pulse, glucose, mood, sleep, steps, VO2 max);
  tiles with no dedicated sub-page stay un-linked rather than misroute.
- Daily-briefing key findings now route hrv, resting HR, active energy,
  flights, distance, body temperature and recovery to their sub-pages —
  the METRIC_HREF map had gone stale as those routes shipped.
- RecentWorkoutsTile adopts the canonical TileHeader (foreground icon +
  title) instead of a muted hand-rolled row.
- Hero-strip header glyph moves off text-primary to the foreground tier.
2026-07-08 23:35:41 +02:00
Marc-André Bombeck ced4e45272 docs(self-hosting): reconcile DATABASE_URL/POSTGRES_PASSWORD guidance with the compose contract
The bundled compose constructs DATABASE_URL inside the app service from
POSTGRES_PASSWORD, so the getting-started 'edit CHANGE-ME' step was a
no-op and .env.example's literal POSTGRES_PASSWORD collided with the
quick-start's appended one. Comment both out in .env.example so the
append pattern yields a single key, and rewrite step 2 to state compose
derives the URL. Add a first-user note to close public registration on
an internet-exposed host and to save the password (no-SMTP CLI reset).
Refresh the stale onboarding step-2 source comment.
2026-07-08 23:34:50 +02:00
Marc-André Bombeck eed705be7b fix(documents): make auto-index enqueue never fail a stored upload
The upload route awaited an unguarded enqueueDocumentIndex after the
document was already committed. A transient boss.send rejection (a DB
hiccup) would then surface as a 500 for an upload that in fact stored the
file. Guard the send inside the enqueue (a failure annotates and no-ops,
matching the missing-boss path) and drop the await at the call site so the
response never blocks on or fails because of background indexing.
2026-07-08 21:56:17 +02:00
Marc-André Bombeck 0b973f4f76 test(documents): mock content-index lookup and pair new animations with motion-reduce
The PATCH detail route now reads the content-index provenance, so its route
test mocks documentContentIndex.findUnique; the searchable status spinner and
the Read-with-AI pulse pair with motion-reduce:animate-none per the coverage
guard.
2026-07-08 21:41:49 +02:00
Marc-André Bombeck bb0ebb1dce feat(documents): expose content-index provenance on the document DTO
Surface the content-search index's source (vision vs local extraction) on
the list + detail DTO so the vault UI can tell an AI-read document from a
locally-indexed one. Threads the source through both read paths and the
serialisers, regenerates the OpenAPI contract, and adds a narrowing helper
for the free-String column.
2026-07-08 21:15:52 +02:00
Marc-André Bombeck 5e253313fc feat(documents): auto-index uploads for content search, provider-first with local fallback
Index a document for content search automatically the moment it is stored,
instead of requiring a manual index action. A fire-and-forget per-document
job runs an AI-first decision tree: when a vision-capable provider is
configured and consented, transcribe the stored original (rich, handles
scanned PDFs and images), budget-reserved exactly like the interactive index
route; otherwise fall back to server-side text-layer extraction via pdf-parse
(no provider, no egress, milliseconds) so content search works at a baseline
even with no AI. Budget exhaustion, a provider that cannot read the file
(a text-layer PDF on a non-Anthropic account), and provider errors all fall
through to the free local path rather than leave a document unsearchable.

Both paths write the same blind, encrypted content index (AES-256-GCM text +
opaque HMAC token tags) — nothing readable at rest. The local path adds the
'local-pdf' / 'local-ocr' source values (free String column, no migration).
Server-side OCR for scanned images and scanned PDFs is left as a documented
seam. Pin pdf-parse + pdfjs-dist into the standalone trace so the wasm text
extractor ships in the runtime image.
2026-07-08 20:50:30 +02:00
Marc-André Bombeck abb2db37ee test(dashboard-hero): cover hero-ring-order default + reorder; i18n hint
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.
2026-07-08 01:23:58 +02:00
Marc-André Bombeck abe688caba feat(dashboard-hero): health-score-first default + reorderable hero rings
Add a persisted hero ring ORDER over the health-score anchor ring plus the
selected score rings. Defaults to the health-score ring on the leading edge;
the user can reorder the full set (health score included) from Settings →
Darstellung with the existing drag-handle + arrow-button pair.

The order rides the dashboard-layout blob (dashboardWidgetsJson) — no
migration. resolveHeroRingOrder reconciles a stored order against the current
ring set on every read/serialize, so a deselected ring drops and a
newly-selected ring appends; the hero orders only the rings it actually
renders.
2026-07-08 01:18:00 +02:00
Marc-André Bombeck fdc0f83a47 feat(documents): share document picker, clinician document list, mobile QR 2026-07-07 21:15:19 +02:00
Marc-André Bombeck 9ff39b5846 Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated
# Conflicts:
#	prisma/schema.prisma
2026-07-07 21:03:04 +02:00
Marc-André Bombeck 1c0c194820 fix(documents): scope the content-index gauge to live documents
The usage endpoint counted every DocumentContentIndex row for the user,
including those whose parent document has been soft-deleted (the index
row survives until the hard purge). Against totalCount, which filters
live documents, the gauge could read past 100 %. Scope the indexed count
to live documents so coverage never exceeds the corpus.

Also drop a redundant identical-branch ternary in the per-document
content-index status row.
2026-07-07 19:31:41 +02:00
Marc-André Bombeck 2653647f97 style(documents): prettier-format the list route test
P2-1. Formatting-only follow-up on the content-index mock additions.
2026-07-07 18:45:48 +02:00
Marc-André Bombeck 0885f331b0 test(share-links): pin the public share document serve route
P3-1-T4. Cover token confinement to the frozen set, EXIF strip on egress,
Class A inline vs Class B attachment posture, HTML/SVG never inline, foreign /
soft-deleted / owner-mismatch id → 404, revocation-and-passphrase-before-
decrypt, per-link rate limit, and fail-closed decrypt.
2026-07-07 18:45:00 +02:00
Marc-André Bombeck 844dc8457d feat(share-links): attach documents and serve them through the share token
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded
documentIds set; validate each id as the caller's own live document and create
the membership rows in the same transaction (frozen write-once). Summary DTOs
gain documentCount (never ids, never bytes).

Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved
(never requireAuth), passphrase-gated via the token-scoped unlock cookie,
revocation/expiry re-checked before any decrypt, confined to the link's frozen
document set (foreign/deleted id → flat 404), fail-closed decrypt, identical
serving-class posture as the owner route (inline Class A / attachment Class B,
nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate
limit.

Carve the serve path out of the proxy's blanket DENY posture with the same
document CSP as the owner route (default-src 'none'; frame-ancestors 'self'),
anchored to exactly two dynamic segments so no lookalike over-matches.
2026-07-07 18:43:13 +02:00
Marc-André Bombeck 1f7807aead test(documents): wire content-index mocks into the list route test
P2-1. The list GET now issues a documentContentIndex lookup and unions a
content-token OR branch when a search term is present, so the existing route
test gains the mock + the third OR branch in its where assertion. Fix the
backfill test's findFirst mock signature under strict tsc.
2026-07-07 18:41:09 +02:00
Marc-André Bombeck b210c743e9 test(documents): cover content index, assist/summary/index routes, backfill
P2-1. Unit tests for the token scheme (whole-word match, stopword drop,
de-accent, no plaintext at rest, subkey follows the active key), HKDF subkey
derivation, the suggest route (drafts only, writes nothing, provider-gated),
the summary route (session-only, budget+audit only), the index route (vision
transcribe→upsert, text posts OCR text, provider/local-OCR gated), and the
backfill (no-provider/no-consent no-op, indexes, budget-reached resume).
2026-07-07 18:29:02 +02:00
Marc-André Bombeck 5a0fd47eb5 feat(documents): content-index backfill job, rotation coverage, OpenAPI
P2-1-T7/T8/T9.

- document-content-index-backfill: on-demand per-user pg-boss job that indexes
  a user's not-yet-indexed image/PDF documents via one provider transcription
  each. Consent + budget gated, id-cursor forward walk, capped per run, stops
  on budget exhaustion, resumable. Queue + worker registered in the maintenance
  registrar (no cron — deliberate action only). POST /reindex triggers it.
- rotation: register DocumentContentIndex.textEncrypted and add a dedicated
  block that re-encrypts the text AND re-tokenises the blind index under the new
  active HKDF subkey in one update (P2-D7).
- OpenAPI: suggest / summary / index / reindex paths; hasContentIndex on the
  document schema; assistAvailable + contentIndex on usage. Regenerated
  docs/api/openapi.yaml.
2026-07-07 18:21:51 +02:00
Marc-André Bombeck 85a343a68c feat(documents): assist, summary, content-index endpoints + search union
P2-1-T2/T3/T4/T5/T6.

- assist.ts + [id]/suggest: one provider call proposes a { title, kind,
  documentDate } filing draft over the stored original or browser-OCR text.
  Runs the full extract gauntlet (module → provider → consent → rate-limit →
  budget) but writes nothing — never touches the fact rail or status.
- describe.ts + [id]/summary?mode=summary|text: session-only summary or raw
  transcribed text. Descriptive only, no diagnosis; persists only the budget
  ledger + audit.
- content-index.ts + [id]/index: normalise → tokenise (de-accent, stopword
  drop, whole-word) → HMAC-SHA256 under an HKDF-derived subkey → store the
  token array plus AES-256-GCM ciphertext of the text. Vision path transcribes
  via the provider (consent + budget); text path indexes posted local-OCR text
  (no egress). Gated on the existing AI consent — no new per-user toggle.
- crypto.deriveSubkey: HKDF-SHA256 purpose-bound subkey off the active key.
- list search now unions title/filename ILIKE with a GIN-accelerated
  whole-word content-token match; DTO gains hasContentIndex.
- usage gains assistAvailable + contentIndex { enabled, indexedCount,
  totalCount }; detail gains hasContentIndex.
2026-07-07 18:17:13 +02:00
Marc-André Bombeck 154d1cfebe Merge branch 'fix/hero-polish' into release/v1.27.21 2026-07-07 17:32:27 +02:00
Marc-André Bombeck 6f1833eb10 fix(dashboard): anchor the hero greeting and unify the ring green
The greeting drifted to the vertical centre of the taller ring column
and the briefing sat a full step below it — the top row now aligns to
its start and the section gap tightens, so the greeting anchors to the
top edge and the briefing follows closely.

The band-fallback ring green landed on its own hue (#3fb868) while every
positive signal and trend delta beside it used --success (#50fa7b); a
green ring now lands on --success too, so 'green' reads as one green
across rings, signals, and deltas. Computed contrast 11.9:1 dark /
6.1:1 light — well past the graphics floor.
2026-07-07 16:19:14 +02:00
Marc-André Bombeck 4760f6695d Merge branch 'fix/med-late-intake-slot' into release/v1.27.18 2026-07-07 15:58:10 +02:00
Marc-André Bombeck ad503c45c0 fix(medications): keep a late take off a future slot the card supplied
A twice-daily card advances its display-due to the evening slot once the
morning slot's catch-up window has lapsed, so a late-morning "Genommen"
tap posts `scheduledFor = <evening slot>` with `takenAt = now`. Band
attribution correctly refuses the take (it lands in no window), but the
intake routes' source-agnostic convergence probe then bound it to the
evening slot's pending REMINDER row — recording the morning dose as the
evening one, silently consuming a slot the user had not reached, and
jumping the dashboard "next intake" to the following day.

Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken
write may not converge onto a slot whose anchor is in the future relative
to the take (the same forward-grace invariant `resolveCanonicalSlotInstant`
already enforces). Off-window late takes now record standalone (ad-hoc),
leaving both real slots intact; skips and pending echoes still name their
slot deliberately. Applied to both the per-medication and bulk intake
routes.
2026-07-07 15:55:50 +02:00
Marc-André Bombeck 03058b0210 Merge branch 'feat/vault-w4-qa' into release/v1.27.17 2026-07-07 15:10:45 +02:00
Marc-André Bombeck d937991af4 fix(documents): source the condition chips from the usage endpoint
The filter bar derived its condition chips from the loaded corpus, so an
episode whose only linked documents sat pages deep in the timeline never
surfaced a chip — the condition route into an older document silently
disappeared at four-digit corpus sizes. The usage endpoint (already read
on page mount) now returns `linkedEpisodes` — every episode carrying at
least one live document link, one indexed grouped query, no blobs — and
the view unions it with the active-filter fallback. Additive DTO change;
OpenAPI regenerated.
2026-07-07 14:15:40 +02:00
Marc-André Bombeck 6bab2ce774 fix(documents): serialise the upload quota gate per user
Concurrent uploads all read the same pre-insert usage SUM, so a burst
could overshoot the per-user quota by rate-limit x cap in one window. A
transaction-scoped advisory lock keyed on the user id serialises the
sum-and-insert; uploads by different users never queue on each other.
Integration probes pin both races: four parallel uploads against a
two-file quota land exactly two, and two racing identical uploads
collapse onto one row via the partial unique index.

Also aligns the round-trip integration test with the serve-route CSP
posture: the proxy carve-out owns the response CSP, the route sets none.
2026-07-07 14:00:38 +02:00
Marc-André Bombeck 23e371965b fix(documents): consume the ?doc= deep link during render, not in an effect
The one-shot deep-link consumption moves to the render-phase derived-
state pattern the view already uses for its search draft (the effect
variant trips the set-state-in-effect rule), gated on resolved auth so
a deep link never burns before the module flag is known.

Refs: document-vault plan W3-T4
2026-07-07 13:46:02 +02:00
Marc-André Bombeck 0d37061d43 feat(admin): document-vault limit fields on the admin surfaces
The two vault knobs become editable where operators already work: the
app-settings card gains the per-file upload cap (MB, 1-100, mirrors the
hard server ceiling) and the default per-account storage quota (GB,
commit-on-blur with clamped conversion to bytes); the user edit form
gains the per-user quota override (GB, empty = instance default). The
admin users list now carries the override so the form can show it.

Refs: document-vault plan W3-T7
2026-07-07 13:34:55 +02:00
Marc-André Bombeck 0ade02e163 feat(documents): labs and health-record entry points into the vault
/labs gains a quiet link to the LAB_RESULT-filtered vault view (the
original report PDFs behind the values); the health-record export panel
links to the vault filtered to the current year next to its date-range
picker (navigation only — attaching documents to the export is out of
scope here). Both render only when the documents module is enabled for
the account, in all six locales.

Refs: document-vault plan W3-T5
2026-07-07 13:30:46 +02:00
Marc-André Bombeck 016b83da1d style: prettier pass over the chart-loader touchpoints
Formatting-only: the chart-runtime import repointing left five files
with non-canonical line breaks.
2026-07-07 13:21:36 +02:00
Marc-André Bombeck 9f2a3e25fe perf(i18n): generate the catalog boot scripts as static assets
The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.

scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.

Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.

H2
2026-07-07 13:15:37 +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 cb9d61a311 perf(dashboard): server-prefetch the snapshot into the first HTML + bundle budget gate
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.

src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.

Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.

H4
2026-07-07 12:51:18 +02:00
Marc-André Bombeck e24db8a889 perf(i18n): stop shipping two full catalogs on every page load
Two independent every-load costs, one per layer:

1. The static EN import in load-locale.ts put the full en.json catalog
   (334 KB raw / ~106 KB gz) into the client JS of EVERY route, for
   every user in every language. No catalog is statically imported by
   client code anymore: the EN fallback floor of the t() chain loads
   lazily on the first actual key miss (the locale-integrity guard
   keeps every locale key-complete, so the miss path is drift-only),
   and the labs biomarker canonical-name lookup fetches EN on demand.

2. The root layout serialized the ENTIRE active-locale catalog into the
   RSC flight payload of every document (392 KB of a 505 KB dashboard
   HTML, re-downloaded on every hard load, uncacheable). The handoff is
   now a versioned, immutable-cacheable boot script
   (/i18n/<locale>?v=<build>, new route handler) that assigns
   self.__HL_I18N before the hydration scripts run, plus a server-side
   cache seed in load-locale.ts behind a typeof-window branch that
   Turbopack compiles out of the browser bundle (verified: zero catalog
   bytes in any client chunk, zero route manifests referencing one).
   SSR text stays correct and the first client render holds the same
   bundle the server rendered with — no EN flash, no hydration drift.

The boot script is public (proxy allowlist: the login page needs it
pre-auth; the body is the same catalog JSON that ships in the public
repo) and cache-first in the service worker, so an offline relaunch
finds it next to the cached shell.

Measured (client-reference manifests, gzip): route JS baseline
/ 391->287 KB gz, /insights 377->273, /measurements 379->275,
/insights/mood 394->290 — the ~104 KB gz EN catalog left every route.

H2
2026-07-07 12:34:26 +02:00
Marc-André Bombeck e260b2d5c4 perf(charts): one shared async boundary for the whole recharts surface
Every next/dynamic chart import used to point at its own module, so
Turbopack emitted a separate chunk group per boundary — the production
build carried EIGHT ~312 KB copies of the recharts module graph, and
/insights (the worst-LCP route) downloaded two of them (~92 KB gz of
pure duplication, one fully unused at load).

All chart loaders now resolve the single chart-runtime barrel
(src/components/charts/chart-runtime.ts), which owns the only path to
recharts: one shared chunk group, recharts exists exactly once in the
build (verified: 8 -> 1 recharts-fingerprinted chunks), cached across
routes. Charts stay recharts and stay pixel-identical; every call site
keeps its own matching skeleton and ssr:false flag.

Riders of the same rule:
- the mood-trio barrel (mood-charts.ts, the v1.16.7 precedent this
  generalises) folds into the runtime and is removed
- the vitals sparkline body extracts to delta-sparkline.tsx and loads
  through the runtime; the tile keeps the fixed 40 px band, so the
  async gap is an identically-sized empty box
- /cycle's BBT chart (the last static recharts import on a live route)
  defers through the runtime with a card-matching skeleton

H3
2026-07-07 12:32:43 +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 5cc35aae6e Merge branch 'fix/google-health-coverage' into release/v1.27.15 2026-07-07 11:24:46 +02:00
Marc-André Bombeck 849607e7df fix(google-health): reconcile the daily-summary filter and two leaf fields with the v4 reference
Daily HRV and resting heart rate returned HTTP 200 with zero imports on a
live Fitbit-band account. Full reconciliation against the v4 reference
(data-types index, DataPoint schema, list/reconcile/dailyRollUp pages)
surfaces three defects:

- The daily-summary `.date` filter prefix is the docs' own contradiction:
  the data-types index's filter-parameter column says snake_case
  (`daily_heart_rate_variability`), while the list reference's only worked
  daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the
  camelCase payload key. Live, the snake form is accepted (200) but matches
  nothing. Send the worked-example camelCase form first and re-walk once
  with snake_case if the very first page 400s; annotate the accepted style
  (`googleHealth.dateFilter.style`) and surface it through the structure
  probe as `requestShape` so a live account settles the grammar.
- `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the
  previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so
  RESPIRATORY_RATE never parsed a single row.
- `height` carries `heightMillimeters` (int64 string), not `heightMeters`;
  the heightCm profile seed never fired. Convert mm -> cm.

Also cover the documented Exercise.ExerciseType catalogue in the sport map
(MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL,
FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values
previously fell through to "other".

Tests pin the documented field names per fixture, the camel-first/snake-
fallback walk (including no-fallback for sample shapes and unfiltered
backfills), and the extended sport map. mapping.md records the filter
contradiction and the corrected leaves.
2026-07-07 11:16:27 +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 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