2659 Commits
Author SHA1 Message Date
Marc-André Bombeck 5445fc67ea chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
2026-07-14 13:10:47 +02:00
Marc-André Bombeck 6d8fe06148 chore(release): v1.28.33 — re-importing a cumulative Apple Health export works again
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.

The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
2026-07-14 11:56:29 +02:00
Marc-André Bombeck a70139c16a chore(release): v1.28.32 — Fitbit re-keyed re-imports can no longer be silently dropped
Single-fix release mirroring the Google Health natural-key rescue from
v1.28.28 into the Fitbit sync. The measurements natural-key unique index
(user, type, measured_at, source, sleep_stage — NULLS NOT DISTINCT,
migration 0055) covers tombstoned rows, so a re-import carrying NEW
externalIds for previously-removed rows collided on the natural key and
skipDuplicates dropped the insert silently. Every planned create now gets
a second chunked probe by natural key with no deletedAt filter; a match —
live or tombstoned — is updated in place with the fresh value, the new
externalId, and deletedAt: null. Probe failure notes the hard-failure
ledger so the watermark holds and the next tick retries. No other code
path touched.
2026-07-12 20:30:12 +02:00
Marc-André Bombeck 1375d20a9d chore(release): v1.28.31 — heart-rate history keeps its daily shape
Long-term retention for the dense intra-day tier (PULSE, HRV, SpO2) folds
to per-local-hour means instead of a single daily mean: the last 14 days
keep every raw sample, older days keep up to 24 points per metric — the
day's shape survives permanently at ~9k rows/metric/year. Hourly rows key
as stats:<HK>:<YYYY-MM-DD>T<HH> anchored at local HH:30, adopt-in-place
against both unique indexes with the P2002 retry outside the transaction.
The pre-fold DAY-rollup recompute extends to HRV (mean fidelity, not only
min/max) and the post-fold recompute is gone for the whole tier. Derived
resting rows for proxy users still mint from the day's raw samples.

A one-shot rebuild reconstructs the hourly shape for every already-folded
day from the soft-deleted raw samples and retires the daily row in the
same transaction — at no instant are both grains live, so no
AVG-over-live-rows reader can double-count. Boot discovery pairs live
daily-grain rows with surviving tombstones (parameter-bound SQL, pinned
against real Postgres) and converges to zero once converted; days whose
tombstones were pruned keep their daily mean. PULSE/SpO2 DAY buckets keep
the true pre-fold stats; HRV DAY buckets recompute from the hourly rows
(strictly better than the collapsed fold-time bucket); WEEK/MONTH/YEAR
respans enqueue whole-bucket recomputes.
2026-07-12 12:07:41 +02:00
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00
Marc-André Bombeck ae57025728 chore(release): v1.28.29 — dashboard edits show up on the way back; one scrollbar again
The third and final layer of the stale-tile bug: saving invalidated the
dashboard snapshot with the default refetchType 'active', but the dashboard
page is unmounted while the user sits in settings — the cell only went stale,
and the snapshot query's deliberate refetchOnMount: false then suppressed the
re-read on same-tab navigation back. The save, reset and ring mutations now
invalidate with refetchType 'all', refreshing the cell mounted or not
(proven live in one SPA session against the real query client).

The sortable settings editors (dashboard layout, medication order, modules,
mood tags x2) painted a second page scrollbar: the sr-only drag hint below
each list is position: absolute, no ancestor was positioned, so it resolved
against the initial containing block, escaped the shell's overflow clipping
and lengthened the DOCUMENT by its static offset. The list wrappers are now
relative; the e2e overscroll guard asserts the one-scroll-floor on these
routes and was negative-tested against the revert.
2026-07-11 12:04:53 +02:00
Marc-André Bombeck 8ee9c53b83 fix(dashboard): refetch the snapshot on window focus
The dashboard is exactly the surface a user leaves open in a background tab
while editing its tile selection elsewhere; with focus refetch off, the stale
layout sat visible for up to the poll interval — a save appeared to do
nothing, then 'worked' minutes later. staleTime still bounds the refetch to
once a minute and the server side is SWR-cached, so a focus flick is a cheap
cache hit. Chart batch series stay on the poll (heavier payloads; the tile
selection itself lives on the snapshot query).
2026-07-11 10:39:24 +02:00
Marc-André Bombeck 13464b512e chore(release): v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.

The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.

Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
2026-07-11 10:36:02 +02:00
Marc-André Bombeck 45da6c3c7d chore(release): v1.28.27 — runs on CPUs without AVX2
The bundled Skia x64 build (document thumbnails, scanned-PDF rasterization)
uses AVX2 instructions; on an x86-64 host without them — Celeron/Atom-class
NAS CPUs, pre-2013 Xeons, VMs masking CPU flags — the first canvas call is an
uncatchable SIGILL that kills the process and crash-loops the container (live
incident: a Synology DS1520+, exit 132 ~18 s after every boot, reported with
kernel traps and the correct root cause in hand).

