3691 Commits
Author SHA1 Message Date
Marc-André Bombeck 49be6851ec Merge pull request #464 from MBombeck/release/v1.28.20
release/v1.28.20 — estimated drug level in efficacy + insights
v1.28.20
2026-07-10 14:45:44 +02:00
Marc-André Bombeck 349cfa4d80 chore(release): v1.28.20 — estimated drug level in efficacy + insights
The GLP-1 estimated active-substance curve (the modelled drug level from logged
doses) now also renders in the efficacy (Wirkung) tab and in the Insights
medication overview, not only under the injection tab. Both reuse the same
self-contained DrugLevelChart, gated on the GLP-1 treatment class, so the drug
level sits next to the efficacy readouts. Additive — no existing surface changes;
non-GLP-1 medications render nothing.
2026-07-10 14:33:47 +02:00
Marc-André Bombeck 5f558abc33 Merge pull request #463 from MBombeck/release/v1.28.19
release/v1.28.19 — document and card polish
v1.28.19
2026-07-10 14:18:22 +02:00
Marc-André Bombeck 993e693ad6 fix(documents): keep the AI read action + provenance pill, trim only the block title
The detail sheet's AI block over-removed: the read-with-AI action and the
read-provenance pill are both contract surfaces (the action is the explicit read
path when auto-read is off / for an external provider; the pill is the read-state
indicator). Only the redundant block title + wand were the wasted row. Restore
the action and pill; drop just the title. The action row still collapses when
auto-read is on, as before.
2026-07-10 14:06:12 +02:00
Marc-André Bombeck c2c4d56b44 chore(release): v1.28.19 — document and card polish
Documents vault card: date + size and an AI-read marker now sit directly under
the title instead of the meta line running off under the thumbnail.

Document detail view: a larger close control replaces the small X and the
redundant top-right shortcut; the inline read-with-AI row under the preview is
gone (the suggest / summarise / show-text actions stay); a button to ask about
the document sits next to share and download.

Mental-wellbeing cards no longer wash the whole tile on hover — only the title
is the open target, matching the medication cards.

Medication API settings render inline instead of behind an expander that never
had anything beside it.
2026-07-10 13:43:24 +02:00
Marc-André Bombeck 719ba84673 Merge pull request #462 from MBombeck/release/v1.28.18
release/v1.28.18 — Google Health sleep reads its true total
v1.28.18
2026-07-10 13:20:18 +02:00
Marc-André Bombeck ef1b5f5de9 chore(release): v1.28.18 — Google Health sleep reads its true total
Google re-scores a night after the fact, and the sync re-reads recent nights to
pick that up. The per-segment sleep externalId keyed on a volatile anchor (the
session end instant plus a positional index), so a re-read minted fresh ids and
landed as a SECOND parallel copy of the night instead of overwriting the first —
the night-total then summed both (a real 7h35 night could read as 10h+).

Each segment now keys on a stable identity: the session's own resource id (as the
workout mapper already does) plus the segment's own start, dropping the shifting
end anchor and the positional index. A mere re-classification of a block now
updates in place. And before writing a re-read night, the sync soft-deletes any
superseded rows left in that night's window (bounded to the session's own span,
never a fresh row), so the night collapses to one canonical set.

Nights already stored with duplicates heal as they are re-read within the sync
overlap; scripts/repair-google-health-sleep.ts re-syncs the full history at once
for older nights (dry-run by default, --run to execute).
2026-07-10 13:08:55 +02:00
Marc-André Bombeck 3f4a845aae Merge pull request #461 from MBombeck/release/v1.28.17
release/v1.28.17 — dashboard edits stick; share scope honoured
v1.28.17
2026-07-10 12:46:52 +02:00
Marc-André Bombeck 9b0d5dc0b7 chore(release): v1.28.17 — dashboard edits stick; share scope honoured
Dashboard layout changes take effect immediately: the save now evicts the
locale-keyed home-screen snapshot it previously missed, so a re-ordered or
toggled tile set no longer reverts to the prior selection for the SWR window.

A share link built with a narrowed report-section selection now serves exactly
that selection. The stored selection is the grouped export shape; the shared
view read it through the flat parser, which dropped every grouped toggle and
fell back to the full defaults — an over-share against the owner's choice. The
view now folds the grouped shape down faithfully. Documents-only shares were
never affected.

