Compare commits

..
401 Commits
Author SHA1 Message Date
Marc-André Bombeck 635d7e2618 Merge pull request #526 from MBombeck/release/v1.30.2
release/v1.30.2
2026-07-18 05:26:28 +02:00
Marc-André Bombeck de8368cf01 chore(release): v1.30.2 — coach history, navigation, correctness, and performance 2026-07-18 05:08:06 +02:00
Marc-André Bombeck 1a642f1173 feat(coach): full-history search + pagination; document the labs watchdog
Coach conversation history was capped at the rail's first ~20 threads and
searched only that loaded page. The rail and the standalone conversations
page now walk the caller's entire history via cursor pagination
(useInfiniteQuery + a scroll sentinel), and search runs server-side against
the conversation title (GET /api/insights/chat?q=) instead of filtering an
already-truncated client array.

Also extends the "automatic screening" info line in the metric catalog to
name the labs "what changed since your last panel" card alongside the
rhythm/breathing/health-status watchdogs it already covered — it un-mounts
the same way when there's nothing to flag and was missing from the copy.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 8dd04e0170 fix(ux): close medium UX-audit findings across flows, IA, and onboarding
Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck e9e186dca5 fix(gamification): bucket achievement streaks by the badge owner's own tz
Every day-key derivation feeding the achievement streak/count badges
(login, entry, weekend, miss-free, sleep-log, on-time, compliance-80)
plus the hidden night-owl/early-bird triggers hardcoded Europe/Berlin,
including the SQL vitals-bucket query's `AT TIME ZONE` literal. A
non-CET user's evening entries could fold into the wrong calendar day,
or two genuinely distinct local days could merge into one, silently
breaking streak badges for every non-CET account.

Threads the account's own `User.timezone` through
`buildAchievementsResult` into the SQL query (bound as a parameter,
not spliced — Postgres accepts a text value on the right side of
`AT TIME ZONE`, verified against a live instance) and every JS-side
day-key helper. The pure helpers in `expansion-metrics.ts` and
`care-metrics.ts` keep the Berlin default when `tz` is omitted, so
every existing caller/fixture is unaffected.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 44ab74e125 fix(nutrients): count each day once and anchor the overview window on user tz
GET /api/nutrients had two correctness bugs:

- `daysWithData` incremented on every row whose day differed from the
  PINNED latest-day field, so a second source's row on an already-seen
  older day (water logged via both Apple Health and a manual entry on
  the same day) opened a phantom second "day". Track a per-nutrient
  set of distinct day keys instead.
- The window floor used a UTC day key against a local-timezone `day`
  column, off by up to a day at the edge for non-UTC users. Anchor on
  the caller's own local "today" instead, mirroring the sibling
  `GET /api/nutrients/daily` route.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 71d7ca78cb fix(insights): scope intraday-pulse reads to the local day and fix the baseline tz
Two bugs in the S11 intraday pulse seam:

- The PULSE/step reads queried a ±15h/39h padded superset around the
  viewed day, then capped the row count ascending. On a dense account
  the cap could be entirely consumed by the PREVIOUS local day's
  samples before reaching the viewed day at all, truncating the chart
  partway through the afternoon. Query the exact local-day window
  (DST-aware) instead — the workout-overlap read keeps the wider
  padding since it has no cap and a workout can legitimately span a
  local-day boundary.
- The resting-baseline PULSE-proxy fallback bucketed every user's
  samples by a hardcoded Berlin day, not the caller's own timezone,
  shifting the personal floor the tension detector judges the day
  against for every non-Berlin user. Threads `timezone` into
  `resolveBaseline`'s `dayKeyOf`.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 5169be5887 fix(insights): order the correlations read desc so a hit cap keeps recent rows
The FDR discovery read capped a dense account's 180-day window at
orderBy asc + take, so exceeding the cap silently dropped the NEWEST
rows. The early-detection pass exists specifically to see the recent
window, so a capped read gave it nothing to work with.

Order desc for both the measurement and mood reads, re-sort ascending
in JS afterward (every downstream day-bucketing pass groups by day
key, so pre-sort order doesn't matter to them), and annotate when a
read actually hit its cap.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck a2cb4fb4a9 perf(workouts): cache the canonical dedup projection per filter, not per page
GET /api/workouts read and deduped the full filtered row set on EVERY
page request because the cache key included offset/limit. A historical
drain (the iOS app's multi-page pull, thousands of rows on a long Apple
Health import) paid the full read + dedup pass once per page instead of
once per filter combination.

Split the route into a cached projection builder (keyed on
userId|since|until|sportType, deliberately excluding offset/limit) and a
cheap in-memory slice per page. Also runs the row read and the
sourcePriorityJson lookup concurrently instead of sequentially.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 513e5309ac Merge pull request #525 from MBombeck/release/v1.30.1
release/v1.30.1
2026-07-18 04:08:44 +02:00
Marc-André Bombeck ccfda544f8 chore(release): v1.30.1 — mobile, feedback, and consistency polish 2026-07-18 03:55:34 +02:00
Marc-André Bombeck 2c08201695 fix(content): German register breaks, stale disclaimer comments, safety-floor wording
Three German strings broke register: the doctor-report wellness
disclaimer and the Sie-register clinician view's attachment hint both
leaked a du-form word into otherwise neutral/Sie text handed to a
clinician, and the sharing settings' "shownOnce" hint used a Sie
imperative inside the app's own du-form settings surface. All three
now match the register their surface actually uses.

daily.line.allClear ("everything's tracking normally") edged toward an
all-clear verdict the safety-floor module deliberately never makes;
drops the second clause to match the already-existing, safer
daily.today.allClear wording, across all six locales.

Four insights explainers (fat mass, visceral fat, daylight, falls)
asserted health-outcome links with no citation while their siblings
(resting HR, pulse wave velocity, VO2 max) all cite a named guideline
body inline; adds the same parenthetical anchor to each.

Two coach-panel comments still described a per-page "always visible"
clinical-decisions disclaimer that DISC-01 (v1.18.6) replaced with the
one-time onboarding acknowledgment — the code was already correct, the
comments were stale and now say so.
2026-07-18 03:52:33 +02:00
Marc-André Bombeck f223ca8979 fix(a11y): explicit accessibilityLayer, tile headings, stronger focus ring
Health-chart and mood-chart now pass accessibilityLayer explicitly on
their ComposedChart root — Recharts 3 already defaults it on, but
pinning it here documents the choice and survives a future default
flip on the two busiest chart surfaces in the app.

TileHeader's titleAs="h2" was previously read as a single-owner
exception; the docstring undersold it — sibling h2 tiles in one grid
nest correctly under the page h1 same as a lone owning block. Wires it
through the dashboard's recent-workouts tile and the insights vitals
grid (both the loaded SparklineDeltaTile card and the two provisional/
building states), so a rotor user gets one heading per tile instead of
landing on the page's single h1 and nothing else.

The coach composer's focus ring rode ring-primary/15 — likely under
the 3:1 non-text-contrast floor next to its /50 border. Raises it to
/50, matching the family of "wrapper focus-within ring, outline-none
inner control" the standard already uses elsewhere at full strength.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3a45fcc3bd fix(ui): close remaining consistency-audit findings
Sweeps the banned gap-5/space-y-5/pl-5 spacing residue the audit found
scattered across coach pages, settings coach cards, the labs/records/
custom-metrics form family, onboarding, and a handful of one-off
surfaces (glucose panel, clinician view, illness correlation card,
invite-tokens, the mental-health check-in wizard including its stray
sm:p-6 shell steps) down onto the 4/6 scale.

Two medication chart colours (dose-strength-curve, drug-level-chart)
painted raw --dracula-purple with no light-theme override; both now
read --chart-1, the same alias the mood-chart fix already established.

Raises ~40 sub-text-xs muted-prose sites below the UI-STANDARDS §3/§5
floor (settings API/MCP/sharing/coach-prefs cards, insights workout
detail, suggested prompts, tab-strip group popovers, sources rail,
metric-stat-strip, the health-score provisional pill) to text-xs,
leaving genuine tabular-nums Badges untouched — the one exemption the
standard carries.

Routes the 10 hand-rolled settings/insights "no data yet" paragraphs
(coach memory, coach reminders, security sessions/activity/keys, API
tokens, sharing, insights pill order, per-metric overrides, nutrient
intake) through the shared EmptyState primitive instead of a bare
muted <p>, so they read the same, centered, icon-ready shape every
other empty list in the app already uses. The notification-status
card's empty copy stays on SettingsCardHeader's description slot —
converting it would mean restructuring the card, not routing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a2f926576e feat(labs): add "save & add another" to the manual result form
A real lab report is 10-20 analytes sharing one blood-draw date,
usually in the past; the add form closed on every save, resetting
takenAt to now and clearing the biomarker pick, so entering a full
panel meant re-setting the date away from now on every single row.
The new button keeps the sheet open, clears the reading, and
deliberately preserves takenAt (and the biomarker pick when not
locked to one detail page) so the next analyte in the same panel just
needs a pick + a value.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck b00450aec8 fix(pwa): fix pull-to-refresh's at-top check and extend coverage
The authenticated shell's actual scroll container is <main
id="main-content"> (AuthShell sets it overflow-y-auto inside a
height-locked layout); document.scrollingElement never moves there, so
the gesture's "only arm at the top of the page" check was permanently
true regardless of how far a user had scrolled a long list. Point it
at the real scroll container instead, with the old document-root check
kept as a fallback for the few body-scrolled surfaces outside the
shell.

Extend pull-to-refresh from 4 pages (labs/checkups/mood/measurements)
to the dashboard, /insights, /medications, and the documents vault —
the sharpest PWA-resume-staleness surfaces, per the audit: a
background-refreshed dose or a cross-device write otherwise sits
stale until a full remount or the staleTime window lapses.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck c1c3aa9754 fix(settings): resync threshold rows after a reset
Each MetricRow is keyed on the stable metric name, so a per-row or
reset-all mutation that lands a new override (typically null) never
re-ran the useState initializers — the switch, min/max fields, and
override mode kept showing the just-cleared values while the
"Überschrieben" badge (driven straight off the prop) vanished, a
contradictory row until a reload. Re-derive an identity from the
override prop and resync local state during render when it changes;
typing in the fields never touches that identity, so this never
fights a live edit.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 7ac9e7833e fix(medications): give backdated/manual intakes the same Undo the card has
Logging a dose via Medications → Add → "Log intake" (incl. backdated)
or the dashboard's medication-intake quick-add toasted success with no
Undo, unlike the card's own take/skip path — a dose logged against the
wrong medication (easy on the quick-add, which auto-picks) was only
correctable via detail page → history → find row → delete. Both paths
now capture the created event id and attach the same Undo action.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 8c385b9ac2 fix(coach): delete-with-undo replaces the arm-forever confirm
The history rail + /coach/conversations armed a row on the first tap
and only disarmed on a successful delete — no timeout, no
outside-click reset — so a row armed minutes earlier deleted on a
later stray tap, and there was no way back once it fired. Replace it
with the same delayed-commit delete-with-undo the Documents surface
already uses: one tap hides the row and schedules the real DELETE a
few seconds out; the toast's Undo cancels it. Centralised in
useDeleteCoachConversationWithUndo so both surfaces, which carried
byte-identical arm/confirm logic, share one implementation.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 9531bbdcec fix(measurements): remember the last-used type on the add form
The add form always defaulted to BLOOD_PRESSURE regardless of what a
user actually logs day to day, forcing a re-pick from a 15-entry
select on every entry unless arriving via a ?add= deep link. Seed the
default from the last manually-saved type (localStorage, client-only)
when no deep link overrides it, and record the type on every
successful save.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3c468b5e00 fix(charts): persist the range-tab pick like the overlay toggles
rangePoints lived in plain useState(30), so every chart reset to the
30 d tab on mount while the sibling trend/comparison/target-band
toggles on the same card persisted per chart. Add rangePoints to
ChartOverlayPrefs (server-validated against the closed 0/7/30/90 set)
and hydrate it once the prefs resolve; a range-tab tap now persists
through the same PUT the toggles already use.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a66a346d0d fix(capture): guard the bottom-nav Log sheet against silent discard
CapturePicker mounted the same measurement/mood/medication-intake forms
the dashboard quick-entry sheets do, but closed unconditionally on a
backdrop tap, Escape, or mobile swipe-down — the dashboard's
sheetBodyHasUnsavedInput() confirm guard never made it into this newer
picker. Reuse it here too, so a half-filled form from the primary
mobile capture path asks before dropping typed input instead of
losing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 20f0d13eba style: prettier reflow on the mobile-audit touch-up files 2026-07-18 03:52:32 +02:00
Marc-André Bombeck 07f161bf25 fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure
WorkoutDetailHeader held three columns at every width, wrapping the
long-format date to 4-5 lines beside a text-2xl duration at 360px;
stack below sm with duration moving under the title block. Same pass
drops a stray text-[11px] muted hint below the size floor.

The intraday pulse chart and the VO2/cardio-fitness link card carried
a static p-4 shell with no md:p-6 step, and the intraday loading state
was a raw pulse div instead of the Skeleton primitive under-reserving
the chart height — both now match the card inset contract used
elsewhere. Same one-line fix on the insights hero shell (sm-stepped
inset instead of the standard md-stepped one) and the glucose advanced
panel's 3-up grid at a 320px fold.

The medication efficacy tab's retarget control failed saves silently —
no toast, no inline alert, an unhandled rejection escaping past the
disabled-button reset. Surface a toast on failure. Also swaps the
level-shift date off a raw toISOString slice onto the locale
formatter, and the primary-target tag off bare muted text onto the
Badge component the rest of the app uses for this kind of marker.

The caffeine card collapsed its 280px skeleton to nothing on every
nutrients-page visit for an account with no logged caffeine; a 60s
staleTime (already the convention on the sibling insights queries)
keeps the flash to once per session. The efficacy chart's dynamic-
import fallback was the full card-chrome ChartSkeleton nested inside
its own Card, snapping ~100px when the real bare chart mounted — swap
for a plain Skeleton sized to the actual chart.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 5532a42ff6 fix(ui): mobile stacked intake history, icon-collapse bulk bar, ResponsiveSheet parity, tap-target floor
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.

The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.

Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).

Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 66fe69b493 fix(ui): break-words on coach prose, hero/wellness skeleton parity, priority-card pending state
Coach chat bubbles and the document OCR text panel wrapped long unbroken
tokens (URLs, IDs) past their column width on narrow viewports — add
break-words. Drop the /90 alpha on the coach error text so it stays on the
AA-retuned status token.

Today hero's loading skeleton had drifted from the v1.29.1 tightened hero
(gap/items-start/space-y/rail-heading/xl:grid-cols-3), causing a visible
shift on every dashboard load; mirror the loaded classes 1:1.

The wellness-scores strip returned null while its shared batch loaded,
so it popped in late and pushed the page below it down — render the
section heading + tile-shaped skeletons instead, matching the vitals
dashboard's loading pattern.

Priority-card actions (coach check-in keep/let-go) had no pending state,
so a slow round trip could be double-tapped into firing the mutation
twice; disable the action row while in flight. Priority-item dismiss is
now optimistic (removes the card from the rail on tap, rolls back on
error) instead of waiting on invalidate-after-success.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck d2ad8e03e0 Merge pull request #524 from MBombeck/release/v1.30.0
release/v1.30.0
2026-07-18 02:41:27 +02:00
Marc-André Bombeck a0bde126dd test(withings): expect the no-credentials connect to redirect, not 400 2026-07-18 02:30:33 +02:00
Marc-André Bombeck 749fb8eef1 chore(dead-code): drop the unused settings group-order export + stale knip ignore 2026-07-18 02:16:08 +02:00
Marc-André Bombeck efdcafb08d chore(release): v1.30.0 — quality pass, document summaries, efficacy chart
Correctness (multi-source dedup, ordering, Strava sport, PR/correlations), medical-safety fixes, faster Today view, onboarding, app-wide year-on-dates, a metric catalog, a fuller backup, accessibility, and internal refactors — plus document auto-summaries and the medication efficacy chart.
2026-07-18 02:12:52 +02:00
Marc-André Bombeck 3dd5dbeb42 feat(export): make the full backup actually full
The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 837c44e4c2 test(migrations): seed→apply→assert harness for the workout-sport backfills
The raw-SQL data backfills 0247 (WHOOP) and 0251 (Strava) rewrite
historical Workout.sport_type rows with no test — the thinnest-covered
class in the tree (audits L2). Add an integration test that seeds
pre-backfill rows, applies the EXACT migration SQL from disk against the
testcontainer Postgres, and asserts:

  - canonical relabel across both value shapes (id placeholder + raw
    label), case/normalisation, unknown→other fallback;
  - the source guard (a WHOOP pass leaves Strava/MANUAL rows verbatim and
    vice-versa);
  - idempotency (a second apply reports zero affected rows).

Runs in CI's integration job; locally needs Docker/OrbStack via
pnpm test:integration.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 3dc6c7436f fix(analytics): replace NUL-byte map-key separators with a visible one
Two in-memory key builders joined their parts with a literal NUL
(`${a}\x00${b}`): the sleep writer-bucket key and the correlation
pair key. The embedded NUL made grep classify both files as binary, so a
plain `grep symbol src/**` skipped them silently.

Swap the separator for U+241F (␟, the unit separator). It cannot appear
in a `MeasurementSource` enum literal, a device-type tag, or a
correlation-channel name, so the key semantics are identical — the sleep
key still slices its source prefix unambiguously and the correlation Set
still excludes double-counts. Both keys are ephemeral (one pass, never
persisted), so the byte change is invisible to any stored state.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 55186ccd3e feat(observability): fireAndForget breadcrumb for background side effects
Best-effort background work was swallowed with `.catch(() => {})` — a
persistent failure (a broken reminder enqueue, a lost re-auth transition)
left no annotate() or logger trail, against the project's own
'annotate() on every interesting path' rule.

Add `fireAndForget(promise, { action })`: it annotates the rejection into
the current wide event under a per-action meta key (so it rides the
central redaction at emit time — the raw message never hits the console)
and warns the action label only for failures that settle after the event
flushed. Control flow is unchanged; the promise stays fire-and-forget.

Adopt it at the meaningful sites: reminder-satisfy enqueues (measurement
+ labs writes and the five integration sync workers), the post-write
safety-floor dispatch, and the consent audit-log write. The Withings
re-auth mark stays awaited (its persisted state feeds the same response)
but now breadcrumbs its failure instead of swallowing it.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck a7c82eb582 refactor(types): restore Prisma's type-checker on the cycle + PR write paths
The cycle write path and the PR worker cast Prisma inputs to `never`,
which switches off the compiler exactly where a schema/enum drift would
otherwise be caught.

- day-log-write: `ExistingRow` and `SensitiveFields` widened every enum
  column to `string`, forcing 21 `as never` writes. Type them to the
  generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*,
  HomeTestResult, ContraceptiveKind) — the Zod input enums already match
  member-for-member — and build the create row from the typed locals
  instead of re-casting the update-input shape.
- cycle/backup: thread the enum element type through `enumOrNull` and pin
  each allow-set to its enum, so the 12 restore writes type-check against
  the generated create input rather than `never`.
- pr-detection-worker: type the candidate `source` as MeasurementSource
  (both Workout and PersonalRecord use it) and give `findBestWorkout` a
  real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`.

No behaviour change; the writes are now compiler-checked. No latent
drift surfaced — every enum set lines up with the schema.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck d978c2b662 refactor(compliance): split the god-module into focused units behind a barrel
The medication-compliance engine lived in one 2,099-line file with 38
exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard
pillar, per-medication tile, insight targets, doctor report, …). A single
edit intended for one surface could silently shift all of them.

Split along the existing internal seams into `analytics/compliance/`:
parsing, types, adapters, dose-status, occurrences, windows, cycle,
ledger, display. `analytics/compliance.ts` is now a thin barrel that
re-exports every public symbol, so no consumer import path changes. Pure
move — no logic edits; the tally, window ladder, ledger and cycle math
are byte-for-byte the code they were. Tests move alongside under
`compliance/__tests__/` and keep exercising the public barrel.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck e7802701ba refactor(insights, settings): unify the Heart group and add settings sidebar groups
The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.

The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.

Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 4c436d7dfc fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells
Admin app-log rows opened their JSON detail dialog through a click-only
<tr> with no keyboard path; the Action cell now wraps a real <button>
that carries the same handler plus a descriptive aria-label, leaving
the row onClick as a pointer convenience.

The target range-bar's current-value marker showed value + band only
in a hover-only tooltip on a non-focusable div. It is now focusable
(tabIndex, role=img) with an aria-label that reuses the same value /
range / delta copy the tooltip already renders, so focus opens the
tooltip and a screen reader gets the text alternative either way.

Mood and medication-compliance heatmap day cells were pointer-only SVG
rects. Each cell is now focusable with its own per-day aria-label
(reusing the existing tooltip copy), and Escape dismisses any open
tooltip — pinned (touch) or focus-shown.

Reference: 2026-07-17 a11y audit, H1 + M1 + M2.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 247bc4f33c fix(ui): close consistency-audit contrast and duplication findings
Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 1ff179178f fix(dates): show the year whenever a date is not the current one
Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.

Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 7f44e2fa37 feat(format): add conditional-year date formatters
dateShortSmart / dateWithWeekdaySmart add the year only when a date
falls outside the current calendar year, so a chart axis or history
row never shows a bare "19.02." for a reading from last year. Also
adds monthYear for Dec-to-Jan rollover labels (next-dose captions).
Year comparison runs in the formatter's own timezone so UTC-pinned
bucket-label formatters agree with what they print.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck e8327109b1 test(nutrients): use relative day-keys so the daily-window test can't date-bomb 2026-07-18 00:38:33 +02:00
Marc-André Bombeck e3dfe9f400 perf(medications): batch the intake CSV import duplicate probe
The CSV import loop ran a findUnique duplicate check before every
single create — up to 2000 sequential round trips on a 1000-row
import. Resolve takenAt + the idempotency key for every row first,
then read every key's existence in one indexed query before the write
loop; create and the per-row inventory consumption stay unchanged.

Also runs prettier over the batch-route test files touched in the
prior two commits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2c6699f682 perf(batch): replace per-entry existence probes with batched reads
Three batch-ingest routes ran a per-entry findUnique/upsert loop —
up to 1000 sequential round trips on a 500-entry batch.

- nutrients/batch: one indexed findMany existence read splits the
  batch into a bulk createMany (new day/nutrient pairs) and a
  $transaction of per-row updates (existing pairs), the shape
  measurements/batch already uses. A write failure now marks its
  whole group skipped rather than one entry, documented at the call
  site; validation failures stay isolated per entry as before.
- mood-entries/bulk: the per-entry findUnique probe is replaced by
  one batched findMany keyed exactly like each entry's own dedup
  contract (externalId when carried, else date+moodLoggedAt). The
  per-entry upsert, tombstone check, tag-link writes and reverse push
  are unchanged — only the existence read is batched.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2bc706a445 perf(insights): read PULSE cards through the day-rollup tier
/api/insights/cards pulled every raw PULSE row from the last 90 days
into JS to compute an average and an anomaly count — on a dense
wearable account that's 100k+ rows the DAY rollups already summarise.
WEIGHT and blood pressure stay on the bounded raw read (manual-entry
volume, and blood pressure needs exact-timestamp pairing for the
in-target percentage); PULSE now reads through the existing rollup
read-swap helper, falling back to a bounded per-day-grouped live read
on a coverage miss.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b89259cce perf(daily): cache the digest's milestone + tension gather
Every 120s poll of every open tab re-derived the S11 tension window
and the S12 milestone from ~8.5k rows even though both are usually
null and change at most once a day. Wrap the two independent reads in
one SWR cell under the existing analytics bucket, keyed per user and
local day, with a TTL that mirrors the dashboard-snapshot cell so a
scheduled poll lands warm. Riding the analytics bucket's `${userId}|`
prefix means the same measurement-write invalidation the snapshot cell
already relies on covers this cell too, so a fresh sleep landing still
refreshes it. Also hoists the milestone gather and the tension read
into one Promise.all instead of two sequential awaits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 95ab0514b9 chore(charts): drop two unused recharts chart components
sleep-hypnogram.tsx and compliance-line-chart.tsx are not imported
anywhere; a future import would mint a second recharts chunk and blow
the bundle budget. Delete both plus the dedicated hypnogram test file,
and drop the now-missing filename from the touch-action guard's file
list.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 1417137162 fix(daily-loop): stop bad ?doc= links from trapping the coach, add upload processing feedback
An invalid ?doc=<id> deep-link (a deleted document, a mistyped id) 404s
its detail fetch forever. The fenced send-gate never cleared, so every
message stayed blocked behind a phantom "untitled" attachment banner
that could never resolve — a bad link was a dead end instead of
degrading to a normal chat. Dropping a staged id the moment its detail
fetch settles into an error now clears the doc-scope, drops the banner,
and lets the coach send normally; a toast explains the link is gone.

Also gives the document vault processing feedback: the auto-index (and
optional AI summary) job that starts right after upload is
fire-and-forget server-side, so a freshly uploaded card sat with no
signal while it indexed. The vault list now polls while any recently
uploaded document is still unindexed (bounded to a few minutes so an
old, permanently-unindexed document never keeps it polling), and the
card shows a "Processing…"/"Ready" chip that clears on its own once the
index lands.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b3ff0e58f fix(daily-loop): thread the overdue medication id into the digest's Log-dose link
The Today digest's dose-window rail item linked to the bare /medications
list even though the overdue medication's id was already resolved
server-side alongside its name — a tap landed the user on a generic list
they then had to scan themselves.

buildMedsTodayBlock now carries nextDueMedicationId; the digest's
"Log dose" action links to /medications?highlight=<id> when the id is
known, falling back to the bare list for an older cached block. The
medications page reads the highlight param, scrolls the matching card
into view, and rings it briefly (mirrors the vault's duplicate-upload
highlight) in both the card grid and the table view. Regenerates
docs/api/openapi.yaml for the new medsToday field.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 844b24c7ba fix(daily-loop): invalidate the Today digest on measurement/mood/water writes
Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.

Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck bf1c052797 feat(insights): add a metric catalog for discoverability
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.

Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck 5f253f7d09 fix(insights): make the nutrients pill reachable once the module is on
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck d124a3239d fix(insights): stop the pulse chart from faking a continuous shape, add honest error states
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.

Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8902d3985c fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):

- GET /api/measurements groupBy=day: the raw-row scan ordered oldest
  first and sliced to `limit` before bucketing, so a long history never
  surfaced its recent days. Reads newest-first now. The per-day sum
  also ran over every source unfiltered — an Apple Health + Withings
  write for the same day doubled the total. Routes through
  pickCanonicalSourceRows first, matching the rollup tile's collapse.

- PR detection's cumulative-day picker summed every row on a calendar
  day regardless of source (false "record" days) and bucketed on the
  UTC calendar day instead of the user's timezone. Rewritten to
  source-collapse before summing and bucket on the user's IANA zone,
  reading through the worker's injected Prisma client so the existing
  dependency-injection contract for tests stays intact.

- The correlation-discovery matrix (the insights route, the per-metric
  assessment card, and the Coach's get_correlations tool — three
  independent copies of the same fetch) averaged cumulative types
  (steps, daylight) and sleep across mismatched grains: per-sample
  chunk means blended with drained daily totals, and sleep averaged
  per-stage segment durations instead of summing a night's total.
  Hoisted the grain resolution into one shared
  buildMeasurementDailySeries helper (source-collapsed day-sum for
  cumulative types, per-night reconstruction for sleep, mean
  unchanged for spot vitals) so all three surfaces agree.

Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 30a0528ad0 fix(analytics): resting-pulse pill drops the diagnosis-shaped verdict
classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120
"Tachycardia", the latter painted destructive-red. The interpretation layer
already frames the same signal calmly for the same range ("below the
standard resting range (common in trained hearts)", neutral valence) — a
trained user at 55 bpm resting read a diagnosis-shaped verdict on one
surface and a calm contextual band on the other.

Rename the categories to "Below typical range" / "Above typical range"
(matching the interpretation layer's own wording) and step the high band
down from destructive/danger to warning/amber — still flagged, never a
verdict noun. Neither category paints the pill red anymore.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck cffebd78db fix(mental-health): crisis card no longer sends AT/CH users DE-only numbers
The PHQ-9 item-9 crisis card routed every "de" locale to the Germany-only
TelefonSeelsorge freephone lines. Those numbers don't connect from Austria
or Switzerland, so a Swiss or Austrian user in crisis was shown dead numbers
first on the one surface where that costs the most.

Add dedicated Austria (Telefonseelsorge 142) and Switzerland (Die
Dargebotene Hand 143) resource sets. `User.locale` only ever stores a bare
short code today, so an unqualified "de" now resolves to a combined
DE/AT/CH set (112 first, then every country's own line) instead of
defaulting to Germany; a region-qualified tag resolves to the exact country
when one is available.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8c0b200a5b fix(doctor-report): footer states the user's own timezone, not Berlin
The clinician PDF footer read "Timezone: Europe/Berlin" for every user
regardless of their actual timezone — a maintainer-anchored default leaking
into a document handed to a clinician, who then reads every timestamp on
the page against the wrong basis for a non-Berlin operator.

`userTz` already carries the resolved per-user zone into the formatters;
thread the same resolution (valid IANA name, else the Berlin fallback) into
the footer string as {timezone} instead of the literal text.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 0518c39050 fix(illness): glucose safety-floor push honours the user's display unit
The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six
locale strings and interpolated the raw mg/dL reading regardless of the
user's glucose display preference. A mmol/L user hitting the confirmed-hypo
alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most
safety-critical moment.

Resolve User.glucoseUnit in the safety-floor write hook and convert both the
reading and the floor threshold before handing them to the notification
copy. The bundle strings now take {threshold} {unit} / {value} {unit}
instead of a baked-in "70 mg/dL" / "54 mg/dL".
2026-07-17 23:12:12 +02:00
Marc-André Bombeck a08e4378a0 Merge pull request #521 from MBombeck/feat/efficacy-scale-history
feat(medications): scale efficacy y-axis and reach older doses
2026-07-17 22:41:36 +02:00
Marc-André Bombeck 0a08b166cf Merge pull request #523 from MBombeck/feat/document-auto-summary
feat(documents): persist an auto summary for uploaded documents
2026-07-17 22:41:30 +02:00
Marc-André Bombeck ba362f1d10 Merge pull request #522 from MBombeck/release/v1.29.5
release/v1.29.5
2026-07-17 17:47:16 +02:00
Marc-André Bombeck 5cff7473c9 feat(documents): persist an auto summary for uploaded documents
When the auto-AI-read opt-in is on, a freshly uploaded document is summarised
once in the background and the encrypted summary is stored and shown on the
document detail view.

- A background step runs after upload alongside the existing content-index and
  thumbnail steps. It generates a short (3-4 sentence) plain-language summary
  only when the user's documentsAutoAiRead opt-in is on (that flag is also the
  standing AI-egress consent), skips a document that already has one, and
  no-ops when no vision provider is configured. It reuses the same provider
  resolution, egress-consent re-assertion, and AI-budget reserve/reconcile
  helpers as the on-demand route; a provider error refunds the reservation and
  never fails the upload.
- The summary is stored encrypted (summaryEncrypted Bytes) with a
  summaryGeneratedAt timestamp on inbound_documents, registered for key
  rotation, and returned decrypted in the document detail response.
- The on-demand transient summary route is unchanged.

Gated by the same consent, budget, and module checks.
2026-07-17 17:46:28 +02:00
Marc-André Bombeck 85a05efb4d chore(release): v1.29.5 — OIDC redirect base + same-origin redirect hardening 2026-07-17 17:30:54 +02:00
Marc-André Bombeck d8e48a6304 fix(security): reject a same-origin next path that re-resolves protocol-relative
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
2026-07-17 17:30:04 +02:00
Marc-André Bombeck 60658be776 Merge pull request #519 from balajiv113/fix-url
fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
2026-07-17 17:26:57 +02:00
Marc-André Bombeck b11ecca8d2 feat(medications): scale efficacy y-axis and reach older doses
- Efficacy target chart: replace the implicit zero-based y-axis with a
  padded data-range domain so a small relative trend fills the plot
  instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
  percentage of the start-anchored baseline; the absolute reference band
  is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
  the full paginated intake history so older doses stay reachable.
2026-07-17 17:17:54 +02:00
Marc-André Bombeck 43eddb63b0 Merge pull request #520 from MBombeck/release/v1.29.4
release/v1.29.4
2026-07-17 17:09:49 +02:00
Balaji Vijayakumar 351cf7dd85 fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
Every browser-facing redirect in the OIDC login/callback routes was built
from req.url. Behind a reverse proxy that doesn't forward the original
host, that reflects the address the app process was addressed as inside
the container rather than the public domain, sending users to an
unreachable internal URL after login. Route every redirect through the
trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the
convention the Withings integration already uses.
2026-07-17 20:30:41 +05:30
Marc-André Bombeck 05f80ad378 fix(workouts): order the list newest-first; show the year on past-year dates
The canonical picker re-sorts its output to startedAt ASC for its rollup callers, so the list route sliced the oldest page and recent workouts never surfaced on a long history — re-establish newest-first after the pick. And a workout from a previous year now shows its year so it is not read as this year's.
2026-07-17 16:53:06 +02:00
Marc-André Bombeck 546c5c7d8f Merge pull request #518 from MBombeck/release/v1.29.3
release/v1.29.3
2026-07-17 16:29:00 +02:00
Marc-André Bombeck c527cd5aa4 chore(release): v1.29.3 — hydration and micronutrients surface
Fluid intake and device-recorded vitamins/minerals get a home: a dashboard water tile with quick-add, and a Nutrients page under Insights showing hydration over the last month, caffeine, and the micronutrients with data — each against its reference intake as context. Manual and device-synced water coexist via a source-aware day-total key.
2026-07-17 16:13:41 +02:00
Marc-André Bombeck 16d142762a feat(insights): /insights/nutrients — hydration, caffeine, vitamins
New routed sub-page, gated on the opt-in nutrients module with an
in-context enable CTA (the module stays opt-in — the 2026-07-17
consent memo's reasoning holds; this just makes the toggle
discoverable instead of buried in Settings):

- Hydration card (hero): today's total, a muted EFSA reference-intake
  line (sex-resolved, omitted when the profile has no sex on file),
  a 30-day bar chart with a dashed reference line, and quick-add
  chips (+200/+300/+500 mL + custom + edit-today's-total) in a
  ResponsiveSheet, POSTing to /api/nutrients/water.
- Caffeine card: same anatomy, rendered only when the window has
  data; the EFSA safe-level reads as a ceiling, never "exceeded"
  colouring.
- Micronutrients card: rows only for the 24 vitamin/mineral codes
  with data in the window (catalog order), a muted "M of 24 tracked"
  footer, and one honest sentence that sparse logging isn't a
  deficiency. Zero rows collapse to a single EmptyState.

No ring, no attainment colour anywhere — the bar chart plus its
dashed reference line IS the context (UI-STANDARDS: values stay
foreground).

The bar chart is a new NutrientDailyBarChart, registered in the
shared chart-runtime barrel behind its own next/dynamic boundary so
recharts stays off the route's first-load JS.

Tab-strip wiring: nutrients joins the flat pill set (event/store
driven like medications/workouts — a lightweight 1-day probe read
drives the pill's data-availability gate, module gate on top).

~20 new i18n keys across en/de/es/fr/it/pl.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck b396b51d35 feat(dashboard): fluid-intake strip tile, nutrients-store-backed
Reuses the TrendCard strip-tile pattern (icon, neutral direction
sentiment, stale-days caption) for a new waterIntake widget, but
sources it from NutrientIntakeDay instead of introducing a parallel
MeasurementType. The dashboard snapshot builder reads the last 30
day-keys for nutrient="water" (summed across sources), folds them
into a synthetic NUTRIENT_WATER summary key alongside the real
MeasurementType summaries, and the existing module-gating machinery
(SUMMARY_TYPE_MODULE, WIDGET_MODULE_BY_ID) strips it when the opt-in
nutrients module is off — no new gating code path, just two new map
entries.

The tile links out to /insights/nutrients instead of a measurement
detail page. Widget catalogue grows 37 -> 38 ids.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck da2de015fe feat(nutrients): source-aware day totals; manual water write path
NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck bef3b9dd3f Merge pull request #517 from MBombeck/release/v1.29.2
release/v1.29.2
2026-07-17 14:46:26 +02:00
Marc-André Bombeck 2b5fb617ba chore(release): v1.29.2 — WHOOP workout sport, intraday history, Today dismiss, vitals info-tip
WHOOP workouts map to canonical sports (bike rides show as cycling) with a history backfill and a richer cross-source merge; the intraday heart-rate view gains a day navigator with 90-day dense retention and an hourly fallback for older days; milestone/ECG/tension cards can be dismissed from Today; vitals context moves into an (i) popover and the VO2max heading no longer clips; Oura cycle phases feed the cycle tracker where available.
2026-07-17 14:35:06 +02:00
Marc-André Bombeck 7445925d58 fix(insights): move vitals method context into an (i) popover; unclip VO2max
The always-on "typical range = median ± MAD" caption was dropped from
the vitals tiles because it repeated across every baseline tile — but
that same inline caption, still rendered unconditionally on the four
derived re-frame tiles (cardio fitness, vascular age, HRV, BMI), was
squeezing the header row hard enough to clip the heading next to it:
measured on the cardio-fitness tile, the "VO2 max" title's rendered
width collapsed from 64px to 15px with the caption in the row, cutting
it down to "V.". Confirmed and re-measured with a Playwright pass over
the actual TileHeader/SparklineDeltaTile markup before touching the CSS.

Replace the inline caption with a small (i) trigger (VitalsInfoTip) on
the tile heading's trailing edge — a click-opened popover carrying the
same method/caveat text plus the cited standard, matching the existing
click-to-open info-tip pattern used elsewhere (touch-friendly, unlike a
hover tooltip). Restore it on the VITALS_BASELINE tiles too, since a
compact icon repeated across tiles doesn't clutter the way the full
sentence did. The heading now keeps its full width regardless of how
long the method copy runs.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 270f33edb2 feat(daily): let the Today rail dismiss milestone / ECG / tension cards
The worth-a-look rail mixed actionable items (an overdue dose, a broken
sync) with observational ones (a milestone, a new ECG pointer, a tension
window) that have nothing to act on beyond "seen it" — there was no way
to clear them short of them expiring on their own.

Add server-side dismissal scoped to the three observational kinds only:
a new DismissedPriorityItem table (userId + itemKey, composite-unique),
a POST /api/daily/digest/dismiss route, and a deterministic itemKey the
digest builder stamps per kind (metric + reach day for a milestone, the
recording's own timestamp for an ECG, the local day + part of day for a
tension window) so a dismissal never bleeds into a later day's instance.
The actionable kinds never carry an itemKey, so the dismiss affordance
and the endpoint are both structurally unreachable for them — an
itemKey not namespaced under a dismissible kind 422s before any lookup.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 4f198ec25f feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.

Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 3aa11b0531 feat(oura): feed Cycle Insights phases into the cycle tracker
Pull Oura's daily_cycle_phases collection (undocumented in the published
OpenAPI spec, but confirmed live and auth-gated identically to every other
daily_* collection) on the existing hourly Oura poll. A luteal→follicular
transition, or a literal "menstrual" phase day, writes a single-day
CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but
unspecified intensity" marker the HealthKit unspecified-flow mapping uses.

Manual entries always win: the write only ever creates a day that has no
existing CycleDayLog row at all, regardless of source, and never updates a
row it did not create itself. No MenstrualCycle span is synthesised, mirroring
the Apple Health importer precedent. A 403/404 on the endpoint (the expected
outcome for most connections — Oura's reproductive-health resources are all
absent from its public spec) is swallowed without touching the rest of the
Oura connection's sync status.

No migration: reuses the existing CycleDayLog.source column and the OURA
value already present on MeasurementSource.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck b3d586066a fix(workouts): backfill richer fields on the canonical-picker winner
pickCanonicalWorkout() picked one whole row per cluster by the source
ladder and dropped the rest, so a higher-priority twin with a null HR
(e.g. a Withings summary) discarded a lower-priority twin's real reading
(e.g. WHOOP's HR strap) instead of keeping it. The ladder winner is now
the base row, backfilled with avgHeartRate, maxHeartRate,
totalEnergyKcal, totalDistanceM, and elevationM from the ladder-highest
member that actually has each field — filling gaps, never overwriting a
value the base row already carries.

The base row also adopts a specific sport type from a cluster member
when its own is generic ("other" / unrecognised). Reaching that case
needs clustering to accept a generic↔specific sport pairing within the
proximity window, alongside the existing exact-match rule — two
different SPECIFIC sports (Walking vs Running) still never cluster, so
the core invariant holds.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 86e92f38ca fix(db): backfill non-canonical WHOOP workout sport_type values
Existing WHOOP Workout rows still carry whatever the pre-fix write path
stored — a whoop_sport_<id> placeholder or WHOOP's raw sport_name — so
the sport-map fix only corrects new syncs. This one-shot UPDATE
re-derives every existing WHOOP row's sport_type from the same table
sport-map.ts uses, generated directly from that table so the historical
data and the live mapper can't drift apart. Unmatched values default to
"other", matching the mapper's fallback. Idempotent: the WHERE clause
skips rows already on a canonical value, so a re-run touches zero rows.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 43884569d3 fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.

sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.

Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 63cdd709af Merge pull request #516 from MBombeck/release/v1.29.1
release/v1.29.1
2026-07-17 11:25:00 +02:00
Marc-André Bombeck 99d80f8512 chore(release): v1.29.1 — polish and fixes from the daily view
Today refreshes right after a dose; a calmer Today hero; the Coach names the attached document; a fresh install starts with tracking on; the vitals explainer line is removed; the illness journal gains an editable end and a settled course line; the morning refresh now triggers from every sleep source and records each skip.
2026-07-17 11:12:30 +02:00
Marc-André BombeckandClaude Opus 4.8 2da6a192a7 feat(polish): default tracking modules on; illness backdate + course fixes
A clean install now defaults every tracking module on (weight, blood
pressure, glucose, sleep, mood, medications, labs, documents, insights,
mental wellbeing, cycle, illness, environment, workouts) so the app is
usable without enabling each surface by hand. Only the two channels that
open an egress or attack surface stay opt-in — the remote MCP endpoint and
the micronutrient sync; the document AI-read and the daily-briefing push
remain separately gated and off. The change touches defaults only:
accounts with a saved preference resolve exactly as before, the new default
applies solely where no preference exists.

Drop the "typical range = median ± MAD" method caption from the vitals
baseline tiles — it repeated across every tile and read as out of place at
the top of the vitals surface. The dedicated bands and composite scores
keep their method copy.

Illness episodes: the capture sheet now carries an editable end date
alongside the start, so a whole past episode can be recorded after the
fact and an open-ended course can be resolved (or re-opened) later. The
end is bounded to on-or-after the start.

Fix the per-episode "how it progressed" card rendering an empty body when
the engine returned a valid result with no notable findings — it now shows
a calm, honest line instead of looking as though nothing was recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:02:57 +02:00
Marc-André Bombeck f6e6593bcb fix(coach): name the attached document(s) in the fenced banner; keep the attach menu within its popover
The fenced-conversation banner said a generic "N documents attached", which
gave no hint what a thread was about when returning to it later. It now names
the attached document(s): one document shows its title, two show both, three or
more show the first two plus an "and N more" tail. Reuses the existing
"Chatting about: …" doc-scope phrasing and the attachment DTO titles; the title
truncates within the pill so a long name never breaks the banner. Falls back to
a plain count only when no title has resolved yet.

Also constrain the composer attach menu: a long localised label — e.g. "Neues
Dokument hochladen" — could run past the popover edge. The menu items now shrink
and truncate within the menu width, mirroring the vault picker items.

Adds a "chattingAboutMore" string across all six locales.
2026-07-17 11:02:47 +02:00
Marc-André BombeckandClaude Opus 4.8 65f90160c7 fix(daily): refresh the Today digest after a dose, drop the hero ring cluster
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.

Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.

Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:00:22 +02:00
Marc-André Bombeck f8c0ea5120 Merge pull request #515 from MBombeck/release/v1.29.0
v1.29.0 — A daily health companion
2026-07-17 09:56:24 +02:00
Marc-André Bombeck af4cad46fe fix(insights): load the intraday pulse chart through the shared chart runtime
The pulse page imported the intraday area chart directly, so its
`from "recharts"` import minted a second recharts fingerprint chunk and
pushed total client JS past its budget. Route it through the shared
chart-runtime barrel via the same single next/dynamic boundary every other
chart uses, so recharts stays one shared chunk.
2026-07-17 09:46:09 +02:00
Marc-André Bombeck ba5a17a54e fix(daily): keep the coach check-in constants out of the client bundle
The Today hero and the check-in hook imported the check-in intent constants
from digest.ts, which — since the milestone slice — transitively reaches the
database through the streak baseline engine. That pulled the Postgres driver
into the client bundle (a Turbopack 'Can't resolve net/tls/dns' build failure
the worktree build couldn't surface). Move the four constants to a tiny,
dependency-free coach-checkin-intents module; the client imports them from
there, and digest.ts (the server builder) stays server-only.
2026-07-17 09:37:39 +02:00
Marc-André Bombeck 2d9237fbad chore(release): v1.29.0 — a daily health companion
The capstone of the daily-value milestone. On top of the Today view, the daily
loop (coach check-in, morning freshness, daily note) and the documents/coach
work already shipped, this completes the picture:
- ECG woven into the insights narrative (device-attributed only, never the
  app's own reading; a new recording surfaces gently in Today).
- Intraday heart rate as the shape of the day, with a careful elevated-at-rest
  tension note (never during activity, never a verdict).
- Calm milestones — states reached, not streaks to keep.
- The cohesion layer: the chosen health rings return to the Today hero, each
  surface carries its metric's calm colour identity, and a quiet reached-moment
  for a milestone — one design throughout.

No new data collected; every daily surface reads the overnight digest. No
migration in this release.
2026-07-17 09:10:56 +02:00
Marc-André Bombeck cb883e1470 feat(dashboard): cohesion polish — hero score rings return, per-metric accents, quiet milestone moment
- Resurface the selected hero score rings on the Today hero: the Settings
  picker's selection and drag order render again as a calm ring cluster
  beneath the health ring, each ring in its wellness hue, flat-painted,
  and linking to its existing detail surface (score anatomy pages,
  medications). The health ring now opens the Insights overview like its
  cluster siblings. Empty selection keeps the hero exactly as before.
- Per-metric identity accents on the daily rail via the existing --tile-*
  hue vocabulary: a soft inset edge (stress for tension windows, strain
  for ECG pointers, primary for dose and coach cards); the same mark ties
  the ECG cross-link and the intraday pulse card shell to their rail
  siblings. Utility items (sync, preventive care) stay accent-less.
- Quiet "reached" treatment for the milestone card: a soft success halo
  over the theme card plus a one-shot spring reveal on --ring-spring,
  with its reduced-motion fallback beside the keyframes.
- The Today hero carries the one sanctioned atmosphere wash (a faint
  primary mix over the card in the wellness-tile pattern); the loading
  skeleton shares it so the swap changes content, never the surface.
- Skeleton primitive upgraded from the whole-block pulse to a single soft
  shimmer sweep, reduced-motion guarded in globals.css; dependent test
  assertions repointed.
- Ring link / dose-tally aria strings restored in all six locales.
2026-07-17 09:05:24 +02:00
Marc-André Bombeck 8ac38926c4 merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones) 2026-07-17 08:38:44 +02:00
Marc-André Bombeck 2f1423fa09 Merge branch 'feat/cap-s11-pulse' into release/v1.29.0
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/daily/__tests__/digest.test.ts
#	src/lib/daily/digest.ts
#	src/lib/daily/load-digest.ts
2026-07-17 08:25:44 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck a33527da57 feat(insights): intraday pulse shape and elevated-at-rest tension window
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.

The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.

Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
2026-07-17 08:16:32 +02:00
Marc-André Bombeck 8b7bb7914e feat(daily): milestones — the calm reward layer
Add the durable-states Milestone primitive and a milestone card to the
daily digest. A milestone marks a state REACHED, never days maintained:
a metric settled back inside its own range, a run of weeks in range, or
a fresh personal best. It is deliberately not a streak — the type carries
a reached-date and no running counter, so nothing here can be tuned into
a flame, and a lapse is invisible rather than mourned.

The primitive is a thin wrapper over engines that already exist: the pure
detectStreak band engine (return-to-range + sustained-in-range) and the
PersonalRecord detection output (new bests). Reached-once needs no new
storage — a milestone's reached-date is a stable fact, so it surfaces only
on the day it was reached and never resurfaces the next day.

The digest emits one calm success card per day when a milestone is freshly
reached, below an overdue dose and above ambient items, with a single
action into the metric's insight. Module- and data-gated: a disabled
insights account or a day with nothing reached shows nothing. Copy carries
no streak, loss, or failure vocabulary, in all six locales.
2026-07-17 08:10:58 +02:00
Marc-André Bombeck 1427125b7e Merge pull request #514 from MBombeck/feat/documents-coach-bundle
v1.28.56 — Documents, closer to hand
2026-07-17 00:22:20 +02:00
Marc-André Bombeck b87f7b50d1 chore(release): v1.28.56 — documents, closer to hand
Bundles the documents/coach slices onto the daily-system release:
- Ask the Coach about a document opens in a side panel (expandable to full
  page), not a full-page jump.
- Attach one or more documents to a Coach chat from the composer (pick from the
  vault or upload); the conversation runs fenced — grounded in those documents
  only, no health data or tools — and can never reach the tool loop (a sticky
  document-scoped flag plus dual client/server enforcement; migration 0245).
- A read document that looks like lab work has its values detected and proposed
  for your labs, added only on your one-tap confirm.
- A lab scan is also filed in your vault (encrypted) and linked to the readings.

Adversarially security-reviewed (multi-document attach): all clear.
2026-07-17 00:10:16 +02:00
Marc-André Bombeck d470d406a7 Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle 2026-07-17 00:02:51 +02:00
Marc-André Bombeck 73de8f6a52 feat(documents): bidirectional labs ↔ documents cross-link
Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
2026-07-17 00:01:45 +02:00
Marc-André Bombeck 46d25361da Merge pull request #513 from MBombeck/release/v1.28.55
v1.28.55 — The Today view comes to life
2026-07-16 23:44:57 +02:00
Marc-André Bombeck e272453327 chore(release): v1.28.55 — the Today view comes to life
Bundles three daily-loop slices onto the Today view:
- Coach check-in: a due plan review surfaces as a calm Today card with
  keep/adjust/let-go, wired to the existing plan lifecycle; silence never
  changes a plan.
- Morning freshness: the sleep-dependent parts of the digest and score refresh
  the moment last night's sleep arrives (a debounced, timezone-correct per-user
  job across the Withings, WHOOP and Apple transports), with an honest
  provisional state until then. Adds migration 0244 (a per-user refresh marker).
- Daily push: an opt-in, once-a-day morning notification carrying the day's
  read, off by default, riding the existing dispatch cascade — a calm note,
  never an alert.

All read the overnight digest; nothing generates on open.
2026-07-16 23:33:33 +02:00
Marc-André Bombeck 502783a3ea feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.

The fence is re-established at both enforcement points, fail-closed:

- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
  fresh chat with staged attachments) to the new /api/insights/chat/fenced
  endpoint; the fenced body is strict and never carries scope, guided-question,
  or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
  fetched row still carries an attachment (flag/join drift), emitting a
  fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
  code path — the fenced pipeline — with no tools and no health snapshot; a
  module-graph test pins that the tool loop is unreachable from it.

The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.

Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.

Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
2026-07-16 23:32:28 +02:00
Marc-André Bombeck 04cb80da6a feat(daily): a calm opt-in morning briefing push
Add a once-daily briefing notification that reads the cached daily digest
and never makes a fresh model call. It rides the existing dispatch cascade
(APNs, Telegram, ntfy, webhook, email, web push), so it inherits the
hard-reject classification and the push-attempts ledger unchanged.

- New DAILY_BRIEFING event type, default off: the briefing stays silent
  until the user opts a channel in from the notifications matrix, so the
  channel choice is explicit and a fresh account never gets an unasked-for
  push.
- One decision seam both triggers share: the sleep-arrival morning refresh
  fires it the moment the day finalises, and a fixed local-morning fallback
  slot covers the case where last night's sleep never synced. Whichever
  wins the morning writes the ledger row; the once-per-local-day cap
  suppresses the other.
- The body is the digest line — the cached briefing lead when present, else
  the deterministic floor — so a keyless self-hoster still gets a
  first-class push. Provisional days carry an honest "sleep not in yet"
  note.
- Non-clinical and non-urgent by construction, fired only inside a local
  morning window: it is a nudge, never the anomaly channel, and never
  bypasses Focus. Empty accounts are left alone rather than nudged with a
  hollow all-clear.
- Localised across all six locales.
2026-07-16 23:23:41 +02:00
Marc-André Bombeck b6ded4d1f9 feat(daily): finalise the morning digest when last night's sleep lands
The nightly insight pre-pass runs ~04:30, before last night's sleep has
synced, so the morning digest and health score reference the day without
the current sleep. Close the gap the event-driven way instead of an
on-mount generation.

A day's digest is now provisional until the sleep-dependent output has
been regenerated with last night's sleep, and final after. The lifecycle
is driven by a real marker (User.morningDigestRefreshedOn, the user-local
date the refresh completed), not a heuristic: it flips the instant the
refresh runs, ahead of the snapshot cache that feeds the sleep last-seen
fallback. A day with no sleep device stays honestly provisional and keeps
the sleep-pending note rather than showing a stale final score.

When a completed sleep segment for last night lands from any of the three
sleep transports — Withings sync-sleep, the WHOOP sync, the Apple-Health
measurement batch — a debounced morning-digest-refresh job is enqueued.
"Last night" is judged in the user's profile timezone, so a full-history
re-sync never re-triggers for an old night. The queue singletonKey plus
the marker collapse the many samples of one night, across all sources,
into one refresh per user per local morning; the job re-runs the same
comprehensive pipeline the nightly cron uses (forced, so the content-hash
gate turns a no-change regen into a cheap no-op) and stamps the marker. A
hard generation failure leaves the day provisional and hands recovery to
the existing 45-minute briefing-retry machinery.
2026-07-16 22:57:27 +02:00
Marc-André Bombeck d72f078764 feat(documents): open the doc-scoped Coach in the side drawer, not a full page
"Ask the Coach" on a document used to navigate to /coach?doc=<id> — a full-page
jump away from the vault. It now opens the real, doc-scoped conversation in the
shared side drawer, and the drawer's maximize control expands it to the full
/coach page while preserving scope.

- Thread an optional documentId through the launch context: askCoach gains a
  fourth argument, the provider tracks it alongside prefill/scope and clears it
  on close, and LayoutCoachMount forwards it to the drawer as initialDocumentId.
  The open-state precedence moves into a pure resolveLaunchState helper.
- The vault detail sheet closes itself and calls askCoach(..., doc.id) instead
  of routing away, so the drawer owns the surface.
- Maximize preserves continuity: the drawer reads the live conversation id via
  a new imperative getter out of the conversation surface (parallel to the
  reset registration) and hands off to /coach?c=<id> once a thread exists,
  falling back to /coach?doc=<id> for a chat maximized before its first turn.

The send path is untouched: every document turn still routes through the
hardened fenced endpoint keyed on documentId — only where the conversation
renders (drawer vs page) changed. The documents-chat e2e now asserts the
drawer opens in place, doc-scoped, rather than a URL change.
2026-07-16 22:34:17 +02:00
Marc-André Bombeck b7a6b4c898 feat(coach): close the plan check-in loop on the Today rail
Every accepted coach plan now earns a gentle weekly check-in. Confirming a
plan (proposed → active) without an explicit review date defaults one to a
week out, so a plan is always reviewed, not only the coach's own experiments.

The daily digest emits a single `coach_checkin` rail item when a standing
plan's review has come due — its pinned review date, or a defaulted
activation + 7 days for plans that predate it, or the read-back state a
reviewed plan carries. Capped at one check-in a day across all plans
(earliest-due wins); the rest wait for a following day. It reads existing
plan state only — no fresh generation, nothing warms on mount, and it is
gated on the coach module.

The card offers three one-tap actions through the existing plan-lifecycle
route: keep it (re-arm for another week), adjust it (open the coach), or let
it go (a guilt-free, respected end — the calm inversion of a streak). An
ignored check-in sits quiet and stops resurfacing after about two cycles;
its status is never changed behind the user's back.

Copy lands in all six locales.
2026-07-16 22:30:46 +02:00
Marc-André Bombeck 7c96abc8ec Merge pull request #512 from MBombeck/release/v1.28.54
v1.28.54 — One hero on the dashboard
2026-07-16 21:52:44 +02:00
Marc-André Bombeck cd3dfb81bc chore(release): v1.28.54 — one hero on the dashboard
Retires the legacy opt-in dashboard hero now that the Today view leads the
dashboard, so the two never stack. Removes the hero mount, its skeleton, the
carousel/spotlight components only it rendered, and the heroVisible layout
field (a stored layout that still carries it is ignored on read, never
re-persisted, so nothing to migrate). The greeting is now always shown. The
mobile-density guard measures the Today hero.

The score-ring selection stays in the layout contract for the native client;
its web picker is a follow-up decision (resurface in the Today view or leave it
native-only).
2026-07-16 21:41:00 +02:00
Marc-André Bombeck 1d592bef78 refactor(dashboard): retire the legacy opt-in hero
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
2026-07-16 21:34:06 +02:00
Marc-André Bombeck d6ad76b1e1 Merge pull request #511 from MBombeck/release/v1.28.53
v1.28.53 — A Today overview at the top of the dashboard
2026-07-16 20:15:13 +02:00
Marc-André Bombeck 08a3ae2ef1 fix(dashboard): pin the Today hero to the skeleton during hydration
The digest query rehydrates from the persisted client cache, so the Today
hero was painting on the first client render while the server had rendered the
skeleton — a text-content hydration mismatch (React #418) that the dashboard
e2e's no-significant-errors gate caught. Gate the hero on `mounted` like the
rest of the dashboard's late-hydrating surfaces, so SSR and the first client
render both show the skeleton and the hero appears once mounted.
2026-07-16 20:03:58 +02:00
Marc-André Bombeck 0be26473b4 fix(dashboard): keep the legacy hero rendering as before
Revert the Today-hero-owns-the-slot suppression from this branch: the legacy
opt-in dashboard hero is asserted by the mobile-density and dashboard e2e
landmarks, and retiring it cleanly (with its settings toggle and those specs)
is a separate change. For now both heroes render exactly as before this branch;
the default account (legacy hero off) still shows only the Today hero.
2026-07-16 19:49:56 +02:00
Marc-André Bombeck 25d720e6e7 chore(release): v1.28.53 — a Today overview at the top of the dashboard
Bundles the daily-digest spine (a single server-built digest read from the
overnight insight cache, never a fresh generation) and the Today hero that
surfaces it: the health score with an honest provisional/finalised face, the
day's lead read, and a short worth-a-look rail of one-tap items. The tiles and
charts stay below, unchanged. The Today hero takes the promoted slot when
Insights is on, so it never stacks with the legacy opt-in hero.

This is the shared foundation the later daily surfaces (morning refresh, an
optional daily note) build on — one digest, one card model, one design language.
2026-07-16 19:37:14 +02:00
Marc-André Bombeck 168c1c85d7 feat(dashboard): promote a Today hero above the tile strip
Surface the day's read at the top of the dashboard: a Today hero that
renders the daily digest — the health score with an honest
provisional/final and sleep-pending face, the briefing lead with a
read-the-full-briefing affordance, and the worth-a-look rail of priority
cards. The dense tile grid and chart rows stay exactly where they are,
one scroll below, so muscle memory survives and no new nav destination
is added.

The hero reads one cached digest cell (GET /api/daily/digest) through the
centralised query-key factory and unwraps the envelope; it recomputes
nothing and never warms a provider on mount. It is gated on the insights
module and self-gates its empty state: a genuinely empty account renders
nothing rather than an alarming card, while an account with a score but
nothing notable gets a calm all-clear line in the rail's place. A shimmer
skeleton holds the footprint while the digest loads.

Priority-card actions wire to their existing destinations by construction
(log dose, reconnect a sync, view check-ups). New copy lands in all six
locales.
2026-07-16 19:31:13 +02:00
Marc-André Bombeck ba6c0990bf feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value
system, with no user surface yet:

- PriorityItem: the one wire-serialisable model behind every "worth a
  look" rail item, coach check-in, milestone, and device-verdict pointer.
  Its Lucide glyph is derived from the closed `kind` enum in the renderer
  so the model itself stays JSON-serialisable across the API and future
  native-client boundaries.
- PriorityCard: a calm, token-only, reduced-motion-aware card composed
  entirely from the shipped Card / TileHeader / ProseBlocks primitives —
  no new visual language, semantic-token status wash only, no markdown.
- buildDailyDigest: one pure, deterministic server composer that assembles
  the day's read from data that already exists — the nightly briefing
  lifted read-only from the insights cache plus the dashboard-snapshot
  health score, meds-today, and sleep freshness. It computes no analytics
  and never reaches an AI provider. It emits the DailyDigest DTO the Today
  surface, the daily push, and a future iOS widget will all consume.
  Deterministic item builders cover overdue doses, broken integrations,
  and due Vorsorge reminders; the push line prefers the cached briefing
  lead with a deterministic score floor so a keyless self-hoster still
  gets a first-class line.

The read seam GET /api/daily/digest gathers the cached inputs, gates on
the insights module (cookie or Bearer; userId from the session), and
returns the digest. OpenAPI regenerated; the route is covered by the
module gate inventory. Digest strings ship in all six locales.
2026-07-16 19:13:01 +02:00
Marc-André Bombeck ecc34880ba Merge pull request #510 from MBombeck/release/v1.28.52
v1.28.52 — Dashboard tiles for more of what you already track
2026-07-16 15:32:30 +02:00
Marc-André Bombeck 7c45c876a0 feat(dashboard): tiles for HRV, blood oxygen, breathing rate, temperature, and body composition
Several readings were synced and charted but had no place on the dashboard
strip: heart-rate variability, blood oxygen, breathing rate, wrist temperature,
and — for body-composition scale users — muscle mass, body water, and bone mass.
Each now has a tile.

The tiles are pure client additions: the dashboard snapshot already ships a
summary for every measurement type, so no backend, query, or migration changed.
Each tile is data-availability gated (renders only when the account has that
reading) and is toggleable and reorderable under Settings like the rest.

hrv and respiratoryRate graduate from iOS-only to web-writable widget ids, and
muscleMass is a new writable id (catalogue 36 to 37, within the 40-id PUT cap);
wristTemperature moves from pin-only to a web-rendered tile. All additive — the
widget enum only widens, which the iOS contract treats as non-breaking.
2026-07-16 15:21:03 +02:00
Marc-André Bombeck e883b64b1f Merge pull request #509 from MBombeck/release/v1.28.51
v1.28.51 — Document chats live in the Coach
2026-07-16 11:34:33 +02:00
Marc-André Bombeck 5440ac9070 test(e2e): repoint document-chat spec to the Coach hand-off
The bespoke document-chat drawer was removed with Design A, so the old e2e
asserting the drawer overlay no longer applies. The spec now pins the
documents-side contract that replaced it: with a provider available the Coach
affordance hands off to the document-scoped Coach (/coach?doc=<id>), and with
no provider the AI area shows only the settings pointer and no Coach entry.

The Coach-side scope banner (the Document badge, "Chatting about", and the
not-indexed hint) moves to a coach-conversation component test, replacing the
drawer's not-indexed/render coverage; the fenced-send guarantee stays pinned by
the coach-send-target unit test.
2026-07-16 11:24:22 +02:00
Marc-André Bombeck 5b24ca0fa1 feat(coach): document chats live in the Coach view and history
Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
2026-07-16 11:01:59 +02:00
Marc-André Bombeck 57539283c8 Merge pull request #508 from MBombeck/release/v1.28.50
v1.28.50 — View your ECG recordings in Insights
2026-07-16 10:06:06 +02:00
Marc-André Bombeck 49ed0f41df feat(insights): view ECG recordings
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
2026-07-16 09:55:35 +02:00
Marc-André Bombeck 7a0a271c70 Merge pull request #507 from MBombeck/release/v1.28.49
release/v1.28.49 — Apple Health re-import no longer stops on a duplicate reading
2026-07-16 09:14:53 +02:00
Marc-André Bombeck 0f4e0be1a6 chore(release): v1.28.49 — Apple Health re-import survives a natural-key collision
The Apple Health ZIP import upserted each spot reading on the
(userId, type, source, externalId) index only. When two readings shared the
natural key (userId, type, measuredAt, source, sleepStage) with different
externalIds — a re-import of overlapping data, or two same-instant Apple
samples from different source apps — the externalId upsert missed and the
create collided on the second unique index (migration 0055), raising P2002
and aborting the entire import (issue #486, after the v1.28.36 dedup fix let
the upload run).

flushSpotBatch now wraps the per-row upsert: on P2002 it probes the occupying
row by the natural key (tombstone-inclusive) and adopts it in place — re-keys
to the incoming externalId, takes the new value/unit, resurrects if
soft-deleted — mirroring the v1.28.39 provider natural-key rescues (closest to
withings/sync-sleep). If no twin resolves it, the one reading is skipped and
the run continues; the catch sits in the per-row loop outside any transaction,
so a collision can never abort the batch or the import. Adopted rows count as
updated, skipped ones surface honestly. Workout + stats: paths verified unable
to hit this (no second unique index / deterministic externalId). Repro +
real-Postgres integration tests added.
2026-07-16 08:48:41 +02:00
Marc-André Bombeck a523d2cc79 Merge pull request #505 from MBombeck/release/v1.28.48
release/v1.28.48 — security follow-ups
2026-07-16 08:20:14 +02:00
Marc-André Bombeck a9a318d4a4 chore(release): v1.28.48 — security follow-ups
From the campaign's security re-verification pass (posture otherwise held
across the 10-release week — no Critical/High):

- MEDIUM: the OIDC auto-provision branch re-introduced the first-admin race
  the password-register route fixed in v1.28.42 — count-then-create with no
  lock, so two concurrent OIDC first-logins (or an OIDC first-login racing a
  password first-registration) in the empty-DB window could both mint ADMIN.
  Now wraps count+role+create in a $transaction taking the SAME advisory lock
  key ('register:first-admin') and re-counts inside, so the two paths
  serialise; every provisioned field preserved.
- LOW: the nutrients batch catch echoed the raw exception message into the
  client-facing reason (violating its closed reason-set). Now returns the
  fixed 'upsert_failed' member and logs the detail server-side only.

No migration (advisory lock is runtime).
2026-07-16 08:03:12 +02:00
Marc-André Bombeck f3a3b8f3a7 Merge pull request #504 from MBombeck/release/v1.28.47
release/v1.28.47 — job queue engine update (pg-boss 12.26)
2026-07-16 07:37:25 +02:00
Marc-André Bombeck 4ec6ae661e chore(release): v1.28.47 — job queue engine update (pg-boss 12.26)
Bumps pg-boss 12.20 → 12.26 (schema-drift detection, self-healing background
index builds, correctness fixes). Some of those fixes surface errors earlier
versions silently swallowed. Verified safe: the app attaches an error listener
to the boss instance (reminder-worker.ts) and uses none of the direct calls the
change affects (insert/fetch/deleteQueue) — only send/work/start/stop/schedule/
getJobById, and the one getJobById is already wrapped in try/catch. The full
integration suite (101 files, 497 tests) exercising the queue against real
Postgres passes unchanged under 12.26. No config change.
2026-07-16 07:26:07 +02:00
Marc-André Bombeck 5b136b91e6 Merge pull request #503 from MBombeck/release/v1.28.46
release/v1.28.46 — server performance on large instances
2026-07-16 07:03:47 +02:00
Marc-André Bombeck 656e96f72b chore(release): v1.28.46 — server performance on large instances
Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):

- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
  with DB prisma.aggregate(_avg) over the exact half-open windows the JS
  reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
  rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
  (per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
   in the 4 backfill discovery scans + migration 0243 (7 additive
  partial indexes matching each predicate). Integration test asserts identical
  discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
  presence — the thread rebuilds it each render), so only the streaming bubble
  re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
  collector uses); the 730d main measurement read left as-is — a blind bound
  would corrupt clinical stats (documented deferred follow-up).

Migration 0243 additive-only.
2026-07-16 06:47:36 +02:00
Marc-André Bombeck a59a5d75e1 Merge pull request #502 from MBombeck/release/v1.28.45
release/v1.28.45 — doctor-report + privacy fixes
2026-07-16 06:01:21 +02:00
Marc-André Bombeck 0796d9fe14 chore(release): v1.28.45 — doctor-report + privacy fixes
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:

- Sleep rendered nothing in the doctor-report PDF: the sleep section is
  default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
  DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
  nothing (FHIR already emitted it). Added it to the allowlist with a
  minutes-to-hours conversion; corrected the stale 'intentionally excluded'
  comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
  glucose module was on, with no report-level privacy control. The toggle
  actually already existed in the UI (with i18n) but was dead because the prefs
  fold dropped the key. Wired it end to end (glucose added to the prefs Zod
  schema + defaults, default-on to preserve behaviour; gated the render loop +
  the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
  GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
  grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
  it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
  skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
  five sources.

Regression test per fix.
2026-07-16 05:51:04 +02:00
Marc-André Bombeck 15a09d77a0 Merge pull request #501 from MBombeck/release/v1.28.44
release/v1.28.44 — loose ends: broken link, API contract, module guards
2026-07-16 05:15:01 +02:00
Marc-André Bombeck cd0fbddd1d chore(release): v1.28.44 — loose ends: broken link, API contract, module guards
From the campaign's dead-code + code-health audits (grep-verified; the
custom-metrics query-key finding was verified-false and excluded):

- sleep-overview empty-state CTA linked to /settings/devices, not a valid
  settings slug (dynamicParams=false → 404). Repointed to
  /settings/integrations (where every device OAuth callback already lands).
- six live, iOS-consumed routes were absent from docs/api/openapi.yaml —
  auth/check-user, insights/cards, integrations/healthkit (GET+PATCH),
  measurement-categories, medications/intake/bulk, mood-entries/bulk —
  registered in the Zod OpenAPI registry mirroring each handler's real shape.
- mood/labs/medications module pages lacked the redirect-when-off self-guard
  their siblings have; added, mirroring cycle/achievements exactly.

No migration. Verifier REJECTED the custom-metrics bare-key finding (they are
invalidateQueries prefix filters, not colliding useQuery keys).
2026-07-16 04:59:27 +02:00
Marc-André Bombeck 8b6024f0d7 Merge pull request #500 from MBombeck/release/v1.28.43
release/v1.28.43 — visual consistency polish
2026-07-16 03:58:08 +02:00
Marc-André Bombeck 6d83115d16 chore(release): v1.28.43 — visual consistency polish
Verified UI-consistency findings from the campaign's design audit, each
pixel-faithful except the intended correction:

- insights hero band: off-scale py-5/gap-5 → the standard scale; H1
  font-semibold → font-bold; sm:text-[28px] → sm:text-3xl.
- medication-compliance-chart + recent-achievements-card: hand-rolled
  muted h-4 icon + text-sm headers → the standard TileHeader primitive so
  they match sibling tiles.
- achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6.
- two text-primary header glyphs (research-mode dialog, enroll-mfa) →
  text-foreground (never accent on a header glyph); the deliberate
  badge-boxed icons left untouched.
- SheetHeader now reserves the trailing close-X gutter like DialogHeader,
  closing the latent overlap class for future raw-Sheet consumers.
- five raw Dialogs on phone-reachable surfaces → ResponsiveSheet.
- new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc.
  only on card shells; list-marker insets + half-steps excluded) at error,
  with the five offenders it flagged swept.
- dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain).

Every data-slot preserved. No migration.
2026-07-16 03:44:00 +02:00
Marc-André Bombeck 3a9ed8dd0e Merge pull request #499 from MBombeck/release/v1.28.42
release/v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
2026-07-16 03:33:46 +02:00
Marc-André Bombeck d4be568625 chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness
audit (the two server-query findings — comprehensive-generate + boot-discovery
distinct — are deferred to a careful separate perf release):

- format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call
  (main-thread stall across every history list). Routed through the existing
  process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle
  in the key); output byte-identical.
- measurement-list rendered every row twice (desktop table + mobile cards, one
  CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe
  useIsMobile(md) hook; row markup + every data-slot unchanged.
- moodDependentKeys omitted dashboardSnapshot(), so a mood write left the
  dashboard tile/score stale ~120s (server cache was already correct). Added it.
- register first-user admin was a count-then-create race (two concurrent
  first sign-ups could both become ADMIN). Now an advisory lock + in-transaction
  re-count makes first-admin atomic; P2002 maps to 409 instead of 500.

Regression test per fix. No migration (0243 still free).
2026-07-16 03:18:55 +02:00
Marc-André Bombeck 9569d1c528 Merge pull request #498 from MBombeck/release/v1.28.41
release/v1.28.41 — complete the translations, guard the gap
2026-07-16 02:49:07 +02:00
Marc-André Bombeck 45fb909c16 chore(release): v1.28.41 — complete the translations, guard the gap
illness.vital.FUNCTIONAL_IMPACT (the recovery-driver track that wins ties
in the illness insights card's driver selection) was referenced but absent
from all six locale bundles, rendering its raw dotted key on a live
surface — the same dynamic-key seam that let the WHO-5/SCI reminder labels
ship untranslated. Added the label in de/en/es/fr/it/pl.

Closed the class structurally: a new enum-driven
dynamic-key-exhaustiveness test imports the source enum/const for each
high-value dynamic key space (illness scan types + functional-impact,
illness type/lifecycle, allergy + family-relationship records enums, the
mental-health instrument slugs — the WHO-5/SCI seam) and asserts every
member's built key resolves non-empty in all six locales, so a missing
translation fails CI instead of reaching a user. Fallback-protected spaces
(Strava raw sport_type) and structurally-unreachable keys are documented
as excluded. Removed one confirmed-orphan key (illness.vital.SKIN_TEMPERATURE,
zero call sites).
2026-07-16 02:35:22 +02:00
Marc-André Bombeck f05354796a Merge pull request #497 from MBombeck/release/v1.28.40
release/v1.28.40 — insight assessments lead with meaning, not a number
2026-07-16 02:25:23 +02:00
Marc-André Bombeck 461c1cfe09 chore(release): v1.28.40 — insight assessments lead with meaning, not a number
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
2026-07-16 02:14:46 +02:00
Marc-André Bombeck 273ead0304 Merge pull request #496 from MBombeck/release/v1.28.39
release/v1.28.39 — sync data-loss hardening
2026-07-16 01:56:12 +02:00
Marc-André Bombeck 7386630962 chore(release): v1.28.39 — sync data-loss hardening
Four verified HIGH data-integrity fixes, each in-place per-provider (no
shared helper), from the campaign's data-integrity audit + adversarial
verification:

F1 — drain-per-sample-cumulative buildScanWhere was the only one of four
sibling drains missing deletedAt: null; the nightly cumulative fold
re-summed soft-deleted Apple samples and hard-deleted the legacy-steps
audit tombstones. Added the filter (mirrors consolidate-daily-mean,
dense-intraday-retention, consolidate-legacy-steps).

F2 — the additive late-fold merge had no single-flight guard, so two
overlapping runs (deploy overlap, or the admin endpoint racing the cron)
produced a permanent 2x daily total. Added a pg_advisory_xact_lock per
(user, externalId) plus an in-transaction re-read of the live
contributors so the serialised-second fold contributes zero (a bare lock
alone would still re-add its stale scanned sum). Mirrors the documents
quota advisory-lock pattern.

F3 — Withings sleep externalId keyed on segment START while measuredAt +
the natural key use END; a re-scored segment missed the externalId probe,
the sweep tombstoned the old row, and the re-keyed insert collided on the
natural key and wedged. Added the natural-key rescue (re-key in place),
mirroring the same-file measure path.

F4 — Withings measure sync stamped lastSyncedAt + recordSyncSuccess
unconditionally after a warn-only per-row loop; a transient write failure
stranded the reading behind the 10-min overlap forever. Now holds the
watermark and records failure on a hard row error (mirrors
google-health/fitbit), while a benign P2002 still advances.

Regression test per finding (reverting the three source files fails
exactly four tests). Integration suite green against real Postgres.
2026-07-16 01:45:05 +02:00
Marc-André Bombeck 51d42f9422 Merge pull request #495 from MBombeck/release/v1.28.38
release/v1.28.38 — documents polish, waist reminders, bulk sharing
2026-07-16 01:05:35 +02:00
Marc-André Bombeck 3e917969c7 fix(documents): surface the read-by-AI provenance in the detail view
v1.28.38 removed the purple AI-read glyph from the vault card (it looked
wrong there), but that glyph had been the only visible surface for the
'read by AI' provenance since v1.28.22 — the detail sheet's AI section
shows no status and collapses entirely under auto-read. Add a discreet
muted 'Read by AI' line under the document's uploaded-metadata in the
detail view (data-slot document-detail-ai-read), independent of the AI
section's collapse logic; reinstate documents.ai.statusAiRead in all six
locales. Card glyph stays removed. The e2e that asserted the card marker
now deep-links the document and asserts the detail-view line.
2026-07-16 00:54:01 +02:00
Marc-André Bombeck 4852421f05 chore(release): v1.28.38 — documents polish, waist reminders, bulk sharing
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.

Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).

Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
2026-07-16 00:33:20 +02:00
Marc-André Bombeck ea71ba2f37 Merge pull request #494 from MBombeck/release/v1.28.37
release/v1.28.37 — provider step totals survive a restart
2026-07-15 23:07:17 +02:00
Marc-André Bombeck 0ba5334b57 fix(measurements): legacy step consolidation no longer sweeps provider daily totals
WIP-commit (worktree safeguard; final assembly + version bump happen in the main tree after v1.28.36 merges).
2026-07-15 22:49:07 +02:00
Marc-André Bombeck 8b1c5deac7 Merge pull request #493 from MBombeck/release/v1.28.36
release/v1.28.36 — re-importing after a failed Apple Health import actually retries
2026-07-15 22:34:21 +02:00
Marc-André Bombeck 7a6598f7c3 test(e2e): stabilise the profile-timezone sessions-card expand
The sessions card toggle's onClick could be dropped when Playwright
clicked before React hydrated the handler, leaving the region hidden for
the whole wait and failing intermittently in CI. Retry the click until
aria-expanded actually flips.
2026-07-15 22:23:04 +02:00
Marc-André Bombeck 82aa66b400 chore(release): v1.28.36 — re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.

reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
2026-07-15 22:07:25 +02:00
Marc-André Bombeck a614542cc3 Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
2026-07-15 21:37:20 +02:00
Marc-André Bombeck 132c113305 Merge pull request #491 from MBombeck/release/v1.28.35
release/v1.28.35 — timestamps honor the profile timezone
2026-07-14 20:32:36 +02:00
Marc-André Bombeck 9bbd8a89dc chore(release): v1.28.35 — timestamps honor the profile timezone
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
2026-07-14 20:19:23 +02:00
Marc-André Bombeck 136653e3d7 Merge pull request #483 from MBombeck/dependabot/npm_and_yarn/shadcn-4.13.0
chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
2026-07-14 13:31:50 +02:00
Marc-André Bombeck 1e8d796c57 Merge pull request #489 from MBombeck/release/v1.28.34
release/v1.28.34 — nutrient intake lands as quiet context (server side)
2026-07-14 13:22:33 +02:00
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
dependabot[bot] a074286951 chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.9.0 to 4.13.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.13.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 10:28:31 +00:00
Marc-André Bombeck 14a9a08a6e Merge pull request #482 from MBombeck/dependabot/npm_and_yarn/types/node-26.1.1
chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
2026-07-14 12:26:33 +02:00
Marc-André Bombeck 0b7d07dce4 Merge pull request #481 from MBombeck/dependabot/npm_and_yarn/lefthook-2.1.10
chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
2026-07-14 12:26:30 +02:00
Marc-André Bombeck bdae462948 Merge pull request #480 from MBombeck/dependabot/npm_and_yarn/simplewebauthn/server-13.3.2
chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
2026-07-14 12:26:28 +02:00
Marc-André Bombeck bb1008f1a1 Merge pull request #478 from MBombeck/dependabot/github_actions/docker/login-action-4.4.0
chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
2026-07-14 12:26:25 +02:00
Marc-André Bombeck ac1105751d Merge pull request #488 from MBombeck/release/v1.28.33
release/v1.28.33 — re-importing a cumulative Apple Health export works again
2026-07-14 12:09:13 +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 b57c517929 docs: self-hosting SSO guide
Setup walk (byte-exact redirect URI), client-registration essentials
for Authentik / Keycloak / Authelia / Pocket-ID, the security model
(identity pinned to (issuer, sub) after a one-time verified-email link;
SSO sessions never satisfy step-up; native-2FA accounts still get their
second-factor step; MFA policy belongs at the IdP), the OIDC_ONLY
consequences incl. the iOS lockout and the break-glass runbook, and a
troubleshooting table for the recurring failure shapes (issuer
trailing slash, unfollowed discovery redirects, clock skew,
IdP-side redirect_uri mismatch).
2026-07-14 11:55:47 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Marc-André Bombeck 9b8b31c864 fix(auth): OIDC sessions never satisfy MFA step-up; 2FA accounts get the challenge step
Two second-factor gaps in the OIDC callback:

The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.

An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).

Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
2026-07-14 11:46:40 +02:00
Marc-André Bombeck fa326c398a feat(auth): pin OIDC accounts to their (issuer, sub) identity
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
2026-07-14 11:43:53 +02:00
Marc-André Bombeck 5b7ea48c46 docs: PR template targets main — the develop branch was retired 2026-07-14 10:36:25 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck 6712619495 Merge pull request #484 from MBombeck/release/v1.28.32
release/v1.28.32 — Fitbit: re-keyed re-imports can no longer be silently dropped
2026-07-12 20:42:48 +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
dependabot[bot] c41ca511cb chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.1.0 to 26.1.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:55:10 +00:00
dependabot[bot] 9ba34befce chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
Bumps [lefthook](https://github.com/evilmartians/lefthook) from 2.1.9 to 2.1.10.
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.9...v2.1.10)

---
updated-dependencies:
- dependency-name: lefthook
  dependency-version: 2.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:54:35 +00:00
dependabot[bot] a21dc50cab chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:53:54 +00:00
dependabot[bot] 4dfb207a6d chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:52:32 +00:00
Marc-André Bombeck 14b61e1084 Merge pull request #477 from MBombeck/release/v1.28.31
release/v1.28.31 — heart-rate history keeps its daily shape
2026-07-12 12:19:23 +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 85267f15b3 Merge pull request #476 from MBombeck/release/v1.28.30
release/v1.28.30 — the daily briefing stops silently skipping days
2026-07-12 11:20:46 +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 14a517387e Merge pull request #475 from MBombeck/release/v1.28.29
release/v1.28.29 — dashboard edits show up on the way back; one scrollbar again
2026-07-11 12:16:24 +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 16f7869ffa Merge pull request #474 from MBombeck/release/v1.28.28
release/v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
2026-07-11 10:52:22 +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 132ede8833 Merge pull request #473 from MBombeck/release/v1.28.27
release/v1.28.27 — runs on CPUs without AVX2
2026-07-11 10:15:46 +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 da0e509095 Merge pull request #471 from MBombeck/release/v1.28.26
release/v1.28.26 — internal restructuring for maintainability
2026-07-11 08:55:16 +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 4303ddaf37 Merge pull request #469 from MBombeck/release/v1.28.25
release/v1.28.25 — every integration held to the same standard
2026-07-11 02:05:44 +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 1085a1e056 Merge pull request #468 from MBombeck/release/v1.28.24
release/v1.28.24 — a deleted Google reading no longer blocks its re-import
2026-07-11 00:46:35 +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 1c0f91adc1 Merge pull request #467 from MBombeck/release/v1.28.23
release/v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork
2026-07-11 00:28: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 755da41019 Merge pull request #466 from MBombeck/release/v1.28.22
release/v1.28.22 — full sync survives dense histories; quieter document chrome
2026-07-10 23:28:31 +02:00
Marc-André Bombeck 3da6c781ed test(e2e): surface the provenance card via search; anchor the auto-read sheet on its footer
The virtualized vault grid only mounts cards near the viewport, so the
provenance marker must be brought into view the way a user would — through the
content search that already backs the neighbouring spec. The auto-read sheet
assertion anchors on the footer's download action (rendered only once the
document GET resolved) so the absence checks cannot pass vacuously against the
loading skeleton.
2026-07-10 23:18:07 +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 94ad48527e Merge pull request #465 from MBombeck/release/v1.28.21
release/v1.28.21 — sleep history heals itself; plateau context; sync diagnostics
2026-07-10 21:33:18 +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 49be6851ec Merge pull request #464 from MBombeck/release/v1.28.20
release/v1.28.20 — estimated drug level in efficacy + insights
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
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
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
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
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
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)
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
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
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
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
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
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)
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
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
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
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 9d37792ca4 Merge pull request #448 from MBombeck/release/v1.28.4
release/v1.28.4 — medication efficacy
2026-07-09 13:30:50 +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 45b7eb53ff Merge pull request #447 from MBombeck/release/v1.28.3
release/v1.28.3 — consistent spacing and alignment
2026-07-09 12:28:24 +02:00
Marc-André Bombeck f1c468f90f chore(release): v1.28.3 — consistent spacing and alignment
A layout-audit-driven consistency pass: one shared left edge for insight cards
(headings and body align, worst on mobile), no scroll past content on settings
and admin pages, Settings/Notifications only in the account menu on mobile, the
dashboard action beside its greeting text, and preventive-care/cycle/onboarding
cards on the shared card anatomy. The spacing and alignment rules are recorded
in the standards. Also stages the document rasterization native binding in the
image so a scanned PDF is read on an image-only AI provider.
2026-07-09 12:17:54 +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 b0ae1b54f0 Merge pull request #446 from MBombeck/release/v1.28.2
release/v1.28.2 — automatic AI document reading (opt-in)
2026-07-09 11:46:04 +02:00
Marc-André Bombeck 23309acd6a test(documents): mock the full AI-availability surface in the auto-read e2e
The auto-read vault specs mocked only the document capability probe, leaving
the usage endpoint (assist + content-index flags) unmocked — so the read
action rendered but stayed disabled and the click timed out. Mock the same
three endpoints the working document-AI e2e does (usage, labs OCR capability,
document capability) so the action is enabled.
2026-07-09 11:35:03 +02:00
Marc-André Bombeck 1a43880ab4 chore(release): v1.28.2 — automatic AI document reading (opt-in)
One opt-in in AI settings reads every uploaded document with the configured
provider — no per-document tap. Off by default; the vault stays local-first
otherwise. Scanned PDFs and photos are read too (a PDF's pages are rendered to
images so an image-only provider can read them). The background indexer now
follows the same opt-in, so nothing leaves the machine automatically unless it
is enabled.
2026-07-09 11:17:07 +02:00
Marc-André Bombeck f2263361b6 style(documents): format document-settings read helper 2026-07-09 11:02:20 +02:00
Marc-André Bombeck 4f145eadd7 docs(documents): document the automatic AI reading opt-in; add e2e
- ai-providers.md: an off-by-default 'Automatic document reading' section
  (what ON sends where, the subscription-training / no-BAA honesty, local =
  no egress, regulated users → BYOK no-train API or local) and a vault
  local-first caveat
- e2e: the settings honesty-confirm flow (switch → confirm once → write) and
  the vault per-egress-notice / explicit-read-action contract, with the
  document capability endpoint mocked on every path
2026-07-09 10:58:15 +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
Marc-André Bombeck 813dc2547b feat(documents): add per-user automatic AI reading opt-in
Add the documentsAutoAiRead per-user Boolean (migration 0231, default
false) that authorises ambient AI reading of uploaded vault documents.
OFF by default keeps the vault local-first for every self-hoster.

- schema column + migration 0231_documents_auto_ai_read
- documentAutoReadEnabled(userId) read helper
- GET/PATCH /api/auth/me/documents-auto-ai-read toggle route; the ON
  write mints an ai_full consent receipt for the audit trail
- the document consent gate short-circuits an external pick when the
  toggle is ON; a local pick stays ungated and OFF still requires a
  receipt for any external egress
2026-07-09 10:32:59 +02:00
Marc-André Bombeck 168a8ea5c5 chore(security): suppress npm-bundled sigstore CVE-2026-48815 in trivy
The finding is npm's OWN bundled sigstore (v3.1.0) inside the Node base
image, used for publish provenance at build time only. It is not in our
application dependency tree and is unreachable at runtime — same class as the
already-suppressed picomatch entry. Re-check when the base image ships an npm
with sigstore >= 4.1.1.
2026-07-09 10:06:20 +02:00
Marc-André Bombeck 3483e7c67e Merge pull request #445 from MBombeck/hotfix/v1.28.1-document-load
hotfix/v1.28.1 — document vault load fix
2026-07-09 09:41:15 +02:00
Marc-André Bombeck 951db4947e fix(documents): load the local PDF parser lazily so the vault routes stop crashing
The local content-extraction reader imported pdf-parse at module top level.
pdf-parse pulls in pdfjs, whose module evaluation references the browser-only
DOMMatrix global; on the server that reference throws during chunk
instantiation and takes down every route sharing the chunk — the document
list, upload, and search among them, so old and new documents alike fail to
load. Move the parser to a lazy import() inside the extraction function
(surface type kept as a type-only import). Verified under plain Node that the
lazy path still extracts a real PDF text layer, so local indexing is
unaffected.
2026-07-09 09:29:35 +02:00
Marc-André Bombeck 403cdf8cab chore: drop stray screenshot-harness files, ignore the pattern
Three local Playwright screenshot harnesses at the repo root were picked up
by a wide stage during the v1.28.0 assembly. They are never imported by the
app build; remove them and ignore the .audit-*.mjs pattern so a root-level
add can't recatch them.
2026-07-09 08:59:46 +02:00
Marc-André Bombeck 3ef067fb53 Merge pull request #444 from MBombeck/release/v1.28.0
release/v1.28.0 → main
2026-07-09 08:58:32 +02:00
Marc-André Bombeck b143576627 chore(release): v1.28.0 — document intelligence
Marks the document line as one coherent milestone: the vault becomes a
place to understand files, not just store them — read, indexed, searched,
shared, and answered — with a local-first / consented AI posture and a
vendor-blind egress notice held at every step. Ships alongside a full
correctness, resilience, and quality sweep down to the low-priority findings.
2026-07-09 08:42:23 +02:00
Marc-André Bombeck 1c7ab080f0 Merge pull request #443 from MBombeck/release/v1.27.33
Release v1.27.33 — chat about a document
2026-07-09 04:18:33 +02:00
Marc-André Bombeck 6577bd287e chore(release): v1.27.33 — chat about a document 2026-07-09 04:08:38 +02:00
Marc-André Bombeck c1bbb01afd test(documents): serialise vault-fixture seeding across parallel e2e workers
fullyParallel spreads a spec's tests across workers and each re-runs the
file's beforeAll; two workers seeding the fixed-id vault corpus at once
raced on the document_condition_links primary key (ON CONFLICT only
suppresses its named arbiter index). Guard both seeders with a Postgres
advisory lock so concurrent workers seed one-at-a-time and every insert
re-runs as a clean idempotent upsert.
2026-07-09 04:01:13 +02:00
Marc-André Bombeck a38b836089 test(documents): e2e injection + XSS-inert render for the document chat 2026-07-09 03:43:26 +02:00
Marc-André Bombeck 8cead5ccdb test(documents): e2e for the document chat panel; clear tail on settle
Add an e2e suite mocking the capability probe, the chat history GET, and the
streaming chat POST so the panel renders (v1.27.31 lesson): the entry opens an
empty safety-noted surface, a turn streams a reply that settles to one assistant
bubble, a non-indexed document shows the calm hint, and no provider leaves no
chat surface. Clear the streamed tail + optimistic bubble once the persisted
twin lands so the reply never double-renders.
2026-07-09 03:24:03 +02:00
Marc-André Bombeck 2ede0886c3 test(documents): split a pure chat body + pin its render states
Extract DocumentChatConversation (pure) from the chat panel so the open
conversation surface is statically renderable, and pin: composer + always-on
safety note, plain-text/XSS-safe assistant render (raw HTML escaped), the
streaming tail vs thinking placeholder, mapped error keys, and the container's
indexed vs not-indexed gating. Assert the AI section renders the chat slot only
in the provider-available branch.
2026-07-09 03:19:42 +02:00
Marc-André Bombeck 635c45011b feat(documents): chat-about-this-document panel in the detail sheet AI area
Add a scoped 'Chat about this document' panel beneath the Read-with-AI
actions. Opens a conversation grounded only in the document's stored text:
a message list + input streaming the assistant reply token-by-token over
the SSE route, with prior turns loaded via GET.

- Availability: the panel renders in the provider-available branch of the
  AI section (a pure chatSlot keeps the section statically renderable), and
  gates internally on whether the document is content-indexed. Not indexed
  shows a calm read-it-first hint, never an error.
- Plain-text only: assistant prose renders via ProseBlocks / StreamedProse
  as React children (no markdown library, no dangerouslySetInnerHTML).
- Streaming reuses the Coach SSE frame parser; the wire frames are a subset.
- A one-line safety note states answers describe the document and are not
  medical advice, consistent with the summary panel's not-a-diagnosis.
- Query keys via the central factory; history invalidated on the done frame.
- i18n x6 for every new string.
2026-07-09 03:16:06 +02:00
Marc-André Bombeck 09bc94a1f0 chore(crypto): register + rotate verbatim index column; format 2026-07-09 03:00:24 +02:00
Marc-André Bombeck 718388ee04 test(documents): document-chat prompt, verbatim capture, and route security-model tests 2026-07-09 02:50:43 +02:00
Marc-André Bombeck 6e84048217 feat(documents): document-chat SSE route + history GET + OpenAPI (no-tools, no-snapshot, fenced, consent/budget/rate) 2026-07-09 02:44:55 +02:00
Marc-André Bombeck f5e4dc2b94 feat(documents): verbatim capture, chat prompt builder, budget, persistence documentId threading 2026-07-09 02:37:38 +02:00
Marc-André Bombeck d4d04c75f4 feat(documents): add document_id chat discriminator + verbatim index column (mig 0230) 2026-07-09 02:34:06 +02:00
Marc-André Bombeck 2b9d5a41c8 Merge pull request #442 from MBombeck/release/v1.27.32
Release v1.27.32 — quality-of-life, accessibility, cold-start and performance polish
2026-07-09 02:26:16 +02:00
Marc-André Bombeck 30b745c62f chore(release): v1.27.32 — quality-of-life, accessibility, cold-start and performance polish 2026-07-09 02:14:31 +02:00
Marc-André Bombeck e4eb3b04ab Merge branch 'fix/qol-a11y-sweep' into release/v1.27.32
# Conflicts:
#	src/components/dashboard/recent-workouts-tile.tsx
2026-07-09 02:09:57 +02:00
Marc-André Bombeck 57c7972755 Merge branch 'fix/coldstart-perf' into release/v1.27.32 2026-07-09 02:08:46 +02:00
Marc-André Bombeck 52a01b1bea Merge pull request #441 from MBombeck/release/v1.27.31
Release v1.27.31 — document AI provider governance
2026-07-09 01:57:20 +02:00
Marc-André Bombeck de93e623f2 test(e2e): mock the new document-scoped capability endpoint
v1.27.31 routed the document AI section through a new document-scoped
capability probe (/api/documents/inbound/capability) instead of the labs
OCR probe. The AI-flow e2e helper only mocked the old probe, so the section
fell back to unavailable and the assist actions never rendered — the tests
timed out waiting for them. Mock the new endpoint with matching availability;
egress-notice behaviour is covered by the SSR component test.
2026-07-09 01:46:22 +02:00
Marc-André Bombeck e1a8da5dfb chore(release): v1.27.31 — document AI provider governance 2026-07-09 01:32:14 +02:00
Marc-André Bombeck 7b1f077888 Merge branch 'fix/docai-governance-rebased' into release/v1.27.31 2026-07-09 01:28:07 +02:00
Marc-André Bombeck aff845875e Merge pull request #440 from MBombeck/release/v1.27.30
Release v1.27.30 — correctness and resilience sweep
2026-07-09 01:16:04 +02:00
Marc-André Bombeck 3d53aa50aa fix(i18n): merge duplicate documents.ai block so egress notice resolves
The merge of the pre-v1.27.29 governance branch left two documents.ai
blocks in each locale bundle: the consolidated Read-with-AI keys and a
separate egressNotice-only block. Duplicate JSON keys drop the first, so
documents.ai.egressNotice never resolved. Fold egressNotice into the
single documents.ai block across all six locales.
2026-07-09 01:10:45 +02:00
Marc-André Bombeck 6165e7f6b8 chore(release): v1.27.30 — correctness and resilience sweep 2026-07-09 01:05:47 +02:00
Marc-André Bombeck 4d6aab2816 Merge branch 'fix/docai-provider-governance' into fix/docai-governance-rebased
# Conflicts:
#	src/components/documents/__tests__/document-ai-section.test.tsx
#	src/components/documents/document-ai-section.tsx
2026-07-09 01:05:35 +02:00
Marc-André Bombeck 20275d19e0 Merge branch 'fix/resilience' into release/v1.27.30 2026-07-09 01:00:04 +02:00
Marc-André Bombeck 9ce88f01df Merge branch 'fix/tz-correctness' into release/v1.27.30 2026-07-09 01:00:04 +02:00
Marc-André Bombeck 023dc4ee4c Merge pull request #439 from MBombeck/release/v1.27.29
Release v1.27.29 — automatic document indexing and AI reading
2026-07-09 00:49:17 +02:00
Marc-André Bombeck 40c46f6f5b test(e2e): scope the AI-read card-marker assertion to desktop
The vault timeline is virtualized; on the narrow mobile viewport the seeded
Radiology-note card renders outside the initial virtual window (not in the
DOM), so the card-level attribute read waited out the full test timeout on a
non-existent node. The provenance contract is asserted on every project via
the detail status pill; the card marker (viewport-independent render logic,
also covered by the SSR card test) is asserted on desktop where the card is
reliably in view.
2026-07-09 00:39:24 +02:00
Marc-André Bombeck 46e6842b55 chore(i18n): drop the orphaned side-effect deleteConfirm key
Replaced by deleteConfirmTitle/deleteConfirmBody when the native window.confirm
became a themed AlertDialog.
2026-07-09 00:26:13 +02:00
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 1a95739561 fix(crypto): log swallowed decrypt on undecryptable read-path fields
The cycle/illness/records note DTOs, the biomarker context, and the custom
mood/cycle labels each degraded an undecryptable value to a blank/generic
fallback with no signal — a key-rotation gap read as 'left blank' and the
catch was silent. Keep the fail-soft read posture (one bad row never 500s
the list) but emit a wide-event on each swallowed decrypt so a systemic
key gap surfaces. The visible per-field 'unreadable' marker in the UI is
the deferred remainder.
2026-07-09 00:21:16 +02:00
Marc-André Bombeck 96c99f4dfa fix(medications): render intake dialogs as bottom sheets on mobile
Port the six medication dialogs (log-intake, intake-edit, dose-history-add,
take-all-due, log-injection-site, intake-import) from the centred Dialog to
ResponsiveSheet with the sticky-footer slot, matching every other module's
add/edit form. Form-backed dialogs associate their footer submit button via a
useId form id so Enter-to-submit and the footer button share one form; the
nested slot-nudge confirm rides the same sheet. Primary actions now sit within
thumb reach on phones.
2026-07-09 00:13:08 +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é Bombeck c76c3f373e chore(documents): drop unused import + prettier the openapi table 2026-07-09 00:04:34 +02:00
Marc-André Bombeck 801aa73de0 style: prettier formatting 2026-07-09 00:04:21 +02:00
Marc-André Bombeck 6831dc2a15 test(offline-banner): update copy assertions to the honest offline message 2026-07-09 00:03:25 +02:00
Marc-André Bombeck 1c57da7d44 fix(labs): mark un-linked lab groups and bridge biomarker detail to source reports
- Lab groups not backfilled to a catalog biomarker now carry a muted
  'not linked to a biomarker yet' affordance in both list and card views, so
  an inert row no longer reads as a broken link next to clickable neighbours.
- The biomarker detail page links to the document vault filtered to
  lab-result reports (gated on the documents module), giving a path from a
  value to its source scans. A per-value document relation remains a
  data-model follow-up.
2026-07-09 00:02:56 +02:00
Marc-André Bombeck 7f968135b5 refactor(theme): back the provider with useSyncExternalStore
Reading localStorage/matchMedia in a mount effect tripped the
react-hooks/set-state-in-effect rule. Model the stored preference and the
OS preference as external stores read via useSyncExternalStore with an
SSR-stable 'dark' server snapshot, and apply the <html> class from a
DOM-only effect — no setState-in-effect, same hydration-seam fix.
2026-07-09 00:00:36 +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 3faa4ee76e fix(coach): log swallowed self-context decrypt failures
The Coach self-context loader failed soft per field (undecryptable
allergies/conditions → null) with no signal, so a key-rotation gap made
the Coach generate guidance without ever seeing the user's recorded
allergies, indistinguishable from an empty profile. Keep the per-field
fail-closed posture but emit a wide-event on each swallowed decrypt and on
the DB-read guard, so a systemic failure surfaces instead of masquerading
as 'the user never wrote anything'.
2026-07-08 23:59:37 +02:00
Marc-André Bombeck e5ca125dfb fix(onboarding,integrations,cycle): deep-link source chips + cycle settings wrench
- Onboarding 'more sources' chips anchor to their provider card
  (/settings/integrations#oura etc.); connection cards gain scroll anchors.
  Apple Health (no web card) stays on the page root.
- Cycle module gains a header settings wrench for parity with labs /
  medications / illness; the tab strip is now controlled so the wrench jumps
  to the settings tab and ?tab= deep-links keep working.
2026-07-08 23:59:10 +02:00
Marc-André Bombeck 9eadd0755f fix(withings): widen incremental overlap from 60s to 10 min
Withings backdates some readings and lastSyncedAt advances on every
success (including a healthy 200-with-0 cycle), so a reading landing more
than 60s before the next cycle's now() could be missed. Widen the poll
catch-up overlap to 10 minutes — the upserts are idempotent, so re-touching
a handful of rows is harmless.
2026-07-08 23:57:02 +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 40be4d180d feat(boot): print a first-boot readiness summary before fail-closed loaders throw
The fail-closed crypto loader throws a stack trace on a missing/short
ENCRYPTION_KEY rather than a sentence an operator can act on, and
check-env is a dev/CI tool the image operator never runs. Add a boot-time
readiness summary (instrumentation register) that prints a clear line per
core secret — with the exact openssl fix on a bad one — before anything
throws, plus a one-glance status of the optional channels and the
Secure-cookie transport hint. Never logs secret values; never throws.
2026-07-08 23:55:33 +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é Bombeck 24dafeafee feat(insights): make the AI no-provider empty state legible and privacy-first
The daily-briefing no-provider state now leads with the local-model path
(nothing leaves your network) before the BYO-key option and shows a
concrete worked-example briefing so a new user can see the payoff before
spending a key. Light discoverability improvement only — the larger
onboarding rework (a first-class AI step) is left for the maintainer.
2026-07-08 23:53:29 +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é Bombeck 930fa81124 fix(a11y): switch names, contrast floors, and chart-toggle pressed state
- Admin SettingsToggle: name the Radix switch via aria-label so it no longer
  announces as a bare 'switch, on/off' (one shared component, six call sites).
- Replace the phantom text-destructive-foreground / text-warning-foreground
  utilities with the vetted destructive pattern (bg-destructive text-white
  dark:bg-destructive/60) and a readable foreground on the warning wash — no
  more dead classes riding on cascade order.
- Drop the sub-AA opacity tiers: lab sparkline + titration marker off
  muted/70, state icons (query-error, chart empty/error, med sort) off
  muted/60 to the full token so they clear the light-theme floor.
- Chart range toggles expose aria-pressed (lab, custom-metric, health, mood,
  compliance) so the active window is conveyed to assistive tech.
2026-07-08 23:51:22 +02:00
Marc-André Bombeck 081bb2289d fix(pwa): fail offline mutations fast instead of pausing + silent write loss
Mutations ran in TanStack's default 'online' network mode, which PAUSES a
write fired offline: isPending stays true forever, onError never fires,
and the paused mutation is dropped on reload — a health entry vanishes
with no 'not saved' signal. Switch mutations to 'always' network mode so
an offline write rejects immediately and the form surfaces an honest
failure, and add a global OfflineMutationToaster backstop for call sites
without their own onError. Correct the offline banner copy, which promised
changes would auto-save.
2026-07-08 23:51:08 +02:00
Marc-André Bombeck 451946b4fa feat(admin): quiet reminder while public registration is open
Registration stays open after the first admin so a household can be
invited, but on an internet-exposed instance that means anyone who can
reach the URL can sign up — and nothing prompted the admin to close it.
Surface a muted hint under the registration toggle whenever it is open
(no behaviour change), and note the reality in getting-started's
first-user section.
2026-07-08 23:51:00 +02:00
Marc-André Bombeck 88bff471cc test(tz): pin DST-safe today bounds, user-tz compliance bucketing, greeting zone
- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today,
  a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary
  days stay 24h, and a negative-offset zone anchors on the local day.
- dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day
  doses land in ONE bucket where the old fixed-Berlin key split them.
- classifyIntakeTiming: an 08:00-local dose reads on_time with the user's
  zone and very_late without it.
- hourInTz: reads the wall-clock hour in the given zone.
- hero-strip greeting: resolves the hour in the stored zone, not the
  browser clock; existing greeting fixtures re-pinned to explicit UTC
  instants mapped to Berlin wall-clock.
2026-07-08 23:50:08 +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 80bbf2ba3b perf(ui): harden theme-provider hydration seam + cache per-row Intl formatters
Theme provider now initializes to the SSR-stable default and reconciles
the stored preference in a mount effect instead of reading localStorage
in the useState initializer — closes the same React #418 hydration seam
the sidebar already fixed (the inline script still handles the pre-paint
class, so no FOUC). Add a process-wide Intl formatter cache and route the
per-row workout/medication list helpers through it so a formatter is
constructed once per (locale, options) rather than once per row per
render.
2026-07-08 23:48:24 +02:00
Marc-André Bombeck 9f4c7e48fe fix(ui): bake password-manager autofill guard into PasswordInput
Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no
autocomplete token, so PasswordInput now defaults to autoComplete=off plus
data-bwignore / data-1p-ignore / data-lpignore for them — Bitwarden /
1Password / LastPass no longer offer to fill or save a config secret.
Credential fields (login, register, password change) pass a real token and
keep manager integration untouched. One change covers every secret call site.
2026-07-08 23:47:46 +02:00
Marc-André Bombeck a53dd2b59c fix(doctor-report): mark an undecryptable allergy reaction instead of blanking it
The clinician-facing export failed SOFT on an undecryptable allergy
reaction (→ null, rendered as a bare '—' indistinguishable from 'no
reaction recorded') while a sibling dose-change note fails CLOSED. Extract
a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from
'unset', log the swallowed decrypt, and render an honest localized marker
in the PDF so a clinician is never shown a silent blank where an
unreadable reaction exists. Read-path only — the fail-closed write posture
is untouched.
2026-07-08 23:46:54 +02:00
Marc-André BombeckandMarc-André Bombeck f7ad2339ae feat(documents): document-scoped provider order and egress consent
Add a document-class provider resolution that demotes external/train-by-default
providers below local (local -> BYOK no-train API -> operator key -> codex last)
and a pick-based document-egress consent gate that requires an active receipt
for any external provider. Coach/insights keep the app-wide cost-first order.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:46:44 +02:00
Marc-André Bombeck 3fcae204f7 perf(backup): keyset-page the intake/mood/cycle reads in the off-host DR dump
Measurements were already paged, but the medication-intake, mood, and
cycle tables in the same per-user payload were still unbounded full-table
findMany reads — peak heap tracked the largest tenant's history. Give
them the same id-keyset page walk (full row projection, so payload bytes
are unchanged) so at most one page is resident per round-trip.
2026-07-08 23:45:04 +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 982fe53b51 fix(measurements): fall back to live SQL when the rollup read throws
The daily-series reader only fell back to live SQL on a coverage MISS
(zero rollup rows); a transient rollup-table error (statement_timeout,
deadlock, connection reset) was unwrapped and 500'd the tile on the
single-type route even though live SQL could serve it — and diverged from
the batched series route, which already caught. Wrap the primary daily
read and the long-range tiered read: treat a throw as a coverage miss and
fall through to the live date_trunc aggregate.
2026-07-08 23:41:52 +02:00
Marc-André Bombeck 62c7278275 fix(fitbit): port the non-rethrowing hard-fail ledger from Google Health
Fitbit's handleCollectionFetchError soft-skipped a 403 but RETHREW any
other hard failure, aborting every endpoint ordered after the bad one in
the same resource and stalling the watermark so the whole integration
stayed dead. Record hard failures on a per-cycle AsyncLocalStorage ledger
and return 0 instead of rethrowing — the sibling endpoints keep fetching,
and the ledger still fails the cycle so success is not stamped and the
next tick refetches. Also wrap the per-metric map fan-out in the same
per-collection catch so one malformed point can't skip its siblings.
2026-07-08 23:40:36 +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 fb6813bcdb fix(medications): bucket the local day in the user's zone, DST-safe
Five timezone-correctness fixes across the medication / compliance surfaces:

- getUserTodayBounds now derives both bounds through the DST-safe
  startOfLocalDayInTz, so the today window is 23h on spring-forward and
  25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
  local dose no longer drops off the med cards, dashboard, PWA badge and
  reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
  per-day rate with userDayKey, so a non-Berlin user's evening dose is
  filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
  the achievements perfect-day check passes the user's zone and reads the
  scheduled minute-of-day in-zone, so a dose far from UTC is graded
  against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
  the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
  schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
  include, so a future engine column reaches both surfaces.
2026-07-08 23:38:48 +02:00
Marc-André Bombeck 50031911c2 feat(auth): warn on a Secure-cookie / plain-HTTP transport mismatch at login
The most common self-host login loop is a Secure session cookie issued
over plain HTTP: the browser drops it and bounces the user back to the
sign-in page with no error. Detect it at cookie-mint time
(detectInsecureCookieTransport) and emit a clear warn-log plus a
session_cookie_secure_transport_mismatch wide-event, pointing at
SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still
completes.
2026-07-08 23:36:48 +02:00
Marc-André Bombeck 4b84a12ede fix(oura): isolate the 8 daily collections per-collection (allSettled)
Oura fetched all 8 collections under one Promise.all: a single flaky
endpoint or one throwing mapper rejected the whole batch and imported
nothing (readiness, sleep, activity, spo2 all went dark together). Settle
each collection independently, import what succeeds, and carry the failed
ones as a per-collection ledger — mirroring Google Health / Fitbit. The
reactive 401-refresh is preserved: a shared expired token 401s every
collection, one refresh is done, and only the failed collections retry.
A partial failure records the failure and does not stamp success, so the
freshness surface stays honest and the next tick refetches.
2026-07-08 23:36:24 +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 77c73517c0 fix(jobs): apply pool cap + statement timeout to worker Prisma clients
The 60s statement_timeout + pool ceiling applied to the web client in
db.ts were never applied to the worker clients. A pathological nightly
drain on a heavy tenant could run unbounded, pin a connection, and wedge
the shared worker pool. Route both worker clients (reminder handlers and
the Apple Health import worker) through the same getPoolMax() +
buildSessionOptions() helpers so every worker session is timeout-bounded
from its first query.
2026-07-08 23:32:48 +02:00
Marc-André Bombeck 50e630bf2d test(e2e): lift expect timeout to 10s and CI retries to 2 for loaded-runner headroom
A rotating single e2e test failed each CI run (wizard step transition,
list-refetch card provenance, disclosure toggle) while 211 passed — DOM-
settle-gated assertions sampling a mid-transition frame on a starved shared
runner. Raise the global expect timeout 5s→10s (suite-wide settle headroom)
and CI retries 1→2 (absorb the transient without masking a real break, which
fails all attempts). Also scroll the provenance card into view before reading
its attribute, since the mobile timeline can render it off-viewport.
2026-07-08 23:04:22 +02:00
Marc-André Bombeck e48f9859c8 test(e2e): widen step-transition and provenance assertion timeouts
The wizard step-advance and the AI-read card marker are gated on async
settles (per-step validation; list invalidate→refetch); on a loaded CI
runner those exceed the default 5s expect timeout, so the assertion samples
a mid-transition frame. Widen just those two assertions to 20s — orthogonal
to test.slow() (which extends the test, not the expect, timeout).
2026-07-08 22:50:00 +02:00
Marc-André Bombeck 1dd9b92b7d test(e2e): mark the medication-wizard variants and AI-flow group slow
The wizard-variant specs (rolling/weekdays/compose/monthly/biweekly/oneshot)
and the document-AI flow group are legitimately long multi-step flows; under
a loaded CI runner they race the default 30s and flake green paths — a
different one fails each run. Give them the tripled timeout, matching the
already-slow daily wizard.
2026-07-08 22:38:30 +02:00
Marc-André Bombeck 76277ca31d chore(release): v1.27.29 — automatic document indexing and AI reading 2026-07-08 22:16:12 +02:00
Marc-André Bombeck e261985a48 test(documents): pin contentIndexSource provenance through list/detail/PATCH
Cover the new DTO provenance end-to-end against a real Postgres: the index
source threads through the list and detail reads, and — the regression the
UI wave flagged — survives a metadata PATCH, which now reads the index row
rather than returning a stale null source.
2026-07-08 22:05:05 +02:00
Marc-André Bombeck ecebe796ef test(documents): cover the Read with AI action and AI-read provenance in e2e
Add two acceptance tests to the vault AI suite: the prominent Read with AI
action maps to the vision index endpoint (empty body, no text-mode misroute)
and confirms with a toast; a vision-sourced document surfaces the AI-read
provenance on both the detail status pill and the timeline card marker.
2026-07-08 22:03:13 +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 11fd48cc0e feat(documents): reframe indexing as automatic and make Read with AI prominent
Auto-index on upload means content search is silent-by-default, so the old
per-document index chore no longer reads as a task. Consolidate every AI
reading action into one inviting block on the detail sheet, led by a
prominent Read with AI action, and show each document's searchable status as
a pill that distinguishes an AI-read document from a locally-indexed one.

- New Read-with-AI block: prominent read/re-read action plus suggest and
  session-only summary, gated on provider availability; the calm settings
  pointer replaces the actions (never an error) when no provider is set up.
- Searchable status pill reflects the auto-index: Read by AI (provider read
  the original, highlighted), Searchable (local), or a quiet not-yet state.
- Card searchable marker distinguishes an AI-read document with a Sparkles
  glyph from a locally-indexed one; both carry an accessible label.
- Drop the manual index/re-index chore copy; keep the corpus baseline
  affordance in the filter bar for older un-indexed documents.
2026-07-08 21:25:15 +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 d7ff74bb2e Merge branch 'feat/docai-backend' into feat/docai-integrated 2026-07-08 21:04:40 +02:00
Marc-André Bombeck 2ecf39c772 chore(documents): format index modules and drop unused helper 2026-07-08 21:02:02 +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 6ad2dc858a Merge pull request #438 from MBombeck/release/v1.27.28
Release v1.27.28 — share a document from the document
2026-07-08 20:48:50 +02:00
Marc-André Bombeck 3829b80aca chore(release): v1.27.28 — share a document from the document 2026-07-08 20:38:27 +02:00
Marc-André Bombeck a887a03c01 Merge branch 'feat/document-share-entrypoint' into release/v1.27.28 2026-07-08 20:34:45 +02:00
Marc-André Bombeck a3276dcfeb Merge pull request #437 from MBombeck/release/v1.27.27
Release v1.27.27 — reorderable hero rings
2026-07-08 20:33:17 +02:00
Marc-André Bombeck 1305e8d4bc docs(documents): correct the detail-sheet header comment for the Share action 2026-07-08 09:56:15 +02:00
Marc-André Bombeck 46c85e8671 test(documents): share entry point seeds the create flow with the document
Unit: the shared ShareLinkCreateForm mounts bare (Settings default) and
seeded (document flow) — a pre-attached document rides in as a chip with
the label pre-filled. e2e: the document detail Share action opens the
create flow with that document pre-attached and reaches the one-time QR
reveal; marked slow (two stacked sheets + a real create).
2026-07-08 09:50:26 +02:00
Marc-André Bombeck d4009a3587 feat(documents): share action on the document detail sheet
Extract the clinician share-link create flow (create form, document
picker mount, one-time QR + passphrase reveal) from the Settings sharing
section into a reusable ShareLinkCreateForm. Settings mounts it unchanged
alongside the active/revoked lists; the document detail sheet adds a
Share action beside Download that opens the same flow in a bottom sheet
with the current document pre-attached and its title pre-filled as the
label. The one-time QR reveal lands in that sheet, scannable on mobile.

No backend change: the shared POST /api/share-links handles the attached
document set as before.
2026-07-08 09:41:47 +02:00
Marc-André Bombeck 99f3824794 chore(release): v1.27.27 — reorderable hero rings 2026-07-08 09:40:58 +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 b00a2206c5 Merge pull request #436 from MBombeck/release/v1.27.26
Release v1.27.26 — hero and wellbeing card polish
2026-07-08 00:57:18 +02:00
Marc-André Bombeck 53d8d30bd2 chore(release): v1.27.26 — hero and wellbeing card polish 2026-07-08 00:46:34 +02:00
Marc-André Bombeck 50b628abb8 Merge branch 'feat/hero-and-wellbeing-polish' into release/v1.27.26 2026-07-08 00:42:33 +02:00
Marc-André Bombeck 052ead6bf3 Merge pull request #435 from MBombeck/release/v1.27.25
Release v1.27.25 — documents filter dropdowns
2026-07-08 00:33:08 +02:00
Marc-André Bombeck 5238fa9923 chore(release): v1.27.25 — documents filter dropdowns 2026-07-08 00:23:23 +02:00
Marc-André Bombeck 96860c69ab Merge branch 'feat/documents-filter-dropdown' into release/v1.27.25 2026-07-08 00:19:29 +02:00
Marc-André Bombeck 4b50ac8b6a feat(documents): make filter dropdowns responsive and update the e2e flows
Give each facet trigger a leading icon and show the text label from sm up
(and on phones only while the facet is active, truncated), so three facets
plus the search never overflow a 360-430px row — the bar stays one line
without a horizontal scroller. Drop the now-dead filter.groupLabel key and
point the vault e2e flows at the dropdown menu items instead of the removed
inline chips.
2026-07-08 00:03:04 +02:00
Marc-André Bombeck 56ca9094c0 Merge pull request #434 from MBombeck/release/v1.27.24
Release v1.27.24 — share documents
2026-07-07 23:48:48 +02:00
Marc-André Bombeck 7cd317f1a5 feat(dashboard,mental-health): tighten hero gap, link hero rings, move wellbeing attribution to a header info popover
- Dashboard hero: pull the briefing closer to the ring row (md:gap-3 -> md:gap-2); mobile spacing unchanged.
- Each hero ring is now a focusable link to its natural detail: readiness/recovery/sleep -> their score-anatomy page, doses -> the medications surface, health score -> the Insights overview. Real anchors keep tap-vs-drag native on the mobile carousel.
- Mental-wellbeing: shorten the page title to the short noun form (matches the nav/module label) across all six locales.
- Instrument cards: move the required attribution/licence line off the space under the Start button to a discreet info control at the top-right of the header; a popover carries the verbatim citation. Start now stands alone.
2026-07-07 23:34:58 +02:00
Marc-André Bombeck f3a93b3439 test(e2e): mark the heavy share-lifecycle and med-wizard flows slow
Both are legitimately long end-to-end flows — the share test spans two
browser contexts (owner create + picker, recipient unlock, multiple
document serves, revoke), and the med wizard gates its Next button on
per-step validation. On a loaded CI runner the default 30s timeout races
the settle; test.slow() triples it so transient runner contention stops
flaking green flows.
2026-07-07 23:30:19 +02:00
Marc-André Bombeck 71698f3248 feat(documents): replace inline type-chip row with compact dropdown filters
The vault filter bar rendered the type/condition/year facets as a
horizontally-scrolling chip set beside the search field, which wrapped to a
second line on phones. Replace it with three compact dropdown facets — type
(multi-select), condition and year (single-select) — that share one
non-wrapping row with the flexing search and the clear control at every
width. The content-search hint keeps its own helper line below.
2026-07-07 23:29:24 +02:00
Marc-André Bombeck 97163d1e24 chore(release): v1.27.24 — share documents 2026-07-07 23:08:19 +02:00
Marc-André Bombeck 77f7caf4bf Merge branch 'feat/vault-p3-qa' into release/v1.27.24 2026-07-07 23:04:20 +02:00
Marc-André Bombeck 1face234cc test(share-links): end-to-end document share, serve, and revoke lifecycle
Adds a Playwright spec that drives the public sharing surface against the
production server and a real Postgres: the owner picks documents into a new
link through the Settings picker, a recipient unlocks it with the passphrase
and pulls each blob through the token-scoped serve route, and a revoke closes
it. Pins the load-bearing guarantees end to end — frozen-set confinement (an
owned but un-attached document 404s), the serving posture (Class A inline with
true type + nosniff + no-store, Class B opaque octet-stream attachment), the
EXIF/GPS strip on the shared image, revocation enforced at serve time, and the
scannable QR promotion at the mobile width.

Seeds a fixed share trio (inline PDF, Exif-bearing JPEG, download-only text)
through the same Postgres transport the vault fixture already uses.
2026-07-07 22:00:27 +02:00
Marc-André Bombeck 788996b934 test(share-links): drop the unused owner binding in the ownership guard
The foreign-document ownership test only needs owner A's upload to exist;
it seeded the user into a discarded local. Call it for its effect instead
so lint stays clean.
2026-07-07 22:00:14 +02:00
Marc-André Bombeck 829345a57b fix(sharing): keep the document cap literal client-side (no DB import leak) 2026-07-07 21:25:17 +02:00
Marc-André Bombeck e734c161f4 test(documents): clinician doc list + share picker/QR + i18n x6 2026-07-07 21:19:58 +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 22feab46e8 test(share-links): end-to-end share document flow against Postgres
P3-1. Integration coverage the unit mocks cannot pin: migration 0229's join +
cascade, create-with-documents in one transaction, foreign-doc refusal at
create, the public serve route (passphrase gate, token confinement to the
frozen set, exact-bytes Class A posture, foreign id + revoked link → 404), and
membership cascade on document delete.
2026-07-07 18:56:52 +02:00
Marc-André Bombeck de84994a95 feat(share-links): expose the frozen document set on the share view + OpenAPI
P3-1-T6. loadShareViewData now returns the link's document set as metadata only
(id, title, kind, documentDate, byteSize, servingClass) — scoped to the
membership rows, the owner, and live rows; the blob column is never selected
(the serve route is the only decrypt path). Register documentCount on the share
summary, documentIds on create, and the public /c/{token}/d/{id} serve route in
the OpenAPI contract; regenerate docs/api/openapi.yaml.
2026-07-07 18:47:02 +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 95eab66945 feat(share-links): strip EXIF/XMP/GPS at the document share egress
P3-1-T3. Dependency-free metadata stripper for JPEG (drops APP1/APP13/COM),
PNG (drops eXIf/tEXt/zTXt/iTXt/tIME), and WebP (drops EXIF/XMP chunks and
clears the VP8X flag bits). No pixel decode, no native dependency; image data
copies through verbatim. Applied on egress only — the stored original is never
rewritten. Non-raster types (PDF/TIFF/HEIC/Office) and any unparseable input
pass through unchanged (fail-safe).
2026-07-07 18:36:44 +02:00
Marc-André Bombeck 32f51b4299 feat(share-links): add frozen document-membership join (migration 0229)
P3-1-T1. Introduce ClinicianShareLinkDocument, the write-once join binding a
stored document to a clinician share link. The document set a share exposes is
a relation, not a mutable column, so it inherits the link's frozen-scope
discipline. Both foreign keys cascade so a deleted link or document takes its
membership with it.
2026-07-07 18:34:58 +02:00
1085 changed files with 97905 additions and 22767 deletions

No files matched your search

+17 -8
View File
@@ -18,15 +18,24 @@
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
# When using docker-compose.yml: the app container reaches Postgres via the
# service name "db", so the hostname is "db" — NOT "localhost".
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
# change the hostname to "localhost" and adjust the credentials.
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
# Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL
# here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD
# (see docker-compose.yml — the `environment:` block wins over any value in this
# file), so a DATABASE_URL line here is ignored for that stack. Set only
# POSTGRES_PASSWORD below and compose wires the connection string for you.
#
# Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your
# own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set
# it explicitly. Hostname is "db" on the bundled compose network, or
# "localhost" for a host-installed Postgres.
# DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public"
# Postgres password — used both by the db service AND by DATABASE_URL above.
# Pick a strong random password, e.g. `openssl rand -hex 32`.
POSTGRES_PASSWORD="CHANGE-ME"
# Postgres password for the bundled `db` service — and the value compose splices
# into DATABASE_URL. The quick-start appends a generated one:
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# Left commented here so that append does not create a duplicate key. Uncomment
# and set it directly instead if you prefer to edit the file by hand.
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
+61 -3
View File
@@ -29,10 +29,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Withings OAuth -- optional integration
# Withings -- optional integration
# -----------------------------------------------------------------------------
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
# The Withings OAuth client id/secret are per-user: each user registers their
# own Withings developer app and pastes the pair into Settings (stored
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
# WITHINGS_WEBHOOK_SECRET=""
@@ -86,6 +87,49 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Strava -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://www.strava.com/settings/api and set its
# "Authorization Callback Domain" to your instance host. Both vars together
# enable the Strava connect button; set neither to leave the integration
# disabled. Per-user BYO client id/secret in Settings take precedence over these
# shared env values. Strava caps every new app at single-player mode (athlete
# capacity 1) until you request a capacity increase — fine for a personal
# self-host. Strava rotates its refresh token on every refresh; the sync
# refreshes reactively on a 401. STRAVA_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`.
# STRAVA_CLIENT_ID=""
# STRAVA_CLIENT_SECRET=""
# STRAVA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# OIDC SSO login -- optional (self-hosted identity provider)
# -----------------------------------------------------------------------------
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
# Authelia, Google Workspace, etc.) with redirect URI
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
# the login page; set none to leave it disabled. First-time sign-in
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client authenticates via password/passkey only -- there is no OIDC
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
# entirely until a native SSO flow ships. Do not enable it on a deployment
# with active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses this connection. Plain HTTP is defensible only on a fully trusted
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_SCOPES="openid email profile"
# OIDC_BUTTON_LABEL="Single Sign-On"
# OIDC_ONLY="false"
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
@@ -168,6 +212,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
@@ -254,3 +303,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
# only). Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
# NATIVE_CANVAS="off"
+1 -1
View File
@@ -28,7 +28,7 @@
## Checklist
- [ ] Targets the `develop` branch (not `main` — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] `pnpm typecheck` passes locally
- [ ] `pnpm lint` passes locally
- [ ] `pnpm test` passes locally
+2 -2
View File
@@ -98,7 +98,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -293,7 +293,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
fi
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+3
View File
@@ -82,3 +82,6 @@ next-env.d.ts
# Per-release internal forensic notes — retained locally for
# traceability across releases but not part of the public docs surface.
/docs/audit/
# Local screenshot/audit harnesses (repo-root for module resolution; never committed)
.audit-*.mjs
+9
View File
@@ -12,3 +12,12 @@
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
# then drop this entry.
CVE-2026-33671
# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only
# in the npm CLI's OWN bundled copy inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses
# sigstore for publish provenance at image-build time only; it is not in our
# application dependency tree at all (`pnpm why sigstore` is empty) and is
# never reachable at runtime. Re-check when the Node base image ships an npm
# build whose bundled sigstore is >= 4.1.1, then drop this entry.
CVE-2026-48815
+1095
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -95,7 +95,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
## Tests and commands
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
## DO-NOTs
+37
View File
@@ -81,6 +81,43 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
# native binary's package into the standalone tree but NOT the pnpm symlinks that
# resolve it, so at runtime nothing can `require('@napi-rs/canvas')`. TWO
# resolutions must work: (1) pdfjs-dist does a bare `require('@napi-rs/canvas')`
# from its OWN dir — Node walks up to /app/node_modules, so the package must be
# HOISTED to the top level there (verified: without it pdfjs logs "Cannot load
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
RUN set -e; \
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
if [ -n "$CANVAS_DIR" ]; then \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
RUN set -e; \
PDFJS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null | head -1)"; \
if [ -n "$PDFJS_DIR" ]; then \
ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \
chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \
echo "pdfjs-dist hoisted: $PDFJS_DIR"; \
else echo "WARN: pdfjs-dist not found; PDF rasterization will degrade to local text"; fi
# Copy Prisma for migrations (schema, migration SQL, config, engines)
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
+31
View File
@@ -80,6 +80,8 @@ services:
# whitelist — vars not listed here don't propagate even though
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
@@ -131,6 +133,30 @@ services:
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
# Both together enable the connect button; an unset pair leaves it
# disabled. STRAVA_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
# values in .env reach the container (the `environment:` block is a
# whitelist).
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
# hides password/passkey login entirely, but only takes effect when
# the three above are also fully set — a half-configured group never
# locks every user out. It IS enforced server-side, and there is no
# OIDC flow for the native iOS client yet — enabling it locks iOS
# users out of sign-in entirely. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
@@ -156,6 +182,11 @@ services:
# in .env reaches the container (the `environment:` block is a whitelist).
# See docs/integrations/ai-providers.md.
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
# Daily-briefing output-token ceiling (optional; default 2500, clamped
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
# Listed here so an operator value in .env reaches the container (the
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
+3 -1
View File
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it.
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+3434 -61
View File
File diff suppressed because it is too large. Load diff
+113 -1
View File
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
their own key fall back to the admin key. This is the last entry
in the default provider chain so it acts as a safety net rather
than the default route.
than the default route. Since v1.28.14 the operator can also share
a subscription-based account that users opt into — see
[Admin-shared subscription access](#admin-shared-subscription-access).
Single-user instances usually pick one mode and stop there.
Multi-user instances tend to run BYOK with the admin-shared key as a
@@ -148,6 +150,35 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.
## ChatGPT OAuth (Codex)
The `CHATGPT_OAUTH` provider routes generations via the
@@ -180,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
audit the request shape; users who just want the integration to
work do not need to read it.
## Admin-shared subscription access
Besides the shared API key, the operator can connect a single
subscription-based AI account for the whole instance — an
operator-shared subscription provider that users without any
personal AI setup can opt into.
- **Admin side.** Under **Admin → AI** the operator runs the same
device-code OAuth flow as the per-user connection
(`POST /api/admin/central-codex/device-start` +
`device-poll`). The tokens land AES-256-GCM-encrypted in the
`AppSettings` singleton and are never returned by any endpoint —
status and disconnect go through `GET` / `DELETE
/api/admin/central-codex`, which sit behind the cookie-only
`requireAdmin()` boundary.
- **User side.** Each user opts in individually in `/settings/ai`
(`PATCH /api/auth/me/use-central-codex`, body
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
- **Chain semantics.** The shared connection is the internal
`admin-codex` chain entry. It is never part of the persisted chain
and never offered in the chain editor — it is appended **last** to
the resolved chain, and only when the user opted in AND the
operator has connected the account
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
resolves to nothing.
- **Cost.** Generations through the shared connection run under the
operator's account, so usage and rate limits land on the operator,
not the user.
The settings UI states this plainly: the shared access is a single
signed-in AI account the operator connected for everyone on the
server — not the user's own key. Users who want their data to reach
only a provider under their own contract should configure a personal
provider instead; their own entries always resolve first.
## Privacy stance
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
@@ -198,6 +264,46 @@ The Coach drawer surfaces a "Local" badge when generation resolves
to the local provider, so users who pinned a local endpoint can see
at a glance that their context stayed on the host.
The document vault is local-first by default: an uploaded document is
only read by an external AI provider when you tap a per-document AI
action, and every text-layer PDF is indexed for search locally with no
egress at all — **unless you opt into automatic AI reading in AI
settings** (next section).
## Automatic document reading (opt-in)
**Off by default.** With the switch off, nothing about the vault
changes: a stored document is read externally only when you explicitly
tap "Read with AI" on it, and that tap still needs an active AI-consent
receipt for any provider that leaves the machine. Text-layer PDFs stay
searchable through local extraction with no egress.
Turn **"Read documents automatically with AI"** on (AI settings) and
each document you upload is read, described, and indexed by your
configured AI provider with no per-document confirmation — the
"upload and it just gets read" flow. What that means, plainly:
- **The document's contents leave this machine** to your configured
provider. The read happens on the server, right after upload.
- **A subscription connection** — a signed-in AI account rather than an
API key — applies its own consumer data settings. **Subscription
providers may use the content to improve their models, and no
data-processing agreement covers it.** Turning the toggle on is the
standing consent for that trade; it is recorded in the consent audit
trail.
- **A self-hosted local model never egresses.** Documents read by a
local provider stay on your server whether the toggle is on or off.
- **Scanned / image-only PDFs** are rendered to page images server-side
so a provider that only accepts images (a subscription connection) can
read them too; a native-PDF provider reads the PDF directly. Only the
first pages of a long document are sent, to bound cost.
Regulated or privacy-sensitive deployments should either leave this off
and read documents per-document on demand, or configure a **BYOK
no-training API key** or a **local model** as the provider — both avoid
the subscription-training trade. The setting is per-user: in a
multi-user deployment each account opts in for its own documents.
## What the Coach is — and isn't
The Coach is an informational assistant that works from your own
@@ -243,6 +349,12 @@ Lower it when running on a tight budget — the 24h cache already
short-circuits read traffic; only force-regens and cache-misses cost
tokens.
`INSIGHTS_MAX_TOKENS` (default `2500`, clamped to 500–8000) sets the
output-token ceiling for the daily-briefing generation. Raise it if a
verbose model gets its briefing cut off mid-JSON — the API reports
that case as "AI response was cut off" rather than the generic
invalid-JSON error.
## Connection test
`POST /api/ai/test` runs a one-shot probe against the resolved
+7
View File
@@ -199,3 +199,10 @@ The wide-event log line `import.apple-health.kickoff` (or
`import.apple-health.kickoff.denied` on failure) captures every
attempt with the upload size, SHA-256, and the resolved `ImportJob`
id for debugging.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
to Apple Health automatically, so the import path on this page is how Garmin data
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
what Garmin withholds.
+20 -17
View File
@@ -5,10 +5,10 @@
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
operator on their behalf) registers their own Fitbit app and pastes the client
id/secret into Settings. The internal integration key is `fitbit`.
operator on their behalf) supplies their own Fitbit app's client id/secret in
Settings. The internal integration key is `fitbit`.
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
> Web API in **September 2026** and moving to the Google Health API behind Google
> sign-in. That replacement currently requires Google Restricted-scope brand
> verification plus an annual third-party CASA security assessment, which does
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
> sunset is still being worked out. Until then the setup below is the supported
> way to connect Fitbit.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
## 1. Register a Fitbit developer app
## 1. Use an existing Fitbit developer app
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
2. **OAuth 2.0 Application Type:**
Registration of new apps is no longer possible (see the note above). For an
app you registered before the shutdown, these are the settings that matter:
1. **OAuth 2.0 Application Type:**
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
and gets intraday (per-minute) heart-rate/steps automatically. This is the
right choice for a single-person self-host.
- **Server** (or **Client**) — needed when the instance connects **other
people's** accounts. Intraday/heart-rate access is then granted case by
case via Fitbit's Intraday Data Access request form.
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
the redirect-URI note below). One entry per environment you connect from.
4. **Scopes:** tick the ones HealthLog requests (next section).
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
— keep the secret out of any chat log.
3. **Scopes:** the ones HealthLog requests (next section).
4. The app's settings page shows the **Client ID** (short, numeric) and the
**Client Secret** — keep the secret out of any chat log.
## 2. Scopes
+52
View File
@@ -0,0 +1,52 @@
# Garmin (via Apple Health or Google Health Connect)
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
there cannot be one.** Garmin's data-sharing program is business-partner-only:
it is approval-gated, requires a public callback endpoint, and is not the
"register your own app, paste a key" model the wearable integrations here rely
on. So instead of a broken half-integration, HealthLog reads Garmin data the
honest way — through the phone health platform your Garmin already feeds.
## The path that works
```
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
→ Google Health Connect ├─→ HealthLog
(Android) ─┘
```
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
the native client's background sync).
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
Health Connect**. Connect HealthLog through the existing
[Google Health](./google-health.md) integration.
No extra code, no Garmin developer account, no approval queue — if your Garmin
already syncs to your phone's health platform, HealthLog already reads it.
## What comes through
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
light), calories (active and total), distance, weight and body composition,
cadence, and — partially — blood-oxygen saturation.
## What Garmin withholds
These are Garmin's **proprietary** scores. Garmin does **not** share them with
Apple Health or Health Connect, so no self-hosted integration — and no amount of
configuration — can obtain them. They are only available inside Garmin Connect
itself:
- Body Battery
- HRV Status
- VO₂ max
- Training Load and Training Effect
- Recovery Time
- Intensity Minutes
- Stress score
- Running dynamics
If you rely on those specific numbers, keep the Garmin Connect app for them;
everything else lands in HealthLog through the platform path above.
+7
View File
@@ -141,3 +141,10 @@ per metric.
The Settings **Disconnect** button removes the connection and its stored tokens.
Previously synced measurements stay in the database.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
what comes through and what Garmin withholds.
+74
View File
@@ -0,0 +1,74 @@
# Strava integration
HealthLog reads your Strava activities and folds them into the same workout
history as every other source. Strava is a **workout source only** — its API is
activity-centric and exposes no sleep, recovery, body-composition or glucose
data, so connecting it adds runs, rides, swims and the rest to your workout
surface and nothing else.
The OAuth app is brought **per user**: each self-hoster registers their own
Strava API application and pastes the client id/secret into Settings. Strava
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
until you request a capacity increase — perfectly fine for a personal
self-host, and the reason a single shared app cannot serve many operators.
## What you get — and what you don't
**Covered** (one `Workout` row per activity): sport type, start and end time,
moving duration, distance, elevation gain, average and maximum heart rate (when
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
flags and the activity name are kept on the row's metadata for provenance but
are **not** surfaced as training analytics — HealthLog treats Strava as another
workout source, not a training-load platform.
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
rate, weight, …). Those come from your wearable's own integration or from Apple
Health / Google Health Connect.
## Duplicate activities are collapsed automatically
If you record a run on an Apple Watch **and** it also lands in Strava, the same
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
canonical row per logical workout using your **source-priority ladder**: by
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
survives untouched. You can promote Strava above the wearables in
**Settings → Sources** if you prefer the Strava copy.
## 1. Register a Strava API application
1. Sign in at <https://www.strava.com/settings/api>.
2. Create an application. For **Authorization Callback Domain** enter the host
of your HealthLog instance (the domain only, no scheme or path).
3. Copy the **Client ID** and **Client Secret**.
## 2. Paste the credentials into HealthLog
1. Open **Settings → Integrations** and find the **Strava** card.
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
save. (An operator can instead configure a shared app via the
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
credentials always take precedence.)
3. Click **Connect Strava** and approve the access request. HealthLog requests
the `activity:read_all` scope so it can read every activity, including those
you marked "only me".
## 3. First sync
The first history import runs in the background shortly after you connect;
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
on every refresh, and HealthLog persists the rotated token each time, so the
connection stays healthy without you re-authorising.
## Disconnecting
Disconnecting revokes the grant at Strava and clears the stored tokens.
Activities already imported stay in your history; reconnecting resumes the sync.
## Related
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
own page, or ingest via [Apple Health](./apple-health.md) /
[Google Health Connect](./google-health.md).
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
+4 -2
View File
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
2. The user signs in on WHOOP and grants the requested scopes.
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
4. HealthLog exchanges the code for an access + refresh token, stores
them encrypted, registers the webhook, and triggers an initial
sync of recent history.
them encrypted, and triggers an initial sync of recent history.
(The webhook is **not** registered per user — it is configured once
at app level in the WHOOP developer console; see the env-var
section below.)
5. Subsequent syncs run on every webhook delivery plus a safety-net
cron pull.
+20 -17
View File
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
## 2. Wire the credentials into HealthLog
## 2. Wire the credentials in Settings → Integrations
Paste the client ID and secret into `.env`:
Withings credentials are stored per user, not in `.env`. Sign in as
the target user, open **Settings → Integrations → Withings**, and
paste the client ID and secret. They are encrypted AES-256-GCM at
rest (`src/lib/withings/credentials.ts`). On a shared instance every
user brings their own Withings app credentials; there is no
server-level client-id/secret fallback.
Two server-level env vars remain:
```env
WITHINGS_CLIENT_ID="your-client-id"
WITHINGS_CLIENT_SECRET="your-client-secret"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
```
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
keeps it indistinguishable from random. Restart the `app` container
after saving.
Per-user override is also possible: the Settings UI accepts a
client-ID/secret pair scoped to one user, useful if you want a
shared instance where every user brings their own Withings app
credentials. Server-level env vars cover the default case.
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
only when the OAuth callback must differ from that default.
## 3. Set the webhook URL
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With env vars in place and the app restarted, sign in as the target
user and open `/settings/integrations/withings`. Click **Connect
Withings**. The flow:
With the credentials saved in Settings, open
`/settings/integrations/withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
### The Connect button errors out
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
are set in the container's runtime environment (`docker compose
exec app env | grep WITHINGS`).
- **`Withings credentials not configured`** — the signed-in user has
not saved a client ID + secret under **Settings → Integrations →
Withings**. Credentials live per user in the database, not in
`.env` — paste the pair from the developer portal and retry.
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
must match an entry in the developer-portal app's callback list.
Add the public URL exactly, including the `https://` scheme and
+14 -8
View File
@@ -68,20 +68,26 @@ The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run:
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
pnpm tsx scripts/restore-backup.ts \
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
+2 -2
View File
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
3. **Run the rotation script** to re-encrypt every existing encrypted column
under the new active key:
```
pnpm tsx scripts/rotate-encryption-key.ts v2
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
@@ -69,7 +69,7 @@ script reports zero `v2.`-prefixed rows remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm tsx scripts/rotate-encryption-key.ts v3
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
+65 -48
View File
@@ -1,26 +1,34 @@
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
deliberate hardening choice on the client side, but it couples the client to
the exact leaf keypair the server serves: when that leaf rotates, a client
that was only ever shipped the old pin refuses to connect. This page explains
the coupling, the server-side alarm that watches for a rotation, and the
operator / release-owner steps when it fires.
The native client SPKI-pins the server's TLS chain at the **CA level**. It
ships a small pin set — currently the issuing intermediate plus the root —
and accepts a connection only when the served chain passes baseline X.509
evaluation AND at least one certificate anywhere in the chain matches a pin
(any-match). The leaf is deliberately **not** pinned, so routine leaf
renewals are a client no-op. The outage risk is a **chain change**: the
served chain stops terminating in the pinned CA keys — the server moves to a
different CA, or the CA rotates its intermediate / root keys. This page
explains the coupling, the server-side alarm that watches the served leaf,
and the operator / release-owner steps when it fires.
## The coupling
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
certificate's public key, not of the whole certificate. The native client
carries a set of these pins and rejects any TLS connection whose leaf public
key is not in the set.
carries a set of these pins (build-time config, ≥ 2 well-formed pins
enforced for release builds) and rejects any TLS connection whose validated
chain contains no pinned key. Hosts outside the client's pinned-host list
fall back to plain system-trust validation, which is the documented model
for self-hosted instances that ship their own chain.
The app host's certificate is issued by Google Trust Services (GTS) and
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
so the SPKI pin changes on every renewal. Intermediate and root rolls have
the same effect on the leaf chain. Because the client pins the leaf, every
such change is a hard outage for any client that hasn't been shipped the new
pin — and there is otherwise no server-side signal that a client is pinning
the served leaf at all.
The app host's certificate auto-renews roughly every 90 days and each
renewal mints a new leaf keypair. Because the client pins the CA keys rather
than the leaf, those renewals pass without a client build. What DOES break
every pinned client is a chain that no longer terminates in a pinned key —
and there is no server-side signal that clients are pinning the served chain
at all. The alarm below exists to catch exactly that early: it watches the
served **leaf**, the most frequently rotating element, as the canary for any
chain movement.
## The alarm
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
### Baseline source: an env var, not auto-learning
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
an env var on purpose, **not** a persisted "last-seen" row:
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
leaf** pins. It is the operator's explicit record of what the server is
expected to serve — distinct from the client's CA-level pin set, which lives
in the client build config. The baseline is an env var on purpose, **not** a
persisted "last-seen" row:
- The pinned client only trusts the pins it was shipped, so the operator must
derive the client's pin set from a single explicit source of truth. The env
var **is** that source — the same value goes into the client's pin set and
into `TLS_LEAF_SPKI_PINS`.
- An explicit baseline forces the operator to acknowledge every leaf
rotation and check the new chain against the client's pinned CA keys
before the alarm goes quiet.
- A persisted last-seen baseline would silently adopt the first rotated pin
and suppress the very alarm the pinned client needs.
- When the env var is unset, the monitor fails **loud, not open**: it logs
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
whether it is set. It is optional: without it the monitor still runs and logs
the served pin, but cannot alarm on a change.
## When the alarm fires — re-pin procedure
## When the alarm fires
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
row, the served leaf has rotated. The pinned client will stop connecting once
its shipped pin no longer matches a served leaf. Act inside the certificate's
remaining validity, with margin for app review and client roll-out — target
**≥ 11 days before the old pin's certificate expires** (the alert body and
audit row both carry the new leaf's `validTo`).
row, the served leaf has rotated. First determine which of two cases you are
in:
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
should equal the `servedPin` in the alert.
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
comma-separated pair, e.g.
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
on the old build keep working through the cutover and the server-side
alarm goes quiet because the served pin is now in the known set. Re-deploy
the server so the new env value takes effect.
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
pipeline above, or the alert payload). If the issuing intermediate and
root are unchanged, the client's CA-level pins still match and no client
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
re-deploy so the alarm goes quiet. No client build.
2. **Chain change.** The new leaf chains through a different intermediate /
root (CA switch, or the CA rotated its keys). Every pinned client will
stop connecting once the old chain is no longer served — run the re-pin
procedure below inside the old chain's remaining validity, with margin
for app review and client roll-out (the alert body and audit row carry
the new leaf's `validTo`).
### Re-pin procedure (chain change)
1. **Extract the new chain's CA pins** (intermediate + root) with the
`openssl` pipeline / the client repo's SPKI-extraction script.
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
the old ones (do not replace them yet). Clients on the old build keep
working as long as the old chain is still served; the dual-pinned build
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
served leaf pin(s) so the server-side alarm reflects reality.
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
onward to release). Give it long enough to roll out to the installed base
before the next renewal.
4. **Retire the old pin** once the old leaf is no longer served and the
dual-pinned client build has reached the installed base: drop the old pin
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
current leaf pin.
onward to release). Give it long enough to roll out to the installed
base before the old chain disappears.
4. **Retire the old pins** once the old chain is no longer served and the
dual-pinned client build has reached the installed base.
Treat the dual-pin window as standing practice: always ship the **next** pin
before the current leaf rotates, so a renewal never catches a single-pinned
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
upcoming leaf ahead of the renewal turns every rotation into a no-op.
CA-level pins move rarely — intermediates and roots carry multi-year
lifetimes and rotations are announced well ahead. Track the pinned
certificates' expiry dates and pre-stage the successor pins before the
cutover, so a chain change never catches a single-chain client.
## Environment assumption
+24 -5
View File
@@ -53,11 +53,14 @@ echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
Then open `.env` and confirm `DATABASE_URL` includes the password you
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
references the literal string `CHANGE-ME`; replace it with the value
you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute
explicitly.
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
service from `${POSTGRES_PASSWORD}` (its `environment:` block overrides
any `DATABASE_URL` in `.env`), so the `openssl` line above is the only
password step. `DATABASE_URL` only matters if you run a **bare Node**
process (`pnpm dev` against a host Postgres) or a **custom compose** that
does not derive it — in that case uncomment and set `DATABASE_URL`
explicitly in `.env` (see the commented example there).
## 3. Bring the stack up
@@ -92,6 +95,22 @@ After the first user lands, you can:
- Open `/measurements` and add a weight or blood pressure to confirm
the data path is wired end-to-end.
> **Close public registration if you expose the instance to the
> internet.** Registration stays **open** after the first admin so you
> can invite the rest of your household — but on a publicly reachable
> host that means strangers can still sign up. Once everyone who needs
> an account has one, turn it off in **`/admin` → registration toggle**
> (or keep it open and hand out invite links instead). The admin
> settings show a reminder underneath the toggle while registration is
> open.
> **No email is configured out of the box** — a deliberate first-boot
> simplicity win, but it also means password reset has no self-service
> path until you set SMTP (see `docs/self-hosting/notifications.md`). If
> you are the sole admin, **save your password in a manager now**: a
> locked-out solo admin with no SMTP recovers only via the CLI script in
> `docs/ops/password-reset.md`.
## 5. Point the instance at a public hostname
Local-only is fine for a single-user trial. For anything more — and
+32 -3
View File
@@ -24,15 +24,44 @@ deployed in three modes:
3. `docker compose up -d --build`.
Both containers connect to the same Postgres. pg-boss claims jobs
atomically via row-level locking, so it is safe to run multiple worker
replicas — they will share the load.
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
running multiple worker replicas is **safe** — no job runs twice —
but it doubles the DB load and muddies telemetry without adding
throughput a personal instance needs (`src/lib/process-type.ts`).
Run **one** worker container and scale the `web` containers instead.
## Apple Health import staging (shared volume required for a split)
The Apple Health `export.zip` import streams the upload to a staging
file on local disk (under the container's temp dir) and hands the
worker only the file **path**. In single-container mode
(`HEALTHLOG_PROCESS_TYPE=all`, the default) the web handler and the
worker share the same filesystem, so the handoff just works.
In a web/worker split — or any topology where the container that
accepts the upload is not the one that runs the job (a `web` +
`worker` split, extra `all` replicas, or old+new containers coexisting
during a rolling deploy) — the worker cannot see the web container's
staging file. The import then fails with a staging-file-missing error
("the import staging file is not visible to the worker"). Two things to
know:
- A pure `web` container has no pg-boss handle, so an import kicked off
there currently returns `503 Background worker is not running`. Run
imports from a container that also runs the worker until send-only
boss support lands.
- If you run web and worker as separate containers, they **must share
the import staging directory** — mount the same named volume at the
temp path on both — or run imports in single-container (`all`) mode.
Without a shared staging volume, imports in split mode cannot
complete.
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
- `app-worker` does not expose a port; its liveness is reported into
the `worker_status` table on every reminder pass and surfaced through
`/api/admin/worker-status`.
`/api/admin/status`.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
+150
View File
@@ -0,0 +1,150 @@
# SSO login (OIDC)
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
that serves a standard discovery document. The flow is the OIDC
authorization-code flow with PKCE against a single, env-configured
provider; by default it is **additive** (the SSO button appears next to
password and passkey login), with an explicit opt-in to make it the only
way in.
## Setup
1. Register a **confidential** OIDC client at your IdP with the redirect
URI
```
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
```
The comparison at the IdP is byte-exact: scheme, host, port, and path
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
`http://healthlog.example.com/api/auth/oidc/callback` are different
URIs, and a trailing slash breaks the match.
2. Set the three provider vars (all three together enable the login-page
button; anything less keeps SSO fully disabled):
```env
OIDC_ISSUER_URL="https://idp.example.com"
OIDC_CLIENT_ID="healthlog"
OIDC_CLIENT_SECRET="..."
```
The issuer must serve
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
authorization code and the token response carrying the ID token cross
this connection. Plain HTTP is defensible only on a fully trusted
private network (IdP and app on the same box, a Tailscale mesh, a
VPN); a LAN-only issuer is explicitly supported.
3. Optional tuning:
```env
OIDC_SCOPES="openid email profile" # default shown
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
OIDC_ONLY="false" # see below before changing
```
4. Remember the compose env whitelist: every `OIDC_*` var must appear
under `environment:` in `docker-compose.yml` (the shipped file lists
them) — vars not on the whitelist never reach the container even when
`.env` has them.
`pnpm check-env` knows all six vars and flags a partially configured
provider group.
## Per-IdP notes
Client registration essentials only — the IdP's own docs cover the rest.
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
client), bind it to an _Application_, and use the provider's issuer URL
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
— the trailing slash is part of Authentik's issuer; the discovery
check tolerates the difference). Signing key: any RSA/EC key; the
default RS256 works.
- **Keycloak.** Create a client with _Client authentication_ ON and the
_Standard flow_ enabled. The issuer is
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
signature algorithm; do not switch the client to `HS256` — HealthLog
rejects HMAC-signed ID tokens.
- **Authelia.** Define a client under `identity_providers.oidc.clients`
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
the secret in the POST body) and the redirect URI above. The issuer is
Authelia's root URL.
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
users by passkey at the IdP; to HealthLog it is a provider like any
other.
## Security model
- **Identity is pinned, not guessed.** The first OIDC sign-in either
provisions a fresh account or links to an existing account matched by
**verified** email (`email_verified: true` — an IdP that does not
assert verification is rejected). Either way the account is stamped
with the provider's `(issuer, sub)` pair, and every later login
matches on that pair alone. An email change at the IdP updates the
displayed email; it can never re-point the login at a different
account. An email that matches an account already pinned to a
_different_ identity is rejected and audited.
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
MFA, rotating encryption keys, encrypted export, account deletion)
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
login is a delegated factor — whatever the IdP checked, this app did
not see it — so an SSO session always has to confirm a native factor
(or the password path) when it hits one of those gates.
- **Accounts with native 2FA keep it.** If the account has a confirmed
TOTP secret or a registered security key, an OIDC login does not
complete on its own: after the IdP round-trip the user lands in the
same second-factor step password login uses. SSO never downgrades an
account's own MFA.
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
MFA in the IdP's policy engine — that is the point of delegating
authentication. HealthLog-side 2FA remains available on top for the
step-up gates above.
- **Linking is once.** There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
update), not something a login can do.
## OIDC_ONLY
`OIDC_ONLY="true"` disables password and passkey login **server-side**
(the routes return 403; hiding the buttons is not the boundary).
Consequences to accept before turning it on:
- **The native iOS app cannot sign in.** It authenticates via
password/passkey only; there is no native SSO flow yet. Do not enable
`OIDC_ONLY` on a deployment with active iOS users.
- **Existing sessions survive.** The flag gates new sign-ins, not live
sessions — nobody is thrown out at flip time, including
password-authenticated sessions.
- **Self-disabling on half-config.** The flag only takes effect while
the three provider vars are fully set, so a typo in the provider group
can never lock every user out.
### Break-glass (IdP down or misconfigured)
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
`docker compose up -d`. Password and passkey login are live again
immediately.
2. Accounts that were **provisioned** through SSO have no password. Set
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
## Troubleshooting
| Symptom | Cause / fix |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
+197
View File
@@ -0,0 +1,197 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Operator-shared central Codex (ChatGPT subscription) — two mocked flows:
*
* A. The per-user opt-in switch on /settings/ai, shown only when the operator
* has connected the shared account (`centralCodexAvailable: true`). Toggle
* on → honesty confirm → PATCH /api/auth/me/use-central-codex.
* B. The admin connect surface on /admin/coach: device-code panel →
* connected state, driven by the three admin endpoints.
*
* The real OAuth handshake bounces through chatgpt.com, which CI never hits;
* every relevant endpoint is mocked so the assertions focus on the UI state.
*/
test.describe("central codex opt-in switch (mocked)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("switch shows when available and writes the opt-in on confirm", async ({
page,
}) => {
let useCentralCodex = false;
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
centralCodexAvailable: true,
useCentralCodex,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
responseTimeoutSeconds: null,
},
error: null,
}),
}),
);
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [{ providerType: "codex", available: true }],
},
error: null,
}),
}),
);
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { documentsAutoAiRead: false },
error: null,
}),
}),
);
await page.route("**/api/consent/ai/web", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { ok: true }, error: null }),
}),
);
// The opt-in write — flips the mocked state so a re-read reflects it.
await page.route("**/api/auth/me/use-central-codex", (route) => {
useCentralCodex = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { useCentralCodex: true },
error: null,
}),
});
});
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
const toggle = page.getByTestId("use-central-codex-enable");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Off → on reveals the honesty confirm; the write only happens on confirm.
await toggle.click();
const confirm = page.locator('[data-slot="central-codex-confirm-cta"]');
await expect(confirm).toBeVisible();
const wrote = page.waitForRequest(
(req) =>
req.url().includes("/api/auth/me/use-central-codex") &&
req.method() === "PATCH",
);
await confirm.click();
await wrote;
});
});
test.describe("admin central codex connect (mocked)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("connect button → device panel → connected badge", async ({ page }) => {
let connected = false;
await page.route("**/api/admin/central-codex", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
status: connected ? "connected" : "disconnected",
connectedAt: connected ? new Date().toISOString() : null,
},
error: null,
}),
});
});
await page.route("**/api/admin/central-codex/device-start", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
userCode: "WXYZ-7890",
verificationUrl: "https://chatgpt.com/codex/device",
intervalSeconds: 1,
},
error: null,
}),
}),
);
let pollCount = 0;
await page.route("**/api/admin/central-codex/device-poll", (route) => {
pollCount += 1;
if (pollCount >= 2) connected = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { status: pollCount >= 2 ? "connected" : "pending" },
error: null,
}),
});
});
await page.goto("/admin/coach", { waitUntil: "domcontentloaded" });
const connect = page.locator('[data-slot="admin-central-codex-connect"]');
await expect(connect).toBeVisible({ timeout: 10_000 });
await connect.click();
// Device-code panel surfaces the user code.
await expect(page.getByText("WXYZ-7890")).toBeVisible();
// After the mocked poll flips to connected, the disconnect action shows.
await expect(
page.locator('[data-slot="admin-central-codex-disconnect"]'),
).toBeVisible({ timeout: 15_000 });
});
});
+75
View File
@@ -98,6 +98,70 @@ test.describe("authenticated dashboard render", () => {
}),
);
// S2 — the Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
// one worth-a-look item) so the hero paints without reaching the real
// route, and so a slow/absent digest can never hang the render or
// surface a console error under the significant-error gate below.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
// only; the mutating taps PATCH the plan route, which this spec
// never clicks, so it stays green without a plan-route stub.
{
kind: "coach_checkin",
title: "Time to check in on a plan",
body: "About a week ago you set: “every morning → weigh in”.",
status: "info",
moduleKey: "coach",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: "coach.checkin.keep:p1",
},
{
labelKey: "daily.action.checkinAdjust",
intent: "coach.checkin.adjust",
href: "/coach",
},
{
labelKey: "daily.action.checkinLetGo",
intent: "coach.checkin.letGo:p1",
},
],
},
],
},
error: null,
}),
}),
);
// v1.18.6 — the dashboard now batches every visible chart's daily
// series into ONE `/api/measurements/series-batch` round-trip and
// hands each chart its slice via `preloadedSeries`. The route is a
@@ -209,6 +273,17 @@ test.describe("authenticated dashboard render", () => {
const tileCount = Number(await strip.getAttribute("data-tile-count"));
expect(tileCount).toBeGreaterThan(0);
// S2 — the Today hero paints above the tile strip from the mocked
// digest: its score ring and its worth-a-look rail render.
const todayHero = page.locator('[data-slot="today-hero"]');
await expect(todayHero).toBeVisible({ timeout: 10_000 });
await expect(
todayHero.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
todayHero.locator('[data-slot="today-hero-rail"]'),
).toBeVisible();
// At least one chart must render. Recharts mounts a `<svg>` with
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
// around it). Use the recharts class so we don't accidentally match
+90 -17
View File
@@ -94,6 +94,29 @@ async function mockAiEnabled(
error: null,
}),
);
// v1.27.31 — the document AI section now probes the document-scoped
// capability endpoint (provider order + vendor-blind egress class), not the
// labs OCR probe. Mirror the same availability so the AI actions render;
// egress-notice-specific tests override this route afterwards.
await page.route("**/api/documents/inbound/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: opts.mode,
reason: null,
pdfSupported: opts.pdfSupported ?? opts.mode === "vision",
egress: "external",
},
error: null,
}),
);
// The detail sheet reads the auto-AI-read opt-in to decide whether to show
// the manual per-document action row. Default it OFF here so the manual
// Read / Suggest / Summarise actions render for these assertions; an unmocked
// fetch would leave the sheet reading a live value.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
}
test.describe("document vault — AI assist + content search", () => {
@@ -104,6 +127,13 @@ test.describe("document vault — AI assist + content search", () => {
await ensureVaultAiFixture();
});
// These AI flows drive several mocked provider round-trips per test; on a
// loaded CI runner they race the default 30s, so the whole group earns the
// tripled timeout rather than flaking green paths.
test.beforeEach(() => {
test.slow();
});
// ── (a) Review-first assist: prefill → edit → save, nothing auto-committed ──
test("assist prefills an editable draft that only saves on commit", async ({
@@ -149,9 +179,6 @@ test.describe("document vault — AI assist + content search", () => {
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
await expect(review).toBeVisible();
await expect(
review.getByText("AI suggestion — review before saving"),
).toBeVisible();
// Applying the title seeds the EDITABLE field only — no write yet.
await review.getByRole("button", { name: "Use title" }).click();
@@ -173,9 +200,7 @@ test.describe("document vault — AI assist + content search", () => {
// ── (b) Session-only summary: transient, note present, closing discards ──
test("summary panel is transient and labelled not-saved / not-a-diagnosis", async ({
page,
}) => {
test("summary panel is transient and discards on close", async ({ page }) => {
await mockAiEnabled(page, { mode: "vision" });
const summaryText = "A chest imaging report issued by a radiology clinic.";
let summaryCalls = 0;
@@ -198,11 +223,6 @@ test.describe("document vault — AI assist + content search", () => {
const panel = sheet.locator('[data-slot="document-summary-panel"]');
await expect(panel).toBeVisible();
await expect(panel.getByText(summaryText)).toBeVisible();
await expect(
panel.getByText(
"Not saved · not a diagnosis. Shown once for this view only.",
),
).toBeVisible();
expect(summaryCalls).toBe(1);
// Closing discards — the panel is gone and nothing lingers.
@@ -230,12 +250,6 @@ test.describe("document vault — AI assist + content search", () => {
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`));
// The matched card advertises its searchable body.
const searchable = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]');
await expect(searchable).toBeVisible();
// Sanity: the word really is absent from the short fields (server-side
// confirmation the union — not an ILIKE — did the work).
const viaApi = await page.request.get(
@@ -298,6 +312,65 @@ test.describe("document vault — AI assist + content search", () => {
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
// ── (g) "Read with AI": the prominent action maps to the index endpoint ──
test('the "Read with AI" action reads a document and confirms', async ({
page,
}) => {
await mockAiEnabled(page, { mode: "vision" });
// AI_PROBE_DOC_ID carries no content index → the action reads "Read with AI"
// (not "Read again") and the status pill is the calm "not searchable yet".
let indexCalls = 0;
let indexHadJsonBody = false;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/index`,
(route) => {
indexCalls += 1;
// VISION mode posts NO JSON body — the endpoint 422s a text body without
// the local-OCR opt-in, so the UI must never send one on this path.
indexHadJsonBody = Boolean(route.request().postData());
return fulfilJson(route, {
data: { documentId: AI_PROBE_DOC_ID, indexed: true, tokenCount: 12 },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The prominent action is present and labelled for a first read.
const read = sheet.locator('[data-slot="document-read-ai"]');
await expect(read).toBeVisible();
await expect(read).toHaveText(/Read with AI/);
await read.click();
await expect(page.getByText("The AI read your document.")).toBeVisible();
expect(indexCalls).toBe(1);
expect(indexHadJsonBody).toBe(false);
});
// ── (h) Provenance: a provider-read document is marked "Read by AI" ──────
test("a vision-indexed document surfaces the AI-read provenance", async ({
page,
}) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
// the vault card no longer carries the AI-read glyph (it looked wrong on
// the card face); the provenance now lives as a calm muted meta line in the
// document DETAIL view. Deep-link straight to the document so the sheet
// opens deterministically (the vault grid is virtualized).
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
await expect(marker).toBeVisible();
await expect(marker).toHaveText("Read by AI");
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
test("text-mode assist refuses a PDF client-side before any request", async ({
+280
View File
@@ -0,0 +1,280 @@
/**
* Automatic AI document reading — the per-user opt-in acceptance suite.
*
* Two surfaces:
* (1) AI settings — the single switch reveals a once-shown honesty confirm
* BEFORE writing the setting (off → on), and the confirm is what commits
* `documentsAutoAiRead: true`. Turning it off is immediate.
* (2) The vault detail sheet — the per-egress "leaves your machine" notice is
* shown once and the per-document "Read with AI" action is the explicit
* path; reading through it succeeds with no per-document consent prompt.
*
* Everything is `page.route`-mocked so the suite mutates no shared demo state.
* The document-scoped capability endpoint is mocked on every path — an unmocked
* / changed capability endpoint is the shipped regression that breaks the vault
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import { expect, test, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
} from "./setup/vault-fixture";
async function fulfilJson(
route: Parameters<Parameters<Page["route"]>[1]>[0],
body: unknown,
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Mock the FULL AI-availability surface the vault section probes so the read
* action renders AND is enabled: `usage` (assist + content-index flags — this is
* what un-disables the action), the labs OCR capability, and the document-scoped
* capability (provider order + vendor-blind egress class). Mirrors
* `mockAiEnabled` in documents-ai.spec.ts; egress-specific tests set the class.
*/
async function mockDocumentCapability(
page: Page,
opts: {
egress: "local" | "external";
pdfSupported?: boolean;
/** The per-user auto-AI-read opt-in. OFF keeps the manual action row. */
autoRead?: boolean;
},
): Promise<void> {
const pdfSupported = opts.pdfSupported ?? true;
const autoRead = opts.autoRead ?? false;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, {
data: { documentsAutoAiRead: autoRead },
error: null,
}),
);
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, {
data: {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: 26_214_400,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 0, totalCount: 1 },
},
error: null,
}),
);
await page.route("**/api/labs/ocr/capability", (route) =>
fulfilJson(route, {
data: { available: true, mode: "vision", reason: null, pdfSupported },
error: null,
}),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: "vision",
reason: null,
pdfSupported,
egress: opts.egress,
},
error: null,
}),
);
}
test.describe("automatic AI reading — settings toggle", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
// The confirm logic is viewport-independent; run the settings flow once on
// desktop to keep the heavy AI-settings page off the mobile matrix.
test.skip(
testInfo.project.name !== "chromium-desktop",
"settings toggle flow runs on desktop only",
);
test.slow();
});
test("turning it on reveals a once-shown honesty confirm before writing", async ({
page,
}) => {
// The toggle starts OFF; capture the write so we can prove it only fires on
// confirm (never on the raw switch flip).
let patchBody: unknown = null;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) => {
if (route.request().method() === "PATCH") {
patchBody = JSON.parse(route.request().postData() ?? "{}");
return fulfilJson(route, {
data: { documentsAutoAiRead: true },
error: null,
});
}
return fulfilJson(route, {
data: { documentsAutoAiRead: false },
error: null,
});
});
await page.goto("/settings/ai");
const card = page.locator('[data-slot="documents-auto-read-card"]');
await expect(card).toBeVisible();
// Flip the switch ON — the honesty confirm appears and NOTHING is written.
await card.getByTestId("documents-auto-read-enable").click();
const confirm = card.locator('[data-slot="documents-auto-read-confirm"]');
await expect(confirm).toBeVisible();
expect(patchBody).toBeNull();
// Acknowledge — now (and only now) the write fires with the true flag.
await confirm
.locator('[data-slot="documents-auto-read-confirm-cta"]')
.click();
await expect.poll(() => patchBody).toEqual({ documentsAutoAiRead: true });
await expect(confirm).not.toBeVisible();
});
test("cancelling the confirm writes nothing", async ({ page }) => {
let patchCalls = 0;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) => {
if (route.request().method() === "PATCH") {
patchCalls += 1;
return fulfilJson(route, {
data: { documentsAutoAiRead: true },
error: null,
});
}
return fulfilJson(route, {
data: { documentsAutoAiRead: false },
error: null,
});
});
await page.goto("/settings/ai");
const card = page.locator('[data-slot="documents-auto-read-card"]');
await expect(card).toBeVisible();
await card.getByTestId("documents-auto-read-enable").click();
const confirm = card.locator('[data-slot="documents-auto-read-confirm"]');
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: /cancel/i }).click();
await expect(confirm).not.toBeVisible();
await page.waitForTimeout(300);
expect(patchCalls).toBe(0);
});
});
test.describe("automatic AI reading — vault per-document contract", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultAiFixture();
});
test.beforeEach(() => test.slow());
test("the vault shows the explicit read action for an external provider", async ({
page,
}) => {
// External egress (the codex/OAuth or any non-local provider case). The
// per-document egress notice was retired — the settings-toggle honesty
// confirm already covers the egress trade — so no per-document notice
// renders here regardless of egress mode.
await mockDocumentCapability(page, { egress: "external" });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// With auto-read OFF the per-document read is the explicit path — the action
// is present and the user taps it. (When auto-read is ON the row collapses;
// that is covered by its own test below.)
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
test("reading through the action succeeds with no per-document consent prompt", async ({
page,
}) => {
await mockDocumentCapability(page, { egress: "external" });
let indexCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/index`,
(route) => {
indexCalls += 1;
return fulfilJson(route, {
data: { documentId: AI_PROBE_DOC_ID, indexed: true, tokenCount: 9 },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const read = sheet.locator('[data-slot="document-read-ai"]');
await expect(read).toBeVisible();
await read.click();
await expect(page.getByText("The AI read your document.")).toBeVisible();
expect(indexCalls).toBe(1);
});
test("a local-egress provider shows the explicit read action", async ({
page,
}) => {
// A self-hosted local model never leaves the machine; the read action is
// offered the same way as for an external provider (no per-document notice
// in either mode after the notice was retired).
await mockDocumentCapability(page, {
egress: "local",
pdfSupported: false,
});
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
test("with auto-read ON the sheet drops the manual AI action row", async ({
page,
}) => {
// Auto-read reads every upload with no per-document tap, so the manual
// Read / Suggest actions are redundant and the WHOLE block collapses
// (v1.28.22 — no status chrome; provenance lives on the vault card).
await mockDocumentCapability(page, { egress: "external", autoRead: true });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
// collapses (no status chrome; provenance lives on the vault card), so the
// sheet content starts straight with the document fields. The footer's
// download action is the loaded-state anchor (it renders only once the
// document GET resolved), so the absence checks below cannot pass
// vacuously against the loading skeleton.
await expect(
sheet.locator('[data-slot="document-download"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-ai-section"]'),
).toHaveCount(0);
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
0,
);
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* Document vault — the "chat about a document" entry point (v1.28.52, R3).
*
* The bespoke in-sheet chat drawer is gone (Documents R3). Asking the Coach
* about a document now opens the REAL Coach conversation, scoped to that
* document, in the shared SIDE DRAWER: the detail sheet's Coach affordance
* closes the sheet and opens the coach drawer pre-scoped to the document (its
* maximize control expands to the full `/coach` page). The conversation runs on
* the same hardened fenced endpoint — only WHERE it renders changed.
*
* This suite pins the documents-side contract: the affordance is offered only
* when a provider is available, and it OPENS THE DOC-SCOPED DRAWER in place (no
* full-page nav); with NO provider the AI area collapses to the settings
* pointer and no Coach entry renders at all. The Coach-side rendering (the
* "Chatting about" scope banner + the not-indexed hint) is covered by the
* coach-conversation component test; the fenced-send guarantee (a document turn
* never reaches the tool route) by the coach-send-target + persistence-scope
* unit tests.
*
* v1.27.31 lesson kept: the sheet's AI area depends on the capability probe +
* usage endpoints, so both are mocked — otherwise the AI area never renders and
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
CONTENT_DOC_ID,
} from "./setup/vault-fixture";
/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */
async function fulfilJson(
route: Parameters<Parameters<Page["route"]>[1]>[0],
body: unknown,
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/** The vault usage DTO the sheet reads to gate the AI affordances. */
function usageBody() {
return {
data: {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: 26_214_400,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 1, totalCount: 2 },
},
error: null,
};
}
/**
* Mock the AI-availability surface (usage + the document-scoped capability
* probe) so the sheet offers the AI area and the Coach entry.
*/
async function mockAiEnabled(page: Page): Promise<void> {
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, usageBody()),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: "vision",
reason: null,
pdfSupported: true,
egress: "external",
},
error: null,
}),
);
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
}
test.describe("document vault — chat about a document", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultAiFixture();
});
test.beforeEach(() => {
test.slow();
});
// ── (a) Provider available: the Coach entry opens the doc-scoped drawer ──
test("offers the Coach entry and opens the document-scoped Coach drawer in place", async ({
page,
}) => {
await mockAiEnabled(page);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
// The neutral Coach affordance lives in the detail-sheet AI area; tapping it
// closes the sheet and opens the REAL coach drawer scoped to this document.
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
// The documents-side contract: the coach drawer opens IN PLACE (no
// full-page nav) scoped to this document — its doc-scope banner renders.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer.locator('[data-slot="coach-doc-scope"]')).toBeVisible({
timeout: 10_000,
});
// Still on /documents — the drawer expands to the page only via maximize.
expect(new URL(page.url()).pathname).not.toContain("/coach");
});
// ── (b) No provider: the calm settings pointer, no Coach entry at all ──
test("with no provider there is no Coach entry, only the settings pointer", async ({
page,
}) => {
// No mocks — the seeded demo user has no AI provider configured, so the AI
// area collapses to the calm pointer and the Coach entry never renders.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await expect(
sheet.locator('[data-slot="assist-unavailable"]'),
).toBeVisible();
await expect(
sheet.getByRole("link", { name: "Open AI settings" }),
).toBeVisible();
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
0,
);
});
});
+17 -10
View File
@@ -108,7 +108,7 @@ test.describe("document vault", () => {
// ── §1A — the 30-second doctor flow, three independent routes ─────────
test("doctor flow A: the type chip reaches the MRT report", async ({
test("doctor flow A: the type filter reaches the MRT report", async ({
page,
}) => {
await page.goto("/documents");
@@ -116,7 +116,11 @@ test.describe("document vault", () => {
page.getByRole("heading", { name: "Documents" }),
).toBeVisible();
await page.getByRole("button", { name: "Imaging", exact: true }).click();
// Open the type dropdown, tick Imaging (multi-select stays open), then
// dismiss the menu.
await page.getByRole("button", { name: "All types" }).click();
await page.getByRole("menuitemcheckbox", { name: "Imaging" }).click();
await page.keyboard.press("Escape");
// Autumn 2025 sits below the newest sections — scroll the window down.
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
await openButton(page, "MRT Knie").click();
@@ -128,13 +132,15 @@ test.describe("document vault", () => {
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
});
test("doctor flow B: the condition chip reaches the MRT report", async ({
test("doctor flow B: the condition filter reaches the MRT report", async ({
page,
}) => {
await page.goto("/documents");
// Open the condition dropdown and pick the linked episode (single-select
// → the menu closes on choice).
await page.locator('[data-slot="document-condition-filter"]').click();
await page
.locator('[data-slot="document-filter-bar"]')
.getByRole("button", { name: "Knie", exact: true })
.getByRole("menuitemcheckbox", { name: "Knie", exact: true })
.click();
// The Knie filter narrows the corpus to the linked MRT trio.
@@ -162,12 +168,13 @@ test.describe("document vault", () => {
page,
}) => {
await page.goto("/documents?kind=IMAGING&year=2025");
// A deep-linked facet surfaces on its dropdown trigger label.
await expect(
page.getByRole("button", { name: "Imaging", exact: true }),
).toHaveAttribute("aria-pressed", "true");
page.locator('[data-slot="document-type-filter"]'),
).toContainText("Imaging");
await expect(
page.getByRole("button", { name: "2025", exact: true }),
).toHaveAttribute("aria-pressed", "true");
page.locator('[data-slot="document-year-filter"]'),
).toContainText("2025");
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`);
@@ -345,7 +352,7 @@ test.describe("document vault", () => {
await expect(openButton(page, "MRT Knie")).toBeVisible();
// Tab from the search into the grid's single roving slot (bounded walk
// across the chip rail), then arrows move the active card.
// across the filter dropdowns), then arrows move the active card.
let reachedGrid = false;
for (let i = 0; i < 40; i++) {
await page.keyboard.press("Tab");
+1
View File
@@ -32,6 +32,7 @@ test.describe("medication wizard — bi-weekly", () => {
test("creates an every-2-weeks Monday medication end-to-end", async ({
page,
}) => {
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+1
View File
@@ -33,6 +33,7 @@ test.describe("medication wizard — compose-mode", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a 2-schedule medication end-to-end", async ({ page }) => {
test.slow();
await stubDashboardAnalytics(page);
const listEntry = {
+4
View File
@@ -29,6 +29,10 @@ test.describe("medication wizard — daily", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a daily medication end-to-end", async ({ page }) => {
// Multi-step wizard with per-step validation gating the Next button;
// under a loaded CI runner the validation settle can lag the default
// 30s, so this legitimately-long flow earns the tripled timeout.
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+5 -1
View File
@@ -221,11 +221,15 @@ export async function expectStep(
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
// viewport-independent root data-attributes the dialog always carries.
const root = page.locator('[data-slot="medication-wizard-dialog"]');
// Each step advance waits on per-step form validation; on a loaded CI runner
// that settle can exceed the default 5s expect timeout (the dialog sits at
// the prior step when the assertion first samples). Give the step-transition
// assertion room so a slow runner doesn't read a mid-transition frame.
await expect(root).toHaveAttribute(
"data-display-step",
String(displayIndex),
{
timeout: 5_000,
timeout: 20_000,
},
);
if (totalSteps != null) {
+1
View File
@@ -30,6 +30,7 @@ test.describe("medication wizard — monthly", () => {
test("creates a monthly-on-day-15 medication end-to-end", async ({
page,
}) => {
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+1
View File
@@ -34,6 +34,7 @@ test.describe("medication wizard — one-shot", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a single-dose medication end-to-end", async ({ page }) => {
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+1
View File
@@ -33,6 +33,7 @@ test.describe("medication wizard — rolling", () => {
test("creates an every-7-days-from-last-intake medication end-to-end", async ({
page,
}) => {
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+1
View File
@@ -29,6 +29,7 @@ test.describe("medication wizard — weekdays", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a Mo/Mi/Fr medication end-to-end", async ({ page }) => {
test.slow();
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
+49 -15
View File
@@ -13,21 +13,21 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* Phone-width density guard (390×844 iPhone class, 360×780 small
* Android class).
*
* Two invariants, per the report against the dashboard hero (a long
* briefing-signal headline squeezed into a one-word-per-line column
* beside a wide delta, the delta spilling past the tile edge at 360 px):
* Two invariants, per the original overflow report (long headline text
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 2. Nothing escapes its tile — for every card / hero band / list
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
* `overflow-hidden` wrapper below the tile is fine; content cut
* off at the tile's own edge is the bug).
*
* The snapshot mock pins the worst case: hero band on, three score
* rings, and a fresh briefing whose signal rows pair wrapping German
* headlines with wide delta strings.
* The mocks pin the worst case: the Today hero (its score face + a
* worth-a-look rail) plus a fresh briefing whose rows pair wrapping
* German headlines with wide delta strings.
*
* Desktop project only — the assertions are viewport-driven via
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
@@ -51,7 +51,7 @@ interface TileEscape {
function collectTileEscapes(): TileEscape[] {
const escapes: TileEscape[] = [];
const tiles = document.querySelectorAll(
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
'[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]',
);
const TOLERANCE = 1;
for (const tile of tiles) {
@@ -113,10 +113,46 @@ test.describe("phone-width density guard", () => {
await page.setViewportSize(viewport);
await mockDashboardSnapshot(page, {
summaries: POPULATED_SUMMARIES,
heroVisible: true,
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
// The Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a
// score + one worth-a-look item) so the hero paints without
// reaching the real route — mirror of the dashboard.spec mock.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
],
},
error: null,
}),
}),
);
if (route === "/insights/mood") {
await mockMoodInsights(page);
}
@@ -124,14 +160,12 @@ test.describe("phone-width density guard", () => {
await page.waitForLoadState("networkidle");
if (route === "/") {
// The guard must not pass vacuously — the hero band and its
// long-headline spotlight rows have to actually render.
// The guard must not pass vacuously — the Today hero and its
// score face have to actually render.
await expect(page.locator('[data-slot="today-hero"]')).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-hero"]'),
page.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
).toHaveCount(3);
}
if (route === "/insights/mood") {
+14
View File
@@ -242,6 +242,20 @@ test.describe("dashboard onboarding card flicker guard", () => {
body: JSON.stringify({ data: { channels: [] }, error: null }),
}),
);
// v1.28 — the checklist's "Turn on insights" row self-satisfies from
// `/api/user/ai-provider` (`aiAvailable` presence-only). Stub it so the
// row renders deterministically as not-done and no real request fires
// while the card mounts.
await page.route("**/api/user/ai-provider", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { aiAvailable: false, managedBy: null },
error: null,
}),
}),
);
// v1.4.16 Wave-C — the dashboard added `/api/dashboard/widgets`
// (A5 tile-strip persistence) since this spec was written. Without
// a stub the unauthenticated layout query 500s in CI and React
+17 -2
View File
@@ -95,6 +95,20 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
},
// v1.28.x — Strava OAuth card reads off this consolidated
// envelope; without a `strava` entry the new card's query key
// never resolves and the integrations page hangs.
{
integration: "strava",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
available: false,
hasOwnCredentials: false,
},
],
},
error: null,
@@ -143,9 +157,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Nightscout.
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
// carries no pill.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(7, { timeout: 10_000 });
await expect(pills).toHaveCount(8, { timeout: 10_000 });
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
+57 -11
View File
@@ -13,18 +13,34 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* the shell's own budget and reopens the dark-band-below-the-last-card
* bug.
*
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content
* element + the shell-owned padding + tolerance. Runs across the
* settings routes (short hub, long sortable-list subpages, long form
* section) at desktop AND phone-shaped viewports, because the two shell
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real
* content element + the shell-owned padding + tolerance. "Real content"
* is measured against each element's CONTENT-box bottom, not its padding-
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
* no longer folds that gutter into `contentBottom`, so a redundant reserve
* FAILS the assertion instead of being absorbed by it. Runs across both
* two-column sub-shells — Settings AND Admin (they share the grid-floor
* source) — at desktop AND phone-shaped viewports, because the two shell
* paddings differ per breakpoint.
*/
const ROUTES = [
// Settings sub-shell — short hub, long sortable-list subpages, long form.
"/settings/layout",
"/settings/layout/dashboard",
// The other sortable-list subpages + the Modules hub: each hosts an
// order editor with the sr-only drag-hint paragraph (see the
// one-scroll-floor assertion below).
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/modules",
"/settings/account",
"/settings/notifications",
// Admin sub-shell — short overview + a longer list page. Guards that the
// pre-#154 column reserve stays retired on every admin breakpoint.
"/admin",
"/admin/login-overview",
"/admin/system-status",
] as const;
const VIEWPORTS = [
@@ -32,7 +48,7 @@ const VIEWPORTS = [
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
test.describe("settings vertical over-scroll guard", () => {
test.describe("settings + admin vertical over-scroll guard", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
@@ -56,11 +72,17 @@ test.describe("settings vertical over-scroll guard", () => {
if (!main) return null;
const wrapper = main.firstElementChild as HTMLElement | null;
if (!wrapper) return null;
// Lowest content edge: max bottom over the wrapper's visible,
// non-fixed descendants, in the scroll container's coordinate
// space. Elements inside nested scroll containers are clipped
// by their own overflow and never add page scroll height, so
// skip anything whose scrollable ancestor is not `main`.
// Lowest content edge: max CONTENT-box bottom over the wrapper's
// visible, non-fixed descendants, in the scroll container's
// coordinate space. Using the content box (padding-box bottom
// minus the element's own bottom padding + border) means a
// layout column's own `pb-*` gutter is NOT counted — its last
// real child (a card) is still measured through the column's
// content box, so a redundant sub-shell bottom gutter shows up
// as pure over-scroll instead of inflating the allowed budget.
// Elements inside nested scroll containers are clipped by their
// own overflow and never add page scroll height, so skip
// anything whose scrollable ancestor is not `main`.
let maxBottom = 0;
const mainTop = main.getBoundingClientRect().top;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
@@ -84,7 +106,10 @@ test.describe("settings vertical over-scroll guard", () => {
p = p.parentElement;
}
if (inner) continue;
const bottom = rect.bottom + main.scrollTop - mainTop;
const padBottom = parseFloat(cs.paddingBottom) || 0;
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
const bottom =
rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop;
if (bottom > maxBottom) maxBottom = bottom;
}
return {
@@ -97,6 +122,27 @@ test.describe("settings vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9): `<main>` is the ONLY vertical
// scroll surface — the document itself must never become scrollable
// next to it (a second painted scrollbar + a dead dark band under
// the shell). The historic offender class: an absolutely-positioned
// sr-only element (the dnd drag-hint paragraph) whose containing
// block resolves to the initial containing block because no ancestor
// between it and the root is positioned — its static position below
// a long list then extends the DOCUMENT's scrollable overflow while
// `<main>` clips everything in normal flow.
const doc = await page.evaluate(() => ({
scrollHeight: document.documentElement.scrollHeight,
clientHeight: document.documentElement.clientHeight,
}));
expect(
doc.scrollHeight,
`document scrollHeight=${doc.scrollHeight} > viewport ` +
`${doc.clientHeight} — a second vertical scroll surface exists ` +
`beside <main> (likely an absolutely-positioned element escaping ` +
`to the initial containing block)`,
).toBeLessThanOrEqual(doc.clientHeight + 1);
// A page shorter than the viewport cannot over-scroll at all.
if (dims.scrollHeight <= dims.clientHeight) return;
+223 -93
View File
@@ -160,6 +160,30 @@ function tinyPng(marker: string): Buffer {
return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]);
}
/**
* A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment so the
* share serve route's egress EXIF strip is observable end-to-end: the stored
* bytes contain `GPSLatitude`, the served bytes must not. SOI → APP1(Exif) →
* SOS(+entropy). Not a decodable photo — the serve route sniffs the STORED
* mime type, never re-decodes pixels, so this is enough to exercise the strip.
*/
function jpegWithGps(marker: string): Buffer {
const payload = Buffer.concat([
Buffer.from("Exif\0\0"),
Buffer.from(`GPSLatitude=48.137 ${marker}`),
]);
const lenField = Buffer.alloc(2);
lenField.writeUInt16BE(payload.length + 2);
const app1 = Buffer.concat([Buffer.from([0xff, 0xe1]), lenField, payload]);
const sos = Buffer.concat([
Buffer.from([0xff, 0xda]),
Buffer.from([0x00, 0x08]),
Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]),
Buffer.from([0xaa, 0xbb]),
]);
return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]);
}
// ─── Fixture identity ───────────────────────────────────────────────────────
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
@@ -173,6 +197,21 @@ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
export const CONTENT_DOC_ID = "e2evaultcontent00000001";
/** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */
export const CONTENT_BODY_WORD = "pneumothorax";
/**
* v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and
* serves through the public `/c/<token>/d/<id>` route:
* - a PDF (Class A, inline preview via <iframe>),
* - a JPEG carrying an Exif GPS marker (Class A image, inline <img>; the
* serve route strips the marker on egress — the stored bytes keep it),
* - a text/plain document (Class B, download-only opaque attachment).
* Titles carry the `Share e2e` prefix so the owner picker's search isolates
* exactly this set. Idempotent (fixed ids upserted).
*/
export const SHARE_PDF_DOC_ID = "e2eshare0000000000000pdf";
export const SHARE_JPEG_DOC_ID = "e2eshare000000000000jpeg";
export const SHARE_TEXT_DOC_ID = "e2eshare000000000000text";
export const SHARE_DOC_PREFIX = "Share e2e";
/** Filing date of the MRT report — "last autumn" relative to the fixture. */
const MRT_DOC_DATE = "2025-10-14";
@@ -196,6 +235,39 @@ interface DocRow {
bytes: Buffer;
}
/**
* Advisory-lock key that serialises the vault-fixture seeders across Playwright
* workers. `fullyParallel: true` spreads a spec's tests across workers and each
* worker re-runs the file's `beforeAll`; two workers seeding at once race on the
* fixed-id inserts, and `ON CONFLICT` only suppresses a conflict on its named
* arbiter index — a concurrent duplicate on a DIFFERENT unique index (e.g. the
* `document_condition_links` primary key) still throws. Holding this lock for the
* span of a seeder makes the concurrent runs sequential, so every insert re-runs
* as a clean idempotent upsert.
*/
const VAULT_SEED_LOCK_KEY = 792244;
/**
* Run `body` while holding the vault-seed advisory lock on a dedicated pooled
* connection, so concurrent workers seed one-at-a-time. The lock auto-releases
* if the connection drops; the explicit unlock keeps the pool clean for reuse.
*/
async function withVaultSeedLock(
pool: pg.Pool,
body: () => Promise<void>,
): Promise<void> {
const lock = await pool.connect();
try {
await lock.query("SELECT pg_advisory_lock($1)", [VAULT_SEED_LOCK_KEY]);
await body();
} finally {
await lock
.query("SELECT pg_advisory_unlock($1)", [VAULT_SEED_LOCK_KEY])
.catch(() => {});
lock.release();
}
}
async function getUserId(pool: pg.Pool): Promise<string> {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
@@ -259,82 +331,84 @@ export async function ensureVaultFixture(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool);
// Opt-in module: merge, never overwrite other module preferences.
await pool.query(
`UPDATE users
// Opt-in module: merge, never overwrite other module preferences.
await pool.query(
`UPDATE users
SET module_preferences_json =
COALESCE(module_preferences_json, '{}'::jsonb)
|| '{"inboundDocuments": true}'::jsonb
WHERE id = $1`,
[userId],
);
[userId],
);
// The "Knie" condition episode the MRT report files under.
await pool.query(
`INSERT INTO illness_episodes
// The "Knie" condition episode the MRT report files under.
await pool.query(
`INSERT INTO illness_episodes
(id, user_id, label, type, lifecycle, onset_at, created_at, updated_at)
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
[KNIE_EPISODE_ID, userId],
);
[KNIE_EPISODE_ID, userId],
);
// 60 background documents spread over 36 months (newest ~current).
const docs: DocRow[] = [];
for (let i = 0; i < 60; i++) {
const monthsBack = Math.floor((i * 36) / 60);
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
// 60 background documents spread over 36 months (newest ~current).
const docs: DocRow[] = [];
for (let i = 0; i < 60; i++) {
const monthsBack = Math.floor((i * 36) / 60);
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
docs.push({
id,
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
title: `Befund ${String(i + 1).padStart(2, "0")}`,
filename: `befund-${i + 1}.pdf`,
mimeType: "application/pdf",
documentDate: anchor.toISOString().slice(0, 10),
bytes: tinyPdf(id),
});
}
// The MRT report + two adjacent images, same date, all linked to Knie.
docs.push({
id,
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
title: `Befund ${String(i + 1).padStart(2, "0")}`,
filename: `befund-${i + 1}.pdf`,
mimeType: "application/pdf",
documentDate: anchor.toISOString().slice(0, 10),
bytes: tinyPdf(id),
});
}
// The MRT report + two adjacent images, same date, all linked to Knie.
docs.push({
id: MRT_DOC_ID,
kind: "IMAGING",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
documentDate: MRT_DOC_DATE,
bytes: tinyPdf(MRT_DOC_ID),
});
for (const n of [1, 2]) {
docs.push({
id: `e2evaultimg000000000000${n}`,
id: MRT_DOC_ID,
kind: "IMAGING",
title: `MRT Aufnahme ${n}`,
filename: `mrt-aufnahme-${n}.png`,
mimeType: "image/png",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
documentDate: MRT_DOC_DATE,
bytes: tinyPng(`e2evaultimg${n}`),
bytes: tinyPdf(MRT_DOC_ID),
});
}
await upsertDocs(pool, userId, docs);
for (const n of [1, 2]) {
docs.push({
id: `e2evaultimg000000000000${n}`,
kind: "IMAGING",
title: `MRT Aufnahme ${n}`,
filename: `mrt-aufnahme-${n}.png`,
mimeType: "image/png",
documentDate: MRT_DOC_DATE,
bytes: tinyPng(`e2evaultimg${n}`),
});
}
await upsertDocs(pool, userId, docs);
// Condition links for the three MRT-day documents.
const linked = [
MRT_DOC_ID,
"e2evaultimg0000000000001",
"e2evaultimg0000000000002",
];
for (const [i, docId] of linked.entries()) {
await pool.query(
`INSERT INTO document_condition_links
// Condition links for the three MRT-day documents.
const linked = [
MRT_DOC_ID,
"e2evaultimg0000000000001",
"e2evaultimg0000000000002",
];
for (const [i, docId] of linked.entries()) {
await pool.query(
`INSERT INTO document_condition_links
(id, document_id, episode_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (document_id, episode_id) DO NOTHING`,
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
);
}
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
);
}
});
} finally {
await pool.end();
}
@@ -353,36 +427,37 @@ export async function ensureVaultAiFixture(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool);
await upsertDocs(pool, userId, [
{
id: AI_PROBE_DOC_ID,
kind: "OTHER",
title: "AI probe report",
filename: "ai-probe-report.pdf",
mimeType: "application/pdf",
documentDate: "2026-04-02",
bytes: tinyPdf(AI_PROBE_DOC_ID),
},
{
// Title + filename deliberately omit CONTENT_BODY_WORD.
id: CONTENT_DOC_ID,
kind: "DOCTOR_REPORT",
title: "Radiology note",
filename: "radiology-note.pdf",
mimeType: "application/pdf",
documentDate: "2026-03-10",
bytes: tinyPdf(CONTENT_DOC_ID),
},
]);
await upsertDocs(pool, userId, [
{
id: AI_PROBE_DOC_ID,
kind: "OTHER",
title: "AI probe report",
filename: "ai-probe-report.pdf",
mimeType: "application/pdf",
documentDate: "2026-04-02",
bytes: tinyPdf(AI_PROBE_DOC_ID),
},
{
// Title + filename deliberately omit CONTENT_BODY_WORD.
id: CONTENT_DOC_ID,
kind: "DOCTOR_REPORT",
title: "Radiology note",
filename: "radiology-note.pdf",
mimeType: "application/pdf",
documentDate: "2026-03-10",
bytes: tinyPdf(CONTENT_DOC_ID),
},
]);
// Seed CONTENT_DOC_ID's blind content index: the body word is only findable
// via the token union. The plaintext text is stored encrypted (never read
// by the search path) purely to mirror a real index row.
const body = `Chest imaging report. Findings consistent with ${CONTENT_BODY_WORD}. No pleural effusion noted.`;
await pool.query(
`INSERT INTO document_content_index
// Seed CONTENT_DOC_ID's blind content index: the body word is only findable
// via the token union. The plaintext text is stored encrypted (never read
// by the search path) purely to mirror a real index row.
const body = `Chest imaging report. Findings consistent with ${CONTENT_BODY_WORD}. No pleural effusion noted.`;
await pool.query(
`INSERT INTO document_content_index
(id, document_id, user_id, text_encrypted, search_tokens, source,
provider_type, tokenizer_version, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'vision', NULL, '1', NOW(), NOW())
@@ -390,14 +465,15 @@ export async function ensureVaultAiFixture(): Promise<void> {
text_encrypted = EXCLUDED.text_encrypted,
search_tokens = EXCLUDED.search_tokens,
updated_at = NOW()`,
[
"e2evaultcidx0000000000001",
CONTENT_DOC_ID,
userId,
encryptStringToBytes(body),
tokeniseAndHash(body),
],
);
[
"e2evaultcidx0000000000001",
CONTENT_DOC_ID,
userId,
encryptStringToBytes(body),
tokeniseAndHash(body),
],
);
});
} finally {
await pool.end();
}
@@ -439,3 +515,57 @@ export async function seedNamespaceDocs(
await pool.end();
}
}
/**
* v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID`
* constants). Enables the vault module for the e2e user and upserts one
* inline-PDF, one Exif/GPS-bearing JPEG, and one download-only text document,
* all owned by the seeded account. Idempotent; safe from a spec `beforeAll`.
*/
export async function ensureShareDocFixture(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await pool.query(
`UPDATE users
SET module_preferences_json =
COALESCE(module_preferences_json, '{}'::jsonb)
|| '{"inboundDocuments": true}'::jsonb
WHERE id = $1`,
[userId],
);
await upsertDocs(pool, userId, [
{
id: SHARE_PDF_DOC_ID,
kind: "DOCTOR_REPORT",
title: `${SHARE_DOC_PREFIX} report`,
filename: "share-report.pdf",
mimeType: "application/pdf",
documentDate: "2026-05-20",
bytes: tinyPdf(SHARE_PDF_DOC_ID),
},
{
id: SHARE_JPEG_DOC_ID,
kind: "IMAGING",
title: `${SHARE_DOC_PREFIX} scan`,
filename: "share-scan.jpg",
mimeType: "image/jpeg",
documentDate: "2026-05-21",
bytes: jpegWithGps(SHARE_JPEG_DOC_ID),
},
{
id: SHARE_TEXT_DOC_ID,
kind: "OTHER",
title: `${SHARE_DOC_PREFIX} notes`,
filename: "share-notes.txt",
mimeType: "text/plain",
documentDate: "2026-05-22",
bytes: Buffer.from(`share e2e class-B payload ${SHARE_TEXT_DOC_ID}`),
},
]);
} finally {
await pool.end();
}
}
+353
View File
@@ -0,0 +1,353 @@
/**
* v1.28 (document vault, Phase 3) — clinician document-sharing lifecycle e2e.
*
* Drives the whole public-surface path against the production server + a real
* Postgres (migrations 0228 + 0229): the owner picks documents into a new share
* link through the Settings picker, a recipient unlocks the link with the
* passphrase and pulls each blob through the public `/c/<token>/d/<id>` serve
* route, and a revoke closes it. The load-bearing assertions:
*
* - the serve route confines to the link's FROZEN set — an owned document NOT
* attached to the link 404s (token-scoped, never a global document fetch);
* - the serving posture matches the owner route — Class A (PDF/JPEG) inline
* with its true type + nosniff + no-store, Class B (text) opaque
* `application/octet-stream` attachment;
* - EXIF/GPS is stripped from the shared JPEG on egress (the served bytes
* drop the marker the stored bytes still carry);
* - revocation is enforced at serve time — a revoked link serves nothing;
* - the clinician view renders inline previews (img/iframe) for Class A and a
* download-only affordance for Class B;
* - the create reveal promotes a scannable QR (runs under the 390px mobile
* project too).
*
* The share fixtures (`ensureShareDocFixture`) seed the attached trio; the
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureShareDocFixture,
ensureVaultFixture,
MRT_DOC_ID,
SHARE_DOC_PREFIX,
SHARE_JPEG_DOC_ID,
SHARE_PDF_DOC_ID,
SHARE_TEXT_DOC_ID,
} from "./setup/vault-fixture";
const BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
/** An owned document from the broad corpus that is NEVER attached to the link
* under test — the foreign-id probe against the frozen-set confinement. */
const UNATTACHED_OWNED_DOC_ID = "e2evaultdoc000000000000";
interface CreatedShare {
token: string;
passphrase: string;
label: string;
}
/**
* Drive the owner create flow: open the picker, isolate the share-fixture trio
* by search, select all three, create the link, and read the one-time token +
* passphrase off the reveal card. Also asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(label);
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
const search = page.getByPlaceholder("Search documents");
await search.fill(SHARE_DOC_PREFIX);
const list = page.getByTestId("share-doc-picker-list");
await expect(list).toBeVisible();
// Exactly the three share-fixture documents match the prefix.
const rows = list.getByRole("button");
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i++) await rows.nth(i).click();
await page.getByRole("button", { name: "Done" }).click();
// Three chips staged before create.
await expect(
page.getByTestId("share-attached-chips").getByRole("listitem"),
).toHaveCount(3);
await page.getByRole("button", { name: "Create link" }).click();
const reveal = page.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
await expect(page.getByTestId("share-created-doc-count")).toContainText("3");
// QR promotion — present and rendered at a scannable size (the UI wave
// promoted it as the primary mobile affordance; runs under the 390px project).
const qrBlock = page.getByTestId("share-qr-block");
await expect(qrBlock).toBeVisible();
const qrImg = qrBlock.getByRole("img");
await expect(qrImg).toBeVisible();
const box = await qrImg.boundingBox();
expect(box, "QR image has a rendered box").not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(160);
// The one-time secrets: token from the share URL, passphrase from its cell.
const shareUrl = (await reveal.locator("code").first().innerText()).trim();
const token = shareUrl.split("/c/")[1];
expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/);
const passphrase = (
await page.getByTestId("share-passphrase").innerText()
).trim();
expect(passphrase.length).toBeGreaterThan(0);
return { token, passphrase, label };
}
/** A recipient browser context (no owner session) that has unlocked `token`
* by following the `#k=<passphrase>` deep link the QR encodes. */
async function openUnlockedClinician(
browser: Browser,
share: CreatedShare,
): Promise<Page> {
const ctx = await browser.newContext({
baseURL: BASE,
serviceWorkers: "block",
});
const page = await ctx.newPage();
// The gate reads `#k=` from the fragment, POSTs the unlock, and reloads with
// the record — exactly what a clinician scanning the QR would trigger.
await page.goto(`${BASE}/c/${share.token}#${"k"}=${share.passphrase}`);
await expect(
page.getByRole("heading", { name: "Shared health record" }),
).toBeVisible({ timeout: 15_000 });
return page;
}
test.describe("clinician document sharing", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureShareDocFixture();
});
test("share → unlock → serve (posture + EXIF strip) → revoke → 404", async ({
page,
browser,
}) => {
// A full lifecycle across two browser contexts (owner create + picker,
// recipient unlock, several document serves, revoke): legitimately heavy,
// so it earns the tripled timeout rather than racing the default 30s on a
// loaded CI runner.
test.slow();
const share = await createShareWithDocs(page);
const clinician = await openUnlockedClinician(browser, share);
// ── The clinician view lists the documents with the right affordances ──
await expect(
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// Class A image → inline <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
).toHaveCount(1);
// Class B text → NO inline frame; the download-only hint is shown.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_TEXT_DOC_ID}"]`),
).toHaveCount(0);
await expect(
clinician.getByText("This file downloads to your device"),
).toBeVisible();
const serve = (id: string) => `${BASE}/c/${share.token}/d/${id}`;
// ── Class A PDF: inline, true type, nosniff, no-store ──
const pdf = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(pdf.status()).toBe(200);
expect(pdf.headers()["content-type"]).toBe("application/pdf");
expect(pdf.headers()["content-disposition"]).toContain("inline");
expect(pdf.headers()["x-content-type-options"]).toBe("nosniff");
// The proxy's `/c/` edge block wins over the route header (middleware
// headers override route headers), yielding a stricter no-store posture.
expect(pdf.headers()["cache-control"]).toContain("no-store");
// The proxy owns the CSP for this path (SAMEORIGIN framing + document CSP).
expect(pdf.headers()["content-security-policy"]).toContain(
"frame-ancestors 'self'",
);
expect(pdf.headers()["x-frame-options"]).toBe("SAMEORIGIN");
// ── Class A JPEG: inline image, EXIF/GPS stripped on egress ──
const jpeg = await clinician.request.get(serve(SHARE_JPEG_DOC_ID));
expect(jpeg.status()).toBe(200);
expect(jpeg.headers()["content-type"]).toBe("image/jpeg");
expect(jpeg.headers()["content-disposition"]).toContain("inline");
const jpegBody = await jpeg.body();
expect(
jpegBody.includes(Buffer.from("GPSLatitude")),
"served JPEG must not carry the stored GPS marker",
).toBe(false);
// ── Class B text: opaque octet-stream attachment (never inline) ──
const txt = await clinician.request.get(serve(SHARE_TEXT_DOC_ID));
expect(txt.status()).toBe(200);
expect(txt.headers()["content-type"]).toBe("application/octet-stream");
expect(txt.headers()["content-disposition"]).toContain("attachment");
expect(txt.headers()["x-content-type-options"]).toBe("nosniff");
// ── Frozen-set confinement: an OWNED but un-attached document 404s ──
const foreign = await clinician.request.get(serve(UNATTACHED_OWNED_DOC_ID));
expect(foreign.status()).toBe(404);
// A syntactically-plausible but unknown id also 404s (same blunt miss).
const guessed = await clinician.request.get(
serve("e2edoesnotexist00000001"),
);
expect(guessed.status()).toBe(404);
// ── Revoke through the owner UI, then the serve route stops cold ──
await page.reload();
const activeItem = page
.getByTestId("share-active-list")
.getByRole("listitem")
.filter({ hasText: share.label })
.first();
await expect(activeItem).toBeVisible();
await activeItem.getByRole("button", { name: "Revoke" }).click();
// Confirm in the alert dialog.
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Revoke" })
.click();
// The already-unlocked recipient can no longer pull any blob — revocation
// is re-enforced at serve time, before any decrypt.
await expect(async () => {
const afterRevoke = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(afterRevoke.status()).toBe(404);
}).toPass({ timeout: 10_000 });
await clinician.context().close();
});
test("the document Share action mints a DOCUMENT-ONLY link — the document, never the record", async ({
page,
browser,
}) => {
// Opens two stacked sheets (detail → share), drives a real create with the
// one-time QR render, then unlocks the link as a recipient to prove the
// served view carries the document and ZERO health metrics. Heavier than
// the default 30s budget. Runs on the desktop and 390px mobile projects.
test.slow();
// Deep-link straight to the document detail sheet.
await page.goto(`/documents?doc=${MRT_DOC_ID}`);
const detail = page.getByRole("dialog").filter({ hasText: "MRT Knie" });
await expect(detail).toBeVisible();
// The Share action sits beside Download in the footer.
await detail.getByRole("button", { name: "Share" }).click();
// The share flow opens with the document already attached and its title
// pre-filled as the link label — no context switch to Settings.
const shareSheet = page
.getByRole("dialog")
.filter({ hasText: "Share this document" });
await expect(shareSheet).toBeVisible();
await expect(shareSheet.locator("#share-label")).toHaveValue("MRT Knie");
const chips = shareSheet
.getByTestId("share-attached-chips")
.getByRole("listitem");
await expect(chips).toHaveCount(1);
await expect(chips.first()).toContainText("MRT Knie");
// Document-only mode hides every record-scope control: no FHIR API toggle,
// no FHIR resource-type checkboxes, no history-range selector. Only the
// document, label, and expiry remain.
await expect(shareSheet.locator("#share-fhir")).toHaveCount(0);
await expect(shareSheet.locator("#share-range")).toHaveCount(0);
await expect(shareSheet.locator("#share-expiry")).toHaveCount(1);
// Create the link — the one-time reveal (with the scannable QR) lands in
// the same sheet, carrying the one attached document.
await shareSheet.getByRole("button", { name: "Create link" }).click();
const reveal = shareSheet.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
await expect(
shareSheet.getByTestId("share-created-doc-count"),
).toContainText("1");
const qrImg = shareSheet.getByTestId("share-qr-block").getByRole("img");
await expect(qrImg).toBeVisible();
const box = await qrImg.boundingBox();
expect(box, "QR image has a rendered box").not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(160);
// Read the one-time secrets off the reveal and unlock the link as a
// recipient (no owner session).
const shareUrl = (await reveal.locator("code").first().innerText()).trim();
const token = shareUrl.split("/c/")[1];
expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/);
const passphrase = (
await shareSheet.getByTestId("share-passphrase").innerText()
).trim();
const clinician = await openUnlockedClinician(browser, {
token,
passphrase,
label: "MRT Knie",
});
// The served view lists the attached document…
await expect(
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
await expect(
clinician.locator(
`iframe[src*="/d/${MRT_DOC_ID}"], img[src*="/d/${MRT_DOC_ID}"]`,
),
).toHaveCount(1);
// …and NOTHING from the health record: no vitals, no labs, no medications,
// no reporting-period line. This is the privacy guarantee — sharing a
// document must never expose a single metric.
await expect(
clinician.getByRole("heading", { name: "Vital signs" }),
).toHaveCount(0);
await expect(
clinician.getByRole("heading", { name: "Lab values" }),
).toHaveCount(0);
await expect(
clinician.getByRole("heading", { name: "Medications & adherence" }),
).toHaveCount(0);
await expect(clinician.getByText("Reporting period:")).toHaveCount(0);
await clinician.context().close();
});
test("picker enforces the document cap at the limit", async ({ page }) => {
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(`${SHARE_DOC_PREFIX} cap`);
await page.getByTestId("share-attach-open").click();
const list = page.getByTestId("share-doc-picker-list");
await expect(list).toBeVisible();
// The picker loads one page (server `limit=50`), which equals the cap; the
// corpus fixture guarantees a full page.
const rows = list.getByRole("button");
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(50);
// Select up to the cap. Each click below the cap is enabled; the click that
// reaches the cap flips `atCap`, after which the max-reached banner shows.
for (let i = 0; i < 50; i++) await rows.nth(i).click();
await expect(
page.getByText("You've reached the maximum of 50"),
).toBeVisible();
// The footer count is pinned at the cap ("50 of 50 selected").
await expect(page.getByText("50 of 50 selected").first()).toBeVisible();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
* fallback (08:30) and never the browser zone (the Playwright context is
* pinned to Europe/Berlin precisely so this assertion is meaningful).
*
* Asserts against the stable `data-slot="security-session-row"` hook,
* not viewport-dependent text.
*/
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
test.describe("profile-timezone display (#490)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
page,
}) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "user_e2e",
username: "e2e-tester",
email: "e2e@healthlog.test",
role: "USER",
heightCm: 180,
dateOfBirth: "1990-01-01",
gender: "MALE",
timezone: "Asia/Manila",
timeFormat: "H24",
dateFormat: "AUTO",
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
gravatarUrl: null,
glucoseUnit: "mg/dL",
},
error: null,
}),
}),
);
await page.route("**/api/auth/me/sessions", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
sessions: [
{
id: "sess_e2e_current",
device: "Chrome on macOS",
ipMasked: "192.168.1.xxx",
location: null,
lastActiveAt: LAST_ACTIVE_UTC,
createdAt: LAST_ACTIVE_UTC,
isCurrent: true,
},
],
},
error: null,
}),
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first. The
// toggle's onClick can be dropped if Playwright clicks before React has
// hydrated the handler, leaving the region `hidden` for the whole wait.
// Retry the click until `aria-expanded` actually flips.
const toggle = page.locator(
'[data-slot="settings-security-sessions-toggle"]',
);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await expect(async () => {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true", {
timeout: 2_000,
});
}).toPass({ timeout: 15_000 });
const row = page.locator('[data-slot="security-session-row"]').first();
await expect(row).toBeVisible({ timeout: 10_000 });
// Manila wall clock (H24). The mirror write from the mocked /me may
// land after the first paint of the sessions row — the poll absorbs
// the re-render.
await expect(row).toContainText("14:30", { timeout: 10_000 });
// Never the Berlin fallback for this instant…
await expect(row).not.toContainText("08:30");
// …and never a 12-hour rendering (the H24 preference rides the same
// mirror pipeline).
await expect(row).not.toContainText("PM");
});
});
+4 -7
View File
@@ -231,11 +231,9 @@ export interface MockSnapshotOptions {
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
heroVisible?: boolean;
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
/** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */
briefing?: DailyBriefing;
/** Selected hero score rings (renders beside the health-score ring). */
/** Selected score rings carried on the snapshot (server-computed). */
scoreRings?: DashboardScoreRing[];
}
@@ -263,9 +261,7 @@ export function buildMockSnapshot(
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: options.heroVisible
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
: DEFAULT_DASHBOARD_LAYOUT,
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),
@@ -283,6 +279,7 @@ export function buildMockSnapshot(
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
},
healthScore: options.briefing
? { score: 84, band: "green", delta: -2, restMode: null }
@@ -60,6 +60,32 @@ ruleTester.run("spacing-scale", rule, {
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// The card-shell `5` check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '<ul className="list-disc pl-5 space-y-1" />',
filename: APP_FILE,
},
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
},
// Form-body rhythm without a shell surface is fine.
{
code: '<form className="space-y-5" />',
filename: APP_FILE,
},
// A shell already on-scale is fine.
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
filename: APP_FILE,
},
// bg-card without a border is not treated as a shell.
{
code: '<div className="bg-card p-5" />',
filename: APP_FILE,
},
],
invalid: [
{
@@ -108,5 +134,34 @@ ruleTester.run("spacing-scale", rule, {
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Card-shell `5` step: bg-card + border + p-5 on one element.
{
code: '<div className="bg-card border-border rounded-xl border p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '<section className="bg-card rounded-lg border py-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
{
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Split across cn() args — the join still sees the shell + the step-5.
{
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Modifier-prefixed step on a shell.
{
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
],
});
+2 -1
View File
@@ -6,7 +6,8 @@
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
* and no off-scale `5` step on a bg-card shell.
*/
"use strict";
+77 -32
View File
@@ -1,34 +1,47 @@
/**
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
* gap-based Card slots.
* @fileoverview ESLint rule — spacing-scale discipline. Two checks:
*
* The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
* 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card
* slots. The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
*
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
* seen. Non-numeric suffixes (`pb-safe`) never match.
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` carries a static `pt-<n>` / `pb-<n>` fragment — including
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
* card on the same viewport. Deliberately NARROW: it fires only when the
* same element paints a card surface, so justified `pl-5`/`pl-7`
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
*
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
* gating as the sibling rules. `src/components/ui/card.tsx` itself
* composes raw `<div>`s and is untouched by construction.
* gating as the sibling rules. `src/components/ui/*` primitives compose the
* shells themselves and are untouched by construction (they carry no
* `bg-card` + `border` + step-5 combination).
*
* There is deliberately NO allowlist: no current site has a documented
* load-bearing reason for a slot padding override. If one ever appears,
* it carries an inline `eslint-disable-next-line` with the reason —
* visible in review — rather than a silent registry entry here.
* load-bearing reason. If one ever appears it carries an inline
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
*/
"use strict";
@@ -44,6 +57,19 @@ const SLOT_PADDING_RE =
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
// Card-shell heuristic: the element paints a card surface (`bg-card`) AND a
// border. `\bborder\b` matches `border`, `border-border`, `border-b`,
// `border-dashed` — any border utility qualifies as a shell.
const SHELL_BG_RE = /\bbg-card\b/;
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
// half-steps are intentionally OUT of scope (see the header).
const SHELL_STEP5_RE =
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
@@ -122,12 +148,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellStep5:
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
@@ -138,9 +166,6 @@ const spacingScaleRule = {
return {
JSXOpeningElement(node) {
const name = elementName(node);
if (!name || !SLOT_NAMES.has(name)) return;
const classNameAttr = node.attributes.find(
(attr) =>
attr.type === "JSXAttribute" &&
@@ -151,13 +176,33 @@ const spacingScaleRule = {
const strings = [];
collectStrings(classNameAttr.value, strings);
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
// Check 1 — pt-/pb- slot padding on the gap-based Card slots.
const name = elementName(node);
if (name && SLOT_NAMES.has(name)) {
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
});
}
}
}
// Check 2 — off-scale `5` step on a card shell. Join every collected
// string so the `bg-card` + `border` pair and the step-5 utility are
// seen together even when split across cn() args, then report once.
const joined = strings.map((s) => s.text).join(" ");
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
const m = SHELL_STEP5_RE.exec(joined);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
node: classNameAttr,
messageId: "cardShellStep5",
data: { match: m[1] },
});
}
}
+56 -3
View File
@@ -66,12 +66,65 @@ const eslintConfig = defineConfig([
// token references, not utilities, and stay legal — the light-theme
// overrides in globals.css keep them AA on both themes.
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
// fights the gap and re-opens the sl-001 drift class. Density lives
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
// Spacing-scale discipline. Two error-clean checks: pt-/pb- on
// CardHeader/CardContent fights the gap-based Card contract (re-opens
// the sl-001 drift class), and an off-scale `5` step (p-5/py-5/space-y-5/
// gap-5) on a bg-card+border shell reads denser than every sibling
// <Card>. The shell check is scoped to the bg-card+border pair so
// justified list-marker insets (pl-5/pl-7) and form-body rhythm never
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
// a swept `p-4 md:p-6` shell. See the rule header.
"healthlog/spacing-scale": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
// shared `chart-runtime.ts` barrel (see its file header): pointing N
// `next/dynamic` boundaries at N different modules mints a separate
// ~312 KB recharts chunk per boundary, a regression the project already
// paid for once. Direct `from "recharts"` imports outside the barrel's
// own static import graph previously only tripped the bundle-budget
// gate at PR-CI; this rule catches the same mistake at local lint,
// mirroring how `healthlog/safe-fetch-required` scopes its own
// wrapper-internals exemption by file list.
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
"src/components/charts/scatter-correlation-chart.tsx",
"src/components/custom-metrics/custom-metric-chart.tsx",
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
"src/components/insights/sleep-stage-stacked-bar.tsx",
"src/components/labs/lab-biomarker-chart.tsx",
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
"src/components/medications/dose-strength-curve.tsx",
"src/components/medications/drug-level-chart.tsx",
"src/components/mental-health/assessment-history-chart.tsx",
],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "recharts",
message:
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
},
],
},
],
},
},
]);
export default eslintConfig;
+2 -2
View File
@@ -47,7 +47,6 @@
".next/**",
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/components/charts/compliance-line-chart.tsx",
"src/lib/logging/index.ts"
],
"ignoreDependencies": [
@@ -62,7 +61,8 @@
"shadcn",
"tailwindcss",
"testcontainers",
"tw-animate-css"
"tw-animate-css",
"@napi-rs/canvas"
],
"ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
"ignoreExportsUsedInFile": true,
+592 -113
View File
File diff suppressed because it is too large. Load diff
+594 -115
View File
File diff suppressed because it is too large. Load diff
+590 -111
View File
File diff suppressed because it is too large. Load diff
+590 -111
View File
File diff suppressed because it is too large. Load diff
+590 -111
View File
File diff suppressed because it is too large. Load diff
+590 -111
View File
File diff suppressed because it is too large. Load diff
+37 -1
View File
@@ -233,6 +233,18 @@ const nextConfig: NextConfig = {
"pg-boss",
"@prisma/adapter-pg",
"pg",
// Document PDF handling MUST run the real, un-bundled modules. When
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
// `require('@napi-rs/canvas')` and its NodeCanvasFactory render path break
// in the standalone image (a scanned PDF fails to rasterize with a bare
// `Error`, so the read falls back to "PDF scanning needs a Claude vision
// provider"), while the identical un-bundled module rasterizes the same
// document fine. Externalise both so the server loads the real files from
// node_modules; the Dockerfile hoists them to the top level so the runtime
// `import()` resolves in the standalone tree, and outputFileTracingIncludes
// still ships pdfjs's wasm decoders.
"pdfjs-dist",
"@napi-rs/canvas",
],
// v1.4.25 Fix-G — `src/lib/ai/prompts/safety-contracts.ts` reads its
// sibling YAML files at runtime via `__dirname + readFileSync`. The
@@ -242,7 +254,31 @@ const nextConfig: NextConfig = {
// files explicitly keeps them in the runtime image alongside the
// bundled module.
outputFileTracingIncludes: {
"*": ["./src/lib/ai/prompts/safety-contracts.*.yaml"],
"*": [
"./src/lib/ai/prompts/safety-contracts.*.yaml",
// Document AI local text extraction runs `pdf-parse` (→ pdfjs-dist)
// server-side in the content-index job — the provider-free fallback that
// keeps content search working with no AI configured. Both are pure JS +
// wasm; there is NO native `.node` on the text-layer path (`@napi-rs/canvas`
// is only pulled for image rendering, which `getText()` never invokes).
// The standalone tracer can miss pdfjs-dist's wasm decoders (they resolve
// via `new URL(..., import.meta.url)`), so pin both packages into the
// runtime image explicitly. pnpm keeps pdfjs-dist unhoisted under `.pnpm`.
"./node_modules/.pnpm/pdf-parse@*/node_modules/pdf-parse/**",
"./node_modules/.pnpm/pdfjs-dist@*/node_modules/pdfjs-dist/**",
// Document AI rasterization (`src/lib/documents/rasterize-pdf.ts`) renders
// a scanned/image-only PDF page to a JPEG via `@napi-rs/canvas` so an
// image-only-wire provider (codex/OAuth) can read it — the ambient
// auto-read path. `@napi-rs/canvas` loads a native `.node` binary that the
// Turbopack NFT tracer resolves dynamically and therefore misses, so pin
// the JS loader AND the two musl prebuilts (the Alpine runtime image is
// amd64 + arm64) into the standalone image explicitly. pnpm keeps them
// unhoisted under `.pnpm`. No cairo/pango apk is needed — the prebuilt
// `.node` is self-contained.
"./node_modules/.pnpm/@napi-rs+canvas@*/node_modules/@napi-rs/canvas/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-x64-musl@*/node_modules/@napi-rs/canvas-linux-x64-musl/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-arm64-musl@*/node_modules/@napi-rs/canvas-linux-arm64-musl/**",
],
},
// v1.4.34 IW-A — silence the Turbopack NFT trace warnings emitted
// during `next build`. The tracer follows the `MAXMIND_LICENSE_KEY`
+9 -6
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.23",
"version": "1.30.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -59,12 +59,13 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "0.1.80",
"@node-rs/argon2": "^2.0.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.1",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-virtual": "^3.14.5",
"@types/sax": "^1.2.7",
@@ -73,6 +74,7 @@
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
@@ -82,7 +84,7 @@
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.20.0",
"pg-boss": "^12.26.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
@@ -109,7 +111,7 @@
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.3",
"@types/node": "26.1.0",
"@types/node": "26.1.1",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
@@ -121,12 +123,13 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "5.4.296",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
"shadcn": "^4.13.0",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
"tw-animate-css": "^1.4.0",
+15 -2
View File
@@ -19,10 +19,17 @@ export default defineConfig({
testIgnore: ["setup/**"],
globalSetup: "./e2e/setup/global-setup.ts",
timeout: 30_000,
expect: { timeout: 5_000 },
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
// assertions (wizard step transitions, list refetch → card provenance,
// disclosure toggles) intermittently sample a mid-transition frame. A
// rotating single test failed each run while 211 passed; a second retry
// absorbs that transient contention without masking a real failure (a true
// break fails all three attempts). The default expect timeout is also
// lifted from 5s → 10s for the same settle headroom.
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
@@ -52,6 +59,12 @@ export default defineConfig({
// matches what real users render on first paint and is the only honest
// a11y baseline for this app.
colorScheme: "dark",
// Issue #490 — pin the browser timezone. Without this the context
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
// so any surface that renders a clock or a day boundary could pass
// locally and skew two hours / a calendar day on CI. Berlin matches
// the app's own display fallback, so CI and local render identically.
timezoneId: "Europe/Berlin",
},
projects: [
+568 -417
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,45 @@
-- Document vault, Phase 3: attach hand-picked documents to a clinician share
-- link.
--
-- `clinician_share_link_documents` — frozen, write-once membership rows
-- binding a stored document to a share link. The document set a share
-- exposes is modelled as this join, never a mutable column on the link, so
-- it inherits the link's frozen-scope discipline: rows are created in the
-- same transaction as the link, each document is owner-checked at create,
-- and the set is never widened afterwards. The public share serve route
-- serves ONLY a document id present in this set for the resolved token.
--
-- Both foreign keys cascade: deleting the link or the underlying document
-- removes the membership cleanly (a shared document the owner later deletes
-- simply disappears from the link).
--
-- Additive; applies clean on a 0228 database and on a fresh database as part
-- of the ordered migration chain.
CREATE TABLE "clinician_share_link_documents" (
"id" TEXT NOT NULL,
"share_link_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key"
ON "clinician_share_link_documents" ("share_link_id", "document_id");
CREATE INDEX "clinician_share_link_documents_share_link_id_idx"
ON "clinician_share_link_documents" ("share_link_id");
CREATE INDEX "clinician_share_link_documents_document_id_idx"
ON "clinician_share_link_documents" ("document_id");
ALTER TABLE "clinician_share_link_documents"
ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey"
FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "clinician_share_link_documents"
ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- Document vault P4: chat about a document.
--
-- Two additive, nullable columns — no backfill, applies clean on a 0229
-- database and on a fresh database:
--
-- 1. `coach_conversations.document_id` — the discriminator that turns a Coach
-- conversation into a DOCUMENT chat. NULL = a normal Coach conversation
-- (health-record surface). Non-NULL = a chat scoped to exactly one stored
-- document, whose only context is that document's text (no health snapshot,
-- no tools). FK cascades: deleting the document removes its chats. A
-- composite index covers both the Coach rail scan (`document_id IS NULL`,
-- newest-first) and the document sheet scan (`document_id = <id>`,
-- newest-first).
--
-- 2. `document_content_index.verbatim_text_encrypted` — AES-256-GCM ciphertext
-- of the VERBATIM extracted text (byte-capped, not lowercased/de-accented),
-- stored additionally alongside the normalised `text_encrypted` so a chat
-- can cite the document faithfully. Nullable: rows indexed before P4 carry
-- NULL until re-indexed, and the chat route falls back to `text_encrypted`.
-- AlterTable
ALTER TABLE "coach_conversations"
ADD COLUMN "document_id" TEXT;
-- AlterTable
ALTER TABLE "document_content_index"
ADD COLUMN "verbatim_text_encrypted" BYTEA;
-- CreateIndex — covers the Coach rail (document_id IS NULL) and the document
-- sheet (document_id = <id>) newest-first scans with one composite.
CREATE INDEX "coach_conversations_user_id_document_id_updated_at_idx"
ON "coach_conversations" ("user_id", "document_id", "updated_at");
-- AddForeignKey — a deleted document takes its chats with it.
ALTER TABLE "coach_conversations"
ADD CONSTRAINT "coach_conversations_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- Automatic AI reading of uploaded vault documents (per-user opt-in).
--
-- One additive, defaulted column on `users`. OFF by default: the document vault
-- stays local-first — a freshly uploaded document is only read by an external AI
-- provider when the user explicitly taps a per-document action (which still
-- requires an active consent receipt). When `true`, the auto-index-on-upload job
-- may read a new document through the user's configured external provider with
-- no per-document tap; flipping the toggle on is itself the standing consent
-- (the write that sets it also mints an `ai_full` consent receipt for the audit
-- trail).
--
-- The default `false` backfills every existing row onto the current behaviour
-- (local-first, per-document-explicit, consent-required), so this is a no-op for
-- anyone who never opts in.
ALTER TABLE "users"
ADD COLUMN "documents_auto_ai_read" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,49 @@
-- Medication efficacy tracking ("Wirkung"): persist ONLY the user's explicit
-- target override.
--
-- `medication_efficacy_targets` — a lean child of `medications`. The
-- drug-class → outcome resolution (WHO ATC-prefix → whole-word name
-- inference) is computed each read from the closed in-repo lookup table, so
-- nothing about a class→outcome association is denormalised (it cannot drift
-- as the map improves). The ONE thing that must survive is the user's own
-- pick when they retarget a medication or choose a target for a med the
-- resolver could not classify — that is this table.
--
-- Exactly one of `measurement_type` (a first-class metric series) /
-- `biomarker_id` (a lab analyte) is set per row; `primary` marks the
-- storyline-leading target when several are pinned. `medication_id`
-- cascades (the override dies with the medication); `biomarker_id` sets
-- null (a deleted biomarker leaves an orphan row the resolver treats as no
-- override). `user_id` is never a column here — ownership is narrowed
-- through the parent medication, never a body field.
--
-- Additive; applies clean on a 0231 database and on a fresh database as part
-- of the ordered migration chain.
CREATE TABLE "medication_efficacy_targets" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"measurement_type" "measurement_type",
"biomarker_id" TEXT,
"primary" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "medication_efficacy_targets_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "medication_efficacy_targets_medication_id_idx"
ON "medication_efficacy_targets" ("medication_id");
CREATE INDEX "medication_efficacy_targets_biomarker_id_idx"
ON "medication_efficacy_targets" ("biomarker_id");
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_biomarker_id_fkey"
FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers" ("id")
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- v1.28.x — Strava OAuth integration (schema + data layer).
--
-- Strava is a per-user BYO-OAuth workout source, mirroring WHOOP/Oura/Polar:
-- Strava caps every newly-created API app at single-player mode (athlete
-- capacity 1), so a single shared app cannot serve many self-hosters — each
-- registers their own Strava app and pastes the client id/secret into Settings.
-- Strava feeds `Workout` rows only (it exposes no sleep / recovery / body
-- metrics) and rotates its refresh token on every refresh, so it follows Oura's
-- reactive-refresh + compare-and-set token model (tokens live on `users`, no
-- token-expiry column).
--
-- 1. `measurement_source` += `STRAVA` for the server-side native ingest.
-- 2. `users` += the encrypted BYO-app credentials + granted tokens, the
-- plaintext athlete id, the incremental-sync cursor, and the backfill
-- marker. Purely-additive: nullable columns, no backfill, no row touched.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: Postgres cannot remove an enum value, so `STRAVA` stays; with
-- no rows carrying it it is inert. The columns drop with `DROP COLUMN IF EXISTS`.
-- ── 1. measurement_source — append the Strava server-owned source ──────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'STRAVA';
-- ── 2. users — per-user Strava credentials, tokens, cursor, backfill marker ──
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "strava_client_id_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "strava_client_secret_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "strava_access_token_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "strava_refresh_token_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "strava_athlete_id" TEXT,
ADD COLUMN IF NOT EXISTS "strava_last_activity_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "strava_backfill_completed_at" TIMESTAMP(3);
@@ -0,0 +1,21 @@
-- Retire the dedicated document-scan (Lab-OCR) provider.
--
-- The separate `ai_ocr_*` provider entry is gone: lab-report OCR now always
-- uses the user's main configured provider chain (the same providers, incl.
-- codex / OAuth, that the Coach and Insights already use), and a PDF is read
-- natively on Anthropic or rasterized to page images for any other vision
-- provider. The five per-user columns that backed the standalone provider are
-- no longer read or written anywhere, so drop them.
--
-- `ai_ocr_key_encrypted` was an AES-256-GCM column; it is removed from the
-- encrypted-column registry + the rotation script in the same change. Dropping
-- it here means those rows are gone before any future key rotation runs.
--
-- Idempotent (IF EXISTS); applies clean on a 0233 database and on a fresh
-- database as part of the ordered replay.
ALTER TABLE "users"
DROP COLUMN IF EXISTS "ai_ocr_enabled",
DROP COLUMN IF EXISTS "ai_ocr_provider",
DROP COLUMN IF EXISTS "ai_ocr_model",
DROP COLUMN IF EXISTS "ai_ocr_base_url",
DROP COLUMN IF EXISTS "ai_ocr_key_encrypted";
@@ -0,0 +1,28 @@
-- Operator-shared central Codex (ChatGPT subscription) OAuth + per-user opt-in.
--
-- The only operator-level AI credential so far has been an API key
-- (`app_settings.admin_ai_key_encrypted`). This adds an operator-level Codex
-- OAuth credential: the operator connects ONE signed-in ChatGPT account (the
-- same device-code flow the per-user `codex_*_encrypted` columns use) and shares
-- it server-wide. Each user opts in individually via `users.use_central_codex`.
--
-- All columns are additive and defaulted, so existing rows keep the current
-- behaviour (no central Codex, no user opted in) with no backfill. The three
-- token pieces are AES-256-GCM encrypted at rest, same crypto as every other
-- `*_encrypted` column, so the key-rotation script covers them.
--
-- Routing through the central Codex is external egress on a shared,
-- train-by-default consumer account bound by the operator's own rate limits.
-- It is consent-gated (server-managed provider) and billed against the operator
-- cost cap, never the user plan. OFF until the operator connects it.
ALTER TABLE "app_settings"
ADD COLUMN "admin_codex_access_token_encrypted" TEXT,
ADD COLUMN "admin_codex_refresh_token_encrypted" TEXT,
ADD COLUMN "admin_codex_account_id_encrypted" TEXT,
ADD COLUMN "admin_codex_token_expires_at" TIMESTAMP(3),
ADD COLUMN "admin_codex_connected_at" TIMESTAMP(3),
ADD COLUMN "admin_codex_connection_status" TEXT NOT NULL DEFAULT 'disconnected';
ALTER TABLE "users"
ADD COLUMN "use_central_codex" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,50 @@
-- Document vault: encrypted preview thumbnails.
--
-- `document_thumbnails` — a 1:1 sibling of `inbound_documents` holding a small
-- JPEG preview (~320px long edge, q70) rendered in the background from the
-- original: images are decoded + downscaled, a PDF's first page is rasterised.
-- The blob rides `thumbnail_encrypted` in the `encrypt()`-string-as-UTF-8
-- BYTEA shape (base64 of the JPEG) `document_content_index.text_encrypted`
-- uses — a scanned medical preview is PHI, never stored in the clear or
-- logged. The canvas re-encode strips EXIF/GPS from the source, so the
-- preview is metadata-free by construction.
--
-- A SIDE TABLE, not a column on `inbound_documents`, so the list/detail
-- queries that `omit` the original blob never drag this blob into the SELECT.
--
-- Both foreign keys cascade: a deleted document (or user) takes its thumbnail
-- with it. Additive; applies clean on a 0234 database and on a fresh database.
-- CreateTable
CREATE TABLE "document_thumbnails" (
"id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"thumbnail_encrypted" BYTEA NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"byte_size" INTEGER NOT NULL,
"source_mime" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "document_thumbnails_pkey" PRIMARY KEY ("id")
);
-- 1:1 with the document.
CREATE UNIQUE INDEX "document_thumbnails_document_id_key"
ON "document_thumbnails" ("document_id");
-- Owner-scoped queries (the backfill discovery + list-page existence probe).
CREATE INDEX "document_thumbnails_user_id_idx"
ON "document_thumbnails" ("user_id");
ALTER TABLE "document_thumbnails"
ADD CONSTRAINT "document_thumbnails_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "document_thumbnails"
ADD CONSTRAINT "document_thumbnails_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,29 @@
-- v1.28.16 — explicit `document_only` flag on a clinician share link.
--
-- Until now "this link serves only documents, no health record" was DERIVED at
-- read time as "are all report sections off?" (`hasAnyReportSection`). That
-- coupling is fragile: the day a new report section is added to the prefs shape,
-- an OLD documents-only link — whose frozen `sections_json` never mentions the
-- new key — would resolve the new key from the defaults (ON), the derived check
-- would flip to "has a section", and the link would start serving that section's
-- data. This column makes the guarantee authoritative and frozen at creation, so
-- no future section can silently re-open an existing documents-only link.
ALTER TABLE "clinician_share_links"
ADD COLUMN "document_only" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: freeze the flag ON for every existing link whose sections are
-- EXPLICITLY all-off (a non-empty object with NO `true` value at ANY depth — the
-- shape the documents-only create flow persisted as `EMPTY_DOCTOR_REPORT_PREFS`).
-- The recursive `$.** ? (@ == true)` JSONPath is deliberate: a record share can
-- store a GROUPED shape (e.g. `{"vitals":{"bp":true}}`) whose only `true` sits in
-- a nested object, so a top-level-only scan would misread it as empty and
-- wrongly pin it documents-only. Checking every depth marks ONLY links that truly
-- enable no section — so this can never remove report access a viewer legitimately
-- had, only pin an already-empty report against a future schema growth. The `{}`
-- case is excluded (empty object = "full-record defaults", NOT documents-only).
-- Safe on a populated table (non-volatile default, no rewrite) and idempotent.
UPDATE "clinician_share_links"
SET "document_only" = true
WHERE jsonb_typeof("sections_json") = 'object'
AND "sections_json" <> '{}'::jsonb
AND NOT jsonb_path_exists("sections_json", '$.** ? (@ == true)');
@@ -0,0 +1,4 @@
-- One-shot Google Health sleep duplicate repair (post-v1.28.18 fix).
-- Null = repair pending; stamped once the boot-time full sleep re-read
-- completes for the connection.
ALTER TABLE "google_health_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3);
@@ -0,0 +1,62 @@
-- v1.28 — Apple Health medications + intake (server contract, #423).
--
-- iOS 26+ HealthKit exposes the user's medication list
-- (`HKUserAnnotatedMedication`, stable `medicationConceptIdentifier`) and
-- dose events (`HKMedicationDoseEvent`, stable UUID, taken/skipped),
-- read-only. The server side is purely additive: a provenance pair on
-- `medications` (a med with `external_source` set is MIRRORED from that
-- source — source-exclusive, no cross-source slot collapse) and an
-- external dose-event id on `medication_intake_events` that makes the
-- bulk re-sync idempotent.
-- ── 1. intake_source — append the Apple Health source ──────────────────
--
-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT
-- EXISTS` makes the rerun safe. The new value is NOT used elsewhere in
-- this migration (the columns below are typed `intake_source` but no row
-- is inserted or updated), so it is safe to extend the enum in the same
-- step — Postgres only forbids USING a freshly-added enum value inside
-- the transaction that added it.
ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
-- ── 2. medications — mirrored-medication provenance ────────────────────
--
-- `external_source` non-NULL marks the row a read-only mirror of an
-- external medication list; `external_id` carries the source's stable
-- concept identifier (HealthKit `medicationConceptIdentifier`). Both
-- nullable, no default, no backfill: every existing row stays a native
-- HealthLog medication.
ALTER TABLE "medications"
ADD COLUMN "external_source" "intake_source",
ADD COLUMN "external_id" TEXT;
-- One mirror row per external concept per user. A plain (non-partial)
-- unique matching the schema's `@@unique([userId, externalSource,
-- externalId])`: Postgres treats NULLs as distinct in unique indexes, so
-- the existing rows (both columns NULL) are unaffected — only genuinely
-- mirrored medications occupy a slot. The create route pre-queries the
-- triple and returns the existing medication; this index is the race
-- backstop (concurrent create surfaces P2002, which the route resolves
-- to the winning row).
CREATE UNIQUE INDEX "medications_user_id_external_source_external_id_key"
ON "medications" ("user_id", "external_source", "external_id");
-- ── 3. medication_intake_events — external dose-event id ───────────────
--
-- Carries the HealthKit `HKMedicationDoseEvent` UUID. Nullable, no
-- backfill: NULL for every non-mirrored row.
ALTER TABLE "medication_intake_events"
ADD COLUMN "external_id" TEXT;
-- Idempotent re-import: one LIVE row per `(user_id, external_id)`. A
-- partial unique like the live-row slot unique from migration 0121 —
-- restricted to non-NULL externalIds (rows without one, i.e. everything
-- pre-Apple, stay unconstrained and out of the index) and to live rows
-- (`deleted_at IS NULL`, so a tombstone never blocks the sync ledger).
-- The bulk intake route pre-checks the batch's externalIds and reports
-- replays as duplicates; this index is the race backstop. Prisma can't
-- express a partial-predicate unique, so this is hand-written and the
-- schema keeps the full `@@unique` for the compound where-key.
CREATE UNIQUE INDEX "medication_intake_events_user_external_id_live_key"
ON "medication_intake_events" ("user_id", "external_id")
WHERE "external_id" IS NOT NULL AND "deleted_at" IS NULL;
@@ -0,0 +1,13 @@
-- v1.28 — OIDC SSO identity pinning.
--
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
-- identity: stamped on first OIDC login (auto-provision or one-time link by
-- verified email), matched on every later OIDC login. Email stays a display
-- field after the stamp — an IdP-side email change can never re-point the
-- login at a different account. Both columns are NULL on accounts that never
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
-- constraint only bites on actual duplicate identities.
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
@@ -0,0 +1,37 @@
-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
--
-- One row per (user, local day, nutrient): vitamins, minerals, water and
-- caffeine as supplement-style day totals synced from Apple Health. The
-- composite PK is the idempotency key — an iOS re-post of a grown day
-- total replaces the row in place (last-writer-wins day-total contract),
-- so there is no externalId column and no soft delete. `day` is a
-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
-- `nutrient` is a bare TEXT code enforced in code against the closed
-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
-- a migration. `unit` is denormalised from the catalog on purpose: rows
-- stay self-describing in exports even if the catalog ever drifts.
CREATE TABLE "nutrient_intake_days" (
"user_id" TEXT NOT NULL,
"day" TEXT NOT NULL,
"nutrient" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
"external_source_version" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
);
-- Read path: the settings-card / read-endpoint window scan per user, and
-- the later per-nutrient series fetch (day DESC for "latest first").
CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
-- Account deletion cascades; rows are worthless without their owner.
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Import-job liveness heartbeat (issue #486 follow-up).
--
-- `reconcileOrphanImportJobs` used to flip EVERY non-terminal ImportJob
-- row to `failed` on any worker boot — killing an import that was still
-- actively parsing in another worker (multi-replica / rolling deploy).
-- The reconcile now gates on this heartbeat: a non-terminal row is only
-- treated as orphaned when its pg-boss job is gone/terminal OR its
-- heartbeat has gone stale. `@updatedAt` bumps this column on every
-- write, including each mid-run progress tick, so a live import keeps it
-- fresh. Existing rows backfill to the ALTER time via the DB default.
ALTER TABLE "import_jobs"
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,54 @@
-- v1.28.46 perf (H4) — partial indexes for the boot-time converging-backfill
-- discovery scans. Each discovery now runs a DB-level `SELECT DISTINCT user_id`
-- over a growth table filtered by a "still-un-migrated" predicate; without an
-- index that is a full filtered-partition scan at worker boot (the boot-storm
-- class on a heavy tenant). Each index below is PARTIAL on exactly the
-- discovery predicate, so the scan is index-only and shrinks to nothing as the
-- one-time backfill converges (a migrated row drops out of the predicate).
--
-- Additive-only: every statement is `CREATE INDEX IF NOT EXISTS`, no table
-- rewrite, no data change. NON-CONCURRENT on purpose — Prisma Migrate wraps a
-- migration file in a transaction and `CREATE INDEX CONCURRENTLY` cannot run in
-- one; these build at boot (docker-entrypoint runs `migrate deploy` BEFORE the
-- app serves traffic), so the brief build lock lands in the pre-traffic window.
-- Partial predicates match the write-side note convention (partial indexes are
-- raw-SQL-only here; Prisma cannot express a partial predicate — see the 0087 /
-- 0108 / 0183 precedents documented in schema.prisma).
-- note-encryption-backfill: measurements (densest table) + mood_entries.
CREATE INDEX IF NOT EXISTS "measurements_note_backfill_idx"
ON "measurements" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "mood_entries_note_backfill_idx"
ON "mood_entries" ("user_id")
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
-- med-notes-encryption-backfill: side-effects, inventory items, dose-changes.
-- Dose-changes carry no user_id; the discovery joins to medications on
-- medication_id, so the partial index leads with that join key.
CREATE INDEX IF NOT EXISTS "medication_side_effects_note_backfill_idx"
ON "medication_side_effects" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "medication_inventory_items_note_backfill_idx"
ON "medication_inventory_items" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "medication_dose_changes_note_backfill_idx"
ON "medication_dose_changes" ("medication_id")
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
-- lab-biomarker-backfill: unlinked live lab results.
CREATE INDEX IF NOT EXISTS "lab_results_biomarker_backfill_idx"
ON "lab_results" ("user_id")
WHERE "deleted_at" IS NULL AND "biomarker_id" IS NULL;
-- document-thumbnail-backfill: live thumbnailable documents. The thumbnail
-- absence lives on the `document_thumbnails` side table (a cross-table
-- anti-join, not indexable here); this partial index bounds the document-side
-- scan on `(user_id, mime_type)` for live rows, and the anti-join rides
-- `document_thumbnails.document_id` (already UNIQUE via the 1:1 relation).
CREATE INDEX IF NOT EXISTS "inbound_documents_thumbnail_backfill_idx"
ON "inbound_documents" ("user_id", "mime_type")
WHERE "deleted_at" IS NULL;
@@ -0,0 +1,15 @@
-- Morning-freshness marker (S4 of the unified daily-value system).
--
-- The nightly insight pre-pass runs ~04:30, before last night's sleep has
-- synced, so the morning digest/health-score reference the day WITHOUT the
-- current sleep. This column records the user-local YYYY-MM-DD (their profile
-- timezone) for which the event-driven morning refresh has completed: last
-- night's sleep arrived AND the sleep-dependent insight/score cache was
-- regenerated with it. The daily digest reports `final` once this equals the
-- user's current local date, `provisional` otherwise (honest degradation when
-- sleep is late or never arrives). Stamped by the `morning-digest-refresh`
-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local
-- morning. NULL for every existing row = provisional until the next sleep
-- landing, which is the correct pre-feature reading.
ALTER TABLE "users"
ADD COLUMN "morning_digest_refreshed_on" TEXT;
@@ -0,0 +1,71 @@
-- S7: attach multiple documents to a coach conversation.
--
-- Replaces the scalar `coach_conversations.document_id` discriminator (which
-- could express only ONE document and would UNFENCE a conversation when that
-- document was deleted) with a join table + a sticky `document_scoped` flag.
--
-- The flag is the fail-closed half of the fenced-only discriminator: it is set
-- true at fenced creation / first attach and NEVER cleared. The join table
-- holds the LIVE attachment set. A count-only predicate would let a deleted
-- attachment flip a conversation (whose history may contain document-derived
-- text) back onto the tool loop; the sticky flag makes "fenced" a one-way latch
-- independent of attachment liveness.
--
-- Backfill runs in the SAME transaction as the DDL (Postgres allows it) BEFORE
-- the scalar column is dropped, so there is no dual-source-of-truth window:
-- every legacy single-doc chat becomes a one-attachment fenced conversation.
-- CreateTable
CREATE TABLE "coach_conversation_documents" (
"conversation_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coach_conversation_documents_pkey" PRIMARY KEY ("conversation_id", "document_id")
);
-- CreateIndex — the document-sheet "chats about this doc" scan + the path-id
-- join filter the single-doc route uses.
CREATE INDEX "coach_conversation_documents_document_id_idx"
ON "coach_conversation_documents" ("document_id");
-- AddForeignKey — deleting a conversation cascades its attachment rows away.
ALTER TABLE "coach_conversation_documents"
ADD CONSTRAINT "coach_conversation_documents_conversation_id_fkey"
FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey — deleting a document cascades its join rows away (its text
-- leaves every conversation's context) but NEVER the conversation itself.
ALTER TABLE "coach_conversation_documents"
ADD CONSTRAINT "coach_conversation_documents_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable — the sticky fence flag.
ALTER TABLE "coach_conversations"
ADD COLUMN "document_scoped" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: every legacy single-doc chat becomes a one-attachment fenced
-- conversation. Runs BEFORE the DROP COLUMN below.
INSERT INTO "coach_conversation_documents" ("conversation_id", "document_id", "added_at")
SELECT "id", "document_id", "created_at"
FROM "coach_conversations"
WHERE "document_id" IS NOT NULL;
UPDATE "coach_conversations" SET "document_scoped" = true
WHERE "document_id" IS NOT NULL;
-- The scalar discriminator goes away in the SAME migration — no dual-source-of-
-- truth window, no "in case" shim. Every reader is repointed in the same
-- release. Drop the FK + covering index first, then the column.
ALTER TABLE "coach_conversations"
DROP CONSTRAINT "coach_conversations_document_id_fkey";
DROP INDEX "coach_conversations_user_id_document_id_updated_at_idx";
ALTER TABLE "coach_conversations"
DROP COLUMN "document_id";
-- CreateIndex — the rail + tool-route-send predicate on the sticky flag.
CREATE INDEX "coach_conversations_user_id_document_scoped_updated_at_idx"
ON "coach_conversations" ("user_id", "document_scoped", "updated_at");
@@ -0,0 +1,289 @@
-- v1.29.x — backfill non-canonical WHOOP `Workout.sport_type` values.
--
-- Root cause (confirmed from prod data): WHOOP was the only workout
-- integration that never normalised its sport to HealthLog's canonical
-- `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `upsertWhoopWorkout()` in
-- src/lib/whoop/sync-workout.ts wrote WHOOP's raw `sport_name` verbatim, or
-- a literal `whoop_sport_<sport_id>` placeholder when WHOOP sent only the
-- (now-retired) numeric id — so existing WHOOP rows carry values the
-- `/insights/workouts` list can't map to an icon/label (a WHOOP-tracked bike
-- ride never rendered as "cycling"). `mapWhoopSportType()`
-- (src/lib/whoop/sport-map.ts) fixes the write path going forward; this
-- migration re-derives every EXISTING WHOOP `Workout` row from the same
-- table so historical data and the live mapper never drift apart.
--
-- The WHEN arms below were generated FROM `WHOOP_SPORT_TABLE` in
-- src/lib/whoop/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed) and cover both shapes a pre-fix row could
-- carry:
-- - the literal `whoop_sport_<id>` placeholder (id present, no name), and
-- - the raw `sport_name` WHOOP sent (name present), normalised the same
-- way the SQL expression below normalises the stored column: lowercase,
-- any run of characters outside `[a-z0-9_]` collapsed to a single `_`.
-- (`normaliseSportKey()` in sport-map.ts mirrors this exact rule so a
-- future re-generation of this list stays consistent with the SQL.)
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapWhoopSportType()` uses, never leaves a row on a `whoop_sport_<n>`
-- placeholder.
--
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
-- one of the 20 canonical values, so a re-run touches zero rows once the
-- first run has completed. Safe to re-run after a partial failure.
UPDATE "workouts"
SET "sport_type" = CASE
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9_]+', '_', 'g'))
WHEN 'whoop_sport__1' THEN 'other'
WHEN 'whoop_sport_0' THEN 'running'
WHEN 'whoop_sport_1' THEN 'cycling'
WHEN 'whoop_sport_16' THEN 'other'
WHEN 'whoop_sport_17' THEN 'basketball'
WHEN 'whoop_sport_18' THEN 'rowing'
WHEN 'whoop_sport_19' THEN 'other'
WHEN 'whoop_sport_20' THEN 'other'
WHEN 'whoop_sport_21' THEN 'other'
WHEN 'whoop_sport_22' THEN 'golf'
WHEN 'whoop_sport_24' THEN 'other'
WHEN 'whoop_sport_25' THEN 'other'
WHEN 'whoop_sport_27' THEN 'other'
WHEN 'whoop_sport_28' THEN 'other'
WHEN 'whoop_sport_29' THEN 'other'
WHEN 'whoop_sport_30' THEN 'soccer'
WHEN 'whoop_sport_31' THEN 'other'
WHEN 'whoop_sport_32' THEN 'tennis'
WHEN 'whoop_sport_33' THEN 'swimming'
WHEN 'whoop_sport_34' THEN 'tennis'
WHEN 'whoop_sport_35' THEN 'running'
WHEN 'whoop_sport_36' THEN 'other'
WHEN 'whoop_sport_37' THEN 'swimming'
WHEN 'whoop_sport_38' THEN 'strength'
WHEN 'whoop_sport_39' THEN 'hiit'
WHEN 'whoop_sport_42' THEN 'dance'
WHEN 'whoop_sport_43' THEN 'mindAndBody'
WHEN 'whoop_sport_44' THEN 'yoga'
WHEN 'whoop_sport_45' THEN 'strength'
WHEN 'whoop_sport_47' THEN 'other'
WHEN 'whoop_sport_48' THEN 'crossTraining'
WHEN 'whoop_sport_49' THEN 'mixedCardio'
WHEN 'whoop_sport_51' THEN 'crossTraining'
WHEN 'whoop_sport_52' THEN 'hiking'
WHEN 'whoop_sport_53' THEN 'other'
WHEN 'whoop_sport_55' THEN 'rowing'
WHEN 'whoop_sport_56' THEN 'hiit'
WHEN 'whoop_sport_57' THEN 'cycling'
WHEN 'whoop_sport_59' THEN 'strength'
WHEN 'whoop_sport_60' THEN 'crossTraining'
WHEN 'whoop_sport_61' THEN 'rowing'
WHEN 'whoop_sport_62' THEN 'mixedCardio'
WHEN 'whoop_sport_63' THEN 'walking'
WHEN 'whoop_sport_64' THEN 'other'
WHEN 'whoop_sport_65' THEN 'elliptical'
WHEN 'whoop_sport_66' THEN 'stairClimber'
WHEN 'whoop_sport_70' THEN 'mindAndBody'
WHEN 'whoop_sport_71' THEN 'other'
WHEN 'whoop_sport_73' THEN 'other'
WHEN 'whoop_sport_74' THEN 'other'
WHEN 'whoop_sport_75' THEN 'other'
WHEN 'whoop_sport_76' THEN 'other'
WHEN 'whoop_sport_77' THEN 'other'
WHEN 'whoop_sport_82' THEN 'other'
WHEN 'whoop_sport_83' THEN 'stairClimber'
WHEN 'whoop_sport_84' THEN 'hiit'
WHEN 'whoop_sport_85' THEN 'other'
WHEN 'whoop_sport_86' THEN 'other'
WHEN 'whoop_sport_87' THEN 'other'
WHEN 'whoop_sport_88' THEN 'other'
WHEN 'whoop_sport_89' THEN 'other'
WHEN 'whoop_sport_90' THEN 'other'
WHEN 'whoop_sport_91' THEN 'other'
WHEN 'whoop_sport_92' THEN 'other'
WHEN 'whoop_sport_93' THEN 'other'
WHEN 'whoop_sport_94' THEN 'crossTraining'
WHEN 'whoop_sport_95' THEN 'other'
WHEN 'whoop_sport_96' THEN 'hiit'
WHEN 'whoop_sport_97' THEN 'cycling'
WHEN 'whoop_sport_98' THEN 'hiit'
WHEN 'whoop_sport_99' THEN 'other'
WHEN 'whoop_sport_100' THEN 'other'
WHEN 'whoop_sport_101' THEN 'tennis'
WHEN 'whoop_sport_102' THEN 'other'
WHEN 'whoop_sport_103' THEN 'hiit'
WHEN 'whoop_sport_104' THEN 'other'
WHEN 'whoop_sport_105' THEN 'other'
WHEN 'whoop_sport_106' THEN 'tennis'
WHEN 'whoop_sport_107' THEN 'mindAndBody'
WHEN 'whoop_sport_108' THEN 'other'
WHEN 'whoop_sport_109' THEN 'other'
WHEN 'whoop_sport_110' THEN 'crossTraining'
WHEN 'whoop_sport_111' THEN 'other'
WHEN 'whoop_sport_112' THEN 'other'
WHEN 'whoop_sport_113' THEN 'other'
WHEN 'whoop_sport_121' THEN 'other'
WHEN 'whoop_sport_123' THEN 'strength'
WHEN 'whoop_sport_125' THEN 'other'
WHEN 'whoop_sport_126' THEN 'cycling'
WHEN 'whoop_sport_127' THEN 'hiit'
WHEN 'whoop_sport_128' THEN 'mindAndBody'
WHEN 'whoop_sport_230' THEN 'tennis'
WHEN 'whoop_sport_231' THEN 'tennis'
WHEN 'whoop_sport_232' THEN 'basketball'
WHEN 'whoop_sport_233' THEN 'other'
WHEN 'whoop_sport_234' THEN 'golf'
WHEN 'whoop_sport_235' THEN 'other'
WHEN 'whoop_sport_236' THEN 'other'
WHEN 'whoop_sport_237' THEN 'other'
WHEN 'whoop_sport_238' THEN 'other'
WHEN 'whoop_sport_239' THEN 'other'
WHEN 'whoop_sport_240' THEN 'other'
WHEN 'whoop_sport_248' THEN 'crossTraining'
WHEN 'whoop_sport_249' THEN 'tennis'
WHEN 'whoop_sport_250' THEN 'crossTraining'
WHEN 'whoop_sport_251' THEN 'other'
WHEN 'whoop_sport_252' THEN 'walking'
WHEN 'whoop_sport_253' THEN 'running'
WHEN 'whoop_sport_254' THEN 'other'
WHEN 'whoop_sport_255' THEN 'other'
WHEN 'whoop_sport_258' THEN 'mindAndBody'
WHEN 'whoop_sport_259' THEN 'yoga'
WHEN 'whoop_sport_261' THEN 'stairClimber'
WHEN 'whoop_sport_262' THEN 'other'
WHEN 'whoop_sport_263' THEN 'other'
WHEN 'whoop_sport_264' THEN 'other'
WHEN 'whoop_sport_266' THEN 'walking'
WHEN 'whoop_sport_267' THEN 'other'
WHEN 'whoop_sport_268' THEN 'other'
WHEN 'whoop_sport_269' THEN 'other'
WHEN 'whoop_sport_270' THEN 'other'
WHEN 'whoop_sport_272' THEN 'other'
WHEN 'activity' THEN 'other'
WHEN 'running' THEN 'running'
WHEN 'cycling' THEN 'cycling'
WHEN 'baseball' THEN 'other'
WHEN 'basketball' THEN 'basketball'
WHEN 'rowing' THEN 'rowing'
WHEN 'fencing' THEN 'other'
WHEN 'field_hockey' THEN 'other'
WHEN 'football' THEN 'other'
WHEN 'golf' THEN 'golf'
WHEN 'ice_hockey' THEN 'other'
WHEN 'lacrosse' THEN 'other'
WHEN 'rugby' THEN 'other'
WHEN 'sailing' THEN 'other'
WHEN 'skiing' THEN 'other'
WHEN 'soccer' THEN 'soccer'
WHEN 'softball' THEN 'other'
WHEN 'squash' THEN 'tennis'
WHEN 'swimming' THEN 'swimming'
WHEN 'tennis' THEN 'tennis'
WHEN 'track_field' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'water_polo' THEN 'swimming'
WHEN 'wrestling' THEN 'strength'
WHEN 'boxing' THEN 'hiit'
WHEN 'dance' THEN 'dance'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'yoga' THEN 'yoga'
WHEN 'weightlifting' THEN 'strength'
WHEN 'cross_country_skiing' THEN 'other'
WHEN 'functional_fitness' THEN 'crossTraining'
WHEN 'duathlon' THEN 'mixedCardio'
WHEN 'gymnastics' THEN 'crossTraining'
WHEN 'hiking_rucking' THEN 'hiking'
WHEN 'horseback_riding' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'martial_arts' THEN 'hiit'
WHEN 'mountain_biking' THEN 'cycling'
WHEN 'powerlifting' THEN 'strength'
WHEN 'rock_climbing' THEN 'crossTraining'
WHEN 'paddleboarding' THEN 'rowing'
WHEN 'triathlon' THEN 'mixedCardio'
WHEN 'walking' THEN 'walking'
WHEN 'surfing' THEN 'other'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'stairmaster' THEN 'stairClimber'
WHEN 'meditation' THEN 'mindAndBody'
WHEN 'other' THEN 'other'
WHEN 'diving' THEN 'other'
WHEN 'operations_tactical' THEN 'other'
WHEN 'operations_medical' THEN 'other'
WHEN 'operations_flying' THEN 'other'
WHEN 'operations_water' THEN 'other'
WHEN 'ultimate' THEN 'other'
WHEN 'climber' THEN 'stairClimber'
WHEN 'jumping_rope' THEN 'hiit'
WHEN 'australian_football' THEN 'other'
WHEN 'skateboarding' THEN 'other'
WHEN 'coaching' THEN 'other'
WHEN 'ice_bath' THEN 'other'
WHEN 'commuting' THEN 'other'
WHEN 'gaming' THEN 'other'
WHEN 'snowboarding' THEN 'other'
WHEN 'motocross' THEN 'other'
WHEN 'caddying' THEN 'other'
WHEN 'obstacle_course_racing' THEN 'crossTraining'
WHEN 'motor_racing' THEN 'other'
WHEN 'hiit' THEN 'hiit'
WHEN 'spin' THEN 'cycling'
WHEN 'jiu_jitsu' THEN 'hiit'
WHEN 'manual_labor' THEN 'other'
WHEN 'cricket' THEN 'other'
WHEN 'pickleball' THEN 'tennis'
WHEN 'inline_skating' THEN 'other'
WHEN 'box_fitness' THEN 'hiit'
WHEN 'spikeball' THEN 'other'
WHEN 'wheelchair_pushing' THEN 'other'
WHEN 'paddle_tennis' THEN 'tennis'
WHEN 'barre' THEN 'mindAndBody'
WHEN 'stage_performance' THEN 'other'
WHEN 'high_stress_work' THEN 'other'
WHEN 'parkour' THEN 'crossTraining'
WHEN 'gaelic_football' THEN 'other'
WHEN 'hurling_camogie' THEN 'other'
WHEN 'circus_arts' THEN 'other'
WHEN 'massage_therapy' THEN 'other'
WHEN 'strength_trainer' THEN 'strength'
WHEN 'watching_sports' THEN 'other'
WHEN 'assault_bike' THEN 'cycling'
WHEN 'kickboxing' THEN 'hiit'
WHEN 'stretching' THEN 'mindAndBody'
WHEN 'table_tennis' THEN 'tennis'
WHEN 'badminton' THEN 'tennis'
WHEN 'netball' THEN 'basketball'
WHEN 'sauna' THEN 'other'
WHEN 'disc_golf' THEN 'golf'
WHEN 'yard_work' THEN 'other'
WHEN 'air_compression' THEN 'other'
WHEN 'percussive_massage' THEN 'other'
WHEN 'paintball' THEN 'other'
WHEN 'ice_skating' THEN 'other'
WHEN 'handball' THEN 'other'
WHEN 'f45_training' THEN 'crossTraining'
WHEN 'padel' THEN 'tennis'
WHEN 'barry_s' THEN 'crossTraining'
WHEN 'dedicated_parenting' THEN 'other'
WHEN 'stroller_walking' THEN 'walking'
WHEN 'stroller_jogging' THEN 'running'
WHEN 'toddlerwearing' THEN 'other'
WHEN 'babywearing' THEN 'other'
WHEN 'barre3' THEN 'mindAndBody'
WHEN 'hot_yoga' THEN 'yoga'
WHEN 'stadium_steps' THEN 'stairClimber'
WHEN 'polo' THEN 'other'
WHEN 'musical_performance' THEN 'other'
WHEN 'kite_boarding' THEN 'other'
WHEN 'dog_walking' THEN 'walking'
WHEN 'water_skiing' THEN 'other'
WHEN 'wakeboarding' THEN 'other'
WHEN 'cooking' THEN 'other'
WHEN 'cleaning' THEN 'other'
WHEN 'public_speaking' THEN 'other'
ELSE 'other'
END
WHERE "source" = 'WHOOP'
AND "sport_type" NOT IN (
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
'mixedCardio', 'other'
);
@@ -0,0 +1,30 @@
-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL
-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The
-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin)
-- never write a row here — they clear on their own once the user acts.
--
-- `item_key` is a deterministic identity for the candidate INSTANCE the
-- digest builder already computes (kind + metric + reach day for a
-- milestone, the recording's own timestamp for an ECG, the local day for a
-- tension window) — not the bare kind — so dismissing today's milestone never
-- suppresses tomorrow's.
-- CreateTable
CREATE TABLE "dismissed_priority_items" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_key" TEXT NOT NULL,
"dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the dismiss upsert's natural key + the digest read's
-- `userId + itemKey IN (...)` lookup.
CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key"
ON "dismissed_priority_items" ("user_id", "item_key");
-- AddForeignKey — deleting a user cascades its dismissal rows away.
ALTER TABLE "dismissed_priority_items"
ADD CONSTRAINT "dismissed_priority_items_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- v1.29 — `source` joins the `nutrient_intake_days` primary key so a
-- manual water entry and the Apple-synced day total coexist as two rows
-- instead of the manual write being silently clobbered by the next
-- Apple last-writer-wins sync (the batch route always upserts the
-- (userId, day, nutrient) row with the CURRENT running total, so a
-- manual increment folded into that same row would vanish on the next
-- sync — a data-loss bug by construction, not a rare race).
--
-- No data rewrite: every existing row already carries
-- `source = 'APPLE_HEALTH'` (the column default since migration 0241),
-- so dropping and recreating the PRIMARY KEY over the four columns is a
-- pure constraint change — same rows, same values, wider key. The
-- existing `(user_id, nutrient, day DESC)` index is untouched; it does
-- not reference `source` and continues to serve the window-scan reads.
ALTER TABLE "nutrient_intake_days" DROP CONSTRAINT "nutrient_intake_days_pkey";
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_pkey"
PRIMARY KEY ("user_id", "day", "nutrient", "source");
@@ -0,0 +1,23 @@
-- Persist a short plain-language summary of an uploaded document.
--
-- Adds two nullable columns to `inbound_documents`:
-- - `summary_encrypted` — AES-256-GCM ciphertext of a 3-4 sentence
-- plain-language description of WHAT the document is, generated ONCE in
-- the background right after upload when the user's `documents_auto_ai_read`
-- opt-in is ON (that flag is also the standing AI-egress consent). NULL
-- when auto-read is OFF, no provider is configured, or generation has not
-- run yet.
-- - `summary_generated_at` — when that background summary was generated
-- (NULL until it runs).
--
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
-- encrypted-note convention already in the schema (the `encrypt()` ciphertext
-- string stored UTF-8 as `bytea`, the shape the rotation script's
-- `rotateBytesColumn` already covers). The column is registered in
-- ENCRYPTED_COLUMNS + the rotation script in the same change.
--
-- ADDITIVE ONLY: existing rows keep NULL summaries; the on-demand transient
-- summary route is unchanged.
ALTER TABLE "inbound_documents"
ADD COLUMN IF NOT EXISTS "summary_encrypted" BYTEA,
ADD COLUMN IF NOT EXISTS "summary_generated_at" TIMESTAMP(3);
@@ -0,0 +1,103 @@
-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values.
--
-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the
-- second workout integration that never normalised its sport to HealthLog's
-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `mapActivity()` in
-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or
-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the
-- literal fallback "workout" when neither field was present — straight into
-- `Workout.sport_type`. Existing Strava rows therefore carry values the
-- `/insights/workouts` list can't map to an icon/label, AND the read-time
-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any
-- two non-canonical ("generic") rows as sport-compatible — a same-source
-- brick session (a Strava "Ride" ending 15:00 immediately followed by a
-- Strava "Run" starting 15:03) could cluster into one and silently drop a
-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write
-- path going forward; this migration re-derives every EXISTING Strava
-- `Workout` row from the same table so historical data and the live mapper
-- never drift apart.
--
-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in
-- src/lib/strava/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed). The match expression lower-cases the stored
-- column and strips every character outside `[a-zA-Z0-9]`, mirroring
-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun",
-- "trailrun", and any stray-separator variant all resolve to the same key —
-- and so the pre-fix literal fallback "workout" resolves to the same row as
-- Strava's own "Workout" catch-all.
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label.
--
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
-- one of the 20 canonical values, so a re-run touches zero rows once the
-- first run has completed. Safe to re-run after a partial failure.
UPDATE "workouts"
SET "sport_type" = CASE
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g'))
WHEN 'alpineski' THEN 'other'
WHEN 'backcountryski' THEN 'other'
WHEN 'badminton' THEN 'tennis'
WHEN 'basketball' THEN 'basketball'
WHEN 'canoeing' THEN 'rowing'
WHEN 'cricket' THEN 'other'
WHEN 'crossfit' THEN 'crossTraining'
WHEN 'dance' THEN 'dance'
WHEN 'ebikeride' THEN 'cycling'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'emountainbikeride' THEN 'cycling'
WHEN 'golf' THEN 'golf'
WHEN 'gravelride' THEN 'cycling'
WHEN 'handcycle' THEN 'cycling'
WHEN 'highintensityintervaltraining' THEN 'hiit'
WHEN 'hike' THEN 'hiking'
WHEN 'iceskate' THEN 'other'
WHEN 'inlineskate' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'kitesurf' THEN 'other'
WHEN 'mountainbikeride' THEN 'cycling'
WHEN 'nordicski' THEN 'other'
WHEN 'padel' THEN 'tennis'
WHEN 'physicaltherapy' THEN 'mindAndBody'
WHEN 'pickleball' THEN 'tennis'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'racquetball' THEN 'tennis'
WHEN 'ride' THEN 'cycling'
WHEN 'rockclimbing' THEN 'crossTraining'
WHEN 'rollerski' THEN 'other'
WHEN 'rowing' THEN 'rowing'
WHEN 'run' THEN 'running'
WHEN 'sail' THEN 'other'
WHEN 'skateboard' THEN 'other'
WHEN 'snowboard' THEN 'other'
WHEN 'snowshoe' THEN 'hiking'
WHEN 'soccer' THEN 'soccer'
WHEN 'squash' THEN 'tennis'
WHEN 'stairstepper' THEN 'stairClimber'
WHEN 'standuppaddling' THEN 'rowing'
WHEN 'surfing' THEN 'other'
WHEN 'swim' THEN 'swimming'
WHEN 'tabletennis' THEN 'tennis'
WHEN 'tennis' THEN 'tennis'
WHEN 'trailrun' THEN 'running'
WHEN 'velomobile' THEN 'cycling'
WHEN 'virtualride' THEN 'cycling'
WHEN 'virtualrow' THEN 'rowing'
WHEN 'virtualrun' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'walk' THEN 'walking'
WHEN 'weighttraining' THEN 'strength'
WHEN 'wheelchair' THEN 'other'
WHEN 'windsurf' THEN 'other'
WHEN 'workout' THEN 'other'
WHEN 'yoga' THEN 'yoga'
ELSE 'other'
END
WHERE "source" = 'STRAVA'
AND "sport_type" NOT IN (
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
'mixedCardio', 'other'
);
+462 -36
View File
@@ -42,6 +42,13 @@ model User {
email String? @unique
passwordHash String? @map("password_hash") // argon2id, optional fallback
role String @default("USER") // "ADMIN" | "USER"
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
// identity. Stamped on first OIDC login (auto-provision or one-time link
// by verified email); every later OIDC login matches on this pair, never
// on the email, so an IdP-side email change can update the display email
// but can never re-point the login at a different account.
oidcIssuer String? @map("oidc_issuer")
oidcSub String? @map("oidc_sub")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -76,6 +83,13 @@ model User {
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
// Per-user opt-in to the operator's shared central Codex (ChatGPT
// subscription) connection stored on `AppSettings.adminCodex*`. OFF by
// default; only takes effect when the operator has actually connected the
// central Codex. Routing through it is external egress on a shared,
// train-by-default account bound by the operator's rate limits — consent-gated
// exactly like any other server-managed egress.
useCentralCodex Boolean @default(false) @map("use_central_codex")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
@@ -97,6 +111,15 @@ model User {
// without re-paying for the findings. This stamps the day a re-roll ran so
// the gate stays at most one extra call per day.
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
// timezone) for which the event-driven morning refresh has run — i.e. last
// night's sleep has arrived AND the sleep-dependent insight/score cache was
// regenerated with it. The daily digest reads `final` when this equals the
// user's current local date, `provisional` otherwise. Stamped by the
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
// string form makes the "same local morning" comparison timezone-honest
// without a second timestamp read.
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -187,6 +210,29 @@ model User {
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
// level). Strava caps every newly-created API app at single-player mode
// (athlete capacity 1), so a single shared app cannot serve many
// self-hosters — each registers their own Strava app and pastes the client
// id/secret into Settings, mirroring the WHOOP/Oura/Polar posture. Strava
// rotates its refresh token on EVERY refresh, so both tokens are persisted
// (Oura's reactive-refresh + compare-and-set model, no token-expiry column —
// the sync refreshes reactively on a 401).
stravaClientIdEncrypted String? @map("strava_client_id_encrypted")
stravaClientSecretEncrypted String? @map("strava_client_secret_encrypted")
stravaAccessTokenEncrypted String? @map("strava_access_token_encrypted")
stravaRefreshTokenEncrypted String? @map("strava_refresh_token_encrypted")
// Strava's numeric athlete id (plaintext opaque id): pins the granted
// account so a cross-account re-auth can be rejected, and is the fan-out key
// if webhooks are added later. Mirrors polarUserId / fitbitUserId intent.
stravaAthleteId String? @map("strava_athlete_id")
// Incremental-sync cursor: the start instant of the newest activity already
// imported. The poll walks `/athlete/activities?after=<epoch>` from here.
stravaLastActivityAt DateTime? @map("strava_last_activity_at")
// First-connect full-history backfill marker (self-converging discovery
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
@@ -289,18 +335,17 @@ model User {
// default timed the Coach out before the first token landed.
aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds")
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default:
// the lab-report OCR path uses the main provider chain unchanged. When
// `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key
// instead, so a user can keep a cheap text Coach on one provider and route
// expensive vision OCR to a vision-capable one. The key is AES-256-GCM at
// rest like every other `*Encrypted` column (registered in
// `src/lib/crypto/encrypted-columns.ts` + the rotation script).
aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled")
aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL"
aiOcrModel String? @map("ai_ocr_model")
aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only
aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted")
// Automatic AI reading of uploaded vault documents. OFF by default: the vault
// stays local-first — a newly stored document is only read externally when the
// user explicitly taps a per-document AI action (which still needs an active
// consent receipt). When `true`, the auto-index job may read a freshly
// uploaded document through the user's configured external provider with no
// per-document tap: the toggle IS the standing consent (the write that flips
// it on also mints an `ai_full` receipt for the audit trail). Mirrors
// `labsLocalOcrEnabled`; all AI provider config is per-user, so the operator
// flips it for their own account. Default `false` is the entire privacy
// contract for every other self-hoster.
documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
@@ -674,6 +719,8 @@ model User {
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
@@ -776,6 +823,10 @@ model User {
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
@@ -785,6 +836,7 @@ model User {
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
@@ -837,6 +889,10 @@ model User {
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
// One account per IdP identity — the (issuer, sub) pair is the durable
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
// unique indexes ignore NULL pairs).
@@unique([oidcIssuer, oidcSub])
@@map("users")
}
@@ -1492,6 +1548,12 @@ enum MeasurementSource {
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so /api/sync/changes decodes the rows.
GOOGLE_HEALTH
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
// server-side as `source = STRAVA` Workout rows (no client write path);
// rejected on the batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so the `/api/sync/changes` delta feed decodes
// the rows. Feeds Workout rows only — Strava exposes no daily metrics.
STRAVA
@@map("measurement_source")
}
@@ -1656,6 +1718,11 @@ model Measurement {
// and series reads for every type NOT covered by the two partials above
// and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a-
// partial-predicate reason it is omitted here.
// `measurements_note_backfill_idx` is a partial index over `(user_id)
// WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
// user_id` an index-only scan that converges to empty. Same partial-predicate
// reason it is omitted here.
@@map("measurements")
}
@@ -2035,6 +2102,34 @@ model PersonalRecord {
@@map("personal_records")
}
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
/// preventive_care, coach_checkin) never write a row here — they clear on
/// their own once the user acts, so a dismiss affordance on them would just
/// duplicate the existing resolution path.
///
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
/// digest builder already computes (kind + metric + reach day for a
/// milestone, the recording's own timestamp for an ECG, the local day for a
/// tension window) — not the bare `kind` — so dismissing today's milestone
/// never suppresses tomorrow's; the underlying item's own freshness window
/// (reached-once / 24h / one-per-day) is what actually retires the row's
/// relevance, this table only fast-forwards that for the rest of today.
model DismissedPriorityItem {
id String @id @default(cuid())
userId String @map("user_id")
itemKey String @map("item_key")
dismissedAt DateTime @default(now()) @map("dismissed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The dismiss upsert's natural key + the digest read's
/// `userId + itemKey IN (...)` lookup.
@@unique([userId, itemKey])
@@map("dismissed_priority_items")
}
// ─── Medications ───────────────────────────────────────────
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
@@ -2195,6 +2290,20 @@ model Medication {
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
/// backfill.
reorderLeadDays Int? @map("reorder_lead_days")
/// v1.28 — provenance of a MIRRORED medication. Non-NULL marks this row a
/// read-only mirror of an external medication list (today only
/// APPLE_HEALTH — the iOS 26+ HealthKit Medications sync). Source-
/// exclusive by policy: dose events from that source attach only to
/// medications mirrored from it, and the app's own dose-logging UI is
/// expected to stay off a mirrored medication client-side. NULL = a
/// native HealthLog medication.
externalSource IntakeSource? @map("external_source")
/// v1.28 — stable external identifier of the mirrored medication (the
/// HealthKit `medicationConceptIdentifier`). Paired with `externalSource`
/// (both set or both NULL; the create route enforces the pairing). Drives
/// the idempotent re-mirror on `POST /api/medications` via the
/// `(userId, externalSource, externalId)` unique below.
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2232,14 +2341,57 @@ model Medication {
/// single-pause marker the reminder/today surfaces read; the era table
/// is the additive durable record the rate paths need.
pauseEras MedicationPauseEra[]
/// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows
/// exist ONLY when the user explicitly overrides the derived (ATC / name)
/// target resolution; absence means the resolver derives the target each
/// read. Nothing about a class→outcome association is denormalised here.
efficacyTargets MedicationEfficacyTarget[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// v1.28 — one mirror row per external medication concept per user
/// (migration 0239). Postgres treats NULLs as distinct in unique
/// indexes, so the vast majority of rows (both columns NULL) are
/// unaffected; only genuinely mirrored medications occupy a slot. A
/// re-mirror of the same concept id returns the existing row (the
/// create route pre-queries the triple and catches the P2002 race).
@@unique([userId, externalSource, externalId])
@@index([userId])
@@index([treatmentClass])
@@map("medications")
}
/// v1.28 — the user's explicit override of a medication's efficacy target,
/// the ONE thing the "Wirkung" view persists. Class→outcome resolution
/// (ATC-prefix → name inference) is computed each read from the closed
/// `med-target-map` table, so nothing drifts as that map improves; the only
/// durable state is the user's own pick when they retarget a medication or
/// choose a target for a med the resolver could not classify. Exactly one of
/// `measurementType` (a first-class metric series) / `biomarkerId` (a lab
/// analyte) is set per row. Cascade-deletes with the medication.
model MedicationEfficacyTarget {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Metric target — one of the closed `MeasurementType` series. NULL when
/// this row targets a lab analyte instead.
measurementType MeasurementType? @map("measurement_type")
/// Lab target — a user-scoped Biomarker. NULL when this row targets a
/// metric series instead. SetNull on biomarker delete so the row survives
/// as an orphan the resolver then treats as "no override".
biomarkerId String? @map("biomarker_id")
/// The storyline-leading target when several are pinned. Default true.
primary Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
@@index([medicationId])
@@index([biomarkerId])
@@map("medication_efficacy_targets")
}
model MedicationSchedule {
id String @id @default(cuid())
medicationId String @map("medication_id")
@@ -2416,6 +2568,11 @@ enum IntakeSource {
API
REMINDER
IMPORT
/// v1.28 — Apple Health medication sync (iOS 26+ HealthKit Medications
/// API). Spelled exactly like `MeasurementSource.APPLE_HEALTH`; the iOS
/// DTO mirrors this wire spelling. Doubles as the provenance label on
/// `Medication.externalSource` for mirrored medications.
APPLE_HEALTH
@@map("intake_source")
}
@@ -2517,6 +2674,13 @@ model MedicationIntakeEvent {
/// stamp freezes history: editing `Medication.unitsPerDose` later
/// never changes what an already-taken dose consumed.
inventoryConsumption Json? @map("inventory_consumption")
/// v1.28 — external dose-event identifier (the HealthKit
/// `HKMedicationDoseEvent` UUID). Drives idempotent re-import on the
/// bulk intake route: a re-synced Apple dose dedups against the live
/// `(userId, externalId)` unique below instead of inserting again
/// (first-write-wins per external dose event). NULL for every
/// non-mirrored row.
externalId String? @map("external_id")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
@@ -2552,6 +2716,16 @@ model MedicationIntakeEvent {
/// full `@@unique` for the `userId_medicationId_scheduledFor_source`
/// compound where-key.
@@unique([userId, medicationId, scheduledFor, source])
/// v1.28 — `medication_intake_events_user_external_id_live_key` is a
/// partial UNIQUE index over `(user_id, external_id) WHERE external_id
/// IS NOT NULL AND deleted_at IS NULL` — see migration 0239. The bulk
/// intake route pre-checks the batch's externalIds and reports replays
/// as duplicates; the index is the race backstop (a concurrent insert
/// surfaces P2002, which the route resolves to the winning row). Prisma
/// can't express a partial-predicate unique (same as the live-row slot
/// unique above, migration 0121), so the schema keeps the full
/// `@@unique` for the `userId_externalId` compound where-key.
@@unique([userId, externalId])
@@index([userId, medicationId, scheduledFor])
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@ -2589,6 +2763,11 @@ model MedicationDoseChange {
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, effectiveFrom])
// `medication_dose_changes_note_backfill_idx` is a partial index over
// `(medication_id) WHERE note IS NOT NULL AND note_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4). The med-notes boot-discovery joins dose-
// changes to medications on medication_id to reach the owner; the index
// leads with that join key. Raw SQL only (partial predicate).
@@map("medication_dose_changes")
}
@@ -2687,6 +2866,10 @@ model MedicationInventoryItem {
@@index([userId, medicationId, state])
@@index([userId, expiresAt])
// `medication_inventory_items_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@map("medication_inventory_items")
}
@@ -2746,6 +2929,10 @@ model MedicationSideEffect {
@@index([userId, medicationId, occurredAt])
@@index([userId, occurredAt])
// `medication_side_effects_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@map("medication_side_effects")
}
@@ -3289,6 +3476,11 @@ model GoogleHealthConnection {
/// Set once the self-converging history backfill walks every data type to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// Set once the one-shot sleep duplicate-repair re-read completed — the full
/// sleep-history re-sync that collapses duplicate rows left by the volatile
/// pre-fix externalId (a re-scored night minted parallel copies). Null =
/// repair pending (or the connection predates the fix).
sleepRepairedAt DateTime? @map("sleep_repaired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -3404,6 +3596,10 @@ model MoodEntry {
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
// `mood_entries_note_backfill_idx` is a partial index over `(user_id)
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
// only (partial predicate not expressible in Prisma).
@@map("mood_entries")
}
@@ -3718,6 +3914,21 @@ model AppSettings {
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
// Operator-shared central Codex (ChatGPT subscription) OAuth credential. The
// operator connects ONE signed-in ChatGPT account here (device-code flow, same
// mechanism as the per-user `codex*Encrypted` columns) and any user who flips
// the per-user `useCentralCodex` opt-in routes their AI egress through it. The
// three token pieces (access, refresh, and the mandatory `ChatGPT-Account-ID`
// claim) are each AES-256-GCM encrypted at rest. OFF until the operator
// connects it; the shared account's rate limits + training-by-default caveat
// apply to every opted-in user (see the admin connect UI's honesty copy).
adminCodexAccessTokenEncrypted String? @map("admin_codex_access_token_encrypted")
adminCodexRefreshTokenEncrypted String? @map("admin_codex_refresh_token_encrypted")
adminCodexAccountIdEncrypted String? @map("admin_codex_account_id_encrypted")
adminCodexTokenExpiresAt DateTime? @map("admin_codex_token_expires_at")
adminCodexConnectedAt DateTime? @map("admin_codex_connected_at")
adminCodexConnectionStatus String @default("disconnected") @map("admin_codex_connection_status")
// v1.4.16 phase B5e — daily-aggregated rec-feedback summary written
// by `feedback-aggregator.ts`. Shape:
// {
@@ -3939,6 +4150,12 @@ model ImportJob {
exportedAt DateTime? @map("exported_at")
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
/// Bumped on every write, including each mid-run progress tick. Acts
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
/// job still actively parsing in another worker (fresh heartbeat) is
/// never flipped to `failed` (issue #486 follow-up).
updatedAt DateTime @updatedAt @map("updated_at")
/// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/
/// import-apple-health-export.ts`). Updated mid-run.
progress Json @default("{}")
@@ -4217,6 +4434,10 @@ model LabResult {
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
@@index([biomarkerId])
// `lab_results_biomarker_backfill_idx` is a partial index over `(user_id)
// WHERE deleted_at IS NULL AND biomarker_id IS NULL` — see migration 0243
// (v1.28.46 H4), serving the lab-biomarker boot-discovery `SELECT DISTINCT
// user_id`. Raw SQL only (partial predicate not expressible in Prisma).
@@map("lab_results")
}
@@ -4256,8 +4477,11 @@ model Biomarker {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
/// v1.28 — medications the user has pinned to this biomarker as their
/// efficacy target. SetNull on delete keeps the override rows as orphans.
efficacyTargets MedicationEfficacyTarget[]
@@unique([userId, name])
@@index([userId])
@@ -4463,13 +4687,53 @@ model CoachConversation {
summaryUpdatedAt DateTime? @map("summary_updated_at")
summaryTurnCount Int @default(0) @map("summary_turn_count")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages CoachMessage[]
// v1.29.x (S7 — attach multiple documents to a coach conversation) — PERMANENT
// fence marker. Set true when the conversation is created via the fenced
// endpoint or when the FIRST document is attached. NO code path ever sets it
// back to false — detaching every document, or a document being deleted,
// leaves it true. A conversation whose history may contain document-derived
// text must never regain the tool loop. The LIVE attachment set lives in the
// `CoachConversationDocument` join table; this sticky flag is the fail-closed
// half of the fenced-only discriminator (see the dual predicate on the tool
// route). Replaces the pre-S7 scalar `documentId` discriminator, which could
// only express one document and would UNFENCE on that document's delete.
documentScoped Boolean @default(false) @map("document_scoped")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages CoachMessage[]
attachments CoachConversationDocument[]
@@index([userId, updatedAt])
// v1.29.x (S7) — the rail lists health threads + fenced threads newest-first
// and the tool-route send path filters on the flag. Replaces the retired
// [userId, documentId, updatedAt] composite.
@@index([userId, documentScoped, updatedAt])
@@map("coach_conversations")
}
/// v1.29.x (S7) — a document attached to a coach conversation. The EXISTENCE of
/// any row for a conversation is one half of the fenced-only discriminator (the
/// other is `CoachConversation.documentScoped`). Composite PK so the SQL
/// backfill needs no client-side id generation and duplicate attaches are
/// impossible by construction. Two cascades:
/// - from DOCUMENT: deleting a vault document removes its join rows (its text
/// leaves every conversation's context) but NEVER the conversation — a
/// fenced thread survives via the sticky flag.
/// - from CONVERSATION: deleting a chat removes its attachment rows, never the
/// documents.
model CoachConversationDocument {
conversationId String @map("conversation_id")
documentId String @map("document_id")
addedAt DateTime @default(now()) @map("added_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@id([conversationId, documentId])
@@index([documentId]) // document-sheet "chats about this doc" scan + path-id join filter
@@map("coach_conversation_documents")
}
// One message in a Coach conversation. `role` is "user" or "assistant"
// — same shape as the OpenAI/Anthropic chat-completion APIs.
//
@@ -4843,6 +5107,15 @@ model ClinicianShareLink {
resourceTypes String[] @map("resource_types")
/// Whether the FHIR API is reachable via this link at all (vs the view only).
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
/// the guarantee from "are all section flags off?" means a future report
/// section added to the prefs shape can never silently re-open an old
/// documents-only link (the previous derived check would have leaked it once
/// the new key defaulted on). Legacy links (pre-column) keep the derived
/// all-off fallback in the view loader.
documentOnly Boolean @default(false) @map("document_only")
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
expiresAt DateTime @map("expires_at")
@@ -4853,13 +5126,41 @@ model ClinicianShareLink {
lastAccessAt DateTime? @map("last_access_at")
accessCount Int @default(0) @map("access_count")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// v1.28 — the hand-picked document set this link exposes (0..N). A relation,
/// not a column: frozen write-once at create, owner-checked, never widened.
documents ClinicianShareLinkDocument[]
@@index([userId, createdAt(sort: Desc)])
@@index([expiresAt])
@@map("clinician_share_links")
}
/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a
/// stored document to a clinician share link. The document set a share exposes
/// is modelled as this relation, never a mutable column: the rows are created
/// in the same transaction as the link, each `documentId` is validated as the
/// caller's own live document at create time, and the set is never widened
/// afterwards. The public share serve route serves ONLY a document id present
/// in this set for the resolved token — a guessed / foreign id is a flat 404.
/// Both foreign keys cascade: deleting the link or the underlying document
/// removes the membership cleanly (a shared document the owner later deletes
/// simply disappears from the link).
model ClinicianShareLinkDocument {
id String @id @default(cuid())
shareLinkId String @map("share_link_id")
documentId String @map("document_id")
createdAt DateTime @default(now()) @map("created_at")
shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([shareLinkId, documentId])
@@index([shareLinkId])
@@index([documentId])
@@map("clinician_share_link_documents")
}
// v1.11.0 W3 — period-narrative cache (Pillar P1).
//
// One durable, typed row per (user, period, locale) carrying the latest
@@ -5760,9 +6061,9 @@ enum ExtractedFactStatus {
}
model InboundDocument {
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
/// User-given label. Deliberately plaintext (like `filename`) so the library
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
@@ -5770,18 +6071,18 @@ model InboundDocument {
/// cleartext at rest / in backups — the same exposure the pre-existing
/// plaintext `filename` already carries. Accepted to keep server-side search
/// and sort.
title String?
title String?
/// Original filename as uploaded (sanitised, optional).
filename String?
filename String?
/// Magic-byte-sniffed MIME type the document was stored as.
mimeType String @map("mime_type")
mimeType String @map("mime_type")
/// Stored byte length of the original (pre-encryption) document.
byteSize Int @map("byte_size")
byteSize Int @map("byte_size")
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
/// shared with the note columns. The original is sensitive and never
/// stored in the clear or logged.
contentEncrypted Bytes @map("content_encrypted")
contentEncrypted Bytes @map("content_encrypted")
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
/// upload for same-user duplicate detection. Nullable: rows stored before
/// the vault release are not backfilled (only new uploads dedupe). A
@@ -5790,35 +6091,59 @@ model InboundDocument {
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
/// ON inbound_documents (user_id, content_sha256)
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
contentSha256 String? @map("content_sha256") @db.VarChar(64)
contentSha256 String? @map("content_sha256") @db.VarChar(64)
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
/// detour). Explicit column, never header-sniffed; the default keeps every
/// pre-existing row correct.
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
/// "local"). Diagnostic only. Null on a store-only document.
providerType String? @map("provider_type")
providerType String? @map("provider_type")
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
/// transcribed by the model — never inferred, never user-set.
reportDate DateTime? @map("report_date")
reportDate DateTime? @map("report_date")
/// Effective filing date the library sorts, groups, and range-filters on
/// (editable). Distinct from `reportDate`, which is the model-transcribed
/// value. On store it defaults to the upload day (now) when the user does
/// not supply one, so a stored row's filing date is never NULL and display
/// == sort == filter. It stays user-editable and MAY be cleared back to
/// NULL via PATCH (the list then sorts such rows last).
documentDate DateTime? @map("document_date")
documentDate DateTime? @map("document_date")
/// Failure reason when status = FAILED (a short code, never the document).
errorReason String? @map("error_reason")
errorReason String? @map("error_reason")
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
/// of WHAT the document is, generated ONCE in the background right after
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
/// provider is configured, or generation has not run yet. Descriptive only —
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
/// codec every other note column uses; PHI, never logged.
summaryEncrypted Bytes? @map("summary_encrypted")
/// When the background summary was generated (null until it runs). Surfaced
/// as meta beside the summary in the document detail view.
summaryGeneratedAt DateTime? @map("summary_generated_at")
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
contentIndex DocumentContentIndex?
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
/// Absent until the background thumbnail job runs; a side table (not a
/// column) so the list/detail `omit: { contentEncrypted }` queries never
/// drag the blob into the SELECT. Cascades on delete.
thumbnail DocumentThumbnail?
/// v1.29.x (S7) — coach-conversation attachments (m:n via
/// `CoachConversationDocument`). Deleting this document cascades its join rows
/// away (its text leaves every conversation's context) but never the
/// conversation itself — a fenced thread survives via its sticky flag.
chatAttachments CoachConversationDocument[]
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
/// Cascades: deleting this document removes it from every share it was on.
shareLinks ClinicianShareLinkDocument[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -5833,6 +6158,11 @@ model InboundDocument {
// a superset of the plain `(userId, deletedAt)` index, which is therefore
// redundant for read planning and dropped.
@@index([userId, deletedAt, documentDate])
// `inbound_documents_thumbnail_backfill_idx` is a partial index over
// `(user_id, mime_type) WHERE deleted_at IS NULL` — see migration 0243
// (v1.28.46 H4). It bounds the document-side scan of the thumbnail
// boot-discovery (which anti-joins document_thumbnails for the missing
// preview). Raw SQL only (partial predicate not expressible in Prisma).
@@map("inbound_documents")
}
@@ -5929,12 +6259,24 @@ model DocumentContentIndex {
/// with the Coach columns. The plaintext is recoverable server-side so
/// rotation can re-tokenise; the list query never selects it.
textEncrypted Bytes @map("text_encrypted")
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
/// de-accented), stored additionally alongside `textEncrypted` so a document
/// chat can cite the document faithfully (exact casing, section names, units)
/// rather than from the normalised search text. Same `encrypt()`-string-as-
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
/// P4 carry NULL until re-indexed, and the chat route falls back to the
/// normalised `textEncrypted` when it is absent. The list query never selects it.
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
/// (raw SQL in the migration; Prisma cannot express a GIN index).
searchTokens String[] @default([]) @map("search_tokens")
/// Provenance of the indexed text: "vision" (provider transcription of the
/// stored original) or "text-ocr" (browser-OCR text posted by the client).
/// stored original), "text-ocr" (browser-OCR text posted by the client),
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
/// adding a source value needs no migration.
source String
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
@@ -5951,3 +6293,87 @@ model DocumentContentIndex {
@@index([userId])
@@map("document_content_index")
}
/// 1:1 encrypted preview thumbnail for a stored document. A small JPEG
/// (~320px long edge, q70) rendered in the background from the original —
/// images are decoded + downscaled, a PDF's first page is rasterised — then
/// stored encrypted at rest. Held in a side table (not a column on
/// `InboundDocument`) so the list/detail queries that `omit` the original
/// blob never drag this blob either. The re-encode strips EXIF/GPS from the
/// source, so the preview is metadata-free by construction. Owner-scoped
/// serve route only; cascades on delete.
model DocumentThumbnail {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
/// key-rotation walk covers it. A scanned medical preview is PHI — never
/// stored in the clear or logged. The list query never selects it.
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
/// size the tile without decoding the blob.
width Int
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("document_thumbnails")
}
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
//
// Micronutrient daily totals synced from Apple Health — vitamins,
// minerals, water, caffeine as supplement-style "substances taken",
// never a food diary (energy / macros are out of scope by directive).
// One row per (user, local day, nutrient); the composite PK IS the
// idempotency key, so an iOS re-post of a grown day total replaces the
// row in place (last-writer-wins day-total contract — the `stats:`
// overwrite semantics without externalId string parsing). `nutrient`
// is a bare string enforced in code against the closed catalog in
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
// migration to add a code). No soft delete: rows are source-owned day
// totals; replace-on-repost is the correction mechanism and account
// deletion cascades. Behind the opt-in `nutrients` module
// (refuse-ingest-when-off).
model NutrientIntakeDay {
userId String @map("user_id")
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
/// the same day-key contract as the `stats:` externalIds).
day String
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
nutrient String
/// Daily total in the catalog's canonical unit.
amount Float
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
/// denormalised on purpose: rows stay self-describing in exports
/// even if the catalog ever drifts.
unit String
/// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a
/// DB enum). v1.29 — joined the composite PK (migration 0249) so a
/// manual water entry and the Apple-synced day total coexist as two
/// rows instead of the manual write being silently clobbered by the
/// next Apple last-writer-wins sync. Apple owns its row (LWW on
/// re-post); manual owns its row (increment/set via
/// `POST /api/nutrients/water`); reads SUM across sources per day.
source String @default("APPLE_HEALTH")
externalSourceVersion String? @map("external_source_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, day, nutrient, source])
@@index([userId, nutrient, day(sort: Desc)])
@@map("nutrient_intake_days")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.27.23";
/* @sw-version-fallback */ "v1.30.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+52 -12
View File
@@ -37,17 +37,9 @@
},
{
"name": "Withings OAuth",
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
"description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.",
"required": false,
"variables": [
{
"name": "WITHINGS_CLIENT_ID",
"purpose": "Withings OAuth client_id from https://developer.withings.com."
},
{
"name": "WITHINGS_CLIENT_SECRET",
"purpose": "Withings OAuth client_secret."
},
{
"name": "WITHINGS_WEBHOOK_SECRET",
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
@@ -86,6 +78,45 @@
}
]
},
{
"name": "OIDC SSO login",
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OIDC_ISSUER_URL",
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
},
{
"name": "OIDC_CLIENT_ID",
"purpose": "OIDC confidential client id."
},
{
"name": "OIDC_CLIENT_SECRET",
"purpose": "OIDC confidential client secret."
}
]
},
{
"name": "OIDC SSO options",
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
"required": false,
"variables": [
{
"name": "OIDC_SCOPES",
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
},
{
"name": "OIDC_BUTTON_LABEL",
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
},
{
"name": "OIDC_ONLY",
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
@@ -167,9 +198,18 @@
"name": "BACKUP_S3_ENDPOINT",
"purpose": "S3-compatible endpoint URL."
},
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
{
"name": "BACKUP_S3_BUCKET",
"purpose": "Bucket name."
},
{
"name": "BACKUP_S3_ACCESS_KEY",
"purpose": "S3 access key."
},
{
"name": "BACKUP_S3_SECRET_KEY",
"purpose": "S3 secret key."
},
{
"name": "BACKUP_ENCRYPTION_KEY",
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
+90
View File
@@ -0,0 +1,90 @@
/**
* scripts/repair-google-health-sleep.ts — operator repair for the historic
* Google Health sleep OVER-COUNT.
*
* THE DEFECT (fixed in code from v1.28.18): the per-segment sleep externalId was
* keyed on a VOLATILE anchor (the session's `interval.endTime` plus a positional
* segment index). When Google re-scored a night after the fact — which the sync
* deliberately re-fetches on a 24 h overlap — the shifted anchor/index minted
* FRESH externalIds, so the upsert created a SECOND parallel set of stage rows
* for the same night instead of overwriting. The night-total then summed both
* copies (a real 7h35 night could read as 10h+).
*
* THE CODE FIX anchors the externalId on Google's stable resource `name` plus
* the segment's own start, and — via `replaceStaleGoogleHealthSleep` —
* soft-deletes any stale row left in a re-fetched night's window before the
* fresh set upserts. That makes every FUTURE sync idempotent and self-healing
* for any night inside the incremental overlap.
*
* THIS SCRIPT heals HISTORY: nights older than the incremental window keep their
* duplicate rows until re-fetched. It runs a FULL Google Health re-sync per
* connected user, which re-imports every sleep session and lets the
* replace-by-window cleanup collapse each night to a single canonical set (and
* re-folds the rollup tier). It reuses the production sync path verbatim — no
* bespoke SQL — so it can only do what an ordinary re-sync does.
*
* SAFETY: dry-run by default (lists the connections that WOULD be re-synced and
* exits). Pass `--run` to execute. Runs users sequentially to avoid a burst
* against Google's API. A per-user failure is reported and skipped, never fatal.
*
* RUN (never `pnpm tsx` — the standalone image strips tsx):
* pnpm dlx tsx scripts/repair-google-health-sleep.ts # dry run
* pnpm dlx tsx scripts/repair-google-health-sleep.ts --run # execute
*/
import { prisma } from "@/lib/db";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
async function main(): Promise<void> {
const execute = process.argv.includes("--run");
const connections = await prisma.googleHealthConnection.findMany({
select: { userId: true },
});
if (connections.length === 0) {
console.log("No Google Health connections found — nothing to repair.");
return;
}
console.log(
`${connections.length} Google Health connection(s) found.` +
(execute
? " Running a full re-sync per user (sequential)…"
: " Dry run — pass --run to execute. Would re-sync:"),
);
if (!execute) {
for (const { userId } of connections) console.log(` • ${userId}`);
console.log(
"\nA full re-sync re-imports all Google Health data for each user and " +
"collapses each re-scored night to one canonical set. Re-run with --run.",
);
return;
}
let ok = 0;
let failed = 0;
for (const { userId } of connections) {
try {
const imported = await syncUserGoogleHealth(userId, { fullSync: true });
ok += 1;
console.log(` ✓ ${userId} — re-synced (${imported} rows imported)`);
} catch (err) {
failed += 1;
console.error(
` ✗ ${userId} — re-sync failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
console.log(`\nDone. ${ok} re-synced, ${failed} failed.`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+62 -8
View File
@@ -168,7 +168,9 @@ async function main() {
// "polarUserIdEncrypted" "polarClientIdEncrypted"
// "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
// "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
// "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
// "ouraClientSecretEncrypted" "stravaClientIdEncrypted"
// "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
// "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
const userFields = [
"codexAccessTokenEncrypted",
@@ -195,10 +197,13 @@ async function main() {
"ouraRefreshTokenEncrypted",
"ouraClientIdEncrypted",
"ouraClientSecretEncrypted",
"stravaClientIdEncrypted",
"stravaClientSecretEncrypted",
"stravaAccessTokenEncrypted",
"stravaRefreshTokenEncrypted",
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"aiOpenaiKeyEncrypted",
"aiOcrKeyEncrypted",
"insuranceNumberEncrypted",
// v1.23 — TOTP shared secret (second factor).
"totpSecretEncrypted",
@@ -241,9 +246,14 @@ async function main() {
// ───── AppSettings — operator credentials (String) ─────
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
// "adminCodexAccessTokenEncrypted" "adminCodexRefreshTokenEncrypted"
// "adminCodexAccountIdEncrypted"
for (const field of [
"adminAiKeyEncrypted",
"webPushVapidPrivateKeyEncrypted",
"adminCodexAccessTokenEncrypted",
"adminCodexRefreshTokenEncrypted",
"adminCodexAccountIdEncrypted",
]) {
results.push(
await rotateStringColumn("AppSettings", field, prisma.appSettings),
@@ -530,6 +540,18 @@ async function main() {
prisma.extractedFact,
),
);
// The short background document summary. Small `encrypt()`-string-as-UTF-8
// Bytes payload (never the multi-megabyte original blob — the select is
// scoped to id + this column), so the generic Bytes walk covers it. NULL on
// rows uploaded with auto-read OFF / no provider — `rotateBytesColumn` skips
// those.
results.push(
await rotateBytesColumn(
"InboundDocument",
"summaryEncrypted",
prisma.inboundDocument,
),
);
// ───── v1.27.22 document content-search index (Bytes text + re-tokenise) ─────
// The blind content index carries TWO coupled artefacts under the index key
@@ -551,9 +573,19 @@ async function main() {
errors: 0,
};
let cursor: string | null = null;
// v1.27.33 (Document vault P4) — re-encrypt the string-shaped BYTEA into a
// fresh Uint8Array under the active key. Shared by `textEncrypted` and the
// nullable sibling `verbatimTextEncrypted` (same codec).
const reEncryptBytes = (asString: string): Uint8Array => {
const reEnc = encrypt(decrypt(asString));
const encoded = Buffer.from(reEnc, "utf8");
const nextBytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));
nextBytes.set(encoded);
return nextBytes;
};
for (;;) {
const rows = await prisma.documentContentIndex.findMany({
select: { id: true, textEncrypted: true },
select: { id: true, textEncrypted: true, verbatimTextEncrypted: true },
orderBy: { id: "asc" },
take: 100,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
@@ -567,14 +599,23 @@ async function main() {
if (!shouldRotate(asString)) continue;
try {
const plaintext = decrypt(asString);
const reEnc = encrypt(plaintext);
const encoded = Buffer.from(reEnc, "utf8");
const nextBytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));
nextBytes.set(encoded);
const nextBytes = reEncryptBytes(asString);
const searchTokens = tokeniseAndHash(plaintext);
// The "verbatimTextEncrypted" column (nullable; written together with
// "textEncrypted" so it shares the same key) rotates in the same
// update when present.
const verbatimBuf = row.verbatimTextEncrypted as Uint8Array | null;
const verbatimNext =
verbatimBuf && verbatimBuf.byteLength > 0
? reEncryptBytes(Buffer.from(verbatimBuf).toString("utf8"))
: undefined;
await prisma.documentContentIndex.update({
where: { id: row.id },
data: { textEncrypted: nextBytes, searchTokens },
data: {
textEncrypted: nextBytes,
searchTokens,
...(verbatimNext ? { verbatimTextEncrypted: verbatimNext } : {}),
},
});
result.rotated++;
} catch (err) {
@@ -590,6 +631,19 @@ async function main() {
results.push(result);
}
// ───── Document preview thumbnails (Bytes column) ─────
// The small JPEG preview, stored as the `encrypt()`-string-as-UTF-8 Bytes
// shape (base64 of the JPEG) — the same codec as the content index — so the
// shared Bytes walk re-encrypts it under the active key. NOT NULL, so every
// thumbnail row rotates.
results.push(
await rotateBytesColumn(
"DocumentThumbnail",
"thumbnailEncrypted",
prisma.documentThumbnail,
),
);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveKey } from "@/lib/i18n/resolve-key";
import {
ILLNESS_SCAN_TYPES,
FUNCTIONAL_IMPACT_RETURN_KEY,
} from "@/lib/illness/correlation";
import {
illnessTypeEnum,
illnessLifecycleEnum,
} from "@/lib/validations/illness";
import {
allergyCategoryEnum,
allergyTypeEnum,
allergySeverityEnum,
allergyStatusEnum,
} from "@/lib/validations/allergy";
import { familyRelationshipEnum } from "@/lib/validations/family-history";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
/**
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
*
* The three existing i18n guards each see one face of the problem and miss the
* fourth:
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
* from the subtree is invisible.
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
* fires when a key is present in SOME locale but not all.
*
* None of them assert the class that actually ships broken: "for enum E, every
* member's dynamically-built key resolves, non-empty, in every locale." Because
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
* (v1.28.41) ship absent from every bundle.
*
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
* every `prefix + member` resolves. Adding an enum member without a bundle key
* fails CI by construction — no hand-maintained floor to drift.
*
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
* - illness.type.* illnessTypeEnum
* - illness.lifecycle.* illnessLifecycleEnum
* - records.allergies.category.* allergyCategoryEnum
* - records.allergies.type.* allergyTypeEnum
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
* - records.allergies.status.* allergyStatusEnum
* - records.family.relationship.* familyRelationshipEnum
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
*
* ── Deliberately EXCLUDED (would produce false positives) ──
* - `insights.workouts.sport.*` — the Strava render path stores a raw
* `sport_type` and the render site legitimately falls back to that raw
* string, so a "missing" key is a graceful degradation, not a broken token
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
* Registering them would demand bundle keys for code paths users never hit.
* - `measurementReminders.types.*` — already exhaustively covered by
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
* const lives in a client component, not a lib-level export.
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
* enums the audit flags as candidates; left for a follow-up extension. Add
* them here once their exact prefix→member mapping is confirmed 1:1 (the
* cycle cervix* prefixes and catalog slug maps need per-space verification
* to stay false-positive-free).
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({
locale: f.replace(/\.json$/, ""),
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
string,
unknown
>,
}));
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
interface KeySpace {
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
prefix: string;
/** The finite member set, derived from the SOURCE enum/const. */
members: readonly string[];
}
const REGISTRY: readonly KeySpace[] = [
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
{
prefix: "illness.vital",
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
},
{ prefix: "illness.type", members: illnessTypeEnum.options },
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
{
prefix: "records.allergies.category",
members: allergyCategoryEnum.options,
},
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
{
prefix: "records.allergies.severity",
members: [...allergySeverityEnum.options, "NONE"],
},
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
// records.family.relationship.*
{
prefix: "records.family.relationship",
members: familyRelationshipEnum.options,
},
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
];
describe("dynamic-key exhaustiveness (enum-derived)", () => {
it("discovers all six shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
"es",
"fr",
"it",
"pl",
]);
});
it("every registered key space has a non-empty member set", () => {
for (const space of REGISTRY) {
expect(
space.members.length,
`${space.prefix} resolved to an empty member set — source import broke`,
).toBeGreaterThan(0);
}
});
for (const { locale, messages } of LOCALES) {
for (const space of REGISTRY) {
for (const member of space.members) {
const key = `${space.prefix}.${member}`;
it(`resolves ${key} in ${locale}`, () => {
const value = resolveKey(messages, key);
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
"string",
);
expect((value ?? "").trim().length).toBeGreaterThan(0);
});
}
}
}
});
@@ -104,7 +104,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"insights.healthScore.provenance.sources.appleHealth",
"measurements.sourceAppleHealth",
"onboarding.source.more.appleHealth",
"settings.ai.ocrProviderOption.ANTHROPIC",
"settings.ai.providerChain.types.anthropic",
"settings.ai.providerChain.types.codex",
"settings.ai.providerSelect.anthropic",
@@ -0,0 +1,105 @@
/**
* v1.28 — banned-verb guard for the `medications.efficacy.*` namespace.
*
* The medication-efficacy view ("Wirkung") is a medication-adjacent claim, so
* its safety boundary is structural, not left to reviewer vigilance: the DTO
* carries no verdict / score field, and the copy must stay strictly
* descriptive — an association, never "the drug works / is effective", never a
* dose change. This test walks every efficacy string in all six locales and
* fails if any value contains a verdict or dose-advice verb, so a forbidden
* word can never creep into the bundle. Mirrors the `i18n-english-leak-guard`
* flatten + filter + `toEqual([])` pattern.
*
* If this fails: rewrite the offending string to descriptive phrasing (numbers
* + neutral connectives). Do NOT weaken the denylist to pass.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
const MESSAGES = join(__dirname, "..", "..", "messages");
const LOCALES = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
/** Verdict + efficacy-adjective + dose-advice verbs across the six locales. */
const BANNED: RegExp[] = [
// English
/\bworks?\b/i,
/\bworking\b/i,
/\beffective(ly)?\b/i,
/\bineffective\b/i,
/\bcures?\b/i,
/\bcured\b/i,
/\bheals?\b/i,
/\bhealed\b/i,
/\bimprove(d|s|ment)?\b/i,
/\bworsen(ed|s)?\b/i,
/\bbetter\b/i,
/\bworse\b/i,
/\bincrease\b/i,
/\bdecrease\b/i,
/\breduce\b/i,
/\braise\b/i,
/\bsuccess(ful)?\b/i,
// German
/\bwirksam\b/i,
/\bunwirksam\b/i,
/\banschlägt\b/i,
/\bheilt\b/i,
/\bverbessert\b/i,
/\bverschlechtert\b/i,
/\berhöhen\b/i,
/\bsenken\b/i,
/\babsetzen\b/i,
// Spanish
/\bfunciona\b/i,
/\beficaz\b/i,
/\bineficaz\b/i,
/\bmejora\b/i,
/\bempeora\b/i,
// French
/\befficace\b/i,
/\binefficace\b/i,
/\baméliore\b/i,
/\baggrave\b/i,
// Italian
/\bmigliora\b/i,
/\bpeggiora\b/i,
// Polish
/\bskuteczn\w*\b/i,
/\bpoprawia\b/i,
/\bpogarsza\b/i,
];
function flatten(obj: unknown, prefix: string, out: [string, string][]): void {
if (obj === null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.push([key, v]);
else flatten(v, key, out);
}
}
describe("medications.efficacy — banned verdict/advice verbs", () => {
it.each(LOCALES)("%s carries no forbidden verb", (file) => {
const bundle = JSON.parse(readFileSync(join(MESSAGES, file), "utf8")) as {
medications?: { efficacy?: unknown };
};
const efficacy = bundle.medications?.efficacy;
expect(efficacy, `${file} is missing medications.efficacy`).toBeDefined();
const flat: [string, string][] = [];
flatten(efficacy, "medications.efficacy", flat);
expect(flat.length).toBeGreaterThan(0);
const violations = flat
.filter(([, value]) => BANNED.some((re) => re.test(value)))
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(
violations,
`Forbidden verdict/advice verb in ${file} — the efficacy view is ` +
`association-only. Rewrite to descriptive phrasing:\n` +
violations.map((v) => ` ${v}`).join("\n"),
).toEqual([]);
});
});
@@ -86,3 +86,42 @@ describe("proxy.ts document serve-route framing carve-out", () => {
);
});
});
describe("proxy.ts share document serve-route framing carve-out", () => {
const shareToken = "hls_000000000000000000000000000000000000000000000000";
it("allows same-origin framing on the /c/<token>/d/<id> serve path", () => {
const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`));
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toContain("frame-ancestors 'self'");
expect(csp).toContain("default-src 'none'");
// No sandbox (Chromium downloads sandboxed PDFs) and no script sources.
expect(csp).not.toMatch(/sandbox/);
expect(csp).not.toMatch(/script-src/);
});
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
const res = proxy(makeRequest(`/c/${shareToken}`));
// The clinician view is a PAGE — it must not be framable and keeps the
// page CSP; only the blob subresource under it takes the serve posture.
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
);
});
it("does not match nested, suffixed, or lookalike share serve paths", () => {
for (const path of [
`/c/${shareToken}/d/doc123/extra`, // extra trailing segment
`/c/${shareToken}/d/doc123x/more/deep`, // deep suffix
`/c/${shareToken}/dx/doc123`, // wrong segment name
`/c/${shareToken}/d`, // missing id
`/c/${shareToken}/d/`, // empty id
"/c//d/doc123", // empty token (traversal-ish)
]) {
const res = proxy(makeRequest(path));
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
}
});
});
@@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => {
// Same outer chrome + min-height floor as the real TrendCard slot.
expect(html).toContain("rounded-xl");
expect(html).toContain("min-h-[8rem]");
// Reduced motion honoured via the Skeleton primitive.
expect(html).toContain("motion-reduce:animate-none");
// Reduced motion honoured via the Skeleton primitive's shimmer class
// (its prefers-reduced-motion guard lives beside the keyframes in
// globals.css).
expect(html).toContain("skeleton-shimmer");
});
});
+30 -49
View File
@@ -5,19 +5,16 @@ import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
* The legacy opt-in `DashboardHero` (gated on a layout `heroVisible`
* flag) was retired: the digest-driven `TodayHero` is the single,
* unambiguous hero, mounted above the tile strip. Pinned here:
*
* - it gates on the layout's `heroVisible` flag (only the literal
* `true` renders — the hero is opt-in) AND on snapshot mode (the
* verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph lives in the hero when that renders; when
* the hero does NOT render (snapshot flag off, or hidden via the
* layout toggle) the header takes it back behind `showGreeting`,
* fed from the page's hero gate — the greeting never disappears.
* - the page mounts `TodayHero` and no longer references the legacy
* hero, its skeleton, or the `heroVisible` gate;
* - the greeting paragraph lives in the header on every mount (the
* header owns it unconditionally now that the legacy hero — which
* once carried it — is gone), while the page itself derives no
* welcome text.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
const HEADER_PATH = join(
@@ -28,60 +25,44 @@ const HEADER_PATH = join(
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
);
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
});
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
expect(matches).toHaveLength(1);
});
it("the hero sits between the header and the strip", () => {
const headerIdx = src.indexOf("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(heroIdx).toBeGreaterThan(-1);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
expect(src).not.toContain("heroVisible");
});
it("the page derives no welcome text (the header owns the greeting)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
describe("dashboard hero — header greeting", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
// The greeting renders here ONLY when the hero band does not — the
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{\s*showGreeting\s*\?/);
it("renders the greeting unconditionally (no showGreeting gate)", () => {
// The greeting line renders on every mount — the legacy hero that
// once owned it is gone, so there is no `showGreeting` prop to gate
// it. The `min-h-5` line-box reservation keeps the header stable
// through the post-hydration name swap.
expect(header).not.toContain("showGreeting");
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
it("the page feeds showGreeting from the inverted hero gate", () => {
it("the page mounts the header without a greeting prop", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
expect(src).toContain(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
});
it("the header keeps the title + customize + add actions", () => {
@@ -77,10 +77,10 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
// moved off the page with the greeting itself: the greeting now
// lives in the header (`dashboard-header.tsx`), so the page no
// longer instantiates any `Intl.DateTimeFormat` for the greeting —
// per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
@@ -108,8 +108,12 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
// plus the v1.20.0 sleep / steps / glucose flips are tile-visible AND
// tile-capable; medications + recentWorkouts are tile-visible in the
// stored layout but paint NO strip tile, so they must not inflate the
// silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(11);
// silhouette. bp paints sys + dia = 2. v1.28.52 added seven vitals +
// body-composition strip tiles (hrv, oxygenSaturation, respiratoryRate,
// wristTemperature, muscleMass, totalBodyWater, boneMass), each
// single-count: 11 + 7 = 18. v1.29 added the fluid-intake strip tile
// (waterIntake), single-count: 18 + 1 = 19.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(19);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
+22 -10
View File
@@ -13,7 +13,6 @@ import {
Heart,
KeyRound,
Languages,
Loader2,
Lock,
LogIn,
Moon,
@@ -33,6 +32,8 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -271,16 +272,12 @@ export default function AchievementsPage() {
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
const { data, isLoading } = useAchievementsQuery({
const { data, isLoading, isError, refetch } = useAchievementsQuery({
enabled: isAuthenticated && achievementsEnabled,
});
if (authLoading || (achievementsEnabled && isLoading)) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
@@ -302,6 +299,21 @@ export default function AchievementsPage() {
);
}
// A failed load must never fall through to the zeroed summary tiles + the
// "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
// retry instead.
if (isError) {
return (
<div className="space-y-6">
<PageHeader
title={t("achievements.title")}
description={t("achievements.subtitle")}
/>
<QueryErrorCard onRetry={() => refetch()} />
</div>
);
}
const summary = data?.summary;
const achievements = data?.achievements ?? [];
const grouped = groupByCategory(achievements);
@@ -322,7 +334,7 @@ export default function AchievementsPage() {
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.points")}
</p>
@@ -342,7 +354,7 @@ export default function AchievementsPage() {
aria-label={t("achievements.points")}
/>
</div>
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.unlocked")}
</p>
@@ -356,7 +368,7 @@ export default function AchievementsPage() {
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
</p>
</div>
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
<p className="text-muted-foreground text-sm">
{t("achievements.nextGoal")}
</p>
Loaded 100 of 1085 files, more files were not shown because too many files have changed in this diff. Show more