The renderer is now gated on a one-time CPU-feature check before the module
ever loads: linux/x64 requires the avx2 cpuinfo flag; arm64 and non-Linux are
unaffected; unreadable cpuinfo fails open. Behind a closed gate, thumbnail
generation and PDF rasterization take their existing fail-soft paths (vault
shows type icons; PDFs read as text) and the boot-time thumbnail backfill
skips enqueueing. NATIVE_CANVAS=off|on overrides detection, documented in the
env example and whitelisted in compose.
2026-07-11 10:02:29 +02:00
Marc-André Bombeck 5433fc8fbd chore(release): v1.28.26 — internal restructuring for maintainability
No user-facing change. Eight of the largest source files split along their
natural seams into focused modules, every move verbatim with stable import
paths:

- mood analytics calculators (mood-tag-influence / mood-crosstab /
  mood-better-days / mood-patterns; hub 2229 → 770 lines)
- insight status-invalidation machinery (status-invalidation.ts)
- feature extraction (feature-blocks / signals-of-day)
- doctor report types + pure helpers (doctor-report-types /
  doctor-report-helpers; collector 1756 → 1002)
- Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic
  client → mappers with type-only back-imports)
- coach chat bubbles + read-aloud controls (chat-bubble / read-aloud;
  message-thread 1713 → 464)
- Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in
  lib; rate-limit-before-secret ordering byte-identical)
- coach snapshot cache, series helpers and the four largest per-metric blocks
  (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896)

The glucose-panel-window structural test follows the moved alias; two unused
imports left by the feature split are removed.
2026-07-11 08:43:41 +02:00
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00
Marc-André Bombeck 2869d0ab27 chore(release): v1.28.24 — a deleted Google reading no longer blocks its re-import
The measurements unique index on (userId, type, source, externalId) is FULL —
it covers soft-deleted rows — so a tombstoned Google row permanently owned its
key: the live-only probe treated it as absent, planned a fresh insert, and
createMany's skipDuplicates dropped that insert silently against the
tombstone's key. Every sync, forever. Live incident: a self-hoster's step days
were stuck exactly this way (five tombstoned dates, confirmed in their data).

The probe now matches tombstoned rows too and the update branch carries
deletedAt: null unconditionally — a no-op on a live row, a deliberate
resurrection on a tombstoned one. Google remains the source of truth for its
own rows; removing Google data permanently means disconnecting the
integration. The stale 'partial unique index' comments are corrected.
2026-07-11 00:35:50 +02:00
Marc-André Bombeck c400e2aa88 chore(release): v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork
The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.

Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.

Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
2026-07-11 00:18:23 +02:00
Marc-André Bombeck 869f5e561b chore(release): v1.28.22 — full sync survives dense histories; quieter document chrome
Google Health: the fullSync rollup-defer tracker collected its touched keys via
a single spread push — one argument PER READING, so a multi-year heart-rate
history (six figures of samples) overflowed the call stack and aborted the
metrics collection mid-cycle, exactly on the accounts with the most data. A
plain loop replaces the spread. Reported by a self-hoster; the v1.28.21 sync
diagnostics confirmed every other read on that account healthy in one log line.

Documents: the vault card's read-by-AI tag no longer takes its own line — a
small glyph rides inline on the date-and-size row. The detail view drops the
status bar under the preview entirely; with automatic reading on and nothing to
review the whole block collapses, so the content starts straight with the
document's fields. The provenance contract moves with it: the e2e specs now pin
the card marker instead of the sheet pill, and the unmounted status-pill
component is removed rather than left dead.
2026-07-10 23:02:52 +02:00
Marc-André Bombeck 1212ed91d5 chore(release): v1.28.21 — sleep history heals itself; plateau context; sync diagnostics
A one-time background pass re-reads each connected account's Google Health
sleep history so nights stored twice before the sleep fix collapse to their
true total (new sleep_repaired_at marker, migration 0238; watermark-safe,
enqueued once at boot per un-repaired connection).

The sleep night read reports when two writers disagree about the same night —
the served total is unchanged, the disagreement rides along as an annotation
(sourceDiscrepancy on the night DTO) so clients can mark the number instead of
silently picking a side.

A GLP-1 medication whose weight has plateaued on the current dose says so next
to the estimated drug level (efficacy tab + insights) — a factual note from the
existing detector, association only, verb-guard covered.