Hardening: the shared-document download path now sanitises the filename
identically to the owner download path, and thumbnail generation re-checks the
pixel ceiling after decode so a malformed image that slips past the header
sniff still cannot exhaust the worker.
2026-07-10 12:36:35 +02:00
Marc-André Bombeck 3cdcb9c97c Merge pull request #460 from MBombeck/release/v1.28.16
release/v1.28.16
v1.28.16
2026-07-10 02:18:42 +02:00
Marc-André Bombeck 6d02711e33 chore(release): v1.28.16 — documents-only shares stay documents-only
A share link created to carry only documents now records an explicit,
frozen document_only flag, and the shared view gates on that flag directly
instead of inferring documents-only from "are all report sections off?".
The inference would have re-opened an existing documents-only link the day
a new report section was added — the link's frozen settings never mention
the new section, so it would default on and begin serving that data. The
flag closes that path; the derived check stays only as the fallback for
links created before the column.

Migration 0237 adds the column and freezes it on for every existing link
whose sections are entirely off (checked at any nesting depth), leaving
full-record shares untouched.
2026-07-10 02:08:03 +02:00
Marc-André Bombeck fcff168112 Merge pull request #459 from MBombeck/release/v1.28.15
release/v1.28.15
v1.28.15
2026-07-10 01:44:10 +02:00
Marc-André Bombeck eb102ac50c chore(release): v1.28.15 — document thumbnails; admin-shared AI access
Document vault shows a per-file thumbnail: a server-rendered preview,
downscaled and re-encoded so location and camera metadata are dropped,
served only to the file's owner over an authenticated request. Files that
cannot be previewed keep their type icon. Generation is bounded — a header
sniff refuses an over-large source before decode, so a crafted image cannot
exhaust the preview worker.

Also folds in admin-managed central AI access — an administrator connects
one access on the server and each user opts in or keeps their own provider,
off until an admin turns it on — and trims the redundant documents-search
caption and per-file searchable/read badges.
2026-07-10 01:32:28 +02:00
Marc-André Bombeck ee76672798 feat(documents): preview thumbnails in the vault overview
Render a small JPEG preview for each stored document and show it at the
card's leading edge, falling back to the kind icon when none exists.

- Generate previews in the background on upload (new document-thumbnail
  pg-boss queue) plus a boot-time corpus backfill for existing documents;
  upload never blocks on or fails because of it. Images are downscaled via
  @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every
  other type is skipped. Generation never throws.
- Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table
  (migration 0236) so the list/detail queries that omit the original blob
  never drag the preview either. Same AES-256-GCM string codec + fail-closed
  decrypt as the content index; wired into the key-rotation walk.
- Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail
  (auth + module gate + userId-scoped query + fail-closed decrypt), always
  image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an
  authed lazy <img>, onError falls back to the icon.
- Add hasThumbnail to the document DTO so the card only fetches a preview
  that exists.

Also drop the "search looks inside your documents" caption from the overview
(the index-all prompt stays) and the per-card searchable marker; both were
noise. Remove the now-unused i18n keys across all six locales.
2026-07-10 01:15:32 +02:00
Marc-André Bombeck e484efb097 Merge pull request #458 from MBombeck/release/v1.28.14
release/v1.28.14 — optional shared central AI access (admin OAuth)
2026-07-10 00:55:10 +02:00
Marc-André Bombeck 0d25f3191a merge admin-central-oauth 2026-07-10 00:39:59 +02:00
Marc-André Bombeck 54c78b527e Merge pull request #457 from MBombeck/release/v1.28.13
release/v1.28.13 — document share is document-only (privacy scope fix)
v1.28.13
2026-07-10 00:38:43 +02:00
Marc-André Bombeck 2706a97838 chore(release): v1.28.13 — sharing a document shares only the document
A link created from a document is now document-only: the create route forces an
empty report scope and the served view never aggregates a report, so the
recipient sees the attached document and no health data. Sharing from Settings
keeps the full record form. Earlier document links carry the full record and
should be revoked and re-created.
2026-07-10 00:28:12 +02:00
Marc-André Bombeck 33bcf18ae6 merge document-only-share fix 2026-07-10 00:20:18 +02:00
Marc-André Bombeck d4965033c2 fix(clinician-share): a document share exposes only the document
Sharing a single document from the vault minted a full-record share: the
create form always defaulted `resourceTypes` to ["Patient","Observation"]
and never signalled a documents-only intent, so the served /c/<token> view
aggregated the whole doctor report — every vital, lab, medication and
wellness figure — alongside the attached file. An empty section blob was
indistinguishable from "defaults", so the report always resolved to the
full record.