Google Health diagnostics: the sync log now separates a daily-totals read that
returned nothing from one the importer could not parse
(googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's
structure probe reports the raw response envelope key names when a rollup type
parses to zero — a per-account naming drift becomes visible from one report.
2026-07-10 21:23:00 +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 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 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 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 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 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 0d25f3191a merge admin-central-oauth 2026-07-10 00:39:59 +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 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 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 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é 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 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 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 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
Marc-André Bombeck 1e2b1d2c6a fix(ai): treat gpt-5.x-codex slugs as vision-capable
The document/lab-OCR vision gate excluded every `-codex` slug from the
GPT-5.x line as text-only. That is stale: the `codex/responses` wire reads
an `input_image` block on these slugs (docs/codex-protocol-spec.md plus the
current model lineup), and the 5.2-Codex line is vision-tuned. An operator
pinned to a `-codex` slug therefore resolved no vision-capable provider and
hit a 422 "No vision-capable AI provider is configured" when reading a
document with AI.

Drop the `-codex` exclusion from the gpt-5 branch so a codex-only chain
resolves a document-vision provider; keep the `-mini`/`-preview`
reasoning-slug guard. Lab-OCR on a `-codex` pin gains vision the same way.
2026-07-09 13:41:59 +02:00
Marc-André Bombeck 783255d833 chore(release): v1.28.4 — medication efficacy
Per-medication effect view: plots the target metric/lab with start, dose
changes, and pauses marked, a before/after-start comparison, and adherence
overlaid — descriptive only, never a verdict or dose advice. Compact summary on
the medication insights area. Target resolved from ATC class or name, with a
per-user override. Migration 0232.
2026-07-09 13:14:07 +02:00
Marc-André Bombeck 8617fa7129 feat(medications): Wirkung tab + Insights efficacy summary
Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds)
rendering the target series with a start marker, dose-change markers, shaded
pause bands and a faint reference band, an adherence lane, a neutral
before/after-start card with honest empty states, an optional level-shift note,
and a retarget chooser. Weave a compact, read-only efficacy summary into the
Insights medication surface that reads the same DTO and links through to the
tab. All copy is descriptive; add a banned-verdict-verb guard test over the
medications.efficacy namespace across all six locales.
2026-07-09 13:02:02 +02:00
Marc-André Bombeck 725f69097f feat(medications): server-authoritative medication-efficacy view + API
Add the migration (0232), Prisma model MedicationEfficacyTarget (persists ONLY
the user's explicit target override; everything else derives each read), and
the server builder buildMedicationEfficacy that composes the target's series,
a before/after-start comparison, the cadence-aware adherence lane (never
recomputed), an optional conservative level-shift note, honest data-floor
states and the retarget options into one strictly-descriptive DTO — no
verdict/score field by construction. Expose GET /api/medications/[id]/efficacy
and PUT /api/medications/[id]/efficacy/target, regenerate the OpenAPI contract.
2026-07-09 12:45:50 +02:00
Marc-André Bombeck f3762cf0f9 feat(medications): extend the drug-target resolver with ATC-prefix and lab-analyte tiers
Add an additive efficacy-target resolver alongside the existing metric-only
class map: a discriminated metric|lab target type, a WHO ATC class-prefix
table (consuming Medication.atcCode), and the statin/thyroid/supplement lab
classes the v1.22 map documented as a follow-on. Resolution order is ATC
prefix then whole-word name inference, conservative-fail to no target. The
existing MED_TARGET_MAP / inferMedTargetClass path stays byte-identical so the
adherence-storyline safety posture is untouched.
2026-07-09 12:36:59 +02:00
Marc-André Bombeck aa415a9e32 style(admin-shell): reflow the main column element
Prettier reflow of the admin-shell main column after moving the
loading-jump floor onto the grid.
2026-07-09 12:12:23 +02:00
Marc-André Bombeck 9f21d9f20d fix(cards): retire banned p-5 shells and accent header icons
Bring the structural card-anatomy offenders onto the spacing scale. The
cycle wellness-tile family (ring tile, phase-education, insight summary,
cycle view) and the insights wellness-scores tile drop banned p-5/px-5
py-5 for p-4 md:p-6 and keep their premium hue surface; their header and
info glyphs move off text-primary to foreground, spinners to muted. The
onboarding hand-rolled shells (anamnesis card, baseline form,
getting-started checklist, tour tooltip) drop p-5/space-y-5/rounded-2xl
for p-4 md:p-6/space-y-4/rounded-xl. settings/anamnesis-section moves its
three raw Card+CardTitle shells onto SettingsCard + SettingsCardHeader.
2026-07-09 12:05:34 +02:00
Marc-André Bombeck de16da43fe fix(insights): unify the left reading edge across metric/score cards
Establish one inset system on the Insights surface. SubPageShell's flat
px-6 header sat 8 px right of every card body on mobile — track the card
slot inset (px-4 md:px-6) so the heading-vs-body step disappears.
DeviceScoreTile and ResilienceTile hand-rolled a muted h-4 header row;
route them through TileHeader size=sm (foreground icon, shared gutter),
latest reading in the right slot. ScoreAnatomyView dropped its banned p-5
+ centred uppercase title for a left-aligned TileHeader on p-4 md:p-6, so
the ring hero stays a centred identity block while the header shares the
reading edge with the stacked assessment card. SparklineDeltaTile swaps
its hand-copied header row for the TileHeader primitive so it can't
drift, and its contributor label rounds off the sub-xs size.
2026-07-09 12:00:12 +02:00
Marc-André Bombeck df78b29cfd fix(shell): make AuthShell the single owner of scroll bottom-space
SettingsShell's content column carried pb-24 md:pb-0 that double-counted
the bottom-nav clearance AuthShell's <main> already reserves, stacking
~96 px of dead scroll below the last card on mobile. AdminShell still
carried the pre-#154 min-h-[calc(100dvh-12rem)] column reserve on <main>,
over-reserving on short sections at every breakpoint.

Delete the redundant SettingsShell gutter, move AdminShell's floor off
<main> onto its grid, and factor the budget-derived grid floor into one
shared SUB_SHELL_GRID_FLOOR constant so the two shells can never drift.
Extend the guard to /admin/* and measure against each element's
content-box bottom so a redundant sub-shell pb-* now fails instead of
inflating the allowed budget.
2026-07-09 11:55:08 +02:00
Marc-André Bombeck 79a78162e2 fix(dashboard): keep the hero verdict action beside the sentence
The overdue-dose CTA sat in a flex-wrap row, so a full verdict sentence
pushed the take/log button onto its own line — wasted vertical space.
Adopt the PageHeader inline-action pattern: items-start justify-between,
the sentence gets min-w-0 flex-1 and wraps internally, the button gets
shrink-0 and stays beside the text. Tighten the greeting to verdict gap
from space-y-3 to space-y-2.
2026-07-09 11:50:34 +02:00
Marc-André Bombeck 472a4904cd fix(nav): keep Settings and Notifications out of the mobile More hub
The mobile bottom-nav More overflow appended the shared utility tail
(Settings, Notifications) after the feature destinations. Those two are
already reachable from the user/avatar menu, so the hub duplicated them
across two menus on the same screen. Return feature destinations only;
the utilities stay in the user menu. Sidebar footer is unaffected.

Flip the three hub assertions to lock the feature-only contract.
2026-07-09 11:49:55 +02:00
Marc-André Bombeck f2263361b6 style(documents): format document-settings read helper 2026-07-09 11:02:20 +02:00
Marc-André Bombeck b3ae35b055 feat(documents): add the AI-settings toggle to read documents automatically
Add a single switch in AI settings — off by default — that turns on ambient AI
reading of uploaded vault documents. Turning it on reveals a once-shown,
vendor-blind honesty confirm (subscription providers may use the content to
improve their models; a self-hosted local model never egresses) that must be
acknowledged before the setting is written. Copy is translated across all six
locales.
2026-07-09 10:55:01 +02:00
Marc-André Bombeck d2451021bc feat(documents): rasterize PDFs so image-only-wire providers can read them
Only Anthropic accepts a native PDF block; codex and other image-only-wire
providers dropped it. Add a bounded, best-effort server-side rasterizer that
renders a PDF's pages to JPEG images those providers read as ordinary image
input, so a scanned/image-only PDF is legible on the auto-read path.

- @napi-rs/canvas promoted to a direct dependency; its musl prebuilts + the
  pdfjs loader pinned into the standalone trace (next.config.ts)
- rasterize-pdf.ts: lazy pdfjs import (keeps DOMMatrix out of the eager chunk),
  first 10 pages, 1600px long edge, JPEG q80, never throws
- prepareVisionInput rasterizes a PDF for a non-Anthropic vision provider and
  keeps the native PDF block for Anthropic; the document capability reports
  PDFs readable via raster so the UI offers the read
2026-07-09 10:49:17 +02:00
Marc-André Bombeck 73a3ac9c36 fix(documents): reconcile the auto-index job onto the document order
The auto-index-on-upload job resolved over the cost-first app-wide chain
(codex priority 1) and gated with the chain-based consent check, so a
pure-codex chain could silently egress a freshly uploaded document to the
subscription backend on upload. Reconcile it onto resolveDocumentVisionProvider
(local-first, codex last) and govern external egress with the
documentsAutoAiRead opt-in: a local pick always runs (no egress); an external
pick runs only when the toggle is ON, otherwise the job stays strictly local.
This tightens the default and makes the opt-in path deliberate.
2026-07-09 10:36:14 +02:00