Introduce an explicit documents-only share:

- add a `documentOnly` flag to the create payload; when set the server
  forces an empty report scope (all-off sections, no FHIR resource types,
  FHIR API off) and requires at least one document, so a document link can
  never widen into a record — whatever the body sends.
- gate the clinician-view aggregation on an enabled section: a share with
  no section on never calls the report aggregator, so no health metric
  leaves the database. The recipient sees only the attached document(s).
- the document detail Share action opens the create form in documents-only
  mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the
  history-range selector, keep the picker/label/expiry/passphrase, and
  always carry the launched document into the created link.
- Settings sharing keeps the full record-share form unchanged.

Extract the create-payload builder as a pure function and cover the scope
contract, the empty-scope data load, and the served-view metric-free
guarantee with tests; refresh the share e2e and regenerate the OpenAPI
contract.
2026-07-10 00:18:57 +02:00
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00
Marc-André Bombeck fa42c8bc0b Merge pull request #456 from MBombeck/release/v1.28.12
release/v1.28.12 — lab scans use your AI provider; tidier dashboard
v1.28.12
2026-07-09 23:44:43 +02:00
Marc-André Bombeck 82b4c09139 chore(release): v1.28.12 — lab scans use your AI provider; tidier dashboard
Lab-report scanning now uses the configured AI provider, rasterizing a scanned
PDF to images for any non-Anthropic vision provider (parity with the document
vault). Removes the separate lab-scan provider setting and its columns
(migration 0234). The overdue-medication log action stacks directly under its
line on the dashboard instead of far to the right.
2026-07-09 23:34:12 +02:00
Marc-André Bombeck 254de2794c merge labs-ocr-main-provider 2026-07-09 23:30:16 +02:00
Marc-André Bombeck 46f8c71ee5 feat(labs-ocr): read PDFs on the configured provider, retire the separate scan provider
Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.

Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.

Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
2026-07-09 23:29:06 +02:00
Marc-André Bombeck c8e67042a8 Merge pull request #455 from MBombeck/release/v1.28.11
release/v1.28.11 — Strava workouts + Garmin guidance
v1.28.11
2026-07-09 21:42:01 +02:00
Marc-André Bombeck bf6231d79e chore(release): v1.28.11 — Strava workouts + Garmin guidance
Adds a Strava connection (BYO app keys, per-user, mirroring the other
wearables): activities arrive as workouts, de-duplicated against other sources
by the existing source-priority picker, workouts-only. Documents that Garmin has
no direct connector and flows in via Apple Health / Google Health Connect.
Migration 0233; new MeasurementSource STRAVA on /api/sync/changes.
2026-07-09 21:32:05 +02:00
Marc-André Bombeck edf7edc0d0 merge strava-integration 2026-07-09 21:26:50 +02:00
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00
Marc-André Bombeck a3f118cef9 Merge pull request #454 from MBombeck/release/v1.28.10
release/v1.28.10 — onboarding shows the AI value early
v1.28.10
2026-07-09 20:48:10 +02:00
Marc-André Bombeck b9c0fd5b57 chore(release): v1.28.10 — onboarding shows the AI value early
The finish screen shows a clearly-labelled static sample briefing (zero egress)
and lays out setup local-first with a plain 'useful without AI' reminder; the
getting-started checklist gains a matching step that satisfies on connect or a
shared operator key.
2026-07-09 20:37:37 +02:00
Marc-André Bombeck 596e2f12cb merge onboarding-ai-aha 2026-07-09 20:34:04 +02:00
Marc-André Bombeck 0adba584a9 feat(onboarding): make AI insights reachable early without a gate
Close the gap between the welcome carousel's AI promise and the payoff:
a fresh keyless user used to meet "No AI provider connected" on every AI
surface, because the only setup prompt was a skippable card on the final
screen.

Add an "insights" row to the getting-started checklist, derived from real
provider state — any personal key, local model, OAuth sign-in, or the
operator's shared key flips it done via /api/user/ai-provider. It persists
past onboarding, self-satisfies on connect, and dismisses like the others.

Elevate the done-screen AI card into a value-first panel: it leads with a
static, clearly-labelled example briefing (no model call, no egress, no
consent), then the local-first ladder — local model, bring-your-own-key,
subscription sign-in with the training caveat — and the honest line that
HealthLog is fully useful without AI. All three exits stay, so the panel
gates nothing. On a deployment with a shared provider it says so plainly.

Extract the worked example into a shared SampleBriefingCard. Add
onboarding.ai.* copy across all six locales and retire the old
single-card done-screen keys.
2026-07-09 20:32:33 +02:00
Marc-André Bombeck 89634e20ee Merge pull request #453 from MBombeck/release/v1.28.9
release/v1.28.9 — document chat in the coach dialog
v1.28.9
2026-07-09 18:17:00 +02:00
Marc-André Bombeck a53c84bc3f chore(release): v1.28.9 — document chat in the coach dialog
Chatting about a document opens the coach chat dialog scoped to that document,
entered from a neutral icon in the document's top-right (matching insights),
instead of a separate inline panel. The document chat's stricter footing is
unchanged — one document only, no health-record context, no tools, plain text.
2026-07-09 18:05:28 +02:00
Marc-André Bombeck 25ac037004 merge documents-coach-unify 2026-07-09 18:01:23 +02:00
Marc-André BombeckandClaude Opus 4.8 dc75ae69ae feat(documents): move document chat into the Coach drawer, neutral header entry
Replace the collapsed bottom-of-card "Chat about this document" outline
button with a neutral, ghost Coach icon pinned to the top-right of the
document detail-sheet header, mirroring the Insights ask-coach affordance.
The icon uses the muted-foreground token only — never the primary/FAB
gradient — and opens a document-scoped chat drawer.

Present the chat in the Coach chat drawer chrome (right-side sheet on
desktop, bottom sheet on phones; same width caps; close-only, no maximize
or conversations rail) with a quiet "Chatting about: <document>" scope
badge. The drawer reuses the existing pure conversation body and the
existing client hooks, so it still posts /api/documents/inbound/[id]/chat
verbatim: no tools, no health snapshot, fenced document, inbound/replay
refusal, outbound screen, numeric grounding, consent + budget + per-user
rate bucket, indexed-only, plain-text render — the route is untouched.
Thread isolation is unchanged (the general Coach filters documentId: null;
the document route always scopes { documentId: id }).

Add a small reusable headerAction slot to ResponsiveSheet for the header
icon. Retire the DocumentChatPanel container and the AI-section chatSlot;
keep the pure DocumentChatConversation body and documentChatErrorKey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPyLteNeTWwEC154X7vLWc
2026-07-09 18:00:06 +02:00
Marc-André Bombeck c07ffe78c0 Merge pull request #452 from MBombeck/release/v1.28.8
release/v1.28.8 — documents automatic when set, cleaner when not
v1.28.8
2026-07-09 17:43:25 +02:00
Marc-André Bombeck 0c72a5c8c6 chore(release): v1.28.8 — documents automatic when set, cleaner when not
When automatic AI reading is on, the per-document AI action buttons no longer
appear (reading happens on upload); they return when it is off. The
AI-suggestion review drops its heading and grey sub-caption.
2026-07-09 17:32:36 +02:00
Marc-André Bombeck 9e5d84ef66 merge auto-read-buttons 2026-07-09 17:29:02 +02:00
Marc-André Bombeck e5de72cae2 feat(documents): hide manual AI actions under auto-read, trim suggestion chrome
When "read documents automatically with AI" is on, the detail sheet no longer
shows the manual per-document action row (Read / Suggest / Summarise) — reading
already happens on upload, so the buttons only nagged. The section header and
the searchable status pill stay, and content search keeps working. With the
setting off the manual controls render exactly as before.

Drop the "review before saving" heading and its grey sub-caption from the
suggestion panel; the reviewable fields and the apply / dismiss controls stay.
Remove the now-unused documents.assist.reviewTitle / reviewHint keys from every
locale.
2026-07-09 17:27:47 +02:00
Marc-André Bombeck 6f439b1572 Merge pull request #451 from MBombeck/release/v1.28.7
release/v1.28.7 — scanned-PDF reading works (un-bundled pdfjs)
v1.28.7
2026-07-09 16:55:41 +02:00
Marc-André Bombeck 73c694e308 test(documents): drop the retired not-saved note assertion from the summary e2e
The per-view 'not saved · not a diagnosis' note was removed from the document
summary panel; the e2e no longer asserts it, keeping the transient/discard
contract.
2026-07-09 16:45:02 +02:00
Marc-André Bombeck a89e511359 fix(documents): run pdfjs + canvas un-bundled so scanned PDFs actually rasterize
The bundled (Turbopack) copy of pdfjs-dist failed its render path in the
standalone image with a bare Error, so a scanned PDF on a non-Anthropic
provider fell back to 'PDF scanning needs a Claude vision provider' — while the
identical un-bundled module rasterizes the same document fine (verified in the
container end to end on the real document). Externalize pdfjs-dist and
@napi-rs/canvas so the server loads the real modules, and hoist pdfjs-dist to
the top level in the image so the runtime import resolves. Also log the render
error message (not just its name) and drop the redundant 'not saved' note from
the document summary panel.
2026-07-09 16:22:00 +02:00
Marc-André Bombeck 8246d9251c Merge pull request #450 from MBombeck/release/v1.28.6
release/v1.28.6 — fix scanned-PDF reading in the container
v1.28.6
2026-07-09 15:14:38 +02:00
Marc-André Bombeck 0e7514f7b0 fix(documents): hoist @napi-rs/canvas so pdfjs can load it in the standalone image
PDF rasterization failed at runtime — pdfjs-dist does a bare
require('@napi-rs/canvas') from its own directory, and the standalone image had
no top-level @napi-rs/canvas for Node's upward resolution to find, so pdfjs
could not polyfill DOMMatrix and every scanned-PDF read fell back to
'PDF scanning needs a Claude vision provider'. Hoist the canvas package and its
musl binary to /app/node_modules/@napi-rs via symlink into the pnpm store (the
prior per-package .node copy only fixed canvas's own loader, not pdfjs's
require). Verified in the container: resolves from pdfjs and renders.
2026-07-09 14:40:12 +02:00
Marc-André Bombeck 9672b7d8f4 Merge pull request #449 from MBombeck/release/v1.28.5
release/v1.28.5 — subscription-AI document reading + surface cleanups
v1.28.5
2026-07-09 13:59:58 +02:00
Marc-André Bombeck d5f279a248 chore(release): v1.28.5 — read documents with a subscription AI, cleaner surfaces
Document reading no longer treats a signed-in subscription model as unable to
see images, so 'read with AI' works on that path instead of erroring; scanned
PDFs and photos are read the same way. Drops two now-redundant blurbs from the
document panel and, on the recovery insights page, moves each metric's
explanation into its own card instead of repeating the heading above it.
2026-07-09 13:47:20 +02:00
Marc-André Bombeck 177029a80d merge oauth-vision-and-cleanups 2026-07-09 13:43:29 +02:00
Marc-André Bombeck 97f8cb4303 fix(ui): trim document-read copy and de-duplicate recovery headings
Remove the two per-document AI copy blocks in the document detail sheet:
the external-egress notice and the "read this document" explainer. The
settings-toggle honesty confirm already states the egress trade, so the
per-document repeat is redundant. Drop the now-unused `egressExternal`
prop and the `documents.ai.egressNotice` + `documents.ai.available` keys
across all six locales. Keep the capability `egress` DTO field — the API
still reports it.

On /insights/recovery, the metric block rendered an outer heading plus an
explainer above a tile whose own header repeated the same title. Move the
explainer inside the tile as a `description` beneath its `TileHeader` and
promote the tile title to a real `<h2>` (new `titleAs` affordance), so each
metric shows one heading with the blurb under it and the h1->h2 outline
stays nested. Applies to every metric the block renders.
2026-07-09 13:42:14 +02:00