Compare commits

..
572 Commits
Author SHA1 Message Date
Marc-André Bombeck a5cd4b15b9 Merge pull request #366 from MBombeck/release/v1.21.2.1
release/v1.21.2.1 — Coach + briefing provider hotfix
2026-06-27 10:41:23 +02:00
Marc-André Bombeck 5142582d3d chore(release): v1.21.2.1
Hotfix: the Coach and daily briefing failed for ChatGPT-provider accounts —
a provider tool-call rejection surfaced as a 500. Provider failures now
degrade gracefully, the Codex tool-call wire format is corrected, and the
provider error body is captured for diagnosis. Also: the thinking bubble
shows only the animated dots.
2026-06-27 10:33:11 +02:00
Marc-André Bombeck 61e21ea28b fix(coach): show only the animated dots while the Coach is thinking
The pre-stream thinking bubble dropped the visible "Thinking…" word and now
renders just the three bouncing dots. The label stays in an sr-only span so
the thinking state is still announced to screen readers.
2026-06-27 10:33:11 +02:00
Marc-André Bombeck 6a0f150218 merge: hotfix/coach-codex-400 into release/v1.21.2.1 2026-06-27 10:28:36 +02:00
Marc-André Bombeck 8b7648bdd0 fix(coach): never 500 on a provider failure; fix Codex tool-call wire
A Codex backend 400 on the richer Coach tool-call request (and the daily
briefing) propagated out of the provider chain runner unwrapped: a 4xx other
than 401/403/429 was not classified as a hard provider failure, so it bypassed
AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain
status-card completions, which never send a tools array, were unaffected.

Harden the failure path so a provider error can never surface as a 500:
- Treat any 4xx a provider wire returns as a hard failure so it cascades and is
  wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422
  surface) stays the only non-hard class, so that diagnostic is preserved.
- The chat route now also classifies a tagged provider error that reaches its
  catch unwrapped and emits the graceful coach.provider.* SSE frame instead of
  rethrowing. Only an untagged error keeps the 500 + error-reporter path.

Make the Codex 400 diagnosable: fold the redacted upstream response body into
the thrown error message and the chain wide-event, so the field the backend
rejects is visible in logs (it was previously dropped).

Fix the Codex request shape behind the tool-call 400:
- Send the required strict flag on each Responses-API function tool.
- Replay prior assistant turns as output_text, not input_text, per the
  Responses API multi-turn contract — the violation only surfaced on round two
  of the tool loop, which is the path that broke.

Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
2026-06-27 10:26:10 +02:00
Marc-André Bombeck ee704cacab Merge pull request #365 from MBombeck/release/v1.21.2
release/v1.21.2 — ambient Coach presence
2026-06-27 00:56:04 +02:00
Marc-André Bombeck 2c33faa648 chore(insights): align the ambient-Coach file comments to the shipped version
The A1–A3 source files carried v1.22.0 docstrings from before the work was
re-cut as the v1.21.2 point release. Comment-only sweep so blame and the
in-file version tags read consistently with the shipped version.
2026-06-27 00:48:40 +02:00
Marc-André Bombeck 1de72d045e fix(insights): localise the Coach scope pill and clarify the return-to-baseline copy
The "the Coach is already on …" pill resolved its metric label through the
brand-free English domain vocabulary, so it read in English on every
non-English locale. It now prefers the existing localised measurements.type*
name for each measurement-backed source, keeping the English phrase only for
the gait/audio long tail that rarely becomes a scoped launch. Also rewords the
English return-to-baseline line so the day count reads as the in-range run
length rather than the time since.
2026-06-27 00:48:29 +02:00
Marc-André Bombeck 388c8b23dd chore(release): v1.21.2
Surfaces the Coach's existing intelligence as ambient presence: a per-metric
"Coach read", a visible scope handoff and a seeded opener, briefing callbacks,
a tension verdict and return-to-baseline note on the score, two motivational-
interviewing moves, and grounding parity on the local model path. No
migrations, no breaking changes.
2026-06-27 00:37:15 +02:00
Marc-André Bombeck f42ac47268 fix(insights): drop the undeclared server-only import from the coach-read builder
The builder is server-only by construction — only the metric-page route
imports it and it reads Prisma — and no other module in the tree depends on
the `server-only` package, so the import was both redundant and an undeclared
dependency. Removing it keeps the dead-code gate clean without weakening the
boundary.
2026-06-27 00:37:14 +02:00
Marc-André Bombeck 58e11d2414 merge: wave/v1212-i18n (ambient Coach presence keys, 6 locales) into release/v1.21.2 2026-06-27 00:33:35 +02:00
Marc-André Bombeck cbc428c89b fix(insights): gate the ambient Coach routes on the Coach assistant matrix
The metric-page "Coach read" strip and the unscoped-hero seeded opener are
Coach surfaces, so both routes now call requireAssistantSurface("coach")
alongside the insights module gate. With the Coach turned off they return
the same refusal every other Coach route does, instead of lingering as a
"Coach read" line while the assistant is disabled. Restores the coach
route-gate inventory invariant.
2026-06-27 00:33:07 +02:00
Marc-André Bombeck df216e082b i18n: add ambient Coach presence keys across locales 2026-06-27 00:33:00 +02:00
Marc-André Bombeck ea1a0e2829 merge: wave/v1212-wire (server→card wiring for tension/returnToBand/memory) into release/v1.21.2 2026-06-27 00:30:09 +02:00
Marc-André Bombeck 4d05bcf8d9 feat(insights): wire the score-card tension + return-to-baseline and the briefing recall
The hero score card and the daily briefing accept tension, returnToBand, and
memory props, but nothing populated them server-side, so the lines never rendered.

Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides
the same payload):

- tension — fires when the readiness composite's contributors disagree (a strongly
  favourable one alongside a strongly unfavourable one), reusing the readiness
  components the wellness ring grades and the shared disagreement detector.
  Suppressed under a clinical red-flag (a coincident-deviation that no illness
  explains) so the red-flag path is never reconciled into a calm verdict.
- returnToBand — runs the streak detector across the salient vitals and surfaces
  at most one metric that has come back inside its own personal range after a
  genuine prior out-of-band run.
- memory — maps the machine-derived coach memory block to the briefing's recall +
  forward-look.

The blocks are locale-agnostic (contributor keys + a metric type); the insights
hero localises them through the existing readiness-contributor and metric-name
labels before handing the cards their resolved strings. Each value omits rather
than guesses. The snapshot cache key now carries the locale for the recall prose.
2026-06-27 00:28:56 +02:00
Marc-André Bombeck 6c1a4b7f45 merge: wave/v1220-narrative into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck f5195cfcda merge: wave/v1220-coachui into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck 089a5254aa merge: wave/v1220-strip into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck 03ef9a0b6f merge: wave/v1220-grounding into release/v1.21.2 2026-06-26 23:58:50 +02:00
Marc-André Bombeck fe6c2e281b feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves
Add a deterministic narrative layer the Coach narrates rather than invents:

- Briefing callbacks + forward-looks: the daily briefing recalls the prior
  period and points ahead, anchored to a figure, one callback per surface.
- Tension Verdict: when the readiness composite's contributors disagree
  (good sleep but a rising resting pulse), surface a brief honest internal
  read on the health-score card. A real clinical red-flag is never reconciled
  away into a calm verdict.
- Streaks + return-to-baseline: a pure detector over a DAY-bucket series
  closes a worry once a metric returns inside the personal MAD band after a
  genuine out-of-band run; a gap day breaks a streak.
- Coach prompt: add develop-discrepancy, roll-with-resistance, and an
  anti-persuasion clause; ratchet PROMPT_VERSION.

The tension flag and streak event are computed server-side; the card lines
render resolved fields and the model narrates them.
2026-06-26 23:57:37 +02:00
Marc-André Bombeck 9136a9bdba feat(coach): make launch scope visible and seed the blank state
Replace the hidden composer prefill with a visible scope affordance: when
the Coach is launched narrowed to a metric, the hero shows a scope pill
naming that metric plus a tappable seed question, and the drawer sources
rail surfaces the same scope line. When the Coach opens unscoped, resolve
today's most notable derived signal server-side and offer it as a tappable
opener; fall back to the neutral greeting when no signal crosses the gate,
so the blank box is never cold and never fabricated.

Add an internal GET /api/insights/coach/seeded-question that reuses the
briefing's derived-signal detector to select the opener server-side; the
client renders the resolved DTO.
2026-06-26 23:56:56 +02:00
Marc-André Bombeck cbf903841c feat(insights): add Coach-read strip above metric charts
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.

The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.

Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
2026-06-26 23:56:28 +02:00
Marc-André Bombeck 92243997f6 fix(coach): run the prose number-verifier on the no-tools path
The post-hoc numeric verifier only graded the tool path, so local /
no-tools providers got no grounding net. Grade the no-tools prose
against the snapshot the model was shown — its structured sections,
which already carry the correlations-snapshot block — and flag any
cited number that traces to no snapshot leaf. Non-blocking: the count
is annotated under coach.prose.number_unverified and the unverified
token soft-stripped, never a stream-killer. Only runs when the full
figures were delivered this turn; a cheap follow-up has no fresh set
to grade and no-ops. Tool-path behaviour and tolerance unchanged.
2026-06-26 23:52:18 +02:00
Marc-André Bombeck 096a486bcc Merge pull request #364 from MBombeck/release/v1.21.1
release/v1.21.1 — dialog footer reachability
2026-06-26 23:03:22 +02:00
Marc-André Bombeck f3ffafaae1 chore(release): v1.21.1
Patch release. Pins the action row in the centred dialog so the primary
button stays reachable without scrolling under long forms, browser zoom, or
display scaling. No migrations, no breaking changes.
2026-06-26 22:55:37 +02:00
Marc-André Bombeck d13dff93f2 fix(ui): pin dialog footer so the primary action stays in view
The desktop branch of ResponsiveSheet rendered DialogContent as a single
overflow-y-auto grid, so a tall form — or one made tall by browser zoom,
display scaling, or a longer-language label — scrolled its header, body,
and footer together and could push the primary button below the fold. The
branch now runs as a flex column capped at max-h-[calc(100dvh-2rem)], holds
the header and footer shrink-0, and scrolls only the body, matching the
bottom-sheet branch that already pinned its footer. Every form mounted
through the shared surface inherits the fix.

Add a desktop end-to-end guard at a short viewport asserting the body
scrolls and the footer's bottom edge stays within the viewport.
2026-06-26 22:55:31 +02:00
Marc-André Bombeck 85a0819eac Merge pull request #363 from MBombeck/release/v1.21.0
Release v1.21.0
2026-06-26 14:43:40 +02:00
Marc-André Bombeck 5411bb3e4c test(e2e): measure the date-field wrapper for the 44px target sweep
The DateField/DateTimeField overlay input sits inside the wrapper's
border (~42px); the 44px tap target is the wrapper. Measure the closest
date-field wrapper so the sweep reflects the real affordance.
2026-06-26 14:35:40 +02:00
Marc-André Bombeck e366ebd8cf test(e2e): exempt visually-hidden inputs from the 44px target sweep
DateField and DateTimeField front an sr-only native date input with a
formatted overlay carrying the 44px target, matching the file-upload
pattern the sweep already exempts. Generalise the exemption to any
visually-hidden or zero-size input.
2026-06-26 14:26:14 +02:00
Marc-André Bombeck e818178be8 test(coach): seed the budget-refusal integration test at the user-plan cap
The daily cap is now provider-aware; the seeded chain resolves to a
user-egress provider, so the refusal must be seeded at USER_PLAN_CAP
rather than the smaller operator cap to trip the gate.
2026-06-26 14:17:36 +02:00
Marc-André Bombeck 5f640e1420 refactor(coach): collapse the duplicate daily-cap export to OPERATOR_COST_CAP
The operator-cost cap carried two export names for one value; keep the
accurate one (it gates only the operator-key path) and drop the alias.
2026-06-26 14:08:35 +02:00
Marc-André Bombeck e6ac604c83 chore(release): v1.21.0 2026-06-26 14:03:51 +02:00
Marc-André Bombeck 8c5ef5df10 fix(medications): render intake datetime fields through the format-aware picker
The three intake dialogs (log, edit, dose-history add) still used a raw
datetime-local input, so they ignored the user's date-format preference.
Route them through DateTimeField like every other date surface. Also
correct a stale diastolic-floor comment in the dashboard verdict.
2026-06-26 14:03:39 +02:00
Marc-André Bombeck 1cee07b9f0 merge(INTEGFIX): wire compliance/symptom FDR channels into the Coach correlations reader 2026-06-26 13:50:48 +02:00
Marc-André Bombeck 57682f8df1 fix(coach): build compliance + symptom channels in get_correlations
The correlation discovery matrix gained two non-MeasurementType channels
(medication compliance, symptom severity), but the Coach get_correlations
reader still filtered its measurement query by hand and never built the new
channels. The channel keys leaked into the Postgres enum cast and threw for any
user with medication or illness data.

Extract the route's two channel-series fetchers into a shared module
(correlation-channel-series.ts) so both consumers build the channels identically,
then wire the reader to derive its type filter from discoveryMeasurementTypes and
fold the fetched compliance + symptom series into the discovery input. The reader
now humanises the channel keys to "medication adherence" / "symptom severity".
2026-06-26 13:50:17 +02:00
Marc-André Bombeck 1fa02ce4cf merge(FDREXTEND): compliance + symptom as FDR correlation channels 2026-06-26 13:42:51 +02:00
Marc-André Bombeck aad72ea0e1 feat(insights): add compliance and symptom severity to the correlation matrix
Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.

Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.

Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
2026-06-26 13:42:06 +02:00
Marc-André Bombeck 21a5eade41 merge(COACHFINAL): coach-core finalization — safety/scope/outlook/illness/learn/perf 2026-06-26 13:41:19 +02:00
Marc-André Bombeck 473b35e997 Guard that get_labs carries each reading's value and unit
The labs block already surfaces value + unit + range + date, and the Coach
prompt instructs it to quote them verbatim, so labs are cited end-to-end — no
gap. Pin that with a tool-level guard asserting value and unit survive
get_labs, so a future change to the labs pass-through can't quietly strip the
number the Coach needs to cite.
2026-06-26 13:39:21 +02:00
Marc-André Bombeck d5cb1e4ef8 Stop the Coach inventing a /learn link
The Coach may point at a published /learn guide, but only a slug in the
catalog. That was a prompt instruction with no enforcement — a model could
hallucinate /learn/lower-your-cortisol and ship a dead link. Add a deterministic
post-filter on the assembled reply that keeps a published slug verbatim and
strips any reference whose slug is not in the catalog, tidying the prose around
the removed link. Makes the catalog's impossible-by-construction claim an
enforced guarantee.
2026-06-26 13:38:31 +02:00
Marc-André Bombeck 976ecf8a75 Surface the computed illness retrospective to the Coach
The illness-recovery tool and the illness context block carried only labels,
lifecycle, and dates plus the recovery composite — never the recovery-gap, the
metric that drove it, the nadir, the pre-onset deviations, or the red flags the
illness card renders. So a recovery question got the composite, not the gap the
user sees, and a sustained-fever or low-SpO2 escalation was invisible
in-conversation.

Run the existing episode-correlation read-layer (the same engine the card and
the red-flag notifier use) for the most relevant episode — active first, else
the most-recently-resolved — and attach the computed scores to get_illness_recovery,
coverage-gated so a thin signal yields nothing rather than a fabricated number.
Document the illnessScores shape in the Coach prompt so it restates those
numbers verbatim and escalates a red flag rather than reassuring.
2026-06-26 13:36:59 +02:00
Marc-André Bombeck abceff04c7 Build the Coach snapshot once per turn and widen the round budget to three
Each retrieval tool re-scoped the snapshot to a single source, so its cache key
never matched the inventory's full-source build — a four-tool turn paid four
extra full snapshot builds on top of the inventory's. Thread the inventory's
probe scope to every tool so a per-tool read lands the one cache entry the
inventory already primed, collapsing the turn to a single build (a window
override still gets its own correct build). Raise the loop's round cap from two
to three so a sequential cross-metric why-chain isn't starved; the per-round
token budget is unchanged and the absolute ceiling tracks one above so the
final round still forces prose.
2026-06-26 13:32:42 +02:00
Marc-André Bombeck d45d60627f Narrow the tool-mode Coach to the metric it was launched from
The tool-mode path dropped the launch scope's sources: the inventory probes the
full source set, so a Coach opened from a metric page saw the whole inventory
and could roam, while only the no-tools path honoured the narrowing. Inject a
one-line FOCUS hint naming the launched domain(s) into the tool-mode user turn
so the model prioritises that metric and fetches its figures first, with every
other domain still reachable if the question leads there. Empty on a generic
open, so the prompt prefix is unchanged on that path.
2026-06-26 13:25:35 +02:00
Marc-André Bombeck 1344b56faa merge(DSTMIG): rollup x-rescale, un-quarantine the regression parity test 2026-06-26 13:25:07 +02:00
Marc-André Bombeck 8265abedff fix(rollups): rebase regression x-axis to a fixed origin for exact accumulators
The per-bucket OLS accumulators stored x on the raw epoch-day axis (x ~ 20540),
so sum_xx accumulated past ~1e10 and the square shed ~10 of a double's
significant digits before the value was ever stored. The sub-day x detail was
lost at accumulation time, which no read-side identity can recover; an
ill-conditioned window's composed slope/r2 then drifted from the live REGR_*
probe past the parity gauge.

Rebase x to a fixed origin (epoch-days of 2020-01-01) at write time so the
squared terms stay O(1e7) and exact. Slope, r2, and population sd are invariant
under the affine x-shift, so the live raw-epoch probe still parity-matches the
cross-bucket compose; composeRegression needs no formula change. The per-bucket
slope/r2 columns stay on the raw axis (shift-invariant).

Migration 0193 recomputes sum_x/sum_xy/sum_xx from measurements on the rebased
basis, one set-based update per granularity. Recompute-from-source is idempotent
and rerun-safe; it touches only rows that already carry the accumulators and
leaves pre-migration NULLs for the boot re-fold.

Un-quarantine the DST-boundary parity test: it now matches live REGR_* within
the existing 1e-9 relative bound, with the WEIGHT case still bit-identical.
2026-06-26 13:24:30 +02:00
Marc-André Bombeck cf331dbef0 Bind Coach safety thresholds to clinical floors and add an outlook contract
The acute red-flag clause hardcoded its blood-pressure numbers as prose
literals and named no glucose or fever floor at all, so the Coach could
silently drift from the dashboard hero and the notification engine. Compose
the systolic/diastolic, glucose, and sustained-fever thresholds straight from
clinical-floors.ts and echo the previously-missing glucose and fever lines, so
every acute number the Coach states is bound to the one source of truth.

Add an outlook contract (gentle forecast, what-to-expect, anticipatory if-then
— all conditional, ranged, association-framed) composed beside the tone
contract on the Coach and the comprehensive briefing, so a reply can sharpen
expectations without a false promise. Tighten the tone contract against
over-validation (affirm the effort, not an unsafe choice; stay neutral when
there is nothing to praise) and route why/conflict questions through
get_correlations and its coincident-deviation flag before answering.

Coverage asserts the clause numbers equal the constants and the new contract
reaches both surfaces.
2026-06-26 13:24:05 +02:00
Marc-André Bombeck 03e1935c8a merge(LEARNUI): Learn-links registry, component, and surface wiring 2026-06-26 13:23:28 +02:00
Marc-André Bombeck 5b22d69362 merge(ILLNESSFIX): calendar-adjacency symptom return + SpO2 local sort 2026-06-26 13:22:57 +02:00
Marc-André Bombeck 7093e46e41 merge(WS3PERF): A2-M2 boundary align + Withings/med-intake N+1 fixes 2026-06-26 13:22:35 +02:00
Marc-André Bombeck 8c58b5b1c7 Wire Learn pointers into vitals, glucose, resilience, and lab surfaces
Add a single discreet LearnMoreLink to the per-vital baseline tiles
(via a new footer slot on the shared tile), the glucose clinical panel,
the resilience tile, and the lab biomarker detail. Each pointer is
registry-resolved and renders nothing when the concept has no mapped
guide, so only surfaces with a real article show one.
2026-06-26 13:22:33 +02:00
Marc-André Bombeck 6ed5b55f3d Add discreet LearnMoreLink pointer component
A small, calm anchor that links a concept out to its public /learn
guide. Fail-closed: the href is registry-backed (a closed-set value,
never user input) and an unmapped concept renders nothing. Plain
anchor, no markdown. Adds a common.learnMore label across all locales.
2026-06-26 13:22:27 +02:00
Marc-André Bombeck fe6d4c10df Add typed Learn-article link registry
Promote the static /learn guide catalog into a typed concept-to-slug
lookup the deterministic UI surfaces can consume. learnUrl is the only
sanctioned /learn URL builder and learnLinkForMetric fails closed for
an unmapped id. A test asserts every mapped slug resolves in the
catalog, so the mapping can never point at a guide that does not exist.
2026-06-26 13:22:21 +02:00
Marc-André Bombeck db7306f9a5 fix(illness): sort the SpO2 red-flag day-points locally
The SpO2 sustained-low scan passed episodeDays straight to runFlag, relying
on the reader to deliver them in day order, while the fever path already
sorts its unioned series. The run scan is calendar-consecutive, so feed it
chronological input by construction: sort the filtered SpO2 points before
the scan, matching the fever path and removing the latent ordering coupling.
2026-06-26 13:22:02 +02:00
Marc-André Bombeck f37d935e54 fix(illness): require calendar-adjacency for the stable-return run
Both lastStableReturn and computeSymptomReturn counted the in-band settle
run by array-index adjacency instead of calendar days, so three sparse logs
spread over weeks could register a stable return and move the headline
recovery gap. Extend the run only while the prior point is in-band and the
calendar day immediately before, matching the runFlag rule, bounding the
run's span to its logged-day count.
2026-06-26 13:21:45 +02:00
Marc-André Bombeck 9dcdf94db3 perf(medications): batch the intake slot-dedup reads
The slot-dedup pass ran a findFirst (latest takenAt) plus a findMany
(all live rows) per medication — 2N round-trips, 40 for a 20-med user.
Pull every live intake row for the user's medication set in one ordered
findMany, group in memory by medicationId, and derive each medication's
lastIntakeAt (max non-null takenAt) from the same grouped slice the
findFirst used to scan. The read cost is now one query regardless of the
medication count.

Behaviour is unchanged: the grouped rows arrive in the same
scheduledFor-asc order the heal and snap passes depend on, and the
derived lastIntakeAt matches the old desc-ordered findFirst.
2026-06-26 13:21:23 +02:00
Marc-André Bombeck 92fc16448a chore(ui): drop the now-unused native date-input primitives
Every callsite moved to DateField/DateTimeField, which honour the
user's date-format preference; the native DateInput/DateTimeInput
wrappers have no remaining importers.
2026-06-26 13:19:44 +02:00
Marc-André Bombeck f854b16e86 merge(DF3): date-format rollout completion + DateTimeField 2026-06-26 13:18:56 +02:00
Marc-André Bombeck be7c5c5f5a perf(withings): batch the activity sync reads and writes
The activity sync ran a per-(entry, field) findFirst followed by an
update or create, roughly entries x 3 x 2 round-trips — about 2,190 on
a 365-day backfill. Flatten the entry/field grid into the concrete rows
the sync wants to land, resolve every overwrite-vs-insert decision in
one findMany keyed on the same four dedup columns, then run a single
createMany for fresh slots and one transaction for the changed-value
overwrites. A backfill is now a bounded handful of queries regardless of
the day count, and an unchanged re-sync is a no-op on the write path.

The overwrite contract is unchanged: an existing WITHINGS row for a
(type, measuredAt) slot keeps its row and only rewrites value; a fresh
slot inserts with the stable externalId. createMany uses skipDuplicates
so a concurrent sibling sync landing the same slot is tolerated by the
four-column unique.
2026-06-26 13:18:54 +02:00
Marc-André Bombeck 01b24c4d73 feat(date): add DateTimeField and migrate datetime inputs
Add a dependency-free DateTimeField that paints the date part in the
user's date-order preference and the time part in the hour-cycle
preference over a hidden native datetime-local input, preserving the
local yyyy-MM-ddTHH:mm value contract. Migrate the seven DateTimeInput
callsites (measurement add/edit, mood add/edit, lab add/edit,
medication intake quick-add) so the chosen format is honoured there too,
and cover the field's value contract and per-preference rendering with a
component test.
2026-06-26 13:17:55 +02:00
Marc-André Bombeck ad74d255e4 merge(UI1): Coach new-chat composer redesign 2026-06-26 13:16:45 +02:00
Marc-André Bombeck 86d917c5e2 fix(insights): align cold-fallback slope window to the rollup day boundary
The rollup read path windows the 7/30/90-day regression on
startOfUtcDay(now - N days), while the live-SQL fallback filtered the
REGR_* aggregates on the wall-clock NOW() - INTERVAL bound. An account
crossing a coverage boundary (warm to cold) therefore saw the boundary
day's readings flip in or out of the slope window depending only on
whether the rollup tier was populated, returning a cache-dependent
slope for the same request.

Anchor both cold-fallback paths on date_trunc('day', NOW() AT TIME ZONE
'UTC') - INTERVAL 'N days', the SQL twin of startOfUtcDay, so warm and
cold return identical boundary-day membership. The avg7/avg30/median
windows stay wall-clock since the warm narrow query computes them the
same way.
2026-06-26 13:16:00 +02:00
Marc-André Bombeck 174c26afc9 refactor(coach): streamline the new-chat page composer
Reduce the full-page Coach new-chat surface to a calmer layout. Drop the
toolbar Conversations and New chat buttons and place the settings gear in
the top-right corner; both removed actions remain in the composer's + menu.
Collapse the greeting to one line and remove the gear from the composer so
its front is only the + actions menu, leaving a single baseline row of
[+] textarea [mic] [send].
2026-06-26 13:15:58 +02:00
Marc-André Bombeck f493ff58cb merge(RECON1): clinical-floors, coincident-tz, correlation quality, local drivers 2026-06-26 13:15:03 +02:00
Marc-André Bombeck 0816b3b0c0 feat(date): migrate remaining date inputs to DateField
Swap the four DateInput callsites (filter bar, export range, schedule
history, onboarding baseline) to DateField so the user's date-order
preference paints every date input. Adapt the onChange from a native
event to the direct ISO value DateField emits, and add an optional
autoComplete prop so the onboarding date-of-birth field keeps its bday
autofill hint.
2026-06-26 13:14:19 +02:00
Marc-André Bombeck b0be36c55b feat(coach): carry discovered drivers in the no-tools snapshot
The FDR cross-metric driver pairs were reachable only through the
get_correlations tool, so a local/no-tools provider got the coincident
flag but zero discovered drivers and the cross-metric layer silently
degraded. The system prompt's fallback to a snapshot driver field
pointed at a field that did not exist on that path.

Attach a bounded top-3 slice of the same gated, ranked driver output the
tool serves to the no-tools snapshot floor, gated on the derived layer
and shed first under prompt-budget pressure. Local users now reach
parity on the flagship cross-metric capability.
2026-06-26 13:12:34 +02:00
Marc-André Bombeck 99a6163343 feat(insights): gate discovered correlations on effect size and confidence
Significance alone surfaced trivial drivers: a large-n pair with r=0.16
narrated as a confident "tends to go with" link while explaining ~2.5%
of variance. Add three gates before a pair is ranked as a driver:

- exclude same-metric-family lagged pairs (mood->mood, BP component
  self-lag) as serial-autocorrelation tautologies, widening the existing
  exact self-pair skip to the whole family.
- shrink each Pearson estimate toward null by n/(n+10) so a thin-data
  correlation cannot out-rank a deep one on an inflated point estimate.
- floor the shrunk effect at 0.2 (drop below) and bind 0.2-0.3 to a
  hedged "faint hint" phrasing tier, 0.3+ to confident phrasing scaled
  by sample depth. The reported r and p stay the honest raw statistics.

Rank by shrunk effect magnitude, q as tie-break.
2026-06-26 13:10:29 +02:00
Marc-André Bombeck b75cef9190 fix(insights): key coincident-deviation 'today' in the user's timezone
The coincident-deviation read grouped the latest day with a UTC date
slice while the sibling readiness read already used the tz-aware day key.
For a user east or west of UTC, a late-evening or early-morning reading
landed on the wrong calendar day, so a fired flag could compare a vital
from the wrong day against its band and narrate '2 or more vitals out of
band today' on the wrong day.

Thread the user's timezone into the latest-day read and mint the day key
with userDayKey, matching readiness. The Coach derived-snapshot and the
correlations reader pass the real account timezone through. Adds a
regression covering a UTC+2 user whose late-night reading rolls to the
next local day.
2026-06-26 13:01:39 +02:00
Marc-André Bombeck f2a9716671 fix(safety): unify clinical floors into one source of truth
The hypertensive-crisis floors were defined in three modules with a
divergent diastolic value: the notification engine and the Coach acute
clause used 180/120, but the dashboard hero used 180/110. A reading like
170/112 lit the red critical-BP banner on the hero yet never tripped the
notification alarm or matched the Coach's stated acute number — one
surface said crisis, the other two stayed calm on the same row.

Promote the absolute floors into a dependency-free clinical-floors leaf
and have the hero, the safety-floor engine, the status registry's fever
band, and the illness escalation all import from it. The crisis diastolic
floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the
wider 110 hero net is dropped so the surfaces tell one story. A coverage
test pins that every consumer resolves the same constants.
2026-06-26 12:58:50 +02:00
Marc-André Bombeck 8471384d01 merge(OAUTHFIX): provider-aware AI budget 2026-06-26 12:53:50 +02:00
Marc-André Bombeck 3783348506 fix(coach): make the daily token budget provider-aware
The daily AI-token gate was a flat 25,000/day for every provider. That cap
exists to bound the OPERATOR's API bill, but a ChatGPT-OAuth (Codex) or BYOK
chain egresses on the USER's own plan/key and costs the operator nothing, so
gating it on the operator-cost ceiling is wrong. A single gpt-5.x reasoning
turn legitimately reports 20k-40k gross total_tokens (re-sent system prompt +
inventory + tool defs per round + hidden reasoning, summed across rounds), so
one or two turns exhausted the flat cap and locked the user out of a plan they
pay for.

Classify the chain's cost owner by its primary provider via resolveDailyCap:
an admin-openai primary (operator pays) keeps the operator-cost cap; every
user-egress primary (codex/openai/anthropic/local) gets a generous
abuse-only ceiling. Threaded into the Coach chat gate and both OCR-extract
modes.

Raise the operator-cost cap 25,000 -> 200,000 so the operator-key path also
survives a normal day of reasoning turns, keeping gross-token accounting
rather than reworking the reconcile math.

Subtract cached input tokens at reconcile: the Responses-API gross total still
includes prompt-cached input the user did not re-pay for. The codex client
already parsed cached_tokens; sum it through the tool loop and bill
total_tokens minus cached.
2026-06-26 12:53:04 +02:00
Marc-André Bombeck 9a3049a6cf fix(coach): phrase the daily-limit reset as midnight UTC
The budget-exceeded copy named "00:00 UTC", which reads as a wrong local
clock for any non-UTC user (Berlin midnight UTC is 01:00/02:00 local) and
drove confused "the limit resets at the wrong time" reports. Reword
dailyLimitBody across all six locales to "midnight UTC", matching the
existing errorBudget wording, so the figure is honest regardless of the
reader's timezone.
2026-06-26 12:52:52 +02:00
Marc-André Bombeck 8ed1d27614 chore(openapi): regenerate for the date-format profile field 2026-06-26 12:42:52 +02:00
Marc-André Bombeck 490c9d240a merge(FALLBACK): v1.21.0
# Conflicts:
#	src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
#	src/lib/insights/narrative/period-narrative-generate.ts
2026-06-26 12:40:03 +02:00
Marc-André Bombeck 37dfce2104 merge(COACHUI): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck a5031607d8 merge(DF2): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck f12f349a38 merge(COACHPROMPT): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck a96ae9bc2b merge(COACHDATA): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck 93433c3197 feat(coach): add "ask the Coach about this" to insight cards
Add a discreet, on-brand action to the high-value cards (briefing,
recommendation, correlation, status/assessment, health score, period
narrative) that opens the Coach pre-scoped to the card topic with a
seeded question. The action self-gates on the operator flag and the
per-user opt-out, never tints the card, and surfaces once per card.
2026-06-26 12:38:03 +02:00
Marc-André Bombeck 44796d1c0e feat(coach): carry metric scope into the Coach launch
Make the launch context's metric scope live instead of discarding it.
A conversation opened from a metric surface now narrows its snapshot to
that metric with a data-aware seed question, and the global FAB inherits
the page's ambient scope so drilling into a metric and tapping it no
longer opens a blank chat. The scope threads through the drawer into the
first turn of a fresh conversation.
2026-06-26 12:37:54 +02:00
Marc-André Bombeck ce07548bc4 feat(coach): fold the coincident-deviation flag into the snapshot
The multi-vital coincident-deviation flag (two or more vitals outside
their usual band on the same day, with the illness-explained reframe)
already reached the period narrative but never the Coach. Attach it,
fired-only, to the derived block so both Coach paths can narrate it: the
tool path via the recovery/correlations tools and the no-tools snapshot
floor. A quiet day adds no entry, keeping the snapshot noise-free.
2026-06-26 12:36:14 +02:00
Marc-André Bombeck 58376a0705 feat(coach): verify Coach prose numbers against fetched figures
The Daily Briefing strips any number absent from its server-computed
figures; the Coach's tool path had no equivalent, so a transcription or
paraphrase drift (a tool returns systolic 128, the reply says ~138) could
ship. Collect the numeric leaves from every present tool result this turn,
extract the numbers the reply asserts, and soft-correct any that match no
fetched figure to a neutral placeholder — annotated, non-blocking, and a
no-op on a qualitative turn or the no-tools path where there is nothing to
grade against. The bounded retrieval loop now returns the present results'
payloads so the check has an authoritative figure set.
2026-06-26 12:36:07 +02:00
Marc-André Bombeck 6de1f34efb feat(coach): widen tool-mode data reach to every stored domain
The retrieval-tool DATA INVENTORY was built against the user's default
narration cluster (cardio/body/mood/medication), so sleep, glucose,
activity, body composition, vascular, SpO2, gait and environment series
reported absent even when the data existed — and the grounding rule then
told the model not to fetch them. Probe presence against the full source
set instead, and generate the metric-series inventory rows from the
complete source-to-section map so every series the user has rows for is
advertised. Per-tool reads still re-scope to their own domain, so the
wider probe never widens a figure read.

Add three retrieval tools whose snapshot blocks already existed but were
unreachable in tool mode:
  - get_workouts: recent sessions + per-sport rollup
  - get_cycle: phase / prediction / correlation (gated by cycle access)
  - get_correlations: the FDR-controlled lagged cross-metric drivers plus
    the coincident-deviation flag, surfaced descriptively

Also honour the caller's window in get_labs and get_illness_recovery so a
cross-metric answer no longer silently mixes horizons.
2026-06-26 12:35:57 +02:00
Marc-André Bombeck d22372e800 fix(insights): warm the period retrospective to the shared house voice
The retrospective narrative was the one model surface that omitted the
shared tone contract, so it read colder than the daily briefing beside it.
Compose the tone fragment into both prompts and warm the hand-written tone
line to name a genuine win when the period earns it, while keeping the
descriptive-never-causal and no-alarm guards intact. The cross-surface
coverage test now asserts the contract reaches this surface too.
2026-06-26 12:29:43 +02:00
Marc-André Bombeck 5b406d58cd fix(insights): close the deterministic score assessment with one grounded step
The per-score deterministic text named the score, its standing, and the
weakest contributor, then stopped at the diagnosis. When the band is not
green and the weakest driver is behaviourally addressable (sleep, mood,
consistency, timing), append a single grounded pointer drawn from that same
contributor. Physiology-only drivers add nothing, so the text affirms and
watches rather than manufacturing a step.
2026-06-26 12:29:35 +02:00
Marc-André Bombeck b470a14187 fix(insights): ground deterministic status fallbacks in the user's own numbers
The no-key and timeout fallback text carried zero reference to the user's
own readings and, on a provider timeout, rendered as hasProvider:true —
indistinguishable from a fresh assessment. Compose each fallback from the
per-metric signal the card already builds: name the current value, place it
against the user's own baseline, and close with one plain-language pointer,
in the same warm voice as the model surfaces. Degrade to the prior generic
tip only when a metric has no usable history. The timeout envelope now
reports hasProvider:false so the UI surfaces it as the computed summary it
is rather than mislabelling it as provider prose.
2026-06-26 12:29:29 +02:00
Marc-André Bombeck 291fb30209 test(e2e): target the DateField overlay and ISO mirror in the medication wizard
The course-window date field now keeps its ISO value on a hidden native
input (data-slot) and edits through a text overlay (data-testid). Fill
the overlay, then assert the committed ISO value on the hidden input.
2026-06-26 12:26:17 +02:00
Marc-André Bombeck bf58d5d0b3 feat(ui): render date inputs in the user's date-format preference
Swap the medication scheduling, inventory, illness, vorsorge, lab-OCR
and profile-birthdate date inputs from the native field to the
DateField primitive, which paints the chosen DMY/MDY/YMD/AUTO order
over an ISO yyyy-MM-dd value. The committed value contract, min/max
bounds and aria wiring are unchanged.
2026-06-26 12:26:09 +02:00
Marc-André Bombeck 9279b062d3 feat(coach): connect signals, confidence-ruler close, three-beat shape
Sharpen the Coach's behaviour-change reflexes from the coaching literature,
prompt-only:

- Connect the signals on a why/pattern question — consult correlations and
  link them descriptively ("after short-sleep nights your next-morning HRV
  tends to read lower") instead of reading metrics in isolation, always an
  association worth a small experiment, never a cause.
- Confidence ruler on action turns — after naming one small step, ask how
  doable it feels 0-10 and shrink it when that lands low, keeping the choice
  the user's.
- Three-beat shape on data-review turns — finding, likely driver, one small
  step; name the dominant contributor when citing a derived band.
- Extend the MI micro-moves with developing discrepancy and rolling with
  resistance, and add an anti-persuasion check: any suggested change must
  serve the user's own stated goal.

One bilingual tone-calibration example added per locale.
2026-06-26 12:24:43 +02:00
Marc-André Bombeck 34ed1a0c93 feat(coach): add acute red-flag escalation to the shared safety contract
The shared contracts cover chronic deferral (dose, diagnosis, drug-level
to a clinician) but had no acute branch. Add a closed-list crisis clause —
chest pain, syncope, sudden severe symptoms, hypertensive-crisis readings,
suicidal ideation — that points to prompt or emergency care without
diagnosing, and keep every other turn calm and non-alarmist. It rides the
single shared-contract source so it lands on the Coach, the per-metric
status cards, the comprehensive briefing, and the period narrative; the
coverage test asserts it on all four surfaces.
2026-06-26 12:22:32 +02:00
Marc-André Bombeck 8c09970d4a merge(DATEFMT-1): date-format preference foundation 2026-06-26 12:17:06 +02:00
Marc-André Bombeck d6430528e9 feat(ui): add the DateField date input
Dependency-free date input that displays the value in the user's
date-format preference while editing rides the native picker behind a
calendar button. The committed value stays ISO yyyy-MM-dd, so it is a
drop-in for DateInput. Height/target-size parity, disabled/min/max/
placeholder, and progressive typed entry parsed against the order.
2026-06-26 12:15:22 +02:00
Marc-André Bombeck 9b7980d020 feat(settings): add the date-format profile control
Surface a Datumsformat select below Stundenformat with the four
preferences, in all six locales.
2026-06-26 12:15:16 +02:00
Marc-André Bombeck 86a9ca2e1e feat(profile): persist and expose the date-format preference
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
2026-06-26 12:15:09 +02:00
Marc-André Bombeck 37e539d0bf feat(format): resolve and apply the date-format preference
Add a date-order resolver + formatter mirroring the hour-cycle path:
AUTO defers to the active locale, DMY/MDY/YMD pin the field order
through a canonical Intl locale. Thread it through makeFormatters and
expose useDateFormatPreference from the i18n context, backed by the
same localStorage mirror as the time-format preference.
2026-06-26 12:14:56 +02:00
Marc-André Bombeck d8ff18948f feat(profile): add date-format preference column
Mirror the time-format preference with a per-user date display
choice. AUTO follows the locale convention, DMY/MDY/YMD pin the
field order. Additive, rerun-safe migration; existing rows read AUTO.
2026-06-26 12:05:49 +02:00
Marc-André Bombeck 7f2059bee6 Merge pull request #362 from MBombeck/release/v1.20.2
Release v1.20.2
2026-06-26 09:52:24 +02:00
Marc-André Bombeck 5e701cebc0 test(insights): bracket the snapshot window floor with the call's own clock
The floor reads Date.now() inside buildComparisonSnapshotForUser, so it
lands in [before - 400d, after - 400d] relative to the test's brackets.
The prior upper bound used before - 400d, which fails when a millisecond
ticks between the bracket read and the snapshot's clock read.
2026-06-26 09:44:35 +02:00
Marc-André Bombeck cd6fff9730 chore(release): v1.20.2 2026-06-26 09:36:02 +02:00
Marc-André Bombeck bf29ab1e0e fix(insights): cancel the Coach tool-loop provider calls on disconnect
Thread the request abort signal through runCoachToolLoop into each
per-round completion so a mid-generation client disconnect tears down
the upstream call instead of paying the full token cost. Completes the
no-tools-path wiring to cover the tool-retrieval path as well.

Regenerate the OpenAPI spec for the trimmed OCR-capability reason enum.
2026-06-26 09:30:08 +02:00
Marc-André Bombeck ca1cf53d2c merge(FIX-E): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck 9c3b119695 merge(FIX-D): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck 9bffa80950 merge(FIX-C): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck a65dde00ce merge(FIX-B): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck fc39d9fa13 merge(FIX-A): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck c304268a9e fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status
The four AI clients threw bare Errors on an empty reply (and, for Codex, on a
stream-level failure) with no httpStatus, so the provider-chain classifier saw
the same null-status shape as a genuine transport failure (ECONNRESET, DNS,
timeout) and could not tell them apart in observability. Attach a sentinel
`httpStatus: 0` plus a `kind` discriminator (`empty_response` /
`stream_failed`) on those throws. The cascade is unchanged:
`isHardProviderFailure` already treats a status <= 0 as a hard failure and
walks to the next provider — this only sharpens the logs.
2026-06-26 09:27:42 +02:00
Marc-André Bombeck 4c571b0e04 fix(insights): cancel the Coach upstream provider call on client disconnect
The Coach SSE turn ran the provider call to completion before the stream
existed and never wired it to the request's abort signal, so a mid-generation
client disconnect still paid the full upstream token cost. Thread an optional
`signal` through CompletionParams and singleUserTurn into each client's
safeFetch (composed with the per-call timeout via AbortSignal.any), and pass
`request.signal` from the chat route's no-tools path. An early disconnect now
tears the upstream request down instead of generating into a closed
connection. Omitting the signal leaves behaviour unchanged (timeout-only).
2026-06-26 09:27:29 +02:00
Marc-André Bombeck e023571a08 chore(labs): tidy OCR capability enum, Fitbit key delimiter, Coach docblock
Drop the unreachable text-only-model reason from the OCR capability
enum and its DTO type. Replace the literal NUL delimiter in the Fitbit
dedup map keys with a named constant so text tooling stops treating the
file as binary (behaviour unchanged). Correct the Coach tool-executor
docblock, which overstated cross-tool snapshot-cache sharing.
2026-06-26 09:27:05 +02:00
Marc-André Bombeck 1c93a670f0 feat(labs): surface low-confidence OCR rows in review
The extraction already self-scores each field; the review screen only
hinted at a shaky numeric value. Add a calm per-row low-confidence badge
when any tracked field scores below the threshold, including qualitative
rows that had no confidence cue. Informative secondary badge, no alarm
colour.
2026-06-26 09:26:59 +02:00
Marc-André Bombeck 561a0694cf fix(admin): return 422 for a malformed query string
The app-logs, audit-log and host-metrics admin endpoints parsed their
query with a throwing parse(), so a bad parameter surfaced as a 500 and
a reported incident. Switch each to safeParse and return the standard
422 validation envelope, matching how body parsing is handled.
2026-06-26 09:26:53 +02:00
Marc-André Bombeck fb54b46034 fix(labs): reserve a proportionate budget for text-mode OCR
Text-mode OCR structures browser-extracted text rather than running a
vision call, so it reserved and reconciled the full vision budget even
though the spend is far lower. Add an ocrExtractText ceiling and reserve
that in text mode, and refund the reservation in full on a clean extract
failure instead of charging the reserved estimate.
2026-06-26 09:26:47 +02:00
Marc-André Bombeck e1e1b0e985 fix(notifications): treat channel config decrypt/parse failure as a hard reject
A channel whose stored config cannot be decrypted or JSON-parsed is a
structurally-permanent failure, not a transient blip: the ciphertext/key
pairing and the malformed body decode the same way on every retry. Routing
it through the transient path burned the 5-failure backoff budget and ended
on the misleading `give_up_after_5_failures` reason — during a key-rotation
gap every channel hit this and auto-disabled for the wrong reason.

Classify decrypt and parse failures as hard rejects so the channel
auto-disables on first occurrence with the precise reason
(`<type>_config_decrypt_failed` / `<type>_config_parse_failed`). Genuine
transient failures (network, timeout, 5xx) keep the existing backoff path.
2026-06-26 09:26:31 +02:00
Marc-André Bombeck 672e3f0e07 fix(rollups): stabilise composed regression via centered identities
Reformulate composeRegression from the determinant form (n*Sxx - Sx^2)
to the mean-centered corrected-sum identities (Sxx = Sxx - Sx^2/n, ...).
Algebraically identical to the prior form and to Postgres REGR_* /
STDDEV_POP, but it subtracts the x mean at matched scale and avoids the
catastrophic cancellation the determinant form suffers on the ~1e10
epoch-day x-axis. Also read the exact stored sum_value accumulator for
the Sy term instead of reconstructing it as mean*count, dropping the
AVG-to-multiply float residual; fall back to mean*count when the column
is null.

Composition-only: no migration, no schema change. The WEIGHT live-parity
case stays bit-identical at the 1e-9 relative bound. The near-flat DST
parity seed improves by ~30x but still exceeds 1e-9, so it remains
quarantined with an updated note pointing at a window-local x-rescale as
the real fix.
2026-06-26 09:25:12 +02:00
Marc-André Bombeck 3e4cc53ede fix(ai): exclude text-only o-series variants from vision capability
The o1/o3/o4 prefix match also caught o1-mini, o1-preview and o3-mini,
which have no image input. A user pinned to one was told OCR works, then
the provider rejected the image block. Treat an o1/o3/o4 slug as
vision-capable only when it is not a -mini or -preview variant, applied
to both the OpenAI and Codex gates.
2026-06-26 09:21:54 +02:00
Marc-André Bombeck e4c0af0970 fix(illness): gate fever red-flag runs on calendar-adjacent days
Symptom and vital points are present-days-only, so a sparse febrile
series (e.g. days 10/15/20/25) was counted as one long run and falsely
fired the sustained_fever escalation. Continue a run only when the next
matching point is the calendar day immediately after the previous one,
using the existing dayDiff helper; otherwise start a fresh run of one.
2026-06-26 09:21:49 +02:00
Marc-André Bombeck 23cc48475d perf(insights): bound the comparison snapshot to a 400-day window
buildComparisonSnapshotForUser read a user's entire measurement history
per type with no date floor, then fed each series to summarize(). The
snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the
widest of those reaches into the [365, 395)-day window (ageMs < 395 days
in meanOfWindow), so nothing older than 395 days can change a result.

Add a 400-day measuredAt.gte lower bound to every per-type read in both
copies of the builder — the page-blocking POST /api/insights/generate
handler and the shared comprehensive-generate path the nightly
pregenerate cron drives. The fan-out runs all seven snapshot types via
Promise.all, so on multi-year accounts the unbounded scan was tens of
thousands of rows per type. 400 days keeps a 5-day floor over the
strict-less-than 395-day reach.
2026-06-26 09:21:29 +02:00
Marc-André Bombeck a20e140a2f Merge pull request #361 from MBombeck/fix/dashboard-render-loop
fix(dashboard): stop the chart-row render loop on tab resume (v1.20.1)
2026-06-23 22:00:41 +02:00
Marc-André Bombeck 9be4c3530a chore(openapi): regenerate spec for the v1.20.1 version stamp 2026-06-23 21:53:54 +02:00
Marc-André Bombeck 2c112c3323 fix(dashboard): stop the chart-row render loop on tab resume
A user reported the dashboard crashing on returning to a backgrounded
tab. The on-focus snapshot refetch re-rendered the page, and each chart
received a fresh inline `() => markChartReady(id)` handler every render;
the chart's data-ready notify effect, keyed on that ever-changing prop,
re-fired on every commit. That per-commit passive effect kept the chart
row's anchored popovers re-committing until the browser tripped its
render-depth guard and the page fell back to its error card.

Latch the notify behind a ref so it fires once, when the chart's initial
query settles, and depend on the load flag alone. The handler is read
through a ref so a late-arriving callback still fires without re-arming
the effect. Extract the pure fire-once decision into a small helper with
direct unit coverage (no DOM runner, per project convention).
2026-06-23 21:45:42 +02:00
Marc-André Bombeck 009d923c87 Merge pull request #358 from MBombeck/release/v1.20.0
v1.20.0 — Coach on-demand retrieval, deeper recovery insight, Fitbit, leaner data tier
2026-06-22 11:05:17 +02:00
Marc-André Bombeck 6d338eab60 test(rollups): quarantine the DST parity case pending a harness rebuild
The DST same-UTC-day case is a cross-implementation parity artifact in the
test harness, not a product defect — the rollup regression math is covered
bit-identically by the WEIGHT case, which folds the same raw rows the live
REGR reads. Skip it with a tracked follow-up so the release is not blocked.
2026-06-22 10:55:02 +02:00
Marc-André Bombeck 1d816d8eae test(rollups): use WEIGHT for the DST parity case
The case exists to pin that the epoch-day x-axis is DST-agnostic, not to
exercise BLOOD_GLUCOSE, whose source-priority/CGM consolidation makes the
live raw-row regression and the bucket-folded accumulators legitimately
read different row sets. WEIGHT folds raw rows directly (the bit-identical
case proves it), so the DST-straddling timestamps now parity-match tightly.
2026-06-22 10:38:32 +02:00
Marc-André Bombeck f6ab1afe31 test(rollups): give the DST parity case a well-conditioned trend
The DST case used near-flat scattered values, where the naive sum-of-
squares closed form is ill-conditioned versus Postgres REGR (a near-zero
r2 amplifies the cancellation). The WEIGHT case with the same epoch-day x
magnitude and uneven per-day counts already matches bit-identically, so a
clear monotonic trend restores tight parity while still exercising the
DST-straddling timestamps and a same-UTC-day reading pair.
2026-06-22 10:22:30 +02:00
Marc-André Bombeck 2c9eacac29 test(rollups): gauge the DST parity at the read-tier display precision
The composed regression is verified correct (slope agrees to ~7e-9 with a
stable mean-centred reference on this seed); the remaining gap is a cross-
implementation artifact between the closed form and Postgres REGR on an
ill-conditioned r2/sd at large epoch-day x. The read tier rounds to 2-3 dp,
so parity is gauged there. Full bit-parity is tracked in the backlog.
2026-06-22 09:58:37 +02:00
Marc-André Bombeck 7dfa1b1953 chore(ci): re-trigger checks for the parity tolerance commit 2026-06-22 09:45:08 +02:00
Marc-André Bombeck 9030d86f22 test(rollups): widen the DST parity gauge to 1e-5
The naive sum-of-squares regression at epoch-day x~20540 is inherently
ill-conditioned; six-figure parity was still inside the cancellation
noise. Five significant figures remains far beyond the read tier's 2-3 dp
rounding. Production unchanged; full bit-parity tracked in the backlog.
2026-06-22 09:32:55 +02:00
Marc-André Bombeck 191221cba6 test(rollups): widen the DST parity gauge to the closed-form precision
The same-UTC-day DST case drives the worst cancellation in the closed-form
slope at large epoch-day x; Postgres REGR_SLOPE is mean-centred and keeps
the digits we lose. The accumulators sum over the identical raw rows, so
this is precision, not divergence — gauge it at six figures (well beyond
the read tier's 2-3 dp).
2026-06-22 09:26:11 +02:00
Marc-André Bombeck 27b0515c24 Merge rollup parity tolerance fix into release/v1.20.0
Gauge the rollup-vs-live regression parity with a relative tolerance
instead of an absolute nine-decimal one. The accumulators sum per raw
reading over the same epoch-day x as the live REGR_* query, so the paths
are algebraically equal; their tiny divergence is float-summation order
amplified by cancellation in the slope denominator at large epoch-day x.
Production code is unchanged.
2026-06-22 09:13:37 +02:00
Marc-André Bombeck ed96a8a5d1 test(rollups): assert regression parity relatively, not at absolute 9 dp
The DST-boundary parity case seeds two readings on the same UTC day, so the
windowed compose folds a closed form over a large epoch-day x (~20540). The
n*Sxx - Sx^2 denominator subtracts two ~1e10 magnitudes and loses about ten
significant digits to cancellation before the divide, so an absolute
toBeCloseTo at 9 dp is the wrong gauge for a slope that small. The populator
sums per-raw-reading accumulators grouped by day, so both paths regress over
the identical raw-row x set and agree mathematically; the residual is float
summation order alone. Switch all parity assertions to a shared relative
tolerance (1e-9 of scale), far tighter than the 2-3 dp the read tier rounds to.
2026-06-22 09:13:02 +02:00
Marc-André Bombeck 755f7e101b Merge Fitbit integration test mocks for the classic Web API
Rewrite the two Fitbit integration tests' fetch mocks to the classic
Fitbit Web API shapes the v1.20.0 client now reads (body log, activity,
1.2 sleep levels, activities/list), so the sync ingests the expected rows
again. Anchor the workout externalId on the numeric logId and pin the
tz-sensitive test user to UTC; all counts, dedup, stage and source-
priority intents are preserved.
2026-06-22 09:01:07 +02:00
Marc-André Bombeck 89de292a31 test(fitbit): remap integration fixtures onto the classic Web API
The v1.20.0 transport switch retargeted the Fitbit client from the Google
Health API onto the classic Fitbit Web API, but the two sync integration
tests still stubbed fetch with Google-shaped dataPoints responses. The mocks
no longer matched the endpoints the client reads, so every ingest assertion
saw zero rows.

Rewrite the fetch-mock layer in both files to key on the classic endpoint
paths and return classic-shaped JSON the real mappers parse:

  - body/log/weight, activities/heart for the metrics sync
  - activities/steps for the cumulative-activity sync
  - 1.2 sleep log levels.data for the per-stage sleep sync
  - activities/list for the workout sync

The ingest contract is preserved (row counts, dedup, source priority, sleep
stages, daily-total overwrite). Two assertions move to their classic
equivalents: the workout externalId now anchors on the numeric logId, and
the workout test pins the user to UTC so the offset-less local wall-clock
timestamps the classic sleep/activity logs emit resolve to the same instants
the cross-source picker buckets on.
2026-06-22 09:00:39 +02:00
Marc-André Bombeck b36671d8c6 refactor(integrations): drop the unused RotatedTokens type
persistRotatedToken takes the fresh access token directly; the interface
was never wired in. Removing it clears the dead-code gate.
2026-06-22 08:43:14 +02:00
Marc-André Bombeck b9368b9a69 chore(release): v1.20.0 2026-06-22 08:36:28 +02:00
Marc-André Bombeck 57adfe3117 Merge pnpm/action-setup v6.0.8->v6.0.9 (v1.20.0) 2026-06-22 08:32:25 +02:00
Marc-André Bombeck 15a129baab Merge actions/checkout v6->v7 (v1.20.0) 2026-06-22 08:32:25 +02:00
Marc-André Bombeck 5cd4a5e6bd chore(deps): bump runtime + dev dependencies for v1.20.0
next 16.2.6->16.2.9, pg-boss 12.18.2->12.20.0, radix-ui 1.4.3->1.6.0,
@testcontainers/postgresql 12.0.1->12.0.3, pg 8.21.0->8.22.0.
2026-06-22 08:32:23 +02:00
Marc-André Bombeck d3b341f8a7 Merge AI client + coach cache fixes into release/v1.20.0
Send the OpenAI JSON response format only when JSON is requested and no
tools are in play, so the Coach prose path (and the tool loop's final
round) is no longer corrupted into a JSON blob. Resolve the Anthropic
finish reason to tool_calls whenever tool_use blocks are present. Memoize
the per-request feature extraction and prefs read and project the feature
query, so the coach tool fan-out shares one read instead of six.
2026-06-22 08:19:12 +02:00
Marc-André Bombeck 600498da12 perf(coach): share the heavy per-user reads across the tool-snapshot fan-out
The F1 coach tools each rebuild a snapshot with a distinct single-source
scope, so the 60s snapshot LRU (keyed on the source list) never shares their
reads. On a multi-metric turn the model can fan out up to six tools that each
fire `extractFeatures` (a user lookup plus a windowed measurement read plus the
all-time extremes) and a prefs `findUnique` concurrently — up to twelve heavy
reads against the shared Prisma pool at once.

Memoise both reads on the request-scoped event cache, keyed on userId (and
windowDays for the features read), so the fan-out collapses to one of each
within a request while staying correct across requests. Project the feature
extractor's measurement read to the six columns its consumers actually use
(type, value, measuredAt, sleepStage, source, deviceType) so the 200k-row-class
PULSE / glucose windows stop pulling every column. Outputs are unchanged.
2026-06-22 08:18:13 +02:00
Marc-André Bombeck c3c90c5117 Merge integration tz + oauth-refresh fixes into release/v1.20.0
Parse Fitbit's offset-less classic sleep/activity wall-clock strings
against the user's timezone (DST-safe) so a non-UTC user's segments land
on the right wake-day. Add a shared rotated-refresh-token persist that
compare-and-swaps on the stored ciphertext and reuses a peer's rotated
token on a lost race, so concurrent syncs across Fitbit, WHOOP and Oura
no longer park a connection at a spurious reauth.
2026-06-22 08:16:55 +02:00
Marc-André Bombeck 0f5e563403 fix(integrations): make rotating-refresh persistence race-safe
Fitbit, WHOOP, and Oura all issue one-time-use refresh tokens. The token
persistence did an unguarded read → refresh → write, so two overlapping
syncs for one user both read the same stored refresh token and both POST
it. The provider honours only the first; the loser's refresh comes back 401,
gets classified as reauth, and the connection is parked at `error_reauth`
spuriously even though the peer rotated the token successfully.

Add a shared `persistRotatedToken` helper that writes the rotated pair with
a compare-and-swap on the stored ciphertext (matched verbatim, since AES-GCM
re-encryption of the same plaintext differs byte-for-byte). When the
conditional update touches zero rows a peer already won the race; re-read the
row and reuse the peer's freshly rotated access token instead of erroring.

Wire it into all three providers — Fitbit and WHOOP `getValidToken`
(proactive expiry refresh, keyed by connection id) and Oura `storeOuraTokens`
(reactive 401 refresh, keyed by user id). Both racing callers now end with a
usable token and neither drops the connection.
2026-06-22 08:16:04 +02:00
Marc-André Bombeck b6f0ad61cb fix(ai): treat Anthropic tool_use blocks as authoritative for finishReason
The Anthropic client derived `finishReason` solely from `stop_reason`. The
Coach tool loop continues only when `finishReason === "tool_calls"`, so if
Anthropic ever returned tool_use blocks under a different (or absent)
stop_reason, the reason resolved to undefined, the loop dropped the tool
request, and the empty tool_use-only reply surfaced as the final answer —
the requested data was never fetched.

Set `finishReason` to "tool_calls" whenever tool_use blocks are present,
clamped ahead of the stop_reason map, so the presence of a tool request is
authoritative regardless of the reported stop reason.
2026-06-22 08:12:11 +02:00
Marc-André Bombeck 7b0dcbf560 fix(ai): gate OpenAI JSON mode on responseFormat instead of forcing it
The OpenAI client pinned `response_format: { type: "json_object" }` on every
call. The Coach tool loop's forced-final round runs with `tool_choice: "none"`
and expects plain prose, but JSON mode coerced the reply into a JSON object —
the user saw a JSON blob or an empty reply instead of the coach's answer.

Send `response_format` only when the caller opts in with `responseFormat: "json"`
and no tools are present, mirroring the Anthropic client's prefill gate. The
JSON-consuming callers that relied on the implicit mode (insight generate +
briefing retry, medication extraction, the connection-test probe) now pass the
flag explicitly; the prose Coach path and every tool round stay out of JSON
mode. Codex already omitted the flag and is unaffected.
2026-06-22 08:11:16 +02:00
Marc-André Bombeck b5f0857a14 Merge illness correlation fixes into release/v1.20.0
Sort the fever red-flag points chronologically before the consecutive-run
scan so a dual-source episode (thermometer plus journaled fever) can no
longer falsely escalate or hide a sustained-fever flag. Treat a trailing
out-of-band tail shorter than the stability window as transient noise so
a settled vital keeps its recovery day; only a sustained re-deviation
clears it.
2026-06-22 08:11:05 +02:00
Marc-André Bombeck b31def38c0 fix(illness): tolerate a transient trailing reading in the recovery return
The last-sustained-return walk nulled the recovery day whenever the final reading
sat out of band, even after a vital had settled for several days. A lone noisy
sample on the last logged day — common in resting-HR and HRV curves — then erased
a real recovery gap that had previously been reported.

Treat a trailing out-of-band tail shorter than the stability window as transient
noise and keep the settle day; only a sustained re-deviation (a tail at least as
long as the stability window, a genuine relapse with no re-settle) clears the
return. Apply the same noise tolerance to the symptom-burden track for parity.
Cover the trailing-outlier, genuine-relapse-then-re-settle, and
sustained-no-re-settle cases.
2026-06-22 08:10:10 +02:00
Marc-André Bombeck 68c67e4700 fix(fitbit): anchor offset-less sleep timestamps to the user timezone
The classic 1.2 sleep log and the activities list emit local wall-clock
ISO strings without an offset. `parseLocalInstant` parsed them with a bare
`new Date(iso)`, which reads an offset-less string in the process zone (UTC
in production). A non-UTC user's sleep-segment `measuredAt` (the segment
end) was therefore shifted by their UTC offset, and a near-midnight segment
could flip onto the wrong wake-day in the night reconstruction.

Resolve offset-less timestamps against the user's stored timezone via a new
`zonedWallClockToUtc` helper (the inverse of `wallClockInTz`, DST-safe via
the same two-pass converge as `startOfLocalDayInTz`). Thread the resolved
zone through the sleep and workout sync paths. Strings that already carry an
offset or `Z` stay absolute and parse as-is; an absent zone keeps the prior
host-local fallback.
2026-06-22 08:09:57 +02:00
Marc-André Bombeck 67d982c1be Merge rollup backfill-miss annotation into release/v1.20.0
Emit regression_source: unavailable_pending_backfill when slope/r2/sd are
null because a tenant's accumulators are not yet backfilled, so the miss
is observable rather than silently logged as a rollup hit. The null value
is unchanged; no live fallback added.
2026-06-22 08:08:47 +02:00
Marc-André Bombeck 21f6dcfe13 observability(rollups): surface the pending-backfill regression miss
The windowed slope / r2 / sd readers compose from the per-bucket
regression accumulators (migration 0190). Between a deploy and the
boot-backfill completing, a tenant's pre-0190 DAY buckets still carry
NULL accumulators, so composeWindowedRegression returns a full miss and
the slim / comprehensive readers emit a null slope. The null is the
honest answer until the backfill refills the accumulators, but the miss
was passing as a plain rollup read with no signal, against the no-silent-
cap rule.

Add hasPendingAccumulatorBackfill to the read tier and key a new
regression_source annotation off it (unavailable_pending_backfill vs
rollup) on both warm paths. No live-SQL fallback is added; the design
intentionally avoids the heavy scan.
2026-06-22 08:07:58 +02:00
Marc-André Bombeck c8ebdba43e fix(illness): sort the fever red-flag points before the run scan
The fever escalation unions two individually-sorted sources — the passive
BODY_TEMPERATURE series and the day-log feverC — into a Map, then feeds the
entries to the consecutive-run scanner. Map iteration is insertion order, not
chronology, so an episode with both a thermometer feed and journaled fever read
the run out of day order: a broken fever could read as a sustained run (a false
seek-care escalation) or a genuine run could be hidden (a missed one).

Sort the unioned points by day before the scan. Cover both directions with
interleaved-source fixtures.
2026-06-22 08:06:26 +02:00
Marc-André Bombeck 745991a922 chore(openapi): regenerate for v1.20.0 schema additions 2026-06-22 07:38:40 +02:00
Marc-André Bombeck 5c2d3f12cb Merge Coach tool-based retrieval into release/v1.20.0
Replace the Coach snapshot-stuffing with a small base context plus a data
inventory and six on-demand retrieval tools (metric series, glucose,
sleep, medication compliance, labs, illness recovery) the model calls as
needed, then answers. Cuts first-turn input ~35-65% while preserving
grounding and every gate; tools return a structured present:false rather
than throwing, so the model cites only what it fetched. Falls back to the
snapshot path when a provider lacks tool support.
2026-06-22 07:38:18 +02:00
Marc-André Bombeck 7a9e326e85 feat(coach): retrieve health data on demand via tools
Replace snapshot-stuffing in the Coach turn with a small base context plus
on-demand retrieval. The model receives the full system prompt, a tool-mode
grounding addendum, and a tiny DATA INVENTORY manifest listing which domains the
user has logged and which tool fetches each; the figures themselves are pulled
only when a question needs them.

Six read-only tools wrap the existing server-authoritative snapshot builder:
get_metric_series (blood pressure / weight / pulse + the additive series),
get_glucose_panel, get_sleep, get_medication_compliance, get_labs, and
get_illness_recovery. Each returns a structured { present: false } for an absent
or opted-out domain rather than throwing or returning an ambiguous empty list,
so the model can always tell missing data from real data. Module and cycle gates
are enforced at the builder, so opted-out domains are unfetchable.

A bounded loop runs the call -> tool-result -> answer cycle: at most two rounds
with a hard cap of three that forces a final answer, parallel tool execution,
and only the final round streamed. The day's budget reserves the per-call
ceiling times the round count and reconciles the summed tokens; the atomic
reserve and reconcile primitives are unchanged. All inbound gates (rate limit,
refusal, replay scan, consent, budget) stay before the loop.

The legacy snapshot path stays alive as the no-tools fallback: tool mode is on
only when every provider in the chain supports tool-calling, so a chain that
includes a local model falls back to the prior behaviour verbatim. The tool
trace is persisted onto the assistant message provenance.
2026-06-22 07:37:29 +02:00
Marc-André Bombeck ec77862286 feat(coach): expose structured snapshot sections + tool-trace provenance
The Coach snapshot builder returns the structured, post-degrade record it
serialises to JSON alongside the JSON string, so a single domain block can be
read out without re-parsing. Add an optional tool-call trace to the provenance
envelope (which retrieval tools ran + whether each found data) and mirror it in
the OpenAPI provenance schema.

Groundwork for on-demand retrieval: the retrieval tools slice one section out
of this record, reusing the builder's gates and I/O verbatim.
2026-06-22 07:37:15 +02:00
Marc-André Bombeck 9021b3e0ee Merge Fitbit classic Web API transport into release/v1.20.0
Retarget the Fitbit integration from the Google Health API (which needs
annual CASA review — a wall for self-hosters) to the classic Fitbit Web
API with PKCE and rotating refresh tokens, so a self-hoster can connect
with an instant developer app (migration 0191 adds the PKCE verifier).
Transport-only fork; the ingest, dedup, source-priority, backfill and
poll paths are reused. Intraday HR and skin temperature are not mapped
(no honest slot); the integration stays experimental.
2026-06-22 07:22:31 +02:00
Marc-André Bombeck 4d892f6867 Merge rollup regression accumulators into release/v1.20.0
Store additive per-bucket regression accumulators (sum_x, sum_xy, sum_xx,
sum_yy) so windowed slope, r2 and standard deviation compose from the
rollup tier instead of a live raw-table scan on every warm request
(migration 0190, backfilled by the boot queue). Median and the exact
anomaly count stay on a minimal live query. Parity with the live REGR_*
path within float tolerance, well below the read rounding.
2026-06-22 07:21:56 +02:00
Marc-André Bombeck 488ca72d12 chore(fitbit): document FITBIT_REDIRECT_URI and rename the card copy to Fitbit
Add the optional FITBIT_REDIRECT_URI to .env.production.example (BYO-key shape,
derives from NEXT_PUBLIC_APP_URL) so check-env documents it. Rename the
settings-card copy from "Google Health" to "Fitbit" across all six locales now
that the transport is the classic Fitbit Web API.
2026-06-22 07:21:12 +02:00
Marc-André Bombeck e748a91a36 feat(fitbit): wire PKCE through connect/callback and fix the test probe
Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and
sends the S256 challenge to the authorize endpoint. Callback reads the verifier
back and presents it on the token exchange, refusing cleanly when it is absent.
The connection-test probe hits the classic /1/user/-/profile.json endpoint.
2026-06-22 07:21:06 +02:00
Marc-André Bombeck 4ff0e4ca0c feat(fitbit): retarget the client to the classic Fitbit Web API
The shipped Fitbit integration spoke the Google Health API, whose Restricted
scopes need brand verification plus an annual CASA assessment before leaving
testing mode — a wall most self-hosters cannot clear. Retarget the transport to
the classic api.fitbit.com Web API: a self-hoster registers an app at
dev.fitbit.com in minutes, no CASA.

Forks only the transport; the upsert/dedup/rollup tail, source-priority,
backfill, poll worker, and status ledger are reused unchanged.

- OAuth: www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token, PKCE
  (S256), classic self-serve scopes.
- Refresh tokens rotate (one-time use): persist the new token every refresh,
  with a defensive keep-existing guard on a malformed reply.
- Per-endpoint date-range fetchers chunked to the API range caps; the backfill
  horizon and poll concurrency are tuned for the 150 req/h-per-user budget.
- Verified classic-shape extractors for weight, body fat, SpO2, HRV (dailyRmssd),
  resting HR, respiratory rate, steps, distance, active calories, floors, VO2 max
  (range midpoint), per-segment sleep stages, and workouts.
- Skips intraday HR (needs a per-app grant; daily resting HR covers it) and skin
  temperature (a baseline delta with no honest absolute slot).
2026-06-22 07:20:58 +02:00
Marc-André Bombeck 062354ab57 feat(fitbit): add PKCE code_verifier column to the OAuth-state ledger
The classic Fitbit Web API authorises with PKCE (Authorization Code + S256),
so the connect route mints a code_verifier and the callback presents it on the
token exchange. Carry it on the short-lived FitbitOAuthState row.

Additive + nullable (migration 0191): a legacy in-flight row carries no
verifier, so the change is reversible and rerun-safe with no data loss.
2026-06-22 07:20:41 +02:00
Marc-André Bombeck 4706120879 test(rollups): pin accumulator regression parity with live REGR_*
Two suites pin that the windowed slope / r2 / population-sd composed from
the rollup accumulators equals the live Postgres REGR_SLOPE / REGR_R2 /
STDDEV_POP over the same raw rows.

compose-regression.test.ts (container-free) builds per-DAY-bucket
accumulators from a raw fixture exactly as the populator's SUM terms do,
then asserts composeRegression over those buckets equals a direct OLS over
the flat raw rows - the same closed form Postgres folds - on an uneven
multi-bucket fixture, plus the coverage contract (NULL accumulator,
< 2 readings, and zero-x-variance all return a full miss so the reader
falls back to live SQL).

rollup-regression-parity.integration.test.ts (real Postgres testcontainer)
seeds raw measurements across many uneven DAY buckets, folds the rollups,
and compares the accumulator compose to live REGR_*/STDDEV_POP to <= 1e-9:
a single-source WEIGHT series, a dual-source RHR series proving the
source-collapse runs BEFORE accumulator summation (composed == live WHOOP
-only, != the cross-source blend), and a DST-boundary series confirming
the UTC epoch-day x-axis parity-matches across the transition.

The residual is pure floating-point reassociation (per-bucket-then-window
vs flat summation), orders of magnitude below the read tier's rounding.
2026-06-22 07:20:08 +02:00
Marc-André Bombeck b07fbcb6fa Merge AI client message-array refactor into release/v1.20.0
Move the AI client layer from a single system/user string pair to a
structured system + messages[] shape, with optional tools/toolChoice and
a result carrying toolCalls/finishReason/cachedInputTokens. Per-provider
prompt-caching is wired (Anthropic cache_control, OpenAI/local auto
prefix); tool plumbing is in place for the Coach to use. Behaviour-
preserving: no streaming change, snapshot-once retained.
2026-06-22 07:17:48 +02:00
Marc-André Bombeck 4e777a7e6a refactor(ai): take a structured message array instead of a string pair
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.

Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.

Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.

Behaviour is unchanged across every surface; the full suite stays green.
2026-06-22 07:17:10 +02:00
Marc-André Bombeck 81d318d7dc feat(rollups): re-fold NULL-accumulator buckets on boot backfill
Migration 0190 left existing DAY rollup rows with NULL regression
accumulators until a re-fold refills them. Add a second discovery arm to
enqueueBootTimeRollupBackfill, UNION-ed with the per-day missing-coverage
arm: a user whose DAY buckets carry sum_xy IS NULL inside the fold window
needs a one-time re-fold so the windowed slope/r2/sd readers compose from
the accumulators instead of falling back to the live REGR_* scan.

New writes self-populate the accumulators (they ride the same aggregate
as mean/sum), so the arm drains to empty once the boot re-fold catches up
- the same convergence the v1.4.39 sum_value rollout reached through this
queue. Bounded to ROLLUP_FOLD_WINDOW_MS so days outside the window never
re-flag a user on every boot.
2026-06-22 07:14:06 +02:00
Marc-André Bombeck be37497118 perf(rollups): compose windowed slope/r2/sd from accumulators
The summaries slice (computeFromRollups) and the comprehensive aggregator
(buildFromRollups) each ran a narrow live $queryRaw over the raw
measurements partition on EVERY warm-rollup request to fold slope7/30/90
and r2_7/30/90 via REGR_SLOPE / REGR_R2 - the un-eliminated live scan on
the happy path.

Drop the six REGR_* columns from both narrow queries and compose the
windowed slope / r2 (and the population sd via composeRegression) from
the per-bucket accumulators stored in measurement_rollups. The 90-day
DAY buckets are loaded once (already loaded in comprehensive; one bounded
findMany added in the slim slice), source-collapsed to the canonical
source, then summed over the day-aligned 7/30/90-day sub-windows. A
window whose buckets lack accumulators (pre-backfill) composes to null,
the same shape the empty REGR_* FILTER returned, so a transitional read
never returns a wrong partial regression.

median (PERCENTILE_CONT) and the exact |z|>2 anomaly count stay on the
minimal live query - both are non-composable across buckets. The cold
coverage-miss path keeps the live REGR_* regression. Annotate
slope_source:"rollup" + median_source/anomaly_source:"live" so a
regression to the heavy path stays observable (no-silent-cap rule).
2026-06-22 07:13:06 +02:00
Marc-André Bombeck 7859ca55b3 Merge recovery-gap deepening into release/v1.20.0
Make the physiological return relapse-aware (the start of the final
sustained in-band run, not the first), so a vital that relapsed and
recovered again reports its true recovery. Add an optional sleep-context
sub-line (baseline vs episode asleep-minutes), withheld on thin data and
never feeding the gap median.
2026-06-22 07:07:33 +02:00
Marc-André Bombeck 8d6d63cd1a feat(illness): surface sleep as a context line on the recovery card
Adds a neutral sleep observation alongside the per-episode recovery gap: the
user's usual asleep minutes over their well baseline vs the mean over the
episode window, narrated as 'you slept more/less than usual' when the two
diverge. It is an observation only — never a recovery claim, never a return
track, and never part of the headline recovery-gap number.

Per-night asleep totals come from the canonical night reconstruction over the
baseline and episode windows, so a multi-source night (two writers for one
sleep) is counted once, matching every other sleep surface. The observation
withholds entirely on thin data — too few baseline or episode nights, or a
delta below a 30-minute floor — so day-to-day jitter is never narrated as a
finding.

Additive optional DTO field; no schema, no migration. Two locale keys across
the six bundles. Regenerate the OpenAPI contract from the Zod source.
2026-06-22 07:06:22 +02:00
Marc-André Bombeck f5d259db58 feat(rollups): store per-bucket regression accumulators
Windowed slope, r2 and population sd still ran a live REGR_*/STDDEV_POP
query over the raw measurements partition on every warm-rollup request,
because the per-bucket mean cannot reconstruct an OLS regression over the
raw points in a window. The per-bucket sd/slope/r2 columns are a
within-bucket fold and do not compose linearly across DAY buckets.

Add the four regression-accumulator sums Postgres folds for REGR_* /
STDDEV_POP - sum_x, sum_xy, sum_xx, sum_yy - per (user, type,
granularity, bucketStart, source) over the same canonical-source rows
the live query reads, with the x-axis in epoch days. With n = count and
sum_y = mean*count already stored, all six terms are additive across
buckets, so a 7/30/90-day slope/r2/sd composes from the summed
accumulators with a bit-identical result to the live closed form.

Migration 0190 adds the four nullable columns; nullable keeps it
additive so existing rows read NULL until the boot re-fold refills them
(mirrors the v1.4.39 sum_value rollout). The populator emits the four
SUM terms inside the existing aggregate (no extra round-trip) and
persistRollupRows writes them. composeRegression / composeWindowedRegression
evaluate the closed form from already-collapsed buckets and return a full
miss when any in-window bucket lacks accumulators, so the reader falls
back to live SQL rather than composing a partial window.
2026-06-22 07:01:42 +02:00
Marc-André Bombeck ff06689a1b Merge dashboard discoverability flips into release/v1.20.0
Default the built, data-gated sleep, steps and glucose dashboard tiles to
visible (each self-hides without data) and surface the Vorsorge chart row,
so shipped capabilities are discoverable on the home dashboard. Seed the
demo with an explicit showcase layout.
2026-06-22 07:01:38 +02:00
Marc-André Bombeck b2ff41070c feat(demo): showcase the seeded tiles on the demo dashboard
The demo account seeds rich sleep, steps, glucose, VO2max and workout
data but inserted no dashboard layout, so the home page rendered the
new-account default and under-sold the product on its primary surface.
Persist an explicit dashboard layout derived from the default with the
seeded-rich strip tiles forced on and the daily-verdict hero enabled, so
a fresh seed renders a full, lived-in dashboard for screenshots.
2026-06-22 07:01:03 +02:00
Marc-André Bombeck efd0470bc1 feat(dashboard): surface sleep, steps, glucose and vorsorge by default
The sleep, steps and glucose strip tiles shipped default-hidden even
though their modules are on by default and rich data flows in from the
sync integrations, so accounts had to hunt through Settings to see them.
Flip the three strip tiles to tileVisible:true and the vorsorge chart-row
card to visible:true, matching the VO2max precedent: each tile self-gates
on having data, so empty accounts keep a clean dashboard. Chart rows for
the three metrics stay off — those charts live on the /insights sub-pages.

totalBodyWater, boneMass and oxygenSaturation stay hidden: they carry as
catalogue ids for the iOS layout round-trip only and have no web render
path, so a flip would surface nothing. Note that at the splice point.

Update the silhouette skeleton and layout default tests for the new
counts.
2026-06-22 07:00:56 +02:00
Marc-André Bombeck e432b53e0d fix(illness): report the last sustained recovery return, not the first
A vital or symptom curve that settles, re-deviates mid-episode, and settles
again now reports the FINAL sustained in-band run as the recovery return rather
than the first. The earlier first-return rule locked onto an interim settle and
under-reported the gap whenever a relapse followed. The change is monotone — a
last return is always at or after the first — so no existing recovery number
shifts; it only extends the gap for episodes that genuinely relapsed.

The anchor-after-first-deviation guard is unchanged: an early in-band run-up is
still never read as a return. The symptom-burden track honours relapse
identically over logged days so the headline median never mixes first- and
last-return semantics.

Adds relapse, multiple-relapse, too-short-final-run, ends-out-of-band, and
symptom-relapse engine fixtures plus a cross-episode integration fixture.
2026-06-22 06:59:02 +02:00
Marc-André Bombeck 461190e63e Merge pull request #357 from MBombeck/release/v1.19.2
v1.19.2 — recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data
2026-06-21 21:32:20 +02:00
Marc-André Bombeck b4bf31a8c9 chore(release): v1.19.2 2026-06-21 21:21:13 +02:00
Marc-André Bombeck c1d05c9e47 chore(openapi): regenerate for v1.19.2 schema additions
Pick up the HR bucket min/max fields, the TELEGRAM measurement source,
and the recovery-gap driver/return additions in one regeneration.
2026-06-21 21:08:28 +02:00
Marc-André Bombeck ad2634e41b Merge data-model additions into release/v1.19.2
Add per-bucket HR min/max (migration 0188) threaded through batch ingest
and the series reader; make telegram_chat_id unique (0189) with a
defensive pre-clean; make migration 0187 rerun-safe. Capture a numeric
measurement from a Telegram reminder reply (source=TELEGRAM, strict chat
binding, self-cleaning); blood pressure stays satisfy-only pending a
guided two-number prompt.
2026-06-21 21:07:46 +02:00
Marc-André Bombeck 5a2d759e50 Merge symptom-severity recovery track into release/v1.19.2
Fold the illness journal's per-day functional-impact curve (falling back
to linked symptom severity) into the recovery-gap as a return track
against the known-constant healthy baseline. It is adverse by
construction, feeds the qualifying-days floor and the gap median, and
withholds on sparse logging. The card names the driver when symptoms
lead ('…before your logged symptoms eased').
2026-06-21 21:07:02 +02:00
Marc-André Bombeck 9c8fb8c7c8 feat(telegram): unique chat binding and numeric measurement capture
Two related changes to the Telegram bot's data path.

One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration
0189, defensive pre-clean of any pre-existing duplicate binding). The
inbound webhook resolves the user from the chat id, so a chat shared
across two accounts would route a reply or button tap ambiguously. The
index is over a nullable column, so an unlinked account (null chat id)
never collides — only two accounts binding the same non-null chat id are
rejected.

Numeric measurement capture. A numeric reply to a measurement reminder is
now captured as a Measurement (`source = TELEGRAM`, added to the enum in
0189). On "Erledigt" for a reminder that names a single-value metric, the
bot offers an optional value prompt via force_reply; a numeric reply is
written through the shared `logTelegramMeasurement` helper — same range
guard, canonical unit, cache + rollup refresh as the manual route. The
reply binds to the reminder and user through a TelegramPromptContext keyed
on the prompt message id (the same strict chat/message binding the
mood-note path uses); the userId is never read from the payload. Comma
decimals are tolerated; non-numeric and out-of-range replies get a calm
hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood
pressure (two values) is out of scope for the single-numeric path and
keeps its satisfy-only flow.
2026-06-21 21:06:42 +02:00
Marc-André Bombeck 5c0583133d chore(db): make the 0187 telegram-prompt-context migration rerun-safe
Guard the CREATE TABLE + both CREATE INDEX statements with IF NOT EXISTS so
a re-run on a database that already has the table is a no-op, matching the
guard convention every other recent migration ships with. Semantics are
unchanged — same table, same columns, same indexes.
2026-06-21 21:06:17 +02:00
Marc-André Bombeck 1352ba2aa6 feat(illness): name the symptom curve in the recovery-gap card
When the logged symptom curve drives the recovery gap, the card names it
('you felt better N days before your logged symptoms eased') instead of
the vital-driven phrasing. Mirror the new gapDriverType and the return
track's adverse flag into the client DTO, and add the driver-aware copy
across all six locales.
2026-06-21 21:06:14 +02:00
Marc-André Bombeck 868dfb72bd feat(measurements): add per-bucket heart-rate min/max to the hourly contract
The v1.19.0 hourly heart-rate bucket
(`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) shipped avg-only:
one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a
client can render the intra-hour range without re-uploading raw samples.

- Two nullable columns on `measurements` (`value_min` / `value_max`),
  migration 0188. Set only on a well-formed hourly HR bucket row; null for
  per-sample readings, per-day cumulative totals, and manual entries.
- Batch ingest threads the spread through the same unit conversion as the
  average and persists / overwrites it alongside `value` on the bucket
  row, pinning it null everywhere else.
- The series reader returns the spread for `kind=pulse` (null on a
  per-sample PULSE row); the OpenAPI Zod contract documents both fields on
  the batch entry and the series point. The avg path is unchanged.
2026-06-21 21:06:09 +02:00
Marc-André Bombeck fc76c39aea feat(illness): fold the logged symptom curve into the recovery gap
The recovery-gap engine reasoned only over passive vitals and ignored
the journal's own per-day symptom series. Add the functional-impact
(symptom-burden) return track: read functionalImpact per episode day
(falling back to the strongest linked symptom severity) and treat it as
one more return against the known-constant baseline (healthy = 0).

The symptom track needs none of the MAD-band / contamination machinery
the passive vitals carry — the band is the constant 0 — so it is more
reliable to fold in than any passive vital. It is adverse by
construction, so it contributes to adverseCoverageDays and the headline
gap median, giving the qualifying-days floor a genuinely
illness-relevant contributor where a weight-only episode had none.

Stability is logged-days-only: a sparse journal that logs impact-0 once
and stops withholds the return rather than fabricating an all-clear from
the absence of further logs. gapDriverType names the dominant return
track, with the symptom curve winning ties as the most illness-specific
signal.
2026-06-21 21:06:06 +02:00
Marc-André Bombeck 38e10b9ca0 Merge multi-year chart range into release/v1.19.2
A multi-year All range was silently truncated to the most recent year by
the DAY-only 365-row cap. Route windows wider than the cap through a new
tiered rollup reader (WEEK/MONTH by span, finer on coverage miss,
uncapped by tier choice); narrow windows stay byte-identical. No silent
truncation — a coverage miss logs and falls through to the daily path.
2026-06-21 21:02:06 +02:00
Marc-André Bombeck 9ea258556d docs(charts): note the multi-year tier step-up in the chart data path
The chart still requests aggregate=daily for windows over 7 days, but
the server now steps the rollup tier up to WEEK / MONTH for ranges past
the DAY cap and returns whole-history coverage. Update the stale
comments that claimed the "All" range was still capped at ~365 daily
buckets; the client fold + bucketTimeSeries downsample the coarse
buckets to the visible range as before.
2026-06-21 21:01:10 +02:00
Marc-André Bombeck 14ba0b013e fix(charts): step the rollup tier up for multi-year ranges
A multi-year "All" range read its series through the DAY-only daily
reader, which caps the result at BUCKET_CAP.daily (365) buckets. The
older history was silently dropped — the range collapsed to roughly the
most recent year instead of the whole span.

Add readTieredRollupSeries, which reads the bucket tier matching the
charts own display downsampler (WEEK for 1-2 years, MONTH beyond) and
returns whole-history coverage at that tier. readDailySeries now routes
any window wider than the DAY cap through it, falling finer on a coverage
miss and logging the miss rather than truncating silently. Short and
normal ranges never enter the branch, so the common path is unchanged.
2026-06-21 21:01:04 +02:00
Marc-André Bombeck 559db472fc Merge Oura resilience tile into release/v1.19.2
Surface the captured Oura resilience band (limited…exceptional) as a calm
tile on the recovery surface, with a quiet above/in-line/below-average cue
and a learning state while the series is sparse. Server-authoritative read
of the existing summaries slice; neutral card chrome.
2026-06-21 20:58:42 +02:00
Marc-André Bombeck ede01e5700 Merge L1/L2 cleanup into release/v1.19.2
Correct the sleep-rhythm peer-card comment to state plainly that each
card reads its own window (the captions disclose the per-card night
count); no computation changed. Remove the dead streaming.startedAt and
streaming.reasoning state left after the thinking-disclosure removal.
2026-06-21 20:57:35 +02:00
Marc-André Bombeck 7f40860891 feat(insights): surface the Oura resilience band on the recovery page
v1.19.0 ingests Oura's daily resilience level end-to-end (RESILIENCE,
ordinal-encoded limited=1 … exceptional=5) but no surface rendered it.
Add a calm resilience tile to the recovery page: it names the latest
band, gives a one-line description, and a quiet "vs the recent average"
cue once a few days exist, with a learning note while the series is
still sparse. Resilience is a categorical band, not a continuous score,
so it reads as a dedicated readout rather than a misleading line chart.

Server-authoritative — the tile reads the stored summaries slice (latest
+ mean, both computed server-side) and never re-derives the value. Calm
posture: one neutral card, no green-when-good / red-when-low tint. The
band label and trend cue carry the read in muted text only.

Self-gating: renders nothing without a reading, and counts toward the
recovery-page data gate so an Oura-only account no longer sees the empty
note. Copy lands in all six locales.
2026-06-21 20:57:30 +02:00
Marc-André Bombeck 1bf8f3d661 refactor(coach): drop the unread streaming startedAt and reasoning state
The thinking disclosure was removed, so the coach streaming bubble no
longer reads startedAt or reasoning. Both fields stayed populated by the
send hook but nothing consumed them. Remove them from
CoachStreamingMessage and every writer, accept the additive reasoning
SSE frame as an explicit no-op so the wire type stays recognised, and
update the thread-test fixture. Streaming is otherwise unchanged.
2026-06-21 20:54:39 +02:00
Marc-André Bombeck aa3d7fcdf7 docs(sleep): correct the sleep-rhythm peer-card window comment
The comment claimed the average-per-night, sleep-debt, and chronotype
cards share one window and cannot disagree. They each read their own
sub-window — the average over the full scorable span, sleep-debt over
its trailing 14 nights, chronotype over CHRONOTYPE_WINDOW_NIGHTS — so
the figures are each internally honest rather than identical, and every
card's caption discloses its own nightsCounted. State that plainly; no
computation changes.
2026-06-21 20:54:33 +02:00
dependabot[bot] 97f34ccde1 chore(deps): bump actions/checkout from 6.0.3 to 7.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 15:52:29 +00:00
dependabot[bot] e377eba545 chore(deps): bump pnpm/action-setup from 6.0.8 to 6.0.9
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.8 to 6.0.9.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0e279bb959325dab635dd2c09392533439d90093...0ebf47130e4866e96fce0953f49152a61190b271)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 15:52:19 +00:00
Marc-André Bombeck 02ce177ac4 Merge pull request #349 from MBombeck/release/v1.19.1
v1.19.1 — Coach flow, sleep & mood polish, navigation, recovery-gap
2026-06-21 14:46:12 +02:00
Marc-André Bombeck 67ead71e24 test(e2e): match the coach conversations handoff query string
The drawer's Conversations button now hands off to
/coach?view=conversations so the full page opens the conversation list on
arrival; widen the navigation assertion to accept the query string.
2026-06-21 14:35:59 +02:00
Marc-André Bombeck 41dab24985 chore(release): v1.19.1 2026-06-21 14:24:39 +02:00
Marc-André Bombeck e2ce064254 fix(illness,sleep): document the sleep average DTO and gate the recovery driver
Add averagePerNight to the SleepRhythmResource OpenAPI schema so the new
average-per-night card's field is part of the iOS-consumed contract (the
endpoint already returned it; the Zod source had not been updated, so the
in-sync check could not catch the omission).

Tag each vital return with whether it deviated in the illness-adverse
direction and tally only adverse returns when naming the recovery driver,
so a neutral move (e.g. weight drift) can no longer be surfaced as the
vital someone recovered ahead of.
2026-06-21 14:23:24 +02:00
Marc-André Bombeck b9f396c72a Merge recovery-gap refinements into release/v1.19.1
Tighten the cross-episode typical-gap floor to count only active days
carrying an adverse-direction reading (a neutral weight-only episode can
no longer clear it), via a single shared adverse-direction predicate.
Surface the most-frequent returning vital so the card names the driver
('…before your resting heart rate settles') instead of a bare line.
2026-06-21 14:12:02 +02:00
Marc-André Bombeck 638bc6c835 feat(illness): name the driving vital in the recovery-gap summary
The cross-episode summary already computes per-vital physiological returns
but the card surfaced only one bare line. Thread the dominant returning
vital through the aggregate and the DTO so the copy reads 'you tend to feel
better before your resting heart rate settles' instead of a context-free
gap figure.

The aggregate tallies which vital produced a real return across the
episodes that qualified for the typical-gap median and reports the most
frequent one (deterministic alphabetical tiebreak); the driver is named
only when a gap actually clears the magnitude gate. Zero new compute — the
returns were already in hand. Adds the localized copy in all six locales.
2026-06-21 14:11:20 +02:00
Marc-André Bombeck 36affd5daf fix(illness): gate the recovery-gap floor on adverse-direction days
The cross-episode typical-gap median counts an episode toward its
qualifying-days floor via the per-episode coverage `historyDays`, which
counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no
illness-adverse direction) could therefore clear the floor and feed the
median despite carrying no illness-relevant signal, weakening the honest-
sample claim.

Compute a distinct-adverse-day count in the engine — days with at least
one notable reading in the metric's adverse direction — and feed that as
the qualifying-days floor instead. Reuses the engine's single adverse-
direction predicate (now exported as `isAdverseDeviation`); a neutral-
direction vital never counts a day. The insufficient arm contributes 0,
which is correct since its gap is null and never reaches the median.
2026-06-21 14:07:44 +02:00
Marc-André Bombeck 62a4dfdf7e Merge S1/S2 sleep average + mood saturation into release/v1.19.1
S1: add an average-per-night sleep card as a third peer beside debt and
chronotype, computed by a shared helper over the same scorable-night set
so the three cannot disagree; grid widens to three columns. S2: restore
full saturation on the two missed mood surfaces (calendar heatmap and the
time-of-day chart), keeping the quiet tint only for no-data cells.
2026-06-21 14:02:09 +02:00
Marc-André Bombeck bbff6ab143 fix(mood): full-saturate the time-of-day chart and mood calendar
The v1.19.0 saturation fix missed two surfaces: the mood-by-time-of-day
bars and the mood calendar still rendered populated values at 0.55
opacity, reading as a dimmed glitch. Drop the dimming on populated bars
and cells (and the legend swatches); keep the quiet tint only on the
no-data empty state.
2026-06-21 14:01:37 +02:00
Marc-André Bombeck c67bd942a2 feat(sleep): add average-per-night card beside sleep debt and chronotype
Compute the mean asleep minutes per night from the same canonical
per-night data the sleep-debt and chronotype cards read, exposed as a
new averagePerNight field on the sleep-rhythm DTO. Surface it as a third
peer card with identical chrome, so the three tiles sit side by side.
Calm partial state under a four-night floor; localised in six locales.
2026-06-21 14:01:30 +02:00
Marc-André Bombeck 430f7e81a1 Merge C1/C2/C3/C5 Coach UI rework into release/v1.19.1
C1: /coach defaults to the new-chat hero, auto-resuming only on an unread
coach-initiated message. C2: always-visible Conversations + New chat
toolbar. C3: replace the thinking disclosure with the typing-dots into
word-fade streaming animation. C5: the drawer Conversations handoff opens
the history list on arrival so the full view is never a blank pane.
2026-06-21 13:59:48 +02:00
Marc-André Bombeck 336c9224f4 Merge S3/S4 settings + sidebar nav into release/v1.19.1
S3: rename the settings nav entry label Layout to Dashboard across all
six locales (route unchanged). S4: fix the main sidebar spine order to
Medications, Vorsorge, Laborwerte, Krankheit, Insights, Coach,
Achievements, with module gating preserved.
2026-06-21 13:59:24 +02:00
Marc-André Bombeck c6f4772606 fix(coach): default to new chat and never dead-end on conversations
Reverse the resume-most-recent entry default: opening the Coach now lands
on the new-chat hero. The only exception is an unread coach-initiated
message (nudge-status `unread`), which auto-opens the most-recent thread so
the user sees what the Coach wrote. The `?c=<id>` deep-link is unchanged.

Add a dedicated, always-visible Conversations button (plus a New chat
button) to the page surface so reaching past conversations no longer hides
in the composer menu. The drawer's Conversations handoff now routes to
`/coach?view=conversations`, which opens the history drawer on arrival, so
the side-view to full-view path always shows the list instead of a blank
pane.
2026-06-21 13:59:05 +02:00
Marc-André Bombeck a86abd3662 fix(coach): restore the typing animation for the live turn
Replace the elapsed-time thinking disclosure with the classic typing
indicator: while a turn is still thinking with no prose, the assistant
bubble shows the pulsing typing dots, then swaps to the streamed
word-by-word prose once tokens land. Drop the now-unused reasoning/
startedAt bubble props and the orphaned disclosure component.
2026-06-21 13:58:57 +02:00
Marc-André Bombeck de14238535 feat(nav): fix the sidebar order of the clinical and insight spine
Below the dashboard/measurements/mood/cycle head, the entries now follow one
fixed sequence: Medications, Vorsorge, Labs, Illness, Insights, Coach,
Achievements. A disabled module drops out; the rest keep this order.
2026-06-21 13:58:43 +02:00
Marc-André Bombeck 5bbb46ef72 Merge C4 Coach token efficiency into release/v1.19.1
Send the full health snapshot only on the first conversation turn (or
when the verbatim history window starts eliding turns); cheap follow-ups
ride the transcript with a one-line grounding pointer. Drops follow-up
snapshot prefix from ~6,150 to ~50 tokens while preserving grounding and
cross-metric correlation.
2026-06-21 13:57:46 +02:00
Marc-André Bombeck bc1f30fec4 perf(coach): send the health snapshot once per conversation
The full SNAPSHOT block (every cluster plus labs, illness, cycle and the
reference-grounding lines) was interpolated into the prompt on every turn,
so a one-word follow-up re-paid the entire grounding prefix. Ship it only on
the first turn, or once the history window starts eliding turns; on a cheap
follow-up send a short pointer back to the snapshot the model already received
earlier in the same conversation. The prior turns ride the transcript, so
grounding and cross-metric correlation are preserved while a follow-up turn
sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply
annotation so the cut is observable.
2026-06-21 13:56:49 +02:00
Marc-André Bombeck 8d1c90b8b6 fix(settings): label the layout nav entry Dashboard to match where it leads
The left-nav entry read "Layout" but the editor it opens is the dashboard
layout home. Rename the label across all six locales; the route stays
/settings/layout.
2026-06-21 13:56:38 +02:00
Marc-André Bombeck bbbafca7f4 Merge release/v1.19.0 into main (v1.19.0)
Release v1.19.0
2026-06-21 13:13:54 +02:00
Marc-André Bombeck a6d9a39c73 fix(crypto,sleep): register the ECG column for rotation and update the sleep-debt route test
The canonical rotation registry (src/lib/crypto/encrypted-columns.ts) is the
source of truth its guard test cross-checks; add EcgRecording.waveformEncrypted
there (the script already references it) and drop the redundant parallel guard.
Update the sleep/rhythm route test to the v1.19.0 rolling 5-night balance
(nightsCounted 5, debt 300) instead of the old 14-night cumulative sum.
2026-06-21 13:06:20 +02:00
Marc-André Bombeck c8673786eb test(integration): export the briefing window constant in the features mock
The insights provider-chain integration test mocks @/lib/insights/features but
omitted BRIEFING_FEATURE_WINDOW_DAYS, which the generate route now reads, so the
route threw on access. Add it to the mock.
2026-06-21 12:55:13 +02:00
Marc-André Bombeck 31efb3e884 chore(release): v1.19.0 2026-06-21 12:46:32 +02:00
Marc-André Bombeck c1a2a2610a fix(crypto): rotate the ECG waveform column + guard rotation coverage
The v1.19.0 EcgRecording.waveformEncrypted Bytes column was not added to the
key-rotation script, so an operator rotation would strand ECG waveforms on the
legacy key and dropping it would make them permanently undecryptable — the same
class as the v1.18.1 clinical-spine gap. Wire the column into the rotation and
add a schema-driven coverage test that fails CI if any encrypted Bytes column
is ever left out again.
2026-06-21 12:43:54 +02:00
Marc-André Bombeck 54fa76de6f style: format feature-cache test 2026-06-21 12:36:48 +02:00
Marc-André Bombeck 324f569f28 fix(i18n): drop the unused telegram.measurementDismissed key
The Telegram reminder flow confirms a dismissed measurement with
telegram.measurementDone; the parallel measurementDismissed string was added
but never wired, tripping the orphan-key i18n guard. Remove it from all
locales.
2026-06-21 12:33:20 +02:00
Marc-André Bombeck 72809280cb Merge w11b/sleepcard into release/v1.19.0 2026-06-21 12:31:58 +02:00
Marc-André Bombeck 26230542ce fix(sleep): make the chronotype tile uniform with the sleep-debt tile
The chronotype card carried its own section heading on top of the title it
already renders, so 'Chronotype' appeared twice and the tile did not match
its sibling. Drop the standalone heading and bring the card to the same
shape as the sleep-debt tile: identical chrome and a bold primary value with
one secondary line. The chronotype band is the primary value at the same
scale the debt tile uses for its hours figure, with the natural sleep-
midpoint clock as the companion line.
2026-06-21 12:31:26 +02:00
Marc-André Bombeck 6d9002bb71 fix(sleep): compute sleep debt as a recovering rolling balance
The cumulative model summed every nightly deficit over a 14-night window
with no credit for catch-up sleep, so a sleeper a modest amount short on
most nights carried hours of standing debt that never recovered while the
pattern held. Replace it with a running balance over a 5-night window:
short nights add their capped shortfall, nights above need pay the balance
down at a sub-linear recovery rate, floored at zero. The headline now
reflects what is still owed tonight and shrinks as the sleeper catches up.
2026-06-21 12:31:18 +02:00
Marc-André Bombeck 7c693e94b5 Merge w11b/tz into release/v1.19.0 2026-06-21 12:22:23 +02:00
Marc-André Bombeck 2760f6b327 Merge w11b/ouraresil into release/v1.19.0 2026-06-21 12:22:21 +02:00
Marc-André Bombeck 0045188899 Merge w11b/telegram into release/v1.19.0 2026-06-21 11:14:24 +02:00
Marc-André Bombeck b24a893b66 feat(telegram): log mood and act on measurement reminders in chat
Extend the self-cleaning Telegram bot beyond medication. A mood reminder
now carries a 1-5 inline keyboard plus a "Note..." force-reply and a
"Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side,
user resolved from the linked chat, idempotent on a per-tap externalId)
and confirms with a calm callback toast, no chat echo. An optional
free-text note attaches to that entry via the prompt-context correlation.
A measurement reminder carries "Done" (satisfies the cadence) and
"Later" (pushes nextDueAt forward) — completion and snooze only, no value
capture.

Prompts and replies self-clean in ~30 minutes: the window drops from 1h
and moves to a shared helper, the sender schedules an unanswered prompt's
deletion at send time, and the existing 15-min sweep also prunes expired
prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual
+ rate-limit-before-secret posture; every write callback authenticates via
the stored chat binding. The Telegram chat id is added to the egress
redaction denylist.
2026-06-21 11:13:33 +02:00
Marc-André Bombeck 7f7ee2c013 feat(telegram): add prompt-context table for mood-note correlation
Add TelegramPromptContext (migration 0187) mapping a bot force-reply
prompt to the entry a free-text reply attaches to, keyed on
(chatId, promptMsgId) and scoped to the owning user. MoodEntry.source
already accepts the free-text TELEGRAM value, so no enum change is
needed.
2026-06-21 11:13:17 +02:00
Marc-André Bombeck 6206f24774 Merge w11b/notifpolish into release/v1.19.0 2026-06-21 11:05:40 +02:00
Marc-André Bombeck 359bb4ffce fix(notifications): drop redundant breadcrumb above the page heading
The notifications page stacked a breadcrumb strip directly above its h1,
so the page read as two headings (the title followed by a channels line).
Remove the breadcrumb so the page starts with a single heading, matching
every other page. Drop the now-unused breadcrumb i18n keys.
2026-06-21 11:05:10 +02:00
Marc-André Bombeck 7969b79443 fix(i18n): add missing notification event labels
The notification preferences build event labels dynamically as
notifications.event<PascalCase>, so the static call-site coverage test
never enumerated them and three event types rendered raw keys:
MEASUREMENT_REMINDER, MEDICATION_INTAKE_SYNC and COACH_NUDGE. Add label
and description for each across all six locale bundles.
2026-06-21 11:05:01 +02:00
Marc-André Bombeck 9680f318a3 Merge w11b/chartrange into release/v1.19.0 2026-06-21 11:00:24 +02:00
Marc-André Bombeck 55b4374b26 fix(charts): honour batch coverage on range-tab change
A range tab wider than the dashboard's batched series slice (90 / All on a
~30-day batch) re-read the narrow slice instead of fetching the requested
window, so the chart kept showing ~30 days. Thread the batch's coverage
day-span to the chart and read the preloaded slice only when the requested
window fits within it; a wider tab falls through to a real fetch. Also raise
the All-range request window past the old 365-day cap so longer histories
are no longer silently truncated client-side.
2026-06-21 10:59:39 +02:00
Marc-André Bombeck 328e1b4544 Merge w11b/demoseed into release/v1.19.0 2026-06-21 10:59:35 +02:00
Marc-André Bombeck 9cf6921ff0 seed(demo): add glucose CGM, richer labs, illness journal, cycle data
Round out the public demo so every feature is explorable:

- Glucose: a 14-day Nightscout-style CGM stream (a reading every 15 min)
  alongside the existing spot panel, so the glucose surfaces render fully.
  Tagged RANDOM to satisfy the glucose-context CHECK.
- Labs: a second older panel plus qualitative rows (negativ / nicht
  nachweisbar) so the list, detail, sort and delete all have content and a
  per-analyte trend.
- Illness journal: a resolved cold with a day-by-day symptom curve and an
  active chronic condition carrying a recent flare.
- Cycle: opt the demo account in and seed five observed cycles with biphasic
  basal body temperature, period flow, fertile-window mucus and OPK, per-day
  symptoms, BODY_TEMPERATURE dual-writes, and a cached forecast.

Cleanup extended to the new tables in FK-safe order; the seed stays
idempotent across re-runs.
2026-06-21 10:58:24 +02:00
Marc-André Bombeck 9cc84e2209 Merge w11b/cycleui into release/v1.19.0 2026-06-21 10:57:39 +02:00
Marc-André Bombeck 75129c798d refine(cycle): consolidate disclaimer, align columns, unify tiles
Move the non-medical disclaimer to the cycle-tracking on-ramp and drop it
from the repeating in-feature surfaces (calendar tab, predictions panel);
delete the now-unused disclaimer component. Align the left ring column with
the tab content rather than the tab strip. Raise the settings subheading
sizes to the standard card-description scale. Reflow the reminder rows so a
wrapping label no longer collides with its button at narrow widths. Unify
the wheel and phase-education tile padding. Cite the symptothermal
double-check primary source under the second fertility sign.
2026-06-21 10:57:16 +02:00
Marc-André Bombeck aff4e9abeb feat(oura): capture daily resilience as an ordinal RESILIENCE metric
Oura's daily_resilience collection reports a categorical level — limited,
adequate, solid, strong, exceptional. Encode it ordinally (limited=1 …
exceptional=5) into the numeric measurement value so it fits the existing
model with no new categorical column: add the RESILIENCE measurement type
(migration 0186), fetch and map daily_resilience in the Oura sync, and skip
an unknown or missing level rather than coercing it to zero.

RESILIENCE_LEVELS in the Oura client is the single source of truth for the
encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored
day overwrites in place. Extend the measurement-type label, unit, range,
category, chart-token, and PR-direction maps; add the metric label across all
six locales.
2026-06-21 10:44:15 +02:00
Marc-André Bombeck 3fe728c20b fix(tz): key the tiered raw daily band on the user's local day
The 0–14d raw band folded same-day readings on a UTC calendar key, so a
late-evening reading slipped onto the wrong day for non-UTC users and
across DST transitions. Key it on userDayKey(tz) instead and make the
timezone a required option so a caller cannot silently fall back to UTC.

The coarser DAY/WEEK/MONTH/YEAR bands stay UTC-anchored on purpose: the
rollup writer and the live aggregator both bucket on the UTC calendar
day and are parity-locked for the read-swap fallback. Document the split
and thread the resolved user timezone from the coach snapshot caller.
2026-06-21 10:39:11 +02:00
Marc-André Bombeck b8fc648bfc Merge w11b/unraid into release/v1.19.0 2026-06-21 10:33:28 +02:00
Marc-André Bombeck 7f9bb6cd71 Merge w11b/a5editorial into release/v1.19.0 2026-06-21 10:33:28 +02:00
Marc-André Bombeck c903c7f1b1 docs(self-hosting): add Unraid CA and Portainer templates, harden compose quick-start
Surface the load-bearing first-run vars in docker-compose.yml and the README
quick-start: secret generation, the SESSION_COOKIE_SECURE=false LAN note, the
single persistent volume, and a /api/version verify step. Add an Unraid
Community Applications template plus a NAS walkthrough covering the external
Postgres setup, and a Portainer app template referencing the compose stack.
2026-06-21 10:32:40 +02:00
Marc-André Bombeck 189065af0f docs(insights): make metric explainer copy vendor-blind and cited
Drop vendor names from four metric definition bodies (wrist temperature,
falls, six-minute walk, sleep breathing disturbances) and frame the
six-minute-walk and breathing-index readings as screening proxies rather
than the supervised clinical test. Add a primary citation to the cardio
fitness body: each one-MET higher fitness associates with roughly
10-25% lower mortality (AHA, Circulation 2016). Propagated across all
six locales.
2026-06-21 10:32:35 +02:00
Marc-André Bombeck 3faafef0df Merge w11b/dense into release/v1.19.0 2026-06-21 10:31:45 +02:00
Marc-André Bombeck 60c8a76b2c feat(measurements): fold out-of-window SpO2 to the dense intra-day tier
Apple Watch Blood-Oxygen samples OXYGEN_SATURATION through the day and
overnight. The readings map to latest aggregation and sit in neither the
cumulative SUM drain nor the daily-mean drain, so every sample piled up raw
forever. Fold out-of-window per-sample SpO2 rows to one daily-mean stats:
row, soft-delete the raw rows, and preserve the daily min/max on the rollup
DAY bucket via a pre-fold recompute — the lowest sample is the overnight
desaturation nadir, the clinically meaningful figure the mean would hide.

Generalise the PULSE min/max preservation to a min/max-preserving set so
the pre-fold recompute and the skipped post-fold recompute cover both PULSE
and SpO2; resting-HR derivation stays PULSE-only. RESPIRATORY_RATE is left
to the existing daily-mean drain it already belongs to.
2026-06-21 10:31:27 +02:00
Marc-André Bombeck 3712b80d0f Merge w11b/a1pipeline into release/v1.19.0 2026-06-21 10:24:20 +02:00
Marc-André Bombeck d7c71f3b32 perf(insights): compute-once feature snapshot per warm scope
The comprehensive briefing's bounded feature read (extractFeatures) was
paid more than once where it should not be: the on-demand route and the
shared generator each run a downgrade ladder that re-reads with identical
arguments, and a nightly warm has no shared object across consumers in a
single tick.

Add a request/run-scoped feature cache: withFeatureCacheScope opens an
AsyncLocalStorage store, getCachedFeatures memoises the first compute per
(userId, day, includeRaw, sinceDays), gated on a cheap salient-input
fingerprint so a mid-scope ingest recomputes and a probe failure fails
safe. Outside a scope it is a pure passthrough. Wired into the
comprehensive generator, the on-demand POST handler, and the nightly
per-candidate loop + forceWarmUser. Coach keeps its own snapshot.
2026-06-21 10:23:14 +02:00
Marc-André Bombeck d62a33a5e7 Merge w11b/a3ecg into release/v1.19.0 2026-06-21 10:23:09 +02:00
Marc-André Bombeck 7f9aeba918 perf(insights): fold correlation-discovery channels into the status input gate
The slow-moving status input fingerprint covered the card's own metric
rows plus mood and height, but not the FDR discovery channels that also
feed the prompt via getRelevantCorrelationsForMetric. A correlation
newly surfacing off a behaviour channel (e.g. daylight -> next-day
weight) left the card's own rows untouched, so the input gate re-stamped
the stale assessment and the discovery never reached the prose.

computeStatusInputFingerprint gains includeCorrelationChannels: it widens
the same grouped probe by the discovery behaviour + outcome channels
(deduped, mood excluded as it is mood-entry backed), so a channel change
flips the hash. Wired into weight-status, the one caller that folds a
relations block.
2026-06-21 10:23:04 +02:00
Marc-André Bombeck 837f215f66 Merge w11b/a4hrwire into release/v1.19.0 2026-06-21 10:22:36 +02:00
Marc-André Bombeck 462948b33b feat(withings): capture the full ECG waveform, not just the AFib verdict
The Heart sync recorded only the device's atrial-fibrillation verdict per
on-device ECG. Fetch each recording's full waveform via the Heart get
endpoint and store the raw micro-volt samples so a real trace can later be
rendered.

The sample array is sensitive health data: it is held AES-256-GCM encrypted
at rest in the new EcgRecording.waveformEncrypted Bytes column (fail-closed
crypto), with the sampling frequency, sample count, duration, lead, average
heart rate, and AFib verdict kept as plaintext descriptors. A re-sync upserts
on (userId, source, externalRecordingId) so a recording is never duplicated;
a waveform fetch failure never fails the verdict ingest. Rendering UI is a
follow-up.
2026-06-21 10:22:29 +02:00
Marc-André Bombeck f38c4bed05 feat(measurements): hourly heart-rate batch upload contract
Define the go-forward aggregated heart-rate upload shape so clients can
stop posting one Measurement per raw HealthKit sample. A heart-rate row
keyed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
carries the hour's average bpm as a single PULSE row and rides the
existing `stats:` overwrite path: a re-post within the hour overwrites
the value instead of duplicating, identical to the per-day cumulative
rows. The hour boundary is UTC so the key is timezone-stable.

The batch route rejects a row that targets the heart-rate bucket prefix
with a malformed hour suffix as a per-entry skip
(`malformed_hr_bucket_id`) rather than persisting an un-overwriteable
row; well-formed buckets, per-sample uuid ingest, and the per-day
cumulative overwrite path are all unchanged.

Average-only for now — the measurement row carries a single value
column, so hourly min/max would need a schema change and is left as a
follow-up. Documents all three externalId shapes plus the `updated`
status in the OpenAPI contract.
2026-06-21 10:22:11 +02:00
Marc-André Bombeck b8e691f6c5 Merge w11b/a2inv into release/v1.19.0 2026-06-21 10:20:26 +02:00
Marc-André Bombeck 2386d1fe78 fix(medications): make inventory Bestand summary server-authoritative
The medication detail clients computed the supply headline in the browser
via summariseSupply, risking web/iOS drift and pulling the shared math
into the client bundle. The GET /api/medications/[id]/inventory route now
computes the canonical SupplySummary server-side through summariseSupply
(the single source of truth) and ships it in the DTO; the Bestand and
Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary
schema to the inventory list response and regenerates the OpenAPI contract.
2026-06-21 10:19:41 +02:00
Marc-André Bombeck c9b1631da7 fix(insights): paint mood bars at full saturation
The muted mood treatment (opacity-55 on the tag bars, fillOpacity 0.55 on the
distribution and weekday charts) read as a rendering glitch rather than a
deliberate calm tint. Drop the dimming so every mood bar paints at full
saturation in its level hue; the guard test now asserts the dimming stays gone.
A separate mood colour concept can be revisited later.
2026-06-21 10:16:06 +02:00
Marc-André Bombeck 7540d0b508 fix(medications): keep the inventory supply helper free of server-only logging
summariseSupply is rendered by the medication detail client surfaces, so the
v1.18.11 underflow wide-event it called pulled node:async_hooks into the client
chunk and failed the production build. Split the observability out: the helper
stays a pure clamp, a new pure detectSupplyUnderflow reports a would-be negative
pool, and the low-stock job emits the medication.inventory.underflow event at
its request-scoped seam where annotate can actually fire.
2026-06-21 09:53:27 +02:00
Marc-André Bombeck 3e578836cc chore(release): v1.18.11 2026-06-21 09:40:53 +02:00
Marc-André Bombeck c17f2abd7b fix(ai): keep gpt-5-codex slugs text-only in the vision gate
The codex vision gate accepted any gpt-5 prefix, which included the gpt-5-codex
specialist slugs that the surrounding contract documents as text-only. Each lab
OCR upload on such a slug burned a doomed image attempt before falling back.
Exclude the -codex slugs from the gate so multimodal gpt-5.x slugs still report
vision while the codex specialists stay on the text path.
2026-06-21 09:38:58 +02:00
Marc-André Bombeck 94ce9e5901 Merge w11/codesplits into release/v1.18.11 2026-06-21 09:32:49 +02:00
Marc-André Bombeck 639b211ca4 refactor(openapi): drop the pre-split medications and insights route files
The split commits added the directory barrels but left the original
single-file modules in the tree; Node resolves the .ts file ahead of the
directory index, so the barrels were shadowed. Remove the originals so
the split modules are the live source.
2026-06-21 01:22:46 +02:00
Marc-André Bombeck 8e34a58c0a refactor(settings): split account-section into profile, passkey, and avatar modules 2026-06-21 01:20:12 +02:00
Marc-André Bombeck fa5c6500b5 refactor(settings): split import-panel into per-source card components 2026-06-21 01:15:54 +02:00
Marc-André Bombeck e9d90a689f refactor(validations): split medication schemas into focused modules behind a barrel 2026-06-21 01:12:35 +02:00
Marc-André Bombeck d1be2a9c6b refactor(openapi): split insights route registry into schemas + paths behind a barrel 2026-06-21 01:06:59 +02:00
Marc-André Bombeck 2e2e596cca refactor(openapi): split medications route registry into schemas + paths behind a barrel 2026-06-21 01:06:53 +02:00
Marc-André Bombeck 85828371a6 test(insights): stub briefing illness/cycle context in generate route test
The v1.18.11 briefing now folds illness-episode and cycle state into the
prompt via buildBriefingIllnessCycleContext, which resolves module gates
through memoizePerRequest and queries cycleProfile. The generate route test
mocks neither getEvent nor the cycle/illness models, so every case threw once
the new call path ran. Stub the context module to no output and add the
getEvent export to the logging-context mock so these cases stay scoped to the
provider and cache behaviour they assert.
2026-06-21 01:02:44 +02:00
Marc-André Bombeck 5666e63a57 Merge w11/aipipeline2 into release/v1.18.11 2026-06-21 00:57:12 +02:00
Marc-André Bombeck 7a5f305a3a perf(insights): gate weight and BMI status on a cheap input fingerprint
Weight and BMI move on a weekly cadence, but the per-metric generators
rebuilt the full snapshot (bounded findMany + per-series rollup reads +
correlation math) every day and relied on the post-build content-hash gate
to skip only the model call. Add an input gate that runs first: a single
grouped probe over the salient inputs (per-type count + newest reading, plus
mood for weight and the profile height for BMI) fingerprints what the
snapshot would read. On a non-forced run whose fingerprint matches the cached
row, the cached assessment is re-stamped under today's key and the whole
build is skipped. A miss falls through to the normal build and the existing
post-build gate.

Consent is checked before any re-date so a revoked-consent user never has
stale text presented as current. Forced runs never gate but still record a
fresh fingerprint for the next day.
2026-06-21 00:56:07 +02:00
Marc-André Bombeck e18bd3e23a perf(insights): generate the per-metric status cards in one batched call nightly
The 02:00 status cron now runs generateStatusBatchForUser for every eligible
user: it builds the seven per-metric snapshots once and issues a single
provider call for the metrics still needing the model, fanning each summary
back into the same per-metric cache rows the standalone generators write.

The six later per-metric crons (02:05-02:30) keep their drivers but now hit
the calendar-day cache the batch already filled, so they resolve as cache
reads and only cover the edges — a user discovered after 02:00, a metric the
batch omitted, or a batch-call failure (which the batch falls back per metric).
Reproducibility and the consent/grounding gates are unchanged: the batch runs
through runStatusCompletion, which still pins the reference seed.

Drops the now-unused per-metric general generator and recordRun plumbing.
2026-06-21 00:55:56 +02:00
Marc-André Bombeck 49add3f43b perf(analytics): fold the comprehensive aggregate's canonical self-join into one CTE
The warm narrow query and the cold heavy query each referenced the
canonical-source subquery twice — once in the window_stats CTE and once in
the outer body — so the DISTINCT-ON pick over the live 90-day rows ran twice
per rebuild. Extract canonicalMeasurementsCte (the SELECT body without the
alias wrapper), bind it once as WITH cm AS (...), and select FROM cm m from
both window_stats and the outer aggregate. Output is identical; the dominant
self-join runs once.

canonicalMeasurementsFrom now wraps the same body, byte-stable for the
single-reference call sites (pinned by a new parity test).
2026-06-21 00:55:47 +02:00
Marc-André Bombeck 5859aaf46f Merge w11/backlog into release/v1.18.11 2026-06-21 00:54:53 +02:00
Marc-André Bombeck 0f5d67033a feat(oura): ingest the daily cardiovascular-age estimate
The daily_cardiovascular_age collection reports a years-unit arterial-age
estimate that maps directly onto the existing VASCULAR_AGE metric, so no
migration is needed. Resilience stays out: it is a categorical level with
no numeric home and would require a new type plus a categorical column.
2026-06-21 00:54:27 +02:00
Marc-André Bombeck 621b6734d0 feat(withings): capture ECG / AFib screening results
The Heart List endpoint returns a per-recording AFib verdict from the
ScanWatch family that no sync fetched. Capture each recording as an
IRREGULAR_RHYTHM_NOTIFICATION event row carrying the device's own verdict
in rhythm_classification, mirroring the Apple Health categorical-event
treatment. Reuses the existing enum, column, and constraint, so no
migration. Fires on the measure webhook path, which shares the metrics
scope the Heart List endpoint needs.
2026-06-21 00:54:16 +02:00
Marc-André Bombeck af86a14824 feat(dashboard): surface the daily briefing on the hero
The briefing only reached the dashboard as the hero's lowest-priority
verdict, which the severity ladder almost always pre-empted, so a fresh
briefing was invisible above the fold. Lift its own signals (falling back
to key findings) onto the hero as a self-gating strip beneath the verdict
row, reusing the insights briefing's tone-bar row language. Placement
only: the verdict ladder and the briefing content are unchanged.
2026-06-21 00:54:09 +02:00
Marc-André Bombeck 118415da12 Merge w11/hragg into release/v1.18.11 2026-06-21 00:30:03 +02:00
Marc-André Bombeck 8f1e3c5d44 feat(measurements): preserve resting + daily min/max when folding dense heart rate
The dense intra-day retention drain folds out-of-window per-sample PULSE rows
to one daily-mean row to bound the high-frequency ingest. The mean-only fold
erased the two clinically-meaningful daily figures derived from the raw
stream: the resting estimate (20th-percentile of each day's PULSE, the
fallback for users without a native resting series) and the daily min/max held
in the persistent rollup DAY bucket.

Preserve both in the PULSE facet of the fold:

- Recompute the PULSE DAY rollup from the raw rows before the fold, while they
  are still live, so the bucket keeps the true intraday min/max/mean; skip the
  post-fold recompute that would collapse them.
- For users with no native resting series, mint one derived RESTING_HEART_RATE
  row per folded day from the day's 20th percentile, sourced COMPUTED so it
  never collides with measured resting rows and reads back as the clean
  resting series. Users with a native series are skipped to avoid
  double-counting; the probe is memoised per user.

Add a per-day failure boundary so one poisoned day can no longer abort the
whole walk. Surface the derived-resting count through the handler return,
the wide-event annotation, and the worker log.
2026-06-21 00:29:35 +02:00
Marc-André Bombeck 41849a9f9a Merge w11/coachdata into release/v1.18.11 2026-06-21 00:26:58 +02:00
Marc-André Bombeck b0a8dfc29e feat(coach): pass recent lab values into the coach context
Add a labs block to the coach snapshot so the assistant can answer
questions about the user's lab results. Each entry is the most recent
reading per biomarker over the last 12 months (capped), carrying the
resolved name, value, unit, reference range, in/out-of-range verdict,
and measured date. Qualitative readings (e.g. negativ) are included
with a neutral range status. Values resolve server-side via the same
helpers the labs API uses, and the system prompt instructs the model to
quote them verbatim and never invent a lab number, so the block stays
grounded. The free-text qualitative result is sanitised before it can
reach the prompt; the encrypted note is never read.
2026-06-21 00:26:19 +02:00
Marc-André Bombeck 899e2f5fcd Merge w11/coachtile into release/v1.18.11 2026-06-21 00:26:17 +02:00
Marc-André Bombeck ead44e79d9 feat(coach): deep-link the Coach page into a conversation
The Coach page opened a blank new chat regardless of where the user came
from. Make it a deep-link target: a `?c=<id>` query param opens that
thread on mount, and absent it the page auto-opens the user's most-recent
conversation. "Most recent" is the `updatedAt desc` head of the rail
list, so the same thread opens on web and mobile.

The shared conversation surface grows two additive props
(`initialConversationId`, `autoOpenMostRecent`) that feed its existing
selection state; the history drawer's selection stays the single source of
truth. The drawer surface is unchanged — both props default off and the
rail list is only fetched when auto-open is requested.
2026-06-21 00:25:54 +02:00
Marc-André Bombeck 397dbefbd7 Merge w11/inventory into release/v1.18.11 2026-06-21 00:25:50 +02:00
Marc-André Bombeck ea314b40f9 fix(medications): clamp pooled inventory stock at zero
Add a central sanity gate in summariseSupply, the single helper every
supply readout flows through. A corrupt or legacy row with a non-finite
or negative unit count could pool straight into the headline and surface
a nonsensical negative Bestand; the helper now floors the pooled
remaining, total and expired figures at zero and emits a
medication.inventory.underflow wide-event when a raw value would have
gone negative, so the underlying data defect stays observable.

closes #31
2026-06-21 00:25:25 +02:00
Marc-André Bombeck 6975849bec Merge w11/perf into release/v1.18.11
Serve achievements via SWR and precompute Berlin day-keys to kill the per-row
Intl cost; switch dashboard/summary to cachedSwr; lazy-load three charts that
bypassed the dynamic boundary.
2026-06-20 19:05:27 +02:00
Marc-André Bombeck a0187c496a Merge w11/aipipeline into release/v1.18.11
Bound the comprehensive briefing's feature read to 400 days while keeping
all-time extremes honest via a full-history groupBy, and append module-gated
illness and cycle context to the briefing prompt.
2026-06-20 19:05:10 +02:00
Marc-André Bombeck f3af5d361a perf(gamification,dashboard,insights): cut achievements cold rebuilds and lazy-load three charts
Achievements: the bucket was 60 s hard-TTL while the app-wide unlock
notifier polls every 2 minutes, so every poll landed on an expired
entry and fired a cold ~400 ms rebuild in the foreground. Add a
10-minute stale-while-revalidate window and read the route through
cachedSwr; the route already persists pendingUnlocks outside the cached
factory, so a stale serve never skips an unlock write.

Achievements builder: the vitals array ran through several full-array
passes, each re-deriving a Berlin day-key (and hour) per row via
Intl.DateTimeFormat. Compute each row's day-key once and thread the
parallel array into the green-day, weekly-consistency, engagement, and
hidden passes; the hidden pass reuses a single precomputed hour array.
Results are byte-identical to the per-row derivation (parity test added).

dashboard/summary: read through cachedSwr so the iOS-polled endpoint
serves its existing 1 h stale window instead of re-paying the cold
rebuild every 60 s on the plain cached path.

Charts: route the three recharts consumers that bypassed the shared
next/dynamic boundary (lab biomarker chart, sleep stage stacked bar,
recommendation rationale charts) through dynamic imports with matching
loading skeletons, taking recharts off the first-load JS for
/labs/[id], /insights/sleep, and the insights recommendations surface.
2026-06-20 19:04:31 +02:00
Marc-André Bombeck 8479ea001b perf(insights): bound briefing feature window and add illness/cycle context
Bound the comprehensive briefing's feature read to a 400-day window instead of
walking the full measurement history on every generation. All-time min/max/avg
are sourced from one grouped aggregation over the whole history, so the
allTime* fields stay honest while the unbounded row materialisation is gone;
the oversize downgrade ladder remains as the safety net.

Fold illness-episode and cycle state into the briefing prompt, reusing the
server-authoritative builders the Coach already assembles. Both are
module-gated and cost nothing for users without them, so a mid-illness or
mid-cycle user finally gets context-aware prose.
2026-06-20 19:04:08 +02:00
Marc-André Bombeck c100b67eda Merge w11/geo into release/v1.18.11
Surface non-ok geo-lookup responses as a warning instead of swallowing them,
make the lookup provider/URL override explicit, and whitelist TRUST_CF_CONNECTING_IP
+ the geo env in compose so the real visitor IP is stored behind Cloudflare.
2026-06-20 19:01:37 +02:00
Marc-André Bombeck 72e944aabb Merge w11/codexvision into release/v1.18.11
Fold image input into the Codex client (input_image over the ChatGPT-OAuth
responses endpoint, no API key); make the codex vision gate model-aware; resolve
labs OCR to vision mode for multimodal codex with safe text/local-OCR fallback.
2026-06-20 19:00:40 +02:00
Marc-André Bombeck 8d73d9721f fix(geo): default to ip-api.com and surface non-ok lookup failures
The ipwho.is default rejected server-side egress with a 403 on the free
plan, which the resolver swallowed as a clean miss, so the admin
login/audit overview rendered "—" for every session. Switch the default
online provider to ip-api.com (the parser already accepts its
status/countryCode shape) and surface a non-ok HTTP status (403/429/5xx)
as a wide-event warning instead of returning null silently, so a future
provider rejection is visible.

Whitelist TRUST_CF_CONNECTING_IP, IP_GEO_LOOKUP_URL and
IP_GEO_LOOKUP_DISABLED in the compose environment block and document them
in .env.production.example so a Cloudflare-fronted deployment can store
the real visitor IP rather than the edge address.
2026-06-20 19:00:27 +02:00
Marc-André Bombeck 178d4d7610 feat(labs): unlock native Codex vision for lab OCR
Image input rides the ChatGPT-OAuth codex/responses endpoint via the
input_image content block (protocol spec 2b) — no vision API key. The
codex client folds params.images into input_image blocks alongside the
input_text; text-only calls keep the prior byte-identical wire. The
vision gate now resolves codex capability against the working slug
(GPT-5.x / gpt-4o-class multimodal) instead of a blanket false, with the
OCR capability resolver feeding the codex slug rather than the user's
aiModel. A non-multimodal slug or an image-block rejection degrades to a
clean extract failure / local-OCR text mode.
2026-06-20 18:59:37 +02:00
Marc-André Bombeck d91cb35569 Merge w11/coachux into release/v1.18.11
Rework the Coach page to a ChatGPT-style IA: drop the top header, make the
composer the control hub (+ actions, settings, mic), open history as a left
slide-in drawer, and keep the composer one constant width across new-chat and
active conversation. The FAB drawer surface is untouched.
2026-06-20 18:58:03 +02:00
Marc-André Bombeck 1469336cfe feat(coach): rebuild the Coach page around a ChatGPT-style composer hub
Drop the dedicated page's top header bar and the conversation-history
strip. The composer now carries the controls: a leading actions menu
(new chat + open conversations) and a settings deep-link beside the mic
and send. Conversation history opens as a left slide-in drawer that works
on phone and desktop.

Pin the composer to one centred, max-width-capped column shared with the
thread so its width stays constant from the new-chat hero into an active
conversation. The drawer surface keeps its own header and body chrome
unchanged.
2026-06-20 18:57:18 +02:00
Marc-André Bombeck 4aebaaf399 Merge release/v1.18.10 into main (v1.18.10)
Data-loss fix, measurement consolidation, AI grounding, full-page Coach, local OCR, Withings sleep vitals, and broad polish. Six upfront audits + diff review; CI green.
2026-06-20 17:38:07 +02:00
Marc-André Bombeck 32296c901c fix(db): raise the default statement timeout to 60s
The 15s default cancelled the live-aggregate analytics fallback on a coverage
miss over a large account (Postgres 57014), surfacing as a 500. Sixty seconds
still bounds a runaway query well before it can starve the pool while leaving
headroom for the heaviest legitimate read.
2026-06-20 17:30:59 +02:00
Marc-André Bombeck 16759b4df8 chore(release): v1.18.10
Fix encrypted day-log data loss and the stalled cumulative-measurement drain;
capture Withings sleep vitals and Oura VO2max; harden AI grounding across the
briefing, Coach, narrative, and status cards; full-page Coach with history and a
working mic; on-device lab OCR; preventive-care, labs, and settings polish; BP
warm/cold parity, a statement timeout, and a covering measurements index.
2026-06-20 17:19:14 +02:00
Marc-André Bombeck 5c75fb6194 Merge w10/reconmobile into release/v1.18.10
Keep the full-page Coach composer clear of the mobile bottom nav and safe-area;
wrap long More-hub labels instead of ellipsising; raise lab-history row controls
to 44px on touch; make the biomarker-detail Add button icon-only on small phones.
2026-06-20 17:16:02 +02:00
Marc-André Bombeck 8ef61b02fe fix(mobile): keep Coach composer above the bottom nav, restore full hub labels
Size the full-page Coach surface to clear the mobile BottomNav band plus the
iOS safe area so the docked composer and Stop control never sit under the nav;
desktop full-bleed height is unchanged (the nav is mobile-only).

Stop truncating the More-hub labels: shrink the label type and allow a
two-line clamp so long words (German Benachrichtigungen, and the long es/fr/it
labels) read in full instead of ellipsising.

Raise the lab reading-history edit/delete controls to 44px on touch
(compact on desktop), and drop the lab-detail Add button to icon-only on the
narrowest phones.
2026-06-20 17:15:35 +02:00
Marc-André Bombeck 1706b67dcf Merge w10/codehygiene into release/v1.18.10
Enforce apiFetch in src/lib/queries and migrate two hand-rolled hooks; thread a
DST-safe day-key into readiness and mood-narratives; lazy-load qrcode; add a
shared day-millisecond constant.
2026-06-20 17:11:51 +02:00
Marc-André Bombeck 643c33250b perf(settings): code-split qrcode out of the sharing chunk
The QR is only rendered after a share link is created, so import qrcode
lazily inside the effect instead of statically at module top. The library
no longer ships in the initial settings bundle.
2026-06-20 17:10:48 +02:00
Marc-André Bombeck a9c327401a fix(insights): key readiness + mood-streak day buckets in user tz
The readiness same-day grouping and the mood-streak anchor/walk both
sliced the UTC instant for their day key, mis-bucketing a late-evening
reading for a logger east or west of UTC. Thread an optional tz (default
Europe/Berlin) through both paths and key the buckets via userDayKey so
the day boundary follows the user's wall clock. Centralise the inline
day-in-ms literal in a shared time-constants module and use it here.
2026-06-20 17:10:36 +02:00
Marc-André Bombeck 320c33b918 lint: enforce api-fetch wrapper across src/lib/queries
Add src/lib/queries/ to the api-fetch-required ENFORCED_ROOTS so the
rule matches its own header comment, then route the two flagged query
hooks through apiGet instead of hand-rolling the envelope unwrap.
2026-06-20 17:10:24 +02:00
Marc-André Bombeck 234c43e4da Merge w10/aigrounding into release/v1.18.10
Ground daily-briefing numbers against the signal features, guard Coach replies
against fabricated doses and risk scores, gate the period narrative, feed the six
status cards a precomputed signal block, add SSE cancellation, and cap the Coach
snapshot read.
2026-06-20 16:59:58 +02:00
Marc-André Bombeck 883383c6a6 test(coach): assert grounding + GLP-1 contract in every locale's prompt
The safety-contract matrix parity test checks rule bodies are non-empty;
this asserts the resolved getCoachSystemPrompt(locale) string carries the
grounding marker and the GLP-1 dose-safety contract for all six UI
locales, closing the gap where a non-EN/DE locale could silently fall
back to a weaker EN-plus-footer body.
2026-06-20 16:58:45 +02:00
Marc-André Bombeck db68f994c2 fix(insights): feed the six specialised cards a precomputed SIGNAL block
BP, pulse, weight, BMI, mood and medication-compliance still asked the
model to derive the recent-vs-baseline comparison from raw buckets — the
exact arithmetic small models do unreliably, and the pattern the
base-system prompt already advertises as precomputed.

Build the SIGNAL block (current / baseline / signed delta / normal-swing
verdict) with buildMetricSignal from each card's graded series and inject
it into the snapshot; BP carries two signals, compliance one per
medication. Each card's prompt section now leads from the signal block
and is told to state, not recompute. The server-computed compliance %
is unchanged.
2026-06-20 16:58:44 +02:00
Marc-André Bombeck 22fac8ae90 perf(coach): cap the snapshot measurement read
The single multi-type window read had no take bound, so a lastYear /
allTime scope over a high-frequency type (PULSE / glucose are
200k-row-class) loaded a year of rows just to fold them into the ~21
daily + ~10 weekly buckets the prompt shows. Read newest-first with a
6000-row cap and restore ascending order; the coarse MONTH/YEAR tail
still comes from the rollup tier, so deep history survives.
2026-06-20 16:58:33 +02:00
Marc-André Bombeck 080ce73475 fix(insights): add a grounding gate + retry to the period narrative
The period narrative was written straight to storage with no
post-generation check and no corrective retry, unlike Comprehensive
Insights. A populated context could still yield causal phrasing or a
number absent from the context block.

Add a validator that flags causal language (EN/DE) and any number not in
the structured context; on a trip, retry once with a corrective suffix,
then write nothing (the last good row stays) rather than persist an
ungrounded or causal story.
2026-06-20 16:58:33 +02:00
Marc-André Bombeck 869f1d9c11 fix(coach): wire the outbound screen and an SSE cancel handler
Apply the outbound safety screen to the assembled Coach reply before
persistence and streaming, with a per-turn audit row on a block.

createSseStream gains a cancel handler + a shared AbortSignal: a client
disconnect now flips the signal so the token-emit loop stops enqueuing
into a closed connection and any provider fetch wired to the signal is
torn down. The chat emit loop honours it between frames.
2026-06-20 16:58:23 +02:00
Marc-André Bombeck 64de009d66 fix(coach): screen the assistant reply before it streams
detectRefusal guards only the inbound user message; the model's reply
was streamed verbatim. A dose-prescription or a fabricated clinical risk
score that slipped past the system-prompt contracts reached the user
unfiltered.

Add a deterministic outbound screen on the assembled reply (which the
route already buffers before streaming): tight dose-change patterns plus
risk-score patterns, EN/DE. On a trip the turn is replaced with a calm,
grounded fallback and any reminder suggestion / key-value provenance from
the discarded reply is dropped.
2026-06-20 16:58:23 +02:00
Marc-André Bombeck 8b5235c25b fix(insights): ground daily-briefing numbers against precomputed signals
The daily-briefing block (paragraph / signalsOfDay / keyFindings) was
schema-shaped but never number-checked the way recommendations[] are: a
restated figure could drift from the snapshot with no server rejection.

Add a grounding cross-check that asserts every number the briefing prose
states traces to a features.signalsOfDay figure (latest / avg / delta)
within a rounding tolerance. Wire it into both generation sites with one
corrective retry, then strip the briefing rather than persist a
fabricated number. Also gate the higher-temperature paragraph reroll on
the same check so a phrasing reroll cannot drift a figure.
2026-06-20 16:58:06 +02:00
Marc-André Bombeck 28dd3b10af Merge w10/settingsmisc into release/v1.18.10
Align the Vorsorge settings card to the canonical pattern, neutralize the danger
zone card, mark the bug-report feature deprecated, make ipwho.is the default geo
resolver, and keep the mobile More-hub labels on one line.
2026-06-20 16:57:55 +02:00
Marc-André Bombeck 6ee7df3098 fix(nav): keep mobile More-menu labels on one line
Trim the longest label ('Benachrichtigungen') with truncate (nowrap +
ellipsis) instead of break-words so every More-hub tile reads on a
single line on a phone, matching its neighbours. The full label stays
discoverable via the title attribute; the desktop layout is untouched.
2026-06-20 16:57:01 +02:00
Marc-André Bombeck 5b6544ec63 feat(admin): mark the feedback feature as deprecated
Add a discreet 'Deprecated' badge and a short note ('will be removed in
a future version') to the bug-report settings entry. The functionality
stays intact; this only signals the upcoming removal across all locales.
2026-06-20 16:56:55 +02:00
Marc-André Bombeck 17a7d1c747 fix(admin): neutralise the danger-zone card surface
Render the danger-zone section on the shared SettingsCard surface
(bg-card + border-border) instead of the destructive-tinted card edge.
A red card surface reads as alarming rather than premium; the danger
signal now lives only in the destructive delete button and its typed
confirmation dialog, with the AlertTriangle header icon still flagging
intent.
2026-06-20 16:56:49 +02:00
Marc-André Bombeck ed99d207e4 fix(settings): align the Vorsorge settings card with the shared pattern
Adopt the standard SettingsCard + SettingsCardHeader contract for the
three Vorsorge settings groups (view, order, manage): bordered card
surface, neutral muted section icon, text-lg title, and a short muted
description, replacing the bare sections with text-sm headings so the
surface reads like every other Settings section. Add a reorder
description string across all locales.
2026-06-20 16:56:42 +02:00
Marc-André Bombeck aae65b8bb1 feat(geo): default IP geolocation to the online ipwho.is resolver
Make the keyless ipwho.is HTTPS lookup the primary resolver for every
self-host. The bundled MaxMind GeoLite2 offline tier becomes an optional
fallback, consulted only when the online lookup misses or when egress is
disabled. A missing offline tier is now the expected baseline rather than
a gap, so the one-shot admin alert fires only when neither resolver can
produce a location.
2026-06-20 16:56:20 +02:00
Marc-André Bombeck b042fcfc27 Merge w10/ingest into release/v1.18.10
Capture Withings sleep vitals (HR/respiratory/SpO2/sleep-HRV/score) via the sleep
summary; collapse overlapping BP sources on the cold path for web/iOS parity; add
a STRESS_SCORE source-priority ladder; add a soft-delete-aware measurements index
and cap the all-time cold scan.
2026-06-20 16:53:35 +02:00
Marc-André Bombeck 3fb1f3989c perf(measurements): cover soft-delete reads and cap the all-time live scan
Add a covering partial index over (user_id, type, measured_at DESC) WHERE
deleted_at IS NULL (migration 0183). The hot reads all filter deleted_at and
order measured_at DESC, but the base index is ASC and excludes deleted_at, so
every type not served by the two existing partial indexes pays a heap re-check
of deleted_at over its partition. The covering partial removes the re-check for
the DISTINCT-ON-latest, list and series reads.

Cap the cold-fallback all-time aggregate in summaries-slice at a 15-year
window. It sits far beyond any real history, so it never changes a real
all-time count/min/max/mean; it exists only so a coverage-miss cold read can't
trigger an unbounded full-partition scan.
2026-06-20 16:52:19 +02:00
Marc-André Bombeck 56d8cf19c5 fix(sources): ladder STRESS_SCORE so the source collapse stays deterministic
STRESS_SCORE has no source-priority ladder, so metricKeyForType returns null
and the SQL source collapse falls to an alphabetical rank-90 tiebreak. It is
safe today because the only producer is the COMPUTED HRV-derived proxy, but
the instant a device-native producer (Oura daily_stress) is enabled both rows
survive and blend nondeterministically.

Add a stress metric key with an OURA-above-COMPUTED default ladder, wire it
through metricKeyForType and RANKED_TYPES, and surface it in the Sources
settings (all six locales). A guard test asserts every RANKED_TYPES member
resolves to a non-null metric key so a future ranked type can't ship unladdered.
Regenerate the OpenAPI for the additive source-priority key.
2026-06-20 16:52:10 +02:00
Marc-André Bombeck 013241ba74 fix(analytics): collapse overlapping sources on the cold BP path
The raw BP pulls in the comprehensive aggregator and the bp-in-target live
fallback read every source's row for BLOOD_PRESSURE_SYS/DIA. BP is a
multi-source metric — a Withings reading mirrored into Apple Health is two
rows for one event — so each shared reading was counted twice in the
in-target denominator and twice in the recency-weighted grade. The rollup
branch of the same fast-path reads source-collapsed measurement_rollups, so a
rollup-warm account and a rollup-cold account returned different BP
percentages and grades for identical data — a web/iOS and cold/warm parity
break.

Route both raw pulls through canonicalMeasurementsFrom so the cold/live path
picks the ladder-winning source per (type, day), matching the warm/rollup
path. Per-event rows of the winning source survive the collapse, so the
downstream 5-minute pairing still sees individual readings.
2026-06-20 16:52:00 +02:00
Marc-André Bombeck d08e462136 Merge w10/vorsorgedash into release/v1.18.10
Move the Vorsorge cadence into the metric row, link card headings to filtered
results, tighten the daily-briefing card rhythm, and invalidate the dashboard
snapshot on briefing regenerate and layout save so the Startseite refreshes.
2026-06-20 16:51:59 +02:00
Marc-André Bombeck fb4e3921e2 feat(withings): capture nightly sleep vitals from the summary endpoint
The per-segment sleep sync wrote only the stage timeline; the per-night
summary's vitals — average HR, respiratory rate, SDNN-based HRV, average
SpO2 and the sleep score — were dropped at the fetch. Add a getsummary
fetch that maps each vital to its existing MeasurementType (RESTING_HEART_RATE,
RESPIRATORY_RATE, HEART_RATE_VARIABILITY, OXYGEN_SATURATION, SLEEP_SCORE), so
no migration and no client-contract change: the rows flow to the sync delta
under the WITHINGS source like every other server-side fetch.

Each vital upserts on the stable (userId, type, source, externalId) composite
keyed by the night's session id, so a re-sync overwrites rather than
duplicates. A summary fetch failure is isolated so the stage segments already
written survive. Fractional SpO2 is normalised to percent.
2026-06-20 16:51:51 +02:00
Marc-André Bombeck d819389483 Merge w10/labsui into release/v1.18.10
Turn the labs add action into a scan/manual choice; add opt-in client-side local
OCR (tesseract.js) so non-vision providers can scan reports; move biomarker
delete to the detail view; make overview tags compact; surface reference-range
confidence for new biomarkers.
2026-06-20 16:51:22 +02:00
Marc-André Bombeck 649bfb3ea8 fix(dashboard): refresh briefing and layout on the Startseite without reload
The dashboard hero reads the briefing headline and the layout from the
server snapshot. Regenerating a briefing or saving the dashboard layout
only touched their own caches, so the snapshot kept stale data until its
own window lapsed and the Startseite looked unchanged until a manual
reload. Invalidate the dashboard snapshot on briefing regenerate and on
layout save/reset.

Harmonise the daily-briefing card body rhythm with its sibling insights
cards (space-y-3, matching correlation-card) so it no longer reads taller.
2026-06-20 16:51:18 +02:00
Marc-André Bombeck 0623316c12 feat(vorsorge): move cadence into metric row and link heading to filtered results
Drop the grey cadence pill from the card heading. The cadence now reads as
part of the metric line, styled like the metric label itself (no shaded
background), via a new MedicationCardHeader metricMeta slot. Nothing else
sits beside the heading.

Link a measurement-linked reminder's heading to the measurements history
pre-filtered to its type, reusing the existing kebab target. Free-text
reminders keep a plain heading.
2026-06-20 16:51:17 +02:00
Marc-André Bombeck d65ed8b0b6 fix(labs): move delete to the detail view and shrink overview tags
Remove the inline trash icon from the lab list and tile rows; delete now
lives only on the value detail view, beside Edit, so a stray tap on the
overview cannot remove a biomarker. Make the reference-range badge beside
each analyte heading quieter on the overview (smaller text, tighter
padding, no icon) so it reads as a subtle qualifier instead of competing
with the heading.
2026-06-20 16:50:23 +02:00
Marc-André Bombeck 028b0d8685 feat(labs): unify the add action into a scan-or-manual choice
Replace the separate, capability-gated scan button with a single Add
menu: "Scan a document" (OCR) or "Add a value manually". The scan entry
appears only when the capability probe reports a usable mode, and routes
to native vision or local in-browser OCR (tesseract.js, lazy-loaded,
deu+eng, images only) per the resolved mode. Add the local-OCR opt-in
toggle to the Labs settings page with honest "less accurate — review
every value" copy.

Surface range confidence prominently on the OCR review screen for new
numeric biomarkers, where the extracted range becomes the catalog range:
flag low range-confidence or a missing range so the user verifies it.
2026-06-20 16:50:16 +02:00
Marc-André Bombeck 3d039d5220 feat(labs): add local-OCR text mode for image-blind AI providers
Add an opt-in in-browser OCR path so a user whose provider cannot read
images (ChatGPT-OAuth/Codex, a text-only model) can still scan a lab
report. The capability probe gains a `mode` field: `vision` when the
provider reads the image directly, `text` when local OCR is enabled and
any provider is configured, or null. The extract route grows a text
branch that accepts the in-browser-OCR'd text as JSON and structures it
through the existing schema + review/commit flow; the raw image never
reaches the server in text mode. New `labsLocalOcrEnabled` user column
(migration 0184, default off) gates it, with a dedicated PATCH endpoint.
2026-06-20 16:50:06 +02:00
Marc-André Bombeck 44ef6eb678 Merge w10/datacrit into release/v1.18.10
Preserve undecryptable encrypted day-log columns on unrelated edits (data-loss
fix); give the cumulative drain a per-day error boundary so one colliding day no
longer aborts the walk; apply a per-session statement timeout.
2026-06-20 16:46:02 +02:00
Marc-André Bombeck 108fcaab4e fix(db): bound every session with a statement timeout
Without a statement timeout a single stuck query or lock wait holds one of
the 20 pool slots indefinitely; enough of them permanently exhaust the pool
and every DB-backed route stops serving. Apply statement_timeout and
idle_in_transaction_session_timeout (default 15s, env-overridable, 0 to
disable) at connection establishment so every pooled session is bounded.

Correct the stale pool-sizing comment: production max_connections is 200,
not 100, and the web and worker containers share the one Postgres.
2026-06-20 16:44:35 +02:00
Marc-André Bombeck 2803df5275 Merge w10/coachui into release/v1.18.10
Make the Coach page full-bleed (no inner panel, no minimize button), keep the
conversation history rail, always render the dictation mic with a disabled
tooltip and error toast when speech is unavailable, and drop the starter chips.
2026-06-20 16:43:26 +02:00
Marc-André Bombeck 398f5c3b6b fix(coach): make the composer mic reliable and never a dead tap
Always render the dictation mic so the affordance stays discoverable. On
a browser without the Web Speech API (or during SSR) render it disabled
with an explanatory tooltip instead of hiding it or leaving a control
that silently does nothing on tap. When a dictation start fails — denied
microphone permission, an insecure-context self-host, or no network for
the recogniser — surface a quiet toast rather than resetting in silence.
Add voiceUnsupported + dictateError strings across all locales.
2026-06-20 16:42:10 +02:00
Marc-André Bombeck 4851f6534b fix(measurements): stop the cumulative drain aborting on a poisoned day
The cumulative pass lacked the per-day error boundary and adopt-in-place
canonical-noon resolution its sibling drains carry, so the first day whose
local-noon mint collided with the (measured_at, source, sleep_stage) unique
index threw and aborted the entire global walk — leaving years of raw
step/distance rows uncollapsed.

Resolve the canonical daily row by externalId then by the canonical-noon
slot and adopt it in place, retry once on a concurrent-writer P2002, and
route per-day failures through onBucketError so one bad day is stepped over
while every other day still collapses.
2026-06-20 16:42:08 +02:00
Marc-André Bombeck 01b73f6379 feat(coach): make the full Coach page a single full-bleed surface
Drop the inner bordered card on the dedicated /coach page so the chat
reads as one continuous conversation rather than a window inside a
window, and remove the top "back to the drawer" minimize control (it is
pointless on the full page; the bottom-right FAB drawer remains the
separate compact surface). Also remove the two starter-question
suggestion chips below the new-chat composer, leaving a greeting +
composer hero that matches the reference new-chat layout. History stays
reachable through the existing conversation rail.
2026-06-20 16:41:58 +02:00
Marc-André Bombeck cf903f5022 fix(crypto): stop day-log upserts wiping undecryptable encrypted fields
A partial-update upsert soft-decrypted the existing encrypted column,
returned null on failure, then re-wrote it — so editing an unrelated
field permanently nulled an undecryptable note or sensitive envelope.

Preserve the existing ciphertext verbatim when the encrypted field is
absent from the update; only re-encrypt on an explicit field write.
Mirrors the v1.18.1 rotation fix.
2026-06-20 16:38:09 +02:00
Marc-André Bombeck fa37941add Merge release/v1.18.9 into main (v1.18.9)
Coach surface, lab-report OCR ingestion, qualitative labs, dashboard freshness, VO2max/Oura coverage, and broad UI polish. CI green; QA reconcile complete.
2026-06-20 13:21:08 +02:00
Marc-André Bombeck 03e1eaf082 chore(openapi): stamp the spec version at 1.18.9 2026-06-20 13:13:43 +02:00
Marc-André Bombeck 731714ebde chore(release): v1.18.9
Coach new-chat surface with streamed answers and a token footer; lab-report OCR
ingestion with a mandatory per-row review; qualitative lab results; labs list
default, sort, and delete; VO2max on the dashboard with Oura ingest; preventive-
care date and due-status polish; faster condition journal; honest report
staleness; dashboard freshness on background sync.
2026-06-20 13:05:34 +02:00
Marc-André Bombeck 0798373877 Merge v189/recon-a into release/v1.18.9
Hard-evict the dashboard snapshot on batch and Withings background sync so the
Startseite reflects synced readings on focus; withdraw the Oura daily_stress
mapping pending source-priority wiring; make OCR in-batch dedup independent of DB
visibility; widen the vision allowlist to Claude 3; null-guard the reasoning
frame; correct OpenAPI and cache-TTL drift.
2026-06-20 13:02:04 +02:00
Marc-André Bombeck 82cdd5c579 docs: correct dashboard-snapshot server cache TTL comment
The hook comment said the server SWR cache is warm for 60 s, but the
snapshot key's fresh TTL is DASHBOARD_REFETCH_INTERVAL_MS + 60_000
(~180 s). Correct both references.
2026-06-20 13:01:05 +02:00
Marc-André Bombeck 3b5b60289e docs(api): mark coach done.usage.model optional to match runtime
The streaming-doc description listed `model` as a required field of the
`done.usage` envelope, but the runtime CoachUsage type has it optional /
nullable. Align the description and regenerate the OpenAPI contract.
2026-06-20 13:00:20 +02:00
Marc-André Bombeck cbc2187705 fix(coach): guard empty reasoning SSE frame against literal undefined
A reasoning frame with no text appended the string "undefined" to the
thinking disclosure. Coalesce evt.text to an empty string on both the
streaming and the trailing-flush paths.
2026-06-20 12:59:29 +02:00
Marc-André Bombeck d0f21a484a fix(labs): allow Claude 3 vision models for OCR
The vision allowlist matched only the 3.5/3.7/4 families, so a
self-hoster pinned to claude-3-opus / claude-3-sonnet / claude-3-haiku
was wrongly blocked from lab OCR though those models read images. Add
the bare claude-3-{opus,sonnet,haiku} prefixes.
2026-06-20 12:59:29 +02:00
Marc-André Bombeck 8c19dc51a5 fix(labs): dedup OCR commit rows in-memory, not via DB visibility
The per-row commit loop ran isDuplicate() then create() sequentially, so
two identical rows in one confirmed document only collapsed because row 1
autocommitted before row 2's live query. Track written keys in a
per-request seen-set keyed on analyte+day+value so in-batch dedup no
longer depends on DB visibility. Note the mint-then-create orphan on a
mid-row failure is benign and self-healing, matching the manual path.
2026-06-20 12:58:07 +02:00
Marc-André Bombeck d6cb2f756f fix(oura): withdraw daily_stress to STRESS_SCORE mapping
STRESS_SCORE already has an HRV-derived COMPUTED producer that is not
wired into the source-priority ladder or the weekly graded-series
collapse, so adding a second Oura producer caused nondeterministic
collapse and double-counting. Drop the daily_stress fetch/map/wiring and
leave a note to re-add once STRESS_SCORE is laddered. The vO2_max
mapping, which is correctly ladder-wired, stays intact.
2026-06-20 12:54:42 +02:00
Marc-André Bombeck f9d3e83bab Merge v189/recon-b into release/v1.18.9
Enlarge the OCR confirm and lab result-type tap targets to 44px; clamp the coach
hero chips; switch the Vorsorge due-status to a calendar-day delta so an item due
earlier today still reads as due today and a prior day reads overdue.
2026-06-20 12:54:22 +02:00
Marc-André Bombeck 90a9407195 fix(vorsorge): key due status on the local calendar day 2026-06-20 12:53:41 +02:00
Marc-André Bombeck a1f5ed625c fix(coach): cap starter-question chips to a single-line pill 2026-06-20 12:53:41 +02:00
Marc-André Bombeck 576264d1d9 fix(labs): raise lab-form result-type toggles to the touch floor 2026-06-20 12:53:41 +02:00
Marc-André Bombeck bf509f6b75 fix(labs): give the OCR per-row confirm a 44px touch target 2026-06-20 12:53:41 +02:00
Marc-André Bombeck 375d5c3ec5 fix(dashboard): hard-evict snapshot on background sync paths
The in-app measurement routes evict the snapshot cache, but the iOS
batch ingest and the server-side Withings sync only marked it stale (or
never touched it), so a focus-refetch could serve pre-sync data for up
to the snapshot TTL. Route both background-sync entry points through a
hard evict so returning to the dashboard surfaces freshly-synced rows.
2026-06-20 12:51:58 +02:00
Marc-André Bombeck 773e4ec4d5 Merge v189/labocr into release/v1.18.9
Add lab-report OCR ingestion: optional image and document inputs on the AI
provider clients, a capability-gated extract route, a mandatory per-row review
screen, and an idempotent commit that mints biomarkers from confirmed rows only.
2026-06-20 12:40:41 +02:00
Marc-André Bombeck 238aac85df docs(api): register Lab-OCR routes in the OpenAPI contract
Add the OcrCapability, OcrExtract, and OcrCommit operations under
/api/labs/ocr/* and regenerate the YAML. Document the multipart upload, the
untrusted-extraction contract, and the source OCR commit value. Mark the three
routes exempt in the module-route gate inventory under the labs data-layer
reasoning.
2026-06-20 12:38:06 +02:00
Marc-André Bombeck ebfebe1fe3 feat(labs): add Lab-OCR scan and review screen
Add the Scan a report entry to the Labs page, gated on the capability probe.
The review dialog uploads a photo or PDF, then shows each proposed reading for
per-row confirm, edit, or discard with duplicate, new-biomarker, and
low-confidence hints in the labs design language. Only confirmed rows commit.
Add the OCR query key and the six-locale strings.
2026-06-20 12:35:32 +02:00
Marc-André Bombeck 4d0856c56f feat(labs): add Lab-OCR extract, commit, and capability routes
Resolve a vision-capable provider, gate with consent + a 6/hour rate bucket +
a reserved per-day token budget, then transcribe an uploaded photo or PDF into
structured proposed rows. The raw upload stays in memory only and is never
persisted. The commit route writes only the rows a human confirmed, resolving
or minting a biomarker per row with source OCR and skipping commit-time
duplicates. The capability probe drives whether the UI shows the scan entry.
Extracted text is treated as untrusted; the review screen is the safety
boundary.
2026-06-20 12:30:06 +02:00
Marc-André Bombeck 7f71f0e7a6 feat(ai): add optional vision inputs and capability resolution
Extend CompletionParams with optional images[] and documents[] so the
provider clients can carry an uploaded lab-report photo or PDF. Anthropic
folds image and native document blocks into the user turn; OpenAI and the
local OpenAI-compatible client fold image_url blocks. Text-only callers are
untouched. Add supportsVisionForConfig / supportsPdfForProvider and an
ocrExtract budget for the Lab-OCR ingestion path.
2026-06-20 12:24:38 +02:00
Marc-André Bombeck 03cb71ab3f test(dashboard): expect the VO2max tile in the default strip count
The VO2max tile is now visible by default, so the configured strip-tile count
rises from 7 to 8.
2026-06-20 12:17:32 +02:00
Marc-André Bombeck 3505ffde24 Merge v189/coachui into release/v1.18.9
Rebuild the /coach surface as a centred-hero chat with a word-by-word fade, a
single thinking disclosure, and a per-message token footer; emit token usage on
the done frame and persist it via migration 0182; plumb an additive reasoning
frame with an elapsed-time fallback for non-reasoning providers.
2026-06-20 12:14:42 +02:00
Marc-André Bombeck 144bfbe553 feat(coach): centred new-chat hero on the page surface
Replace the cramped empty thread on the /coach page with a centred hero:
a greeting, the live composer beneath it, and HealthLog's own starter
questions as chips, over a faint purple atmospheric backdrop with one
staggered entrance reveal. The hero re-parents the existing composer
rather than forking it, so dictation, auto-grow, and the send/stop
control behave identically; the first send docks the composer to the
bottom and the conversation takes over. Gated to the page surface and
the fresh new-chat state, so the drawer, guided-question, and adopt
flows are untouched.
2026-06-20 12:12:54 +02:00
Marc-André Bombeck 7ef4f8b9d6 fix(analytics): rank Oura in the VO2max source-priority ladder
Oura now ingests vO2_max, so add OURA to the vo2Max priority list after Fitbit
to mirror the resting-heart-rate and HRV ladders.
2026-06-20 12:11:50 +02:00
Marc-André Bombeck 73b4037a1a Merge v189/coverage into release/v1.18.9
Make the VO2max dashboard tile visible by default; fetch and map the Oura
vO2_max collection to VO2_MAX; derive a stress score from Oura daily_stress.
2026-06-20 12:10:59 +02:00
Marc-André Bombeck 72b1d1ef8a feat(oura): ingest the vO2_max and daily_stress collections
The Oura v2 client fetched sleep, readiness, activity, daily sleep, and
daily SpO2 but skipped the dedicated vO2_max collection and daily_stress.
An Oura user got no VO2max from any path even though the enum, the
cardio-fitness page, fitness-age, norms, and the LOINC code already exist.

Add fetchVo2Max + mapVo2Max (vo2_max -> VO2_MAX, mL/(kg·min)) and
fetchDailyStress + mapDailyStress. Oura exposes no headline stress score,
only minutes in high stress vs high recovery, so derive a bounded 0-100
STRESS_SCORE from the high-stress share of the day's charged time. Both
collections wire into the existing reactive-refresh sync tail; no
migration, no schema change, no iOS-contract impact.
2026-06-20 12:10:04 +02:00
Marc-André Bombeck d9ed7961bb feat(dashboard): show the VO2max tile by default when data exists
The VO2max strip tile was opt-in, so accounts that already sync a
cardio-fitness sample from Withings, Apple Health, Fitbit, or Oura saw
nothing on the dashboard. Default the strip tile on; it still self-gates
on having a VO2_MAX sample, so empty accounts keep a clean tile strip.
The chart row stays off — VO2max charts live on /insights/cardio-fitness.
2026-06-20 12:09:51 +02:00
Marc-André Bombeck 88ee5bb4f8 feat(coach): word-fade stream, thinking disclosure, token footer
Stream the assistant turn word-by-word with a soft motion-safe fade
(StreamedProse), reduced-motion collapses to instant. Replace the
dots-plus-word placeholder with a single thinking indicator: a live
elapsed-time line that freezes into an auto-collapsed past-tense
disclosure, which surfaces a reasoning summary when one is present and a
factual fallback otherwise — never both a spinner and a Thinking word at
once. Add a quiet per-message token footer (count plus model, no cost)
fed by the live done.usage frame and persisted tokensUsed on reload.
Add the localised strings across all six bundles.
2026-06-20 12:05:31 +02:00
Marc-André Bombeck b0e6079858 Merge v189/fresh into release/v1.18.9
Invalidate the dashboard snapshot on measurement writes and refetch on focus so
fresh and background-synced readings surface without a hard reload; expose the
no-provider state on the briefing read with a connect-a-provider hint; align the
briefing footer caption.
2026-06-20 12:01:20 +02:00
Marc-André Bombeck 5cebc168df fix(insights): flag a stale briefing that has no provider to refresh it
An account with no AI provider configured still reads the last cached
briefing — the read path serves it without ever touching a provider — so
the days-old report rendered as current. The pregenerate cron runs fine;
the cache simply can never refill without a provider. Report provider
availability from the read GET and pair the briefing's relative age with a
discreet 'this can't refresh — connect a provider' hint linking to
Settings -> AI, on both the overview hero meta row and the briefing card
footer. The hint only shows when a briefing is actually rendered; the
empty-state already owns its own no-provider branch.

Also align the briefing card footer caption to the 11px scale the
adjacent period-in-review card uses, so the two overview footers read at
one size.
2026-06-20 12:00:21 +02:00
Marc-André Bombeck 09a04f6eb7 Merge v189/labsui into release/v1.18.9
Default the labs surface to list view and reuse the medication card header for
tiles; surface confirm-gated biomarker delete on detail and list rows; add
alphabetical sort; align the labs settings page to the canonical SettingsCard.
2026-06-20 12:00:11 +02:00
Marc-André Bombeck 2d5c4cfe46 fix(dashboard): surface freshly-added and synced measurements without a reload
A measurement added in-app left the dashboard hero, score ring and tile
strip showing yesterday's value until a hard reload. The snapshot query
carries refetch-on-mount/focus off and a 120 s poll, but the measurement
mutation bundle never invalidated the snapshot key, so an in-app create /
update / delete updated every other surface and left the Startseite stale.
Add the snapshot key to the measurement-dependent bundle, mirroring the
medication fix; the manual routes already hard-evict the server snapshot
bucket, so the triggered refetch returns post-write data at once.

Background syncs (Withings, Apple Health, iOS batch) fire no client
mutation event, so the keys were never invalidated and the dashboard only
caught up on the 120 s poll or a reload. Refetch the snapshot on window
focus so returning to the tab after a sync surfaces the new readings; the
60 s staleTime keeps rapid focus toggles free and the warm server cache
keeps the focus refetch cheap.
2026-06-20 12:00:07 +02:00
Marc-André Bombeck b27d8509cd Merge v189/ill into release/v1.18.9
Defer illness recovery-gap correlation to an opt-in detail expansion; gate the
typical-gap insight behind signal-density thresholds; align the illness settings
page to the canonical SettingsCard pattern.
2026-06-20 12:00:00 +02:00
Marc-André Bombeck b60b2c2bbf refactor(labs): align Labs settings with the standard Settings pattern
Restructure the Labs settings sub-page to the canonical Settings dialect:
each block is a SettingsCard with a SettingsCardHeader (neutral icon, title,
short description), matching the AI / notifications / mood sections instead of
bare sections with text-sm headings. Surface the new alphabetical sort options
in the sort select.
2026-06-20 11:59:27 +02:00
Marc-André Bombeck 8ae498ffb2 feat(labs): surface biomarker delete on the detail page
Add a delete affordance with a confirm step to the biomarker detail header,
reusing the existing DELETE /api/biomarkers/:id endpoint. On success the
catalog and result-list queries invalidate and the view returns to the labs
surface. Removes the Settings detour for clearing a stray biomarker.
2026-06-20 11:59:19 +02:00
Marc-André Bombeck 4da155ddcb feat(labs): default to list view and reuse medication tile primitives
Set the compact list as the Labs surface default; tiles in the card view now
route through MedicationCardHeader so a lab tile reads identically to a
medication or Vorsorge tile side-by-side. Add alphabetical (A-Z / Z-A) sort
options to the per-module sort choice.
2026-06-20 11:59:13 +02:00
Marc-André Bombeck f46e9d3bb8 feat(coach): surface per-turn token usage on the chat stream
Carry an additive usage envelope on the SSE done frame
({ totalTokens, model }) and persist tokensUsed + model onto
coach_messages (migration 0182, nullable, no backfill) so the
per-message token footer survives a conversation reload. Add an
optional reasoning frame the client can render inside the thinking
disclosure when a reasoning-capable provider emits a summary; providers
without reasoning emit none. Extend CoachMessageDTO and regenerate the
OpenAPI contract. Server-authoritative: clients display the count, never
recompute it.
2026-06-20 11:58:58 +02:00
Marc-André Bombeck 696daf3b8f fix(illness): align the journal settings page with the Settings pattern
The illness settings sub-page rendered bare sections with text-sm headings
and no card surface, breaking from the canonical Settings layout the
Stimmung and Medikamente sections follow. Wrap each block in a SettingsCard
with a SettingsCardHeader (neutral icon, text-lg title, short muted
description), so the view and order controls read as first-class Settings
cards consistent with the rest of the surface.
2026-06-20 11:58:52 +02:00
Marc-André Bombeck 1de63f2b5c perf(illness): compute the recovery gap lazily and gate it on signal density
The illness list load triggered the cross-episode correlation fan-out on
every mount, so the page waited on up to 24 per-episode reads before it
painted. Move that work behind an explicit opt-in: the insights endpoint
takes an includeRecoveryGap query param (default off), the list reads the
count breakdown on a single fast query, and the recovery gap is computed
only when the viewer opens the Analyse expansion.

Tighten the typical-gap gate against coincidental data. A gap now counts
only from an episode that cleared a per-episode measurement floor
(MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such
episodes must qualify, and the median magnitude must reach
MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental
baseline and is withheld as noise.
2026-06-20 11:58:39 +02:00
Marc-André Bombeck 9e1a256525 Merge v189/vorsorge into release/v1.18.9
Drop the Vorsorge trend strip; show last-completed as relative today/yesterday
via a shared helper; render next-due status as discreet coloured inline text.
2026-06-20 11:55:16 +02:00
Marc-André Bombeck 590b08286a feat(vorsorge): drop trend strip, relative last-done, discreet due status
Remove the 7-day bar strip under the Vorsorge card last-done line and its
backing component, query key, and i18n strings. Render the last-done value
as a relative date (today / yesterday / absolute) through a shared
relativeCalendarDate helper that reuses the medication today/yesterday keys.
Show the next-due status as discreet coloured text - green today, orange
overdue - leaving the card surface neutral.
2026-06-20 11:54:11 +02:00
Marc-André Bombeck f600ee1ac3 Merge v189/quick into release/v1.18.9
Coach FAB glyph token softened from text-white to the on-accent text-background;
sleep-debt and chronotype sections share an sm:grid-cols-2 row.
2026-06-20 11:50:50 +02:00
Marc-André Bombeck 08107fb669 fix(insights): pair sleep-debt and chronotype in one row
Wrap the sleep-debt headline and the chronotype section in a shared
grid on the Sleep view so they stack on narrow viewports and sit
side-by-side from the sm breakpoint up. Both already read the same
sleep-rhythm cache, so the pairing adds no extra fetch; each card keeps
its own internals.
2026-06-20 11:49:39 +02:00
Marc-André Bombeck 979d1ced8d fix(insights): soften Coach FAB glyph to the on-accent token
Drop the pure-white Coach floating-action-button icon for the button's own
text-background on-accent token, so the glyph reads as a calmer mark on the
purple-to-pink gradient instead of a harsh white. Update the colocated
assertion to pin the glyph token.
2026-06-20 11:49:33 +02:00
Marc-André Bombeck a5b3715a46 feat(labs): record qualitative lab results
Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
2026-06-20 11:17:43 +02:00
Marc-André Bombeck e631396af3 Merge feat/fhir-codings into main (v1.18.8)
v1.18.8 — SNOMED category codings on journal Conditions and LOINC + canonical UCUM on lab Observations in the FHIR export. Additive, export-only.
2026-06-20 10:33:22 +02:00
Marc-André Bombeck a2de09daf3 style: format the new FHIR coding modules 2026-06-20 10:19:44 +02:00
Marc-André Bombeck 7bb4b8d9c5 style: format the demo seed 2026-06-20 10:19:33 +02:00
Marc-André Bombeck 3dd5b3dd5b chore(release): v1.18.8 — coded condition and lab terminology in the export 2026-06-20 09:51:10 +02:00
Marc-André Bombeck 9a29de4b02 feat(fhir): code illness conditions and lab observations
Close the three FHIR R4 coding gaps in the health-record export. Both are
export-layer only — no schema column, no migration, no contract change.

Illness Conditions now carry a broad SNOMED CT category concept per
IllnessType (infectious disease, allergy, injury, mental disorder, autoimmune
disease, chronic disease, with the generic Disease root as the fallback for an
unknown type) instead of the single generic placeholder. The user's own label
stays on code.text, the class on category.text, and the unconfirmed +
patient-reported guard rails are unchanged — the export still asserts no
specific diagnosis.

Lab Observations resolve a curated analyte to a real LOINC coding alongside the
user's code.text for the common biomarkers (HbA1c, lipid panel, ferritin, TSH,
vitamin D, creatinine, eGFR, ALT, AST, CRP, fasting glucose, hemoglobin), with
analyte-key and EN/DE alias normalisation. The canonical UCUM code is stamped
on the value only when the recorded unit matches the mapped canonical symbol;
an unmapped analyte or a non-matching unit keeps the honest text-only code and
display-only unit, never a fabricated code. The resolver takes an optional
canonical-name argument so a future Biomarker-catalog link can take precedence
over the free-text analyte without a further change here.
2026-06-20 09:48:22 +02:00
Marc-André Bombeck 9b8cd819f4 fix(demo): consent the demo user and anchor today's doses to their slots
Mint an active AI-processing consent receipt for the demo user so the
Coach renders its sample conversation without a consent-accept write the
read-only tenant rejects. The receipt is kind `ai_full`, the master grant
the Coach and Insights gates both accept; the read path only checks an
active row of that kind, so a placeholder artefact suffices. Add a
consent_receipts reset to keep the seed idempotent.

Anchor every taken intake's scheduled_for on its canonical dose-slot
instant (08:00 morning, 20:00 evening, Europe/Berlin) with taken_at set
slightly later, so the row is slot-anchored and resolves its slot within
the dashboard's resolution radius. Previously today's morning rows used
scheduled_for = taken_at, which the scheduler reads as an ad-hoc take that
only resolves within a minute of its own instant, leaving the 08:00 slot
reading overdue while the take floated beside it. Today's picture now reads
on-track at any daytime clock, with the evening dose pending and not yet due.
2026-06-20 09:46:43 +02:00
Marc-André Bombeck 69d03c45c4 seed(demo): bake AI insight texts into the demo cache rows
The public demo has no live AI provider, so the Insights, daily-briefing,
and per-metric status surfaces rendered empty or prompted to connect a
provider. Pre-populate the same cache rows a real generation would write
so the demo shows finished, grounded English prose that survives every
re-seed without any outbound call:

- users.insights_cached_text: comprehensive insight + daily briefing
  (paragraph, signals of the day, key findings, trend annotations), with
  insights_cached_at refreshed so the read serves it as current.
- audit_logs: the seven per-metric status cards, stamped with today's
  Berlin day key and a real model name so the cache read treats each as a
  fresh assessment.
- insight_narratives: week and month narratives, AES-256-GCM at rest like
  every other narrative row.

All prose follows the assessment voice: second person, grounded in the
seeded numbers, autonomy-supporting, never diagnostic. English only, the
demo locale. Adds insight_narratives to the cleanup block so the seed
stays idempotent.
2026-06-19 23:49:29 +02:00
Marc-André Bombeck e990c12529 Merge release/v1.18.7 into main
v1.18.7 — dashboard, Coach and insights polish; AI request batching; mobile consistency; restored share-link section with passphrase gate; medication supply freshness; security hardening. Three additive migrations.
2026-06-19 22:44:21 +02:00
Marc-André Bombeck 90efad09fd chore(git): stop tracking the generated client path
A worktree symlink at src/generated was committed by mistake; on a fresh
checkout it resolves to a self-referential link and breaks prisma generate.
Untrack it and ignore the whole src/generated path, not just its prisma
subdirectory, so the build always materialises a real client there.
2026-06-19 22:27:03 +02:00
Marc-André Bombeck ad163f811b test(medications): point the supply source-guard at the renamed detail file
The inventory summary spec reads the detail-tab source by path to assert
both supply readouts ride summariseSupply; update the literal to the
kebab-case filename.
2026-06-19 22:22:34 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck b3fea2ef59 ci(quality): add a prettier format-check gate
Wire `pnpm format:check` into the Lint/Typecheck/Test job and extend
.prettierignore so prettier never rewrites generator-owned artifacts:
the OpenAPI contract (whose exact bytes the openapi:check job enforces),
the build-emitted service worker, the Prisma client, and the lockfile.
2026-06-19 22:20:11 +02:00
Marc-André Bombeck 68033b2d66 refactor(onboarding): point consumers at the kebab-case component files
Update the onboarding step page, baseline form, and the anamnesis-card
spec to import from the renamed kebab-case files. Export identifiers stay
unchanged.
2026-06-19 22:17:34 +02:00
Marc-André Bombeck 922f770b41 refactor(medications): rename PascalCase component files to kebab-case
Bring the medication card, scheduling, wizard, and detail component
files in line with the kebab-case file-naming convention. Component
export identifiers are unchanged; only filenames and the import paths
that point at them move.
2026-06-19 22:17:13 +02:00
Marc-André Bombeck 7c2762b0a5 docs(changelog): expand the v1.18.7 entry for migrations and contract changes
Record the share-link restore and passphrase gate, the Labs/preventive/
condition settings sections, the AI-request batching and prompt
consolidation, the mobile consistency pass, the medication-supply
freshness fix, the budget-gate hardening, and the three new migrations.
2026-06-19 22:11:47 +02:00
Marc-André Bombeck 9c28016faa chore(openapi): regenerate for the share-link passphrase fields 2026-06-19 22:08:01 +02:00
Marc-André Bombeck ce684cc429 chore(db): renumber the share-link passphrase migration to 0180
The status-batching work claimed no migration, so the passphrase column
takes 0180 and the sequence stays gapless after 0179.
2026-06-19 22:07:46 +02:00
Marc-André Bombeck aa6196a789 Merge wave-sharing-harden: passphrase + QR second factor for share links
Gate the clinician share view behind an auto-generated passphrase carried
in the QR URL fragment and stored only as an HMAC hash. The public view
verifies it in constant time behind a rate limit and sets a short-lived,
path-scoped cookie before rendering; links minted before this change keep
working ungated.
2026-06-19 22:05:49 +02:00
Marc-André Bombeck e057589574 Document the passphrase fields and unlock route in the API contract
Extend the share-link create response with the passphrase, share URL, and
QR deep link, and add the protected flag to the summary. Document the
public POST /api/c/{token}/unlock passphrase-gate route. Regenerate the
committed OpenAPI YAML to match.
2026-06-19 22:03:59 +02:00
Marc-André Bombeck d75a538614 Show the QR, passphrase, and protected badge in the owner UI
On a successful create the Sharing section now renders the QR code (from
the #k= deep link), the passphrase as copyable text, and the link, with a
clear shown-once notice. The active-link list carries a small passphrase
indicator. Add the new strings (QR label, passphrase label, copy buttons,
hints, protected badge, and the public unlock prompt) across all six
locale bundles.
2026-06-19 22:03:53 +02:00
Marc-André Bombeck 5783acacbb Gate the public share view behind the passphrase
A protected share link no longer renders the record until the passphrase
is verified. The public page checks the gate state first (without bumping
access counters), and for a link with a passphrase hash it serves a gate
island unless a valid, token-scoped unlock cookie is present. A legacy
link with a null passphrase hash renders exactly as before.

The unlock route verifies the submitted passphrase with a constant-time
compare against the stored hash, is rate-limited by share-unlock token
and IP before any compare, and on success sets a short-lived (30 min)
httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view
path. Every failure class answers one blunt 401; no-store and the flat
404 for unknown links are preserved. The gate island auto-submits the
#k= fragment (scrubbing it from history first) or prompts for input.
2026-06-19 22:03:46 +02:00
Marc-André Bombeck 1aeb6f7957 Mint a passphrase second factor on share-link create
Every new share link now carries a high-entropy passphrase (80-bit,
Crockford-base32, grouped XXXX-XXXX-XXXX-XXXX). The create response
returns the raw passphrase, the share URL, and a QR deep link EXACTLY
ONCE alongside the token; the server stores only the passphrase's HMAC
hash (the same key as the token hash). The QR deep link carries the
passphrase in the URL fragment (#k=) so it never reaches a server log,
referrer, or access record.

Add the passphrase generator, normaliser, and constant-time verifier;
the short-lived token-scoped unlock-cookie mint/verify; a gate-state
resolver that reports live + passphrase status without bumping access
counters; and the unlock request schema.
2026-06-19 22:03:36 +02:00
Marc-André Bombeck 53d41c190b Add nullable passphrase hash to clinician share links
Introduce a nullable passphrase_hash column on the share-link model so a
link can carry a passphrase second factor. The column is nullable, so
every link created before this change keeps the possession-only contract
and the public view renders for it without a gate.

Migration 0181 hand-authors the ALTER TABLE; the column stores only the
HMAC hash, never the raw passphrase.
2026-06-19 22:03:25 +02:00
Marc-André Bombeck 5e541ebf4f refactor(ai): drop dead status-batch reverse map, consume the budget type
Remove the unused output-key reverse map the batch path never read, and
have the budget table satisfy the AiBudget interface directly so the type
is enforced rather than orphaned.
2026-06-19 21:51:22 +02:00
Marc-André Bombeck 208c607b93 Merge wave-ai-status-batch: batch the per-metric status assessments
Split each per-metric status generator at the provider boundary so the
snapshot build, cache read, and content-hash gate run without the model,
then send one structured request covering every metric that has data and
fan each summary back into the same per-metric cache row. Collapses the
seven specialised status calls per warm cycle into one while leaving the
read path, caching, and single-card behaviour unchanged.
2026-06-19 21:47:46 +02:00
Marc-André Bombeck 07333e17c7 test(insights): type the batch finalize fixture to satisfy lint 2026-06-19 21:46:28 +02:00
Marc-André Bombeck e6d48e2de5 test(insights): cover the batched status generator
Prove one provider call covers every present metric, absent metrics are
omitted from the prompt and never fabricated, an all-served cycle makes no
call (the per-metric hash gate still short-circuits), and the batch degrades
gracefully: an omitted metric or a provider error falls each affected card
back to its single-card path, invalid JSON retries once, and a no-provider
cycle serves each card's precomputed no-key result.
2026-06-19 21:43:59 +02:00
Marc-André Bombeck b3b6720723 feat(insights): batch the seven status assessments into one provider call
Add generateStatusBatchForUser: it prepares the seven specialised status
snapshots once, sends one prompt for a { perMetric } envelope covering only
the metrics with data, and fans each summary back into the same per-metric
cache rows the standalone generators write. One JSON-correction retry guards
the parse; any omitted metric or a failed batch degrades to that card's own
single-call path, so a partial response never goes dark. The nightly warm and
the forced warm route through the batch in production while the injected
per-card seam keeps the cron tests intact. Adds the status-batch prompt
scaffold composed from the shared assessment contract.
2026-06-19 21:41:14 +02:00
Marc-André Bombeck f4812f9af2 Merge wave-sharing-restore: bring back the Freigabe settings section
Restore the clinician share-link section as its own Settings entry next to
the health-record export, rendering through the shared SettingsCard and the
envelope-aware api helpers. The backing routes and the public view were
untouched by the earlier trim, so the owner UI reconnects to them as before.
2026-06-19 21:39:46 +02:00
Marc-André Bombeck 0518b26d08 feat(settings): restore the clinician share-link section
Bring back the Settings → Sharing ("Freigabe") section: the owner mints
a time-boxed, revocable, read-only share link to their health record at
the public /c/[token] view. The backing model, the /api/share-links
lifecycle routes, and the public clinician view were left in place; this
restores the owner Settings surface and re-wires it.

Place the slug directly after gesundheitsakte and before export — sharing
the health record belongs next to the health-record export. The entry is
always available (no module gate), like account and export.

Render the card through the shared SettingsCard primitive so it matches
every sibling section; drop the section's own page title (the shell
supplies the heading and subtitle). Route the list, create, and revoke
calls through the api-fetch helper instead of raw fetch, and align the
touch targets with the mobile sizing contract.
2026-06-19 21:38:40 +02:00
Marc-André Bombeck 52d3e5bc10 refactor(insights): split the remaining status cards at the provider boundary
Apply the prepare -> run -> finalize split to blood-pressure, pulse, bmi,
mood, general, and medication-compliance status, matching weight-status.
Each public generator keeps its signature and one-call behaviour by driving
its prepared card through the shared single-card path; the snapshot build,
cache read, read-only miss, and content-hash gate are now reusable by a
batched caller without changing what each card persists.
2026-06-19 21:36:11 +02:00
Marc-André Bombeck beb0be7452 refactor(insights): split weight-status at the provider boundary
Introduce a prepare -> run -> finalize contract for the per-metric status
cards so the snapshot build and the provider call can be driven separately.
The public generator keeps its signature and one-call-per-metric behaviour;
weight-status now resolves through the shared single-card path. Add the
status-batch AI budget entry sized for one combined seven-metric call.
2026-06-19 21:31:16 +02:00
Marc-André Bombeck 4daa5a428f Merge wave-mobile-settings: SettingsCard primitive + phone tap targets
Replace the hand-rolled Settings card divs across every section with a
shared SettingsCard wrapping the Card primitive, eliminating the sm-vs-md
padding split and the primitive bypass, and raise the Settings touch
controls to a 44px floor that condenses at sm+.
2026-06-19 21:30:35 +02:00
Marc-André Bombeck 407cb40ef7 chore: drop accidentally tracked src/generated worktree symlink 2026-06-19 21:29:08 +02:00
Marc-André Bombeck e0c1601a36 fix(settings): raise account form CTAs to the 44px touch floor
The profile save, add-passkey, change-password trigger, and password
dialog submit ran at 40 px on phones; apply min-h-11 sm:min-h-9.
2026-06-19 21:28:34 +02:00
Marc-André Bombeck d9393a2148 fix(settings): raise primary controls to the 44px touch floor
Apply the canonical min-h-11 sm:min-h-9 (and min-w-11 for icon-only
controls) to the primary Settings controls that sat at 32-40 px on
phones: the AI fallback-chain reorder/remove/save/add icons, provider
form save/test/connect actions, coach-memory forget, avatar replace and
remove, section save/cancel/reset CTAs, the export and import action
rows, the About external links, and the thresholds editor row. Collapse
the injection-sites checkbox grid to one column on phones and stack the
password-change grid below lg.
2026-06-19 21:27:05 +02:00
Marc-André Bombeck 8b3b1e1985 chore(db): renumber the briefing-reroll migration to 0179
Two parallel changes both claimed 0178; the session-expiry indexes keep
0178 and the briefing-reroll column moves to 0179 so the sequence stays
unique and ordered.
2026-06-19 21:25:22 +02:00
Marc-André Bombeck 0b82d109f0 Merge wave-ai-foundation: AI request efficiency, standardization, variety
Centralise per-surface AI budgets and right-size status maxTokens; add
seed support (pinned for reference surfaces, seedless for the briefing);
force JSON on Ollama and Anthropic; reroll only the daily-briefing prose
once a day for variety while findings stay stable; reuse the corrective
retry on the comprehensive path; extract shared prompt contracts as one
source of truth for grounding, tone, and the GLP-1 safety rule; reserve
the coach budget atomically before the call and reconcile burned tokens;
turn the local-AI private-host flag into a host allowlist.
2026-06-19 21:25:04 +02:00
Marc-André Bombeck ea95826415 feat(ai): make the local-host SSRF escape hatch a host allowlist
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`,
opened every private/internal host at once — including the cloud
metadata endpoint and any internal panel. Replace the binary check at
all three sites (write-time validation, local client, test resolver)
with a shared `isLocalAiHostAllowed` helper: a comma-separated host list
permits only those exact hostnames, while the legacy `true` still
permits any private host for backward compatibility. The safeFetch /
isPublicUrl floor is untouched; the allowlist only widens what the
operator deliberately permits. Docs and the SSRF-pin inventory test are
updated accordingly.
2026-06-19 21:20:01 +02:00
Marc-André Bombeck b10d978bce Merge wave-mobile-rest: phone tap targets, shared list-row, scroll-touch
Raise primary touch controls across the non-Settings surfaces to a 44px
floor that condenses at sm+, add a shared ListRow primitive and migrate
the three bespoke list-row mini-cards onto it, and add a scroll-touch
utility for the horizontal scrollers.
2026-06-19 21:19:32 +02:00
Marc-André Bombeck a371678460 test(coach): guard composer controls against the sub-44px floor
Assert the send and stop controls render the `size-11 sm:size-9` tap-
target pattern so a future restyle cannot quietly drop them back to the
36px size the audit flagged. Renders the real component and matches the
emitted class string, the same model as the bottom-nav size test.
2026-06-19 21:18:27 +02:00
Marc-André Bombeck e3071d4f0a fix(mobile): raise primary touch controls to the 44px floor
Sweep the primary touch controls that sat below the 44px tap-target
floor on phones onto the canonical `min-h-11 sm:min-h-9` (text) /
`size-11 sm:size-9` (icon) pattern, condensing back at `sm:` upward:

- Coach composer mic / stop / send icon buttons (were `size-9`).
- Dashboard hero CTA, link and action variants (were `size="sm"`).
- Vorsorge list-view "mark done" button (was `min-h-9`) and its kebab,
  which now uses `size="icon-lg"` so the rendered control is 44px
  rather than the icon variant's 40px.
- Illness list-view "log day" button (was `min-h-9`).
- Medication wizard Step 3 injection-sites disclosure (was `h-9`).

The Vorsorge dashboard-card capture action was raised alongside the
ListRow migration.
2026-06-19 21:18:19 +02:00
Marc-André Bombeck 54a2d19833 feat(ui): add shared ListRow primitive and adopt it for mini-cards
The bordered list-row "mini-card" appeared three times with three
shapes: the Vorsorge dashboard row (`rounded-md p-2.5`), the daily-
briefing rows (`rounded-md p-3`), and the measurement-list mobile row
(bespoke `rounded-lg p-3` div). Introduce `ListRow` (`rounded-lg border
p-3`, `asChild` per the shadcn convention) and migrate all three to it.
The primitive owns only the box shape; each caller keeps its layout,
colour, and interaction classes, so this is structural normalization
with no content or behaviour change.
2026-06-19 21:18:11 +02:00
Marc-André Bombeck d2be72d57a feat(mobile): add scroll-touch momentum utility for horizontal strips
Add a `.scroll-touch` utility that restores iOS WebKit momentum
scrolling (`-webkit-overflow-scrolling: touch`) and contains the
overscroll chain so a flick at the end of a horizontal row does not
rubber-band the whole page. Apply it to the insights tab strip and the
cycle tab list — the two in-app horizontal scrollers that lost inertial
flick under the default `overflow-x-auto`.
2026-06-19 21:18:05 +02:00
Marc-André Bombeck 76bc0f2e1f refactor(settings): compose import/export card shells from SettingsCard
Migrate the ImportCardShell and ExportCardShell containers to
SettingsCard, threading their dynamic outerClassName through cn. The
layout hub-link row keeps its element (a full-row Link, not a content
card).
2026-06-19 21:17:35 +02:00
Marc-André Bombeck 5f2a76ddbc fix(coach): close the budget-gate TOCTOU and count burned tokens
The daily token cap was read before the provider call and recorded only
after a successful reply, so concurrent requests could all read
`spent < cap` and all hit the provider, and an empty/sentinel reply
recorded zero spend despite burning upstream tokens.

Reserve the budget atomically before the call: a single upsert-increment
returns the new total, so concurrent requests serialise on the row and
cannot both pass the cap; over-cap refunds the reservation and surfaces
`coach.budget.exceeded` as an SSE error frame. Reconcile the reservation
to the actual token count immediately after the provider returns —
before any empty/sentinel short-circuit — so burned tokens are always
recorded. An integration test proves two concurrent requests cannot both
exceed the cap and that an empty reply still records its cost.
2026-06-19 21:16:43 +02:00
Marc-André Bombeck 96f2b47545 refactor(settings): compose multi-card sections from SettingsCard
Migrate the remaining hand-rolled card containers in the account,
about, medications, api, mood, notification-status, and advanced
sections to SettingsCard, preserving each card's heading, body, and
test hooks. Leaves the passkey list-row (rounded-lg) for the list-row
token track.
2026-06-19 21:16:10 +02:00
Marc-André Bombeck e31f9690e2 refactor(settings): compose single-card sections from SettingsCard
Swap the hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6
container for SettingsCard across the single-card sections (integration
cards, notification channel cards, coach prefs, cycle, modules, mood
reminder, thresholds, dashboard-layout, about-me, low-stock, and the
health-record export panel). Drops the sm: padding split and the
primitive bypass; landmark sections keep their element via as="section".
2026-06-19 21:14:57 +02:00
Marc-André Bombeck bc31787053 Merge wave-demo-seed-aug: full-coverage demo across every domain
Extend the demo tenant with body composition, blood glucose with meal
contexts, cardio vitals, recovery and sleep scores, and realistic workouts
with heart-rate samples, all reconciled against the existing series and
dated relative to now. Replace the stale achievement ids the prior seed
referenced with verified registry keys.
2026-06-19 21:11:58 +02:00
Marc-André Bombeck e7d14a07e8 docs(ai): record the last-working-provider coordination decision
The per-worker in-process "last working provider" hint stays volatile
and is deliberately NOT folded into the durable Postgres health ledger.
The ledger already shares the load-bearing skip signal across workers;
the only uncoordinated piece is a soft reorder whose worst case is one
redundant first-hop per worker per hour. Promoting it to a per-request
ledger write would add hot-path write amplification for a negligible
saving. Documented inline so the trade-off is explicit.
2026-06-19 21:11:51 +02:00
Marc-André Bombeck 57242b9884 refactor(ai): extract shared prompt contracts as a single source
The grounding, tone/banned-opener, GLP-1 dose-safety, metric-identifier
ban and forbidden-filler rules were independently encoded across the
comprehensive, status, Coach and narrative prompts and had drifted. Move
the canonical wording into `prompts/shared-contracts.ts` (locale-keyed
fragments) and compose each surface from it.

Where the surfaces had drifted, adopt the stricter wording: most notably
the GLP-1 dose-prescription contract — previously present only in the
comprehensive and Coach prompts — now reaches the status cards and the
period narrative too. A cross-surface coverage test asserts every
enforced fragment appears verbatim in every surface, in de and en.
2026-06-19 21:11:28 +02:00
Marc-André Bombeck 5ebe97d5a3 feat(seed): broaden demo coverage across every tracking domain
Extend the demo tenant so it reads as a long-term, fully engaged user
across the whole app, not a thin sample. All series span the existing
ninety-day window with the latest point on today and sit in healthy
bands:

- Body composition (fat, fat-free, lean, muscle mass, total body water,
  bone mass, visceral fat, BMI) derived from the same-day weight and
  body-fat series so the numbers reconcile with the headline tiles.
- Blood glucose in a healthy non-diabetic band — fasting, post-meal and
  bedtime readings stored in mg/dL with the glucose context tagged.
- Cardio fitness and vitals: HRV (SDNN and RMSSD), oxygen saturation,
  respiratory rate, periodic VO2 max, active energy, walking/running
  distance and flights climbed.
- WHOOP-style recovery, day strain and sleep performance/efficiency/
  consistency scores aligned with the seeded sleep nights.
- Three to four workouts a week (running, strength, cycling) with
  duration, energy, average and max heart rate, distance, a sparse
  per-workout heart-rate sample series, and per-workout strain in the
  metadata blob.

Clear the workout tables in the same truncate pass so the seed stays
re-runnable.
2026-06-19 21:11:09 +02:00
Marc-André Bombeck 4b26e1cef9 fix(seed): point demo achievements at real registry ids
The demo seed unlocked twelve achievement ids that no longer exist in
the registry (first-measurement, week-streak-measurements,
three-month-streak, …), so the achievements surface rendered them as
unknown placeholders. Map the unlocked set to live registry keys —
first-entry badges, logging and in-range streaks, compliance and
miss-free streaks, plus the self-context and seven-night sleep badges —
each on a plausible unlock date the seeded history supports.
2026-06-19 21:10:58 +02:00
Marc-André Bombeck 9182b60dc8 feat(settings): add SettingsCard primitive for the standard card shape
Wrap the ui Card so every Settings section composes from one bordered
container with the shared p-4 md:p-6 padding contract, replacing the
hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6 div that
bypassed the primitive and stepped padding at sm: instead of md:.
2026-06-19 21:10:21 +02:00
Marc-André Bombeck 876e94b5c4 Merge wave-med-staleness: reflect supply changes on the next read
The medication card and table render dose-derived stock from the cached
medications list, but the inventory write routes evicted no server cache
and the client invalidated only the per-item inventory key, so a new
container's stock kept serving the pre-write value for the stale window.
Hard-evict the user's medication caches on every supply write and
invalidate the list key alongside the inventory key on the client.
2026-06-19 21:09:08 +02:00
Marc-André Bombeck 6609ce0dc7 fix(medications): evict supply caches so the card reflects stock at once
The /medications card and table render the dose-derived stock
(stockUnitsRemaining / stockDosesRemaining) straight from the
GET /api/medications list payload, which reads through cachedSwr on the
60s-fresh + 10-min-stale medications bucket. Registering, adjusting, or
deleting an inventory container changes that stock, but the inventory
routes never invalidated any cache — so the list served the pre-write
stock for the rest of the stale window. On the client the add / adjust /
delete supply mutations only invalidated the per-medication inventory
query, never the medications-list query that the card reads, so a mounted
list never refetched even once the server caught up.

Hard-evict the per-user medications + compliance buckets from every
supply-affecting write (inventory POST, the per-item PATCH / DELETE, and
the deprecated glp1 POST dose-change + ledger branches) so the next read
reflects the change rather than a stale SWR value. On the client,
centralise the supply mutations on a shared invalidateSupplyQueries helper
that drops both the medications-list key and the per-medication inventory
key. The card's neutral styling is unchanged.
2026-06-19 21:08:35 +02:00
Marc-André Bombeck eedfd9bff1 Merge wave-settings-ia: Labs, Vorsorge, and Illness as Settings sections
Fold the three standalone module settings pages into the Settings shell so
they open in-context with the same left-nav, heading, and chrome as every
other section. Remove the standalone pages and the now-unused
ModuleSettingsFrame, and drop the orphaned settings i18n blocks.
2026-06-19 21:06:54 +02:00
Marc-André Bombeck 57cf8340e2 fix(insights): retry the comprehensive JSON parse once before failing
The comprehensive generator did an ad-hoc JSON.parse with no corrective
retry and failed cold to `invalid-json` on a first-pass miss, while the
strict insight wrapper's `buildRetryCorrectionMessage` sat right next
door. Export that helper and reuse it: on a parse miss, append the
correction to the user prompt and re-run the chain once before declaring
failure. Unifies the JSON-robustness story across the two paths.
2026-06-19 21:06:53 +02:00
Marc-André Bombeck 43d9610f03 refactor(settings): fold Labs, Vorsorge, and Illness into the shell
Promote the three per-module customise surfaces from standalone
ModuleSettingsFrame pages to first-class Settings sections. They now
render inside the Settings shell — same left nav, heading, and chrome as
account / ai / integrations / notifications — instead of their own
back-button-and-title frame outside it.

Add labs, illness, and vorsorge to the slug registry (after mood) and to
SETTINGS_SECTIONS with FlaskConical / Thermometer / CalendarCheck icons.
labs and illness gate on their toggleable modules; vorsorge has no
module, so its entry is always shown. Three thin section components wrap
the existing LabsSettings / IllnessSettings / VorsorgeSettings content,
mirroring MedicationsSection / CoachSection.

Delete the three standalone /settings/{labs,vorsorge,illness} routes so
the wrench deep links resolve through the dynamic [section] route, and
drop ModuleSettingsFrame now that nothing renders it. Add the section
title / subtitle / description keys across all six locales and remove the
dead per-module *.settings.* blocks the old pages consumed.
2026-06-19 21:06:25 +02:00
Marc-André Bombeck 853a1cd301 feat(insights): vary daily-briefing prose on unchanged data
The content-hash gate re-stamps the prior text whenever the snapshot is
unchanged, so the "daily" briefing read byte-stable day-over-day. Decouple
phrasing freshness from data freshness for the dailyBriefing.paragraph
alone: on a hash-unchanged hit, re-roll just the paragraph once per
calendar day at a higher, seedless temperature, keeping every structured
finding verbatim from the cached payload.

A new `insightsBriefingRerollDate` column gates it to at most one extra
call per day, and a re-roll miss still stamps the day so a failing
provider cannot be re-driven on every page-open. The hash gate stays
fully intact for the status cards (reference assessments — monotony is
fine).
2026-06-19 21:05:19 +02:00
Marc-André Bombeck 56abdac9fc Merge wave-demo-seed: reshape the demo tenant for current, healthy data
Recent in-band BP, stable healthy weight, a resting-heart-rate series, a
full week of per-stage sleep nights, on-track medication doses, two
preventive-care reminders, a healthy lab panel, one resolved illness
episode, and a sample Coach conversation — all dated relative to now so
the public demo reads current for screenshots.
2026-06-19 21:04:32 +02:00
Marc-André Bombeck a4b7ae5966 feat(apns): forward reminderId to iOS on measurement reminders
The measurement-reminder push already carries the reminder id in its
metadata, but the iOS allowlist stripped it before it reached the device.
Forward the key so a foreground or handled notification can post the
completion directly instead of routing through the capture deep link.
2026-06-19 21:03:59 +02:00
Marc-André Bombeck 431259bb06 feat(demo): reshape demo seed to read current and healthy
Tune the public demo tenant for App-Store screenshots:

- Extend the measurement loop to include today so every headline metric
  has a current reading.
- Retarget blood pressure into the optimal band (~118/76), weight to a
  stable healthy BMI, and add a clean resting-heart-rate series so the
  resting-pulse tile scores it directly instead of the spot pulse.
- Write a full week of per-stage sleep nights (awake/REM/core/deep,
  ~7.5h, source APPLE_HEALTH) including last night, in minutes, and skip
  the aggregate row for those nights to avoid double-counting.
- Schedule today's doses on-track: morning meds taken, the evening dose
  pending and never auto-missed, so today never reads overdue or 0%.
- Add Vorsorge reminders (dental check-up, annual physical), one healthy
  lab panel, one resolved illness episode, and an AI-configured Coach
  with a short encrypted sample conversation.

All dates stay relative to now so the demo refreshes on every re-seed.
2026-06-19 21:02:42 +02:00
Marc-André Bombeck a61b7fe4ad feat(ai): force JSON on the non-OpenAI provider chains
Add an opt-in `responseFormat: "json"` to CompletionParams that the
structured surfaces (comprehensive briefing + per-metric status cards)
set. On local (Ollama / OpenAI-compatible) it adds `format: "json"`; on
Anthropic it prefills the assistant turn with `{` and re-prepends the
brace to the reply, so the first emitted token is already inside a JSON
object. Both cut the first-pass JSON-failure rate that fence-stripping
otherwise has to absorb.

OpenAI already pins `response_format: json_object` and is unchanged. The
period narrative (plain text) and the Coach (streaming prose) leave the
flag unset, so their non-JSON paths are untouched.
2026-06-19 21:01:27 +02:00
Marc-André Bombeck a460c74e6c feat(ai): support a deterministic seed on the reference surfaces
Add an optional `seed` to CompletionParams and thread it onto the
OpenAI and local (Ollama / OpenAI-compatible) request bodies. The field
is dropped when unset, so existing callers are byte-identical.

Pin a stable seed for the status/reference cards (defaulted in the
status provider) and the period narrative, so a prompt change is
diff-able against a fixed baseline rather than masked by low-temperature
non-determinism. Anthropic's Messages API has no seed knob, so its
client ignores the field (documented inline). The seed is annotated on
the narrative wide-event.
2026-06-19 20:58:46 +02:00
Marc-André Bombeck 572ff3502e Merge wave-qol-mechanical: route stray query keys through the factory
Fix the target-edit-sheet invalidation that used a bare ["insights"] array
not matching the factory prefix the reads nest under (a silent no-op), and
migrate 12 further bare queryKey call sites onto the centralised factory.
2026-06-19 20:58:16 +02:00
Marc-André Bombeck 7530324fd1 refactor(ai): centralise per-surface generation budgets
Replace the scattered `?? 0.3` / `?? 1000` temperature and maxTokens
magic numbers across the status provider, Coach route, comprehensive
generator and period-narrative generator with a single typed
`ai-budgets.ts` map. Each entry documents the output contract that
fixes its token ceiling.

Right-size the per-metric status output from 1000 to 250 tokens: the
card renders one `{ "summary": "..." }` of 2-4 sentences (30-60 words),
so 1000 was an order of magnitude over the real ceiling.
2026-06-19 20:57:37 +02:00
Marc-André Bombeck bda06c9ec4 refactor(query-keys): route remaining bare arrays through the factory
The target editor invalidated a bare ["insights"] array on save and reset.
That literal sat outside the centralised factory, so the broad insights
fan-out it intended never matched the reads, which key through
insightsTargets/insightsRoot. Route the editor's reads and invalidations
through userThresholds(), insightsRoot(), insightsTargets() and analytics()
so a saved target actually refreshes every insights surface.

Migrate the other call sites the lint rule does not reach onto the factory:
the Coach conversation rail + thread reads, the per-metric personal-records
badge, the per-day measurement drill-down, the dashboard intake
compliance-chart fan-out, the per-medication side-effects list, the
onboarding auth invalidations, the notification-preferences read, and the
PWA persister's auth-me lookup.

Add the matching factory keys: coachConversations, coachConversation,
personalRecordsByMetric, measurementDrilldown, complianceChartInline and
medicationSideEffects. Each new key reuses the prefix its reads already use
so no invalidation shape drifts.
2026-06-19 20:57:18 +02:00
Marc-André Bombeck e956767371 Merge wave-backend-hardening: expiry indexes, Coach retention, crypto fail-closed
- Session + AuthChallenge @@index([expiresAt]) (migration 0178)
- CoachMessage daily retention job (365d default, 30d floor)
- crypto dev-key padding fails closed unless NODE_ENV is development/test
2026-06-19 20:57:17 +02:00
Marc-André Bombeck 288569297f fix(crypto): fail closed on weak dev key outside development/test
The dev-only SHA-256 key padding was gated on the absence of a production
marker, so a staging or preview container with NODE_ENV unset silently
derived a weak key the operator never recorded — rotating to a real key
later orphaned those rows (decrypt is fail-closed). Gate the padding on an
explicit NODE_ENV of development or test; every other case, including unset
NODE_ENV, throws the same hard error as in production.
2026-06-19 20:55:46 +02:00
Marc-André Bombeck 0278e22ed1 feat(jobs): add retention prune for stored Coach history
coach_messages is append-only and grows forever — a daily Coach user
accrues encrypted conversation rows with no lifecycle, while every
sibling ledger (push_attempts, audit_logs, host_metrics) already prunes.
Add a daily pg-boss cleanup that hard-deletes rows past a configurable
window (COACH_MESSAGE_RETENTION_DAYS, default 365, 30-day floor so a
misconfig cannot drop live in-context history). Wire it into the
maintenance registrar with the dead-queue contract guard and a focused
helper test.
2026-06-19 20:54:56 +02:00
Marc-André Bombeck b3d303217c perf(db): index session and challenge expiry columns
The sliding-30-day session reaper and the passkey-challenge sweep both
filter on expires_at, but neither sessions nor auth_challenges indexed
that column, so the cleanup degraded to a sequential scan on a busy
instance. Every sibling expiry table already indexes its expiry column;
add the two missing indexes via migration 0178.
2026-06-19 20:52:31 +02:00
Marc-André Bombeck 3e04035de8 fix(coach): paint the launcher glyph white on the gradient
The floating Coach launcher inherited the button's dark foreground token,
which left the sparkle glyph as a dark outline on the purple-pink
gradient. The glyph is decorative and the button carries its own
accessible name, so the white mark reads cleaner without affecting the
control's non-text contrast, which the offset focus ring already
provides.
2026-06-19 20:48:02 +02:00
Marc-André Bombeck f90ce8bace test(withings): scope the expired-nonce assertion to the token URL
The expired-nonce callback test asserted the global fetch spy was never
called. A preceding successful-connection test fires its webhook
subscriptions fire-and-forget, and those promises resolve into the next
test's freshly installed spy, so the broad assertion failed whenever the
background calls landed in time. Filter the recorded calls to the token
endpoint and assert none reached it, matching the replay and cross-user
tests in the same file and keeping the check true to its intent.
2026-06-19 20:45:46 +02:00
Marc-André Bombeck 2e27c3c441 refactor(insights): share the briefing row layout
KeyFindingRow and SignalRow rendered an identical tone bar + metric icon
+ headline/body/delta and the same link-when-routed behaviour, differing
only in their data-slot and body copy. Extract a single BriefingRow and
have both delegate to it.
2026-06-19 20:35:25 +02:00
Marc-André Bombeck f002a9d565 fix(bp): keep non-physiological readings out of the seed and the series
The demo seed's random-walk BP generator could drift below the
physiological floor and mint a systolic-0 row (iOS #33). Clamp the walk
to the same band the input validator enforces (systolic >= 40,
diastolic >= 20), reusing the VALUE_RANGES floors, so a fresh seed never
writes a sub-floor reading. Add a read-side selection guard on the blood
pressure series endpoint that excludes rows below those floors from both
the series and the implicit latest point, so a corrupt row that slipped
in before the floor was enforced can never surface as the latest
reading. Export VALUE_RANGES so both sites share one source of truth.
2026-06-19 20:34:20 +02:00
Marc-André Bombeck a3b274171c fix(a11y): announce coach history state and Vorsorge trend
Add aria-expanded to the coach drawer's history-collapse control so its
expanded state is exposed, matching the expand toggle. Give the Vorsorge
trend strip an sr-only summary of the latest value and seven-day
direction, and key the bars on measuredAt rather than the array index.
2026-06-19 20:31:58 +02:00
Marc-André Bombeck 1f8420a6d0 chore(i18n): drop orphaned integration help and overlap notes
Remove the integration keys left dead after the integrations refactor:
the WHOOP and Fitbit overlap notes, the Fitbit experimental note, the
per-provider credentials-help strings (WHOOP, Fitbit, Polar, Oura) and
the Nightscout help. None carry a t() call site any longer. Add the
Vorsorge trend-strip screen-reader summary strings (latest value plus
seven-day direction) across all locales.
2026-06-19 20:31:52 +02:00
Marc-André Bombeck d2007f2f37 perf(coach): read only coarse bands for the deep-history tail
Add a coarseOnly option to buildTieredSeries that skips the raw 0–14d
read and the DAY (14–30d) / WEEK (30–90d) band reads, returning only the
MONTH / YEAR bands plus the anomaly envelope. The Coach snapshot's
coarse timeline tail consumes only month/year + anomalies, so the
near-term bands were read and discarded every turn; it now passes
coarseOnly. The briefing path is unchanged. Look the learn-guide prompt
example up by slug rather than by array index so reordering the catalog
cannot silently change the example link.
2026-06-19 20:29:02 +02:00
Marc-André Bombeck ebb9d0c607 fix(insights): correct signals-of-day extraction and determinism
Drop steps from the signals-of-day set: Apple Health steps ingest as
many intraday stats-prefixed samples, so the newest raw row is a
partial-day fragment, not a daily total — same exclusion already applied
to per-night sleep. Add a glucose signal off the canonical stored value
(the model reads the snapshot, not display units) so the block matches
the priority comment that already claimed it; drop the dead sleep entry
from SIGNAL_PRIORITY. Track the raw extreme magnitude when keeping the
most extreme recent anomaly so a larger anomaly is no longer dropped by
comparing against an already-rounded value. Thread the injected clock
through trendSlope so the emerging-trend cutoff is deterministic under a
test clock. Rename the anomaly day-count to anomalyDaysAgo so the
snapshot hash staleness-tiers it instead of letting the raw count drive
the fingerprint. Cap the returned set at three to match the schema.
2026-06-19 20:27:36 +02:00
Marc-André Bombeck cdb76f1930 chore(release): v1.18.7 2026-06-19 20:03:28 +02:00
Marc-André Bombeck c29dae7ad9 Merge wave-coachctx: tiered + illness/article-aware Coach context 2026-06-19 20:00:33 +02:00
Marc-André Bombeck c334be22e9 feat(coach): tier history context + illness and learn-guide awareness
Wire the Coach snapshot's core-metric history onto the shared tiered
series so blood pressure, weight, and pulse carry a coarse tail the
in-window weekly fold could not produce — 90d-1y monthly and >1y yearly
buckets plus the preserved peak/trough envelope, so a spike from months
ago survives a coarse bucket. The tiered read skips the raw 0-14d band
the snapshot already holds, so no duplicate round-trip; the coarse tail
is bounded per band and sheds first under the prompt-budget degrader, so
token size holds.

Surface the active illness episodes and Rest Mode state to the Coach
prompt: the snapshot already carries the illness block, and the system
prompt now instructs the Coach to read an off-band vital as plausibly
illness-explained and avoid pushing a heavier cadence while the user is
in Rest Mode.

Add a static catalog of the public /learn guides and inject a compact
list into the system prompt. The Coach may reference at most one guide
by topic and URL when genuinely helpful, and only a slug the catalog
lists — a fabricated /learn URL is impossible by construction.
2026-06-19 20:00:04 +02:00
Marc-André Bombeck 675456eea3 Merge wave-integrations: unified integration-card skeleton + normalised copy 2026-06-19 19:59:12 +02:00
Marc-André Bombeck aa092df9f0 refactor(settings): unify the integration card skeleton
Every Settings → Integrations block now paints one consistent shape:
heading (icon + name + status pill) → a white primary description → a
grey secondary description that ends with the setup-guide link rendered
inline, instead of a standalone bottom-of-card element.

OAuth-only (WHOOP/Fitbit), BYO-credential (Withings/Oura/Polar) and
manual-key (Nightscout) cards share the same skeleton via the new
IntegrationCardDescription helper; the credential form stays as an extra
block where it applies. The "where to find these credentials" hint moves
above the Save button instead of sitting below it.

Rewrite the per-provider description strings across all six locales into a
parallel two-part shape with even length, and drop the now-orphaned
overlap/experimental sub-notes and the standalone setup-guide link.
2026-06-19 19:58:48 +02:00
Marc-André Bombeck d35c7660ea test(insights): assert the overview no longer wires the inline ask-Coach affordance
Wave A removed the inline ask-the-Coach / suggested-questions block from
the overview hero (Coach is reached via the FAB + drawer). Update the
stale assertion that still expected onAskCoach/onPickPrompt + the launch
context on the page.
2026-06-19 19:52:21 +02:00
Marc-André Bombeck 5fca70524d Merge wave-sleep: drop last-night card, big chronotype, fix AI-hint 2026-06-19 19:48:50 +02:00
Marc-André Bombeck 43388eabbf Merge wave-coach: Claude/ChatGPT-style Coach frontend redesign 2026-06-19 19:48:33 +02:00
Marc-André Bombeck 4bda26f47c fix(insights): rework the Sleep view — drop the redundant last-night card, promote chronotype, fix the assessment slot
Remove the single-night "Last night" hypnogram from the sleep overview: its
per-stage breakdown (time + %) duplicated the phase-distribution stacked bar
directly below it. The /api/sleep/night route stays (the dashboard reads it);
only the redundant surface is gone.

Promote the chronotype to the prominent bottom block of the view. Split it out
of the rhythm section (now the sleep-debt headline alone) into its own section:
a large band label with the natural sleep-midpoint clock beneath it and an
expandable disclosure for social jetlag + MSFsc. It reads the same
["sleep-rhythm"] cache, so it adds no round-trip, and keeps the calm
"still learning your rhythm — N of M nights" state.

Fix the sleep-quality assessment slot. It disabled the status fetch whenever the
AI narrative was absent, so the card could not read hasProvider and fell back to
its no-provider default — surfacing the "connect an AI provider" CTA even for
accounts that have a provider connected, stacked under the grounded note. The
card now stays enabled and reports the truthful state: no provider shows its
clean AI-settings CTA alone; a connected provider with no text yet shows the
grounded note; warm or preparing AI text wins. Exactly one block fills the slot.
2026-06-19 19:48:10 +02:00
Marc-André Bombeck 36adedad58 feat(coach): premium chat surface — narrower column, collapsible rail, voice dictation
Redesign the Coach frontend toward a calm, prompt-first reading
experience:

- Narrow the reading column to a centred max-w-2xl with a roomier
  vertical rhythm so the conversation reads like a document rather than
  a packed log.
- Make the page-surface conversation rail collapsible and collapsed by
  default. A single labelled toggle in the rail strip opens it (lg+),
  the rail heading carries a matching close control, and the column
  folds to zero width with a smooth grid transition. Below lg the
  bottom tray is unchanged. The fullscreen header keeps only the
  minimize control.
- Replace the boxy default scrollbar on the thread, history list, and
  composer with a thin, rounded, subtle bar across WebKit and Firefox,
  scoped to the components.
- Polish the pre-stream beat: the Thinking indicator now pairs bouncing
  dots with the label, and the assistant turn fades in so the hand-off
  to streamed tokens reads as one motion.
- Return voice dictation to the composer via the Web Speech API. The
  mic mounts only where the browser supports recognition, dictates into
  the textarea, and is keyboard- and screen-reader-accessible.

New labels land in all six locale bundles.
2026-06-19 19:48:03 +02:00
Marc-André Bombeck 0a4be95aba Merge wave-rings: flatten the health-score rings to the chart style 2026-06-19 19:45:44 +02:00
Marc-André Bombeck deccdaca0a Merge wave-vorsorge: 7-day strip + filtered-measurements deep-link 2026-06-19 19:45:36 +02:00
Marc-André Bombeck d92b3b9efd style(insights): calm and flatten the health-score rings
The score dials wore a token-tinted drop-shadow bloom, a conic specular
sheen, a breathing pulse on the green ring, and a dark-to-neon two-stop
arc gradient. Against the flat 2-D Recharts charts that read as a glowing
3-D gauge and made the overview restless.

Make the calm treatment the default everywhere, not only behind the `flat`
prop:

- Drop the bloom, the sheen, and the pulse for every ring. `.wellness-ring-arc`
  is now a plain flat stroke; the sheen overlay paints nothing. The shared
  cycle ring inherits the same quiet.
- Mute the arc colours toward the chart palette: the two gradient stops sit a
  hair apart for a whisper of depth instead of a neon sweep, pulled down and
  desaturated per hue in both the Dracula and Alucard sets. Band semantics
  (green/yellow/red thresholds) are unchanged.
- `flat` now only drops the entrance sweep + count-up, so the dashboard hero
  ring paints its value at once. The /insights strip keeps the subtle spring
  fill; prefers-reduced-motion still zeroes it.

Score and band logic, thresholds, and the data-band/aria contract are
untouched.
2026-06-19 19:45:23 +02:00
Marc-André Bombeck c7425ca426 feat(vorsorge): add 7-day trend strip + measurements deep link to reminder cards
Enrich the Vorsorge cards with a discreet 7-day trend strip and a path
into the underlying readings.

- A quiet seven-bar strip of the reminder metric's last seven readings
  sits under the metric context on both the dashboard summary card and
  the full-grid cards, mirroring how a dashboard tile carries a small
  trend. Neutral muted bars, no status colour. A free-text reminder or a
  metric with fewer than two readings shows nothing, so card height stays
  stable. The strip reads a bounded last-7 window per type through the
  existing measurements endpoint, cached by type under the measurements
  invalidation prefix.
- The card kebab gains a 'Show measurements' item for measurement-linked
  reminders that opens /measurements pre-filtered to that type.
- The measurements list accepts a ?type=<MEASUREMENT_TYPE> deep link that
  seeds the existing client-side type filter while leaving the rail
  interactive; unknown tokens are dropped. The ?add= behaviour is
  unchanged.
- New labels added across all six locales.
2026-06-19 19:45:14 +02:00
Marc-André Bombeck f931f2f5c2 feat(insights): render signals of the day and remove the overview ask-coach block
Render the new signalsOfDay list at the top of the daily-briefing card — a
present-focused row (headline + concrete nudge) sharing the tone bar, metric
icon, and sub-page routing of the key-finding row, with matching typography
(text-sm headline, text-xs muted body) so the card reads consistently with the
rest of the insight surfaces. Add the signalsTitle string across all six
locales.

Remove the "Ask the coach" action button and the guided-questions chip strip
from the overview hero band: the Coach stays the bottom-right drawer mounted by
the insights layout shell. The hero is now greeting + subtitle + baseline meta
+ the optional Health Score panel, and no longer reads the coach flags or the
launch context. Update the coach-cascade / disable-cascade fixtures and the
hero-strip slot tests to pin the affordances absent.
2026-06-19 19:37:01 +02:00
Marc-André Bombeck 0b01bfb483 feat(insights): signals-of-the-day briefing and fix the stale-briefing gate
Compute a present-focused "signals of the day" block in extractFeatures from
the in-memory measurement set (no extra query): the freshest reading vs the
user's own 7d/30d windows, the normal-swing verdict, an emerging slope, and a
recent anomaly per salient metric, ranked by clinical priority and capped at
five. Add a signalsOfDay array to the dailyBriefing schema feeding the
briefing alongside the existing keyFindings cap, and rewrite the briefing
prompt (EN + DE) to lead with NOW: a warmer, motivating read that surfaces the
emerging signal and gives one concrete, doable nudge while still drawing on the
tiered history.

The signals block lands inside the compacted features payload, so it also
seeds insightsSnapshotHash — a fresh daily signal now changes the fingerprint
and forces the daily 04:30 warm to regenerate instead of re-stamping a
days-old narrative. Bump PROMPT_VERSION to 4.29.0.
2026-06-19 19:36:51 +02:00
Marc-André Bombeck 775d624228 feat(rollups): add progressive tiered-context builder for AI surfaces
Introduce buildTieredSeries(): a five-band progressive data-context helper —
0-14d raw daily, 14-30d DAY, 30-90d WEEK, 90d-1y MONTH, >1y YEAR — plus a
per-metric min/max-envelope anomaly list so a peak from months ago survives a
coarse bucket. Reuses probeRollupCoverage / ensureUserRollupsFresh with the
existing replace-fall-back-on-miss contract; readers never throw, so a
coverage gap simply yields an empty band. Caps each metric near 600 tokens to
stay within the existing context budget. Shared across the Coach snapshot and
the daily briefing.
2026-06-19 19:36:42 +02:00
Marc-André Bombeck 85272a956b Merge v1.18.6.1 into main
v1.18.6.1 — settings/admin alignment, first-run-only tour, preventive-care card, coach scroll, FHIR section.
2026-06-19 00:13:49 +02:00
Marc-André Bombeck 155cedda50 chore(release): v1.18.6.1 2026-06-19 00:05:20 +02:00
Marc-André Bombeck dae7138fc6 fix(settings): keep the health-record entry reachable regardless of the doctorReport opt-out
The health-record section (PDF + FHIR R4 export) was the only entry-point
gated on the doctorReport module's nav toggle, so flipping that opt-out in
the Modules hub made the whole section vanish from Settings with no
in-context way back. Drop the nav gate: the entry-point now always shows.
The doctorReport module still governs the data layer — the server-side
/api/export/health-record route stays the hard enforcement (403
module.disabled over Bearer when opted out). Assert the entry shows for a
default, empty, and opted-out modules map.
2026-06-19 00:03:22 +02:00
Marc-André Bombeck 6f866ff1c6 fix(coach): bound the conversation scroll region so the composer stays pinned
The message thread used h-full inside a plain-block wrapper, so its height
resolved against an auto-height parent: the scroll container grew with its
content instead of scrolling, pushing the composer down and leaving a stray
scrollbar on the page.

Make the thread wrapper a flex column and switch the thread (and its empty
state) from h-full to min-h-0 flex-1, so the scroll region takes a bounded
track from the flex parent. The conversation now scrolls cleanly inside the
card while the composer stays pinned to the bottom.
2026-06-18 23:58:43 +02:00
Marc-André Bombeck 8234427a94 fix(vorsorge): match the medication-card grammar on the reminder card
Mirror the medication card's name + cadence + status layout:
- the cadence ("alle 7 Tage") renders as a chip directly to the right of the
  metric name via a new MedicationCardHeader nameChip slot, instead of on the
  state-badge row below the category;
- the due / overdue status renders as plain text, not a chip;
- the "zuletzt erledigt" line always renders, falling back to an em dash when
  there is no last-done value, instead of being hidden.

The green action button is unchanged. Also drop the per-module tour trigger
from this surface (the tour is first-time-only now).
2026-06-18 23:58:35 +02:00
Marc-André Bombeck 3ba542f797 fix(onboarding): make the guided tour first-time-only, drop every re-entry trigger
The guided tour now exists only as the first-time auto-start after onboarding.
Remove every in-app trigger that re-opened it: the per-module "show this tour"
button on the dashboard, measurements, mood, medications, labs, illness, cycle,
achievements, insights, and coach surfaces; and the Settings replay card.

Delete the now-orphaned ModuleTourTrigger component, the tour-restart worker,
the healthlog:module-tour and healthlog:tour-restart event listeners, the
single-stop overlay path, and the force-launch session marker. The first-time
auto-launch gate and the tour overlay itself are unchanged. Prune the orphaned
i18n keys across all locales and update the affected tests.
2026-06-18 23:58:27 +02:00
Marc-André Bombeck f67542d29e fix(settings): align section nav with the first card via the grid, not a spacer
The settings and admin shells reserved the page-heading height in the left
nav with a hidden replica block. The replica wrapped differently in the 220px
nav column than the real heading in the wide content column, so the first nav
item sat too low and drifted across locales.

Hoist the heading into the shell and place it in its own grid row spanning only
the content column: row 1 holds the heading, row 2 holds the nav and the cards.
The nav's first item and the first card now start the same grid row, so they
align by construction regardless of how the title or subtitle wraps. Drop the
spacer component.
2026-06-18 23:58:10 +02:00
Marc-André Bombeck ee16b05c93 Merge release/v1.18.6 into main
v1.18.6 — offline reads, grounded guidance, and one consistent app.
2026-06-18 23:11:36 +02:00
Marc-André Bombeck 8b03fb450e fix(deps): patch undici and nodemailer high-severity advisories
Bump undici 8.4.1 -> 8.5.0 (GHSA-vmh5-mc38-953g, TLS certificate
validation bypass in the SOCKS5 ProxyAgent) and nodemailer 9.0.0 -> 9.0.1
(GHSA-p6gq-j5cr-w38f), both direct dependencies. Clears the production
high-severity dependency audit; no API change.
2026-06-18 23:04:27 +02:00
Marc-André Bombeck 742faffdda refactor(dashboard): drop the duplicate band-builder export
buildTargetBands was a same-file alias of buildDashboardBands, which the
dead-code gate flags as a duplicate export. Keep the historic name only as
a re-export alias from the snapshot module, so callers and the parity test
are unchanged while bands.ts exposes a single builder.
2026-06-18 22:55:22 +02:00
Marc-André Bombeck 1118d36cf8 docs: consolidate the v1.18.6 changelog for the full cycle
Cover the whole release — offline reads, cited reference ranges + Coach
grounding, responsible safety alerts + diabetes opt-in, the module tour,
the module-consistency and settings/admin IA pass, sleep/coach polish,
and the reminder completion route — and correct the additive migration
note (0173–0177).
2026-06-18 22:45:26 +02:00
Marc-André Bombeck 8ff3b51ba3 refactor: remove dead exports and an unused prop
Drop the health-score card's inert onAskCoach prop and its now-dead
pass-through from the hero strip, the buildDashboardBands re-export that no
caller reached through the snapshot module, an unused tour-progress status
type, and an orphan service-worker cache helper.
2026-06-18 22:42:27 +02:00
Marc-André Bombeck 22f588b34d refactor(ui): correct stale comments and steady the settings-nav spacer
Drop the overstated claim that the due-reminder green button mirrors the
medication card (the med take button is always the default tone), fix the
module-tour anchor attribute named in a doc comment, and feed the active
section title into the settings nav spacer so its heading line wraps the same
way a long localized title does.
2026-06-18 22:42:22 +02:00
Marc-André Bombeck 3ec9aca798 perf(dashboard): memoise the band math and the module-list parse
The dashboard recomputed the client band fallback on every render even when
the server snapshot supplied bands, and the module-list-prefs hook reparsed
its stored JSON into a fresh object each render, defeating downstream
memoisation. Wrap both in useMemo keyed on their real inputs.
2026-06-18 22:42:14 +02:00
Marc-André Bombeck 9eed8d9cfe chore(i18n): drop orphaned message keys across the six locales
Remove three diabetes settings strings whose web toggle was never built, four
dead reference-state explainer keys with no consumer, and the generic
reminder-toggle aria label that the per-row aria-labelledby wiring replaced.
The backend and resolver behind the diabetes preference stay in place.
2026-06-18 22:42:10 +02:00
Marc-André Bombeck 9014203d8a fix(a11y): label per-reminder toggles and wire disclosure controls
The diabetes PATCH parsed its body without a size cap; bound it through
safeJson like the sibling routes. Label each reminder's enable/disable switch
by its own name span so screen readers hear the reminder rather than one
generic label repeated per row. Point the chronotype and illness disclosure
triggers at the regions they reveal via aria-controls.
2026-06-18 22:42:04 +02:00
Marc-André Bombeck 397423dddc docs(api): register the onboarding disclaimer endpoint in the contract
The one-time disclaimer acknowledgment route shipped without an OpenAPI
entry, leaving a gap in the contract the iOS client consumes. Add the POST
path and regenerate the spec.
2026-06-18 22:41:57 +02:00
Marc-André Bombeck ddbb25d5d4 fix(coach): cap proactive nudges by the persisted conversation, not push success
The proactive-nudge frequency gate only counted delivered push attempts. A
user with no push channel never wrote a delivered row, so the cap was inert
for them: every cron tick that hit a persistent trigger minted a fresh nudge
conversation in the rail.

Count a recently persisted nudge conversation toward the same rolling window
so the once-per-window contract holds regardless of push outcome. Add a
covering index on coach messages for the assistant-message scans the FAB poll
and the gate both run, and drop the unused providerType parameter from the
persistence helper now that the tag is fixed.
2026-06-18 22:41:47 +02:00
Marc-André Bombeck 442eb83e2b test(e2e): drive coach launch through the FAB after the inline icon retired
The per-metric inline coach launch icon was removed from the insights
sub-page header; the floating FAB is now the single entry point. The spec
still asserted the removed icon, so every inline-launch case would fail.

Rewrite the per-slug and drawer-open cases to assert the inline icon is
absent and to open the drawer via the FAB (the pattern the surviving unread
case already used), and update the docstring. Route mocks and the hydration
gate stay as they were.
2026-06-18 22:41:38 +02:00
Marc-André Bombeck 547a7f8904 fix(a11y): give the due-reminder action a readable foreground
The Vorsorge "Jetzt messen" / mark-done button hard-coded white text on a
solid green fill. In the dark theme the fill is the bright Dracula green, so
white-on-green reads about 1.37:1 — below the WCAG AA floor.

Add a theme-aware `--success-foreground` token (near-black glyph on the dark
fill, white on the darker light-mode green) mirroring the existing
`--primary-foreground` pattern, and use `text-success-foreground` on both the
card and list variants. Green stays only on the action button; the card
surface remains neutral.
2026-06-18 22:41:31 +02:00
Marc-André Bombeck 6acc3a72df docs(api): document the Coach nudge-status and seen endpoints
Add the GET /api/insights/coach/nudge-status unread signal and the POST
/api/insights/coach/seen open-stamp to the OpenAPI contract and regenerate
the spec.
2026-06-18 22:12:38 +02:00
Marc-André Bombeck 04a50b2702 refactor(insights): drop the per-page generic medical disclaimers
Remove the page-level Insights disclaimer footer and the health-score and
correlation card disclaimers now that the not-a-diagnosis acknowledgment
is shown once at onboarding and the legal text stays on the privacy page.
Device-provenance, contraception-safety, statistical-method and
shared-report notes stay where they convey specific information.
2026-06-18 22:11:45 +02:00
Marc-André Bombeck bfba92cb83 refactor(settings): share one heading frame across all settings pages
Teach SettingsSectionFrame an explicit title/subtitle mode alongside the
slug-driven mode, and have the per-module settings pages (Vorsorge,
Illness, Labs) delegate to it so every settings surface renders the same
heading frame.
2026-06-18 22:06:40 +02:00
Marc-André Bombeck 39505a5d35 merge: measurement-reminder completion route
Add an idempotent, owner-scoped completion endpoint for measurement
reminders that records satisfaction without firing a notification.
2026-06-18 22:05:22 +02:00
Marc-André Bombeck c941ab24f4 fix(migrations): renumber coach last-seen migration to 0176
The disclaimer-preference migration already claims 0175 in this release;
move the coach last-seen stamp to 0176 so both apply uniquely and in
order.
2026-06-18 22:05:17 +02:00
Marc-André Bombeck 6614e0646f merge: Coach v1.18.6 nudge and conversation continuity
Add the Coach proactive nudge-status surface and last-seen tracking
(migration renumbered to 0176) plus the seen endpoint.
2026-06-18 22:04:31 +02:00
Marc-André Bombeck d1355a2999 merge: sleep, insights and recovery surfacing
Suppress the synthesized sleep timeline for non-measured nights while
keeping measured-night detail, and refine the insights and recovery
surfacing.
2026-06-18 22:04:06 +02:00
Marc-André Bombeck 3d3ad8d070 merge: per-module settings pages and disclaimer preference
Add the P1 module settings surfaces and the one-time medical-disclaimer
acknowledgment preference (migration 0175).
2026-06-18 22:03:42 +02:00
Marc-André Bombeck 325f4f6b6b merge: Settings and Admin information-architecture redesign
Bring the shared SettingsSectionFrame and the reorganised settings and
admin navigation onto the v1.18.6 line.
2026-06-18 22:03:23 +02:00
Marc-André Bombeck 70f92558e6 feat(reminders): add server-side completion route for Vorsorge reminders
Add POST /api/measurement-reminders/{id}/complete so a client can mark a
measurement reminder done on the server, rather than only dismissing the
notification locally. The route is the explicit user-action equivalent of
the cron auto-resolve: it routes through the one shared satisfaction
primitive, stamps lastSatisfiedAt, re-anchors the server-authoritative
nextDueAt, and fires no notification of its own.

Idempotent via the primitive's forward-only guard — completing an
already-completed or auto-satisfied reminder returns 200 with
completed=false so a repeat tap need not be special-cased. Owner-scoped;
a cross-user or tombstoned id 404s and never satisfies. No schema change:
lastSatisfiedAt already records the completion instant.

Extend the OpenAPI contract with the new path and the completion result
schema, and cover the route with unit tests (marks done, idempotent
no-op, owner-scoped 404, no double-notify).
2026-06-18 21:39:24 +02:00
Marc-André Bombeck 0a8ccaa30c feat(onboarding): one-time medical-disclaimer acknowledgment
Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.

- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
  migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
  /api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
2026-06-18 21:37:13 +02:00
Marc-André Bombeck 267dcad13d feat(modules): unify Vorsorge, Illness and Labs with the medication pattern
- add a wrench beside each module's add button linking to the module's own
  settings page; rename every add button to a plain "hinzufügen"
- give each module a settings page: view (cards/list) + manual reorder; Labs
  also gets a sort-order choice (by last reading) and biomarker CRUD/reorder
- collapse the Illness "abgeschlossen" list by default
- rebuild the Vorsorge card on the medication card's building blocks: a green
  "Jetzt messen" action, the interval as a chip beside the name, and an
  in-interval progress signal; drop the kebab "anpassen" and stray actions
- persist per-module view/order/sort client-side; add reverse-coverage-clean
  strings across all six locales
2026-06-18 21:36:58 +02:00
Marc-André Bombeck 33d7d103da feat(settings): standardize settings and admin section framing
Introduce a shared SettingsSectionFrame (the settings twin of the admin
renderer's SectionFrame) that emits the canonical visible heading +
subheading, and route all 19 settings sections through it. Every settings
page now shows a real h1 + subtitle in the reference Übersicht style
instead of an invisible sr-only title; the section bodies drop their own
headings and off-style intro paragraphs (the explanatory copy folds into
the frame subtitle).

- Route the admin About page through SectionFrame so it carries the same
  visible heading as every other admin section.
- Rename the AI settings section from KI-Auswertung to KI-Anbieter across
  six locales: the page configures the provider and key, not an analysis.
- Add a heading-block spacer to the settings and admin sidebars so the
  first menu item lines up with the top of the first card.
- Remove the always-on core-domains card from Module: a tracking area that
  can't be turned off doesn't need listing.
- Move the proactive-coach control out of Benachrichtigungen into Coach.
- Move admin module availability out of the Coach section into its own
  page: it gates every module, not just the coach.
- Give the Layout Insights editor a proper Insights heading.

Adds settings.sections.<slug>.subtitle and the new admin keys across all
six locales; removes the now-unused module core-domain strings.
2026-06-18 21:28:33 +02:00
Marc-André Bombeck 34fbfe1c67 feat(insights): link the overview Trends cards to their detail pages
The overview charted each trend but never offered a way into the metric's
detail page — the steps page in particular exists and is used, yet Insights
never linked to it. Give every Trends-row chart a routed detail target and a
discreet "view details" link below the caption, so a metric the row surfaces
(steps included, when the briefing flags it) drills into /insights/<slug>.
2026-06-18 21:24:02 +02:00
Marc-André Bombeck 71b29f5b6f refactor(recovery): drop the per-group sub-headings on the recovery page
Read next to the page's "Erholung" heading, the "Recharge" / "Strain & load" /
"Daily heart & energy" sub-headings were redundant chrome. Remove them and
render the present signal blocks as one flat, evenly-spaced stack — each block
already carries its own title + explainer, so nothing loses context. The group
structure stays internally to drive block order and the data gate. Promote the
block title to an h2 so the page heading outline stays nested (h1 then h2) with
no skipped level, and drop the now-orphaned group i18n keys.
2026-06-18 21:20:19 +02:00
Marc-André Bombeck 2535d45fc7 refactor(insights): drop the redundant per-metric Coach launcher
Every metric sub-page header carried a Coach launch icon. With the Coach
FAB anchored bottom-right on every authenticated page, that header icon
was a second entry point to the same surface. Remove it from the shared
sub-page shell so the FAB is the single Coach launcher app-wide. The
coachLaunch prop stays accepted on the shell (the metric pages still pass
it) but no longer renders a control, so no per-page churn is needed.
2026-06-18 21:19:42 +02:00
Marc-André Bombeck 96c5641787 fix(coach): give the chat page more width and fix the rail scrollbar
The Coach page read as cut off against other pages and the left
conversation scrollbar looked broken.

Width: the page sat inside the shared max-w-screen-xl content frame with
a tall pb-20 FAB gutter that does nothing on /coach (the FAB hides
there). Break out of the frame so the chat claims the full content width
and viewport height, edge-to-edge on small screens and a contained card
from sm up, and centre the thread on a comfortable reading measure.

Scrollbar: the page-surface history rail was wrapped in an overflow-auto
container while the rail itself is a flex column with its own
overflow-auto list — two nested scroll regions. The inner list's height
never resolved, so its scrollbar rendered against an indeterminate track
and the search field scrolled away. Drop the outer scroll: the rail
keeps a fixed search header and one internal list-scroll.
2026-06-18 21:19:36 +02:00
Marc-André Bombeck 3a3f88a80c feat(coach): mark an unread proactive nudge on the Coach launcher
Add a discreet unread dot to the Coach FAB when the Coach has something
the user has not opened yet, and clear it when they open the Coach.

The signal is server-authoritative: a new coach_last_seen_at column on
users records the last time the Coach was opened, and the launcher's
status endpoint reports unread when the newest assistant message is
newer than that stamp — so the dot works even with no push channel and
clears across every device, web and iOS. Opening the Coach (FAB or the
/coach page) stamps the column via a new /api/insights/coach/seen
endpoint; the launcher keeps a local stamp only for an instant paint.

The dot is a calm cyan, not an alarming red, per the card-status rule.
2026-06-18 21:19:27 +02:00
Marc-André Bombeck f453f8a758 fix(coach): surface the proactive nudge in the conversation rail
The proactive Coach nudge dispatched a notification only — it never
wrote a CoachMessage, so it was invisible under the conversation list
and entirely silent for a user with no push channel configured.

Record the nudge as a real conversation before dispatching: create a
conversation and write the nudge body as the initial assistant message
(encrypted at rest like every other Coach reply), then fire the existing
notification + /coach deep link unchanged. A persistence failure is
caught locally so it can never swallow the notification, and the tick
summary tracks a separate persisted count alongside dispatched.
2026-06-18 21:19:17 +02:00
Marc-André Bombeck aa9b713a66 feat(sleep): ground the sleep-quality assessment in the user's own scores
The sleep-quality "Einschätzung" read "no assessment yet" until an AI provider
had run, and stayed blank on an account with no provider configured. Fill that
slot with a deterministic note built from the user's own quality averages —
efficiency, performance, consistency and the headline score graded against
recognised reference bands — so the assessment is always substantive. The
richer AI narrative still wins when present; the grounded note only steps in
once the assessment query settles with no narrative, so the two never stack.
2026-06-18 21:17:00 +02:00
Marc-André Bombeck d8c139698c feat(sleep): surface the chronotype as a labelled corner readout
Move the chronotype band out of a body badge into a labelled top-right corner
of the card — a small "Chronotype" label over the value — so the resolved
type is the first thing the eye lands on. Tighten the mid-sleep copy to name
it as the body's natural sleep midpoint on free days.
2026-06-18 21:16:43 +02:00
Marc-André Bombeck c69e6deb67 fix(sleep): drop the misleading last-night timeline for estimated stages
The last-night card drew a stepped stage timeline for every source. For a
source that reports per-stage totals without measured onsets (WHOOP / Polar),
the server lays the segments out in a fixed physiological order, so the bar
invents a phase progression that was never recorded — it reads as one Core
block then one Deep block, not a real hypnogram.

Suppress the timeline when the night is reconstructed and keep the per-stage
breakdown + totals, which are real. A calm note explains that stage timing is
estimated for that source. Real-series nights (Apple Health, Withings, Fitbit)
keep the measured timeline unchanged.
2026-06-18 21:11:18 +02:00
Marc-André Bombeck ae3091df9e merge(W7): ground the Coach in cited reference ranges
Inject a deterministic, citation-aware grounding block into the coach
snapshot/prompt for the metrics present in a user's snapshot: each
metric's reference band + where the value sits, framed as general
guidance, never a diagnosis, no brand attribution, BP labelled ESH 2023.
Glucose respects the diabetes opt-in resolver. Also fixes the reference
classifier to resolve the normal band by an explicit marker rather than
index 0 (BMI/pulse-pressure were mis-placed) — correcting both the
grounding and the in-app four-state explainer. Steps stay out of the
grounding until step aggregation sums intra-day rows.
2026-06-18 20:42:04 +02:00
Marc-André Bombeck 9265ab89c6 fix(reference): resolve the normal band by marker, not index 0
The reference-range classifier assumed bands[0] was always the
normal/target band, so any metric whose first band is abnormal was
placed wrong: a healthy BMI 22 read as just outside the reference and
an underweight BMI 17 read as inside; pulse pressure had the same flaw
(Narrow is its first band). The same classifier drives the in-app
four-state explainer, so both surfaces were affected.

Mark the population-normal band explicitly with a `normal: true` flag
on every metric, add `normalBandIndex`, and place values by distance
from the marked band rather than from index 0. `headlineBandText` now
renders the marked band, so BMI grounds against 18.5-24.9 kg/m2.

Exclude daily steps from the Coach grounding: it is fed the intra-day
mean of the step rows, which averages a multi-row day down and can
misplace it below the 8,000 floor. Grounding it on a per-day sum is the
correct fix and is left as a follow-up; until then drop the line rather
than ship a wrong placement.

Extend the classifier and grounding tests for BMI 17/22/27, a pulse
pressure case, the normal-band-not-at-index-0 contract, and that steps
is not grounded.
2026-06-18 20:41:29 +02:00
Marc-André Bombeck 22ffb19fa5 feat(coach): ground replies in cited reference ranges
Surface the population reference bands behind the metrics a Coach turn
carries as a compact, citation-aware grounding block appended after the
SNAPSHOT. For each present metric the block states the headline band, its
source (blood pressure on the ESH 2023 line, US/ESC framings as context
only), and where the user's value sits via the shared four-state
placement contract.

Framed strictly as general guidance: the block opens and closes with a
not-a-diagnosis caveat, never overrides the existing refusal/dose/drug-
level safety discipline, and carries no commercial brand name. Glucose
respects the diabetes opt-in — the tighter ADA management goal only when
the user has declared it, otherwise the general non-diabetic band.

The block is deterministic and inspectable: built purely from the
reference-range backbone over the same scalars the snapshot already
surfaces, so it can never cite a value or band the snapshot does not
carry.
2026-06-18 20:32:45 +02:00
Marc-André Bombeck 6fc4b62988 merge(W6): clinical safety floors + diabetes opt-in
Wire absolute red-flag floors (BP >=180/120, SBP <90, glucose <70/<54/
>=250) into the illness notification path, escalating only on a confirmed
re-measured reading inside a 1h window, never diagnosing; asymptomatic ->
re-check/contact doctor, symptom-coupled -> emergency. Glucose gated behind
its module, BP core. Add an opt-in hasDiabetes pref (migration 0174) that
switches the glucose target resolver to the tighter ADA goal bands; never
auto-inferred. Resolver lives in the extracted targets builder.
2026-06-18 20:21:11 +02:00
Marc-André Bombeck 1b39e191a1 merge(W5): cited reference-range backbone + inline explainers
Add src/lib/reference-ranges.ts — a citation-backed per-metric range
backbone with a four-state placement classifier — and expand the medical
citations registry. Fill the empty metric-status normalRange fields,
enrich metric explainers/tooltips with cited bands (SpO2 darker-skin
caveat, device-honesty copy, steps 8,000 floor), label BP as ESH 2023
with ESC/AHA shown only as context, and surface pulse pressure + MAP on
the BP detail from stored readings. General guidance, no brand attribution.
2026-06-18 20:21:03 +02:00
Marc-André Bombeck 961257fffd i18n: add safety-floor alert and diabetes-target strings
Add the safety-floor escalation copy (per-reason title plus a contact-your-
doctor and an emergency body variant) and the diabetes opt-in setting strings
across all six locales. The alert copy frames as general guidance, never names
a condition, and routes symptom-coupled breaches to the emergency wording.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck f20be29189 feat(illness): escalate confirmed clinical safety floors
Add absolute, guideline-backed safety floors that escalate a freshly logged
reading — but only after a second, confirming reading inside a one-hour
re-test window, so a single oscillometric spike or a one-off finger-stick can
never raise a false alarm:

  - blood pressure 180/120 or higher (re-check, contact your doctor; symptom-
    coupled lifts to emergency copy) and a symmetric low-systolic-under-90
    cautionary tier;
  - glucose under 70 (low) and under 54 (very low), and a sustained very-high
    250-or-higher tier. The 200 DKA criterion deliberately never shows an
    all-clear and never urgently escalates on its own, since a value alone
    cannot rule euglycemic DKA in or out.

The escalation reuses the urgent SYSTEM_ALERT path with a 24h per-reason
dedup, never names a condition, and gates glucose behind the glucose module.
An optional transient `symptomsPresent` flag on the measurement write lifts a
confirmed breach from the contact-your-doctor copy to the emergency copy. The
measurement POST runs the check fire-and-forget so a notification failure
never fails the user's write.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck cf620de315 feat(glucose): resolve tighter ADA goal targets when diabetes is on
Add a diabetes-aware glucose target resolver: when the user has opted in, the
fasting/post-prandial targets switch to the tighter ADA glycemic GOAL bands
(fasting 80–130, post-meal under 180 mg/dL) instead of the general
non-diabetic normal bands. A user threshold override always wins over both, so
a clinician-pinned individualised goal is never replaced by the population
band. The targets surface now reads through the resolver and labels the source
accordingly. The general band display is untouched.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck 2c87a93827 feat(glucose): add a diabetes opt-in preference
Introduce an explicit, user-declared "I have diabetes / clinician glucose
targets" preference (`User.hasDiabetes`, migration 0174, defaulted false) plus
its GET/PATCH endpoint at `/api/auth/me/diabetes`. The flag selects which
glucose target band a reading is judged against; it is never inferred from a
value and never asserts a diagnosis. Mirrors the existing per-user-scalar
preference pattern: 60/min rate limit, Zod validation, audit-logged,
field-by-field write. OpenAPI regenerated for the new route.
2026-06-18 20:17:13 +02:00
Marc-André Bombeck 9be30897fa feat(insights): add a cited reference-range backbone and richer metric explainers
Introduce src/lib/reference-ranges.ts: a structured, citation-backed
per-metric reference-range backbone (band + source body + year) with a
deterministic four-state placement helper, read by the UI surfaces. Each
band traces to the medical-citations registry, which gains the supporting
sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse
pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity,
FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature,
RCP NEWS2 and American Lung Association respiratory rate, visceral-fat
imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV,
Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep).

Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and
ESC 2024 appear only as displayed placement context. Enrich the metric
explainers with concise cited reference bands and a general-guidance
tooltip on every metric page: a darker-skin pulse-oximeter over-read
caveat, device-proxy honesty for readiness/recovery/strain/stress, an
HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill
the empty registry normal ranges, reconcile the steps green floor to
8,000, and tighten the body-temperature band below the fever line. Add
free pulse-pressure and mean-arterial-pressure context on the blood-
pressure detail page, plus cycle-physiology and sleep-hygiene copy across
all six locales. Cycle thresholds carry a verification marker pending an
ACOG source check.
2026-06-18 20:17:04 +02:00
Marc-André Bombeck 1d83e10764 merge(W4): module-by-module guided tour
Extend the in-house spotlight into a cross-page tour: 15 module stops,
each gated on its module, walking the app and teaching what each module
does and how it feeds the others. Auto-starts after the anamnesis,
resumes from onboarding_tour_progress_json, re-enters per-module via a
header trigger, and replays from one Settings card. Adds the tour
progress pref + migration 0173 and the onboarding tour checkpoint route.

# Conflicts:
#	src/components/onboarding/DoneScreen.tsx
2026-06-18 19:55:00 +02:00
Marc-André Bombeck 91fd0b438e refactor(onboarding): stabilise the tour progress callback
Memoise the launcher's progress handler so the overlay checkpoint effect
fires on a real step transition rather than on every launcher re-render.
2026-06-18 19:52:40 +02:00
Marc-André Bombeck 33fec28b29 feat(onboarding): per-module tour re-entry + consolidated replay
Add a 'Diese Tour zeigen' affordance to every module header that opens the
tour narrowed to that module's card in place, plus additive data-tour
anchors (measurements/medications/labs/illness/vorsorge/cycle/mood/
insights/coach/integrations/export/achievements) so the cross-page walk
has a real target on each page. Consolidate the full-tour replay onto a
single Settings -> Advanced card, relabel it, and drop the duplicate from
About. The replay reset clears the stored progress so it restarts cleanly.
2026-06-18 19:49:37 +02:00
Marc-André Bombeck f0c25cc275 feat(onboarding): rebuild the tour as a 15-stop cross-page module walk
Replace the five chrome-pointer stops with a module-by-module guided tour
in the data->intelligence->export order. Each stop teaches the module's
value, one hidden capability, and how it feeds the others. Stops carry a
route + module gate; the overlay router.pushes to the route, waits for the
anchor to mount, then measures, so a single tour walks the whole product.

Move the launcher to the app shell so the overlay survives navigation,
auto-open immediately after anamnesis for fresh users (24h path stays the
fallback for wizard-skippers), resume from the persisted checkpoint, and
gate stops + the step counter on the resolved module map. Add tour copy
for all 15 stops across the six locales.
2026-06-18 19:39:10 +02:00
Marc-André Bombeck 59e7b33285 feat(onboarding): add resumable module-tour progress field + API
Add nullable onboarding_tour_progress_json to users (migration 0173),
a Zod-validated progress shape, and extend POST /api/onboarding/tour to
accept a mid-tour resume checkpoint and clear it on replay. Surface the
resume point on GET /api/auth/me and register the contract in OpenAPI so
the native client mirrors it.
2026-06-18 19:27:28 +02:00
Marc-André Bombeck 56b6d5b998 merge(W3): product clarity and coherence
End the AI dead-end — onboarding gains a skippable provider card and the
Coach/Insights/Briefing surfaces explain what the AI does and why a
provider is needed instead of a bare error. Extract buildDashboardBands
so band/target math is one server-side source the page fallback reuses.
Surface Rest Mode on the score card, restore the module-hub intro, link
the glucose panel to Labs, and route the remaining day-key copies through
the canonical helper.
2026-06-18 19:19:58 +02:00
Marc-André Bombeck e0d4d809dd feat(glucose): cross-link the clinical panel to Labs
Glucose has three front doors — measurements/CGM as readings over time,
the clinical panel as derived TIR/GMI/estimated-A1c, and Labs as dated
panel results — with no signpost between them. The clinical panel now
carries a one-line cross-link clarifying that a blood-draw HbA1c or
fasting glucose belongs in Labs, so the spot-estimate view and the
lab-result view reference each other instead of competing. Adds
insights.bloodGlucose.clinical.labsLink across all six locales.
2026-06-18 19:17:36 +02:00
Marc-André Bombeck 04adcc6aa5 feat(settings,measurements): restore module-hub intro and clarify list/insights split
The module hub lost its intro line in v1.18.1, so a new user could not
tell that the page drives the navigation, tiles and assessments they
see. Restore the section intro and the toggleable-modules hint (both
strings already in the bundle). Reframe the measurements subtitle from a
metric list to its role — log + full history here, trends and analysis
under Insights — so the two homes for 'look at my data' read as distinct
rather than redundant.
2026-06-18 19:15:51 +02:00
Marc-André Bombeck ef1fbb8396 feat(insights): state Rest Mode on the health-score card
During an active illness episode the server suppresses (never penalises)
the health score, but the card gave no sign of it, so a held number read
as a silent decline. The card now carries an explicit 'paused during
illness — not being judged today' line when Rest Mode is active, driven
from the value-free annotation already on the analytics payload. Adds
insights.healthScore.restModePaused across all six locales.
2026-06-18 19:11:58 +02:00
Marc-André Bombeck cd816607e4 feat(onboarding): surface AI-provider setup on the done step
AI Insights, Coach and the daily briefing are default-ON but need a BYOK
or local provider that onboarding never mentioned, so a fresh user met
the flagship feature as a cold error. Add an optional, skippable card to
the onboarding done step that explains what the AI does (plain-language
summaries of your readings, data stays with you) and deep-links to
Settings -> AI. Adds onboarding.done.aiTitle/aiBody/aiCta across all six
locales.
2026-06-18 19:09:12 +02:00
Marc-André Bombeck 3d213d1fbf feat(insights): turn the no-provider dead-ends into guided setup
Coach, the daily briefing, and the per-metric assessment tiles all
default ON but need a connected AI provider that onboarding never
mentions, so a fresh user hits a bare error on the headline feature.

- The daily-briefing and per-metric no-provider states now explain what
  the AI does and why a provider is needed (your own key or a local
  model, data stays with you) rather than a one-line 'unavailable'.
- The per-metric tile gains a direct Settings link.
- The Coach 'no provider configured' turn maps to its own copy with a
  Settings link instead of the transient 'try again in a moment' that
  would 422 forever.

Adds insights.noProviderAction, insights.coach.errorNoProvider and
errorNoProviderAction across all six locales.
2026-06-18 19:07:38 +02:00
Marc-André Bombeck 63bca26177 refactor(dashboard): extract buildDashboardBands into a client-safe leaf
The band / target math lived inline in two places — the server snapshot
builder and the dashboard page's snapshot-disabled fallback — so a
threshold change had to be hand-mirrored or the two surfaces would grade
the same metric differently. Move the construction into the new
client-safe @/lib/dashboard/bands leaf (buildDashboardBands, with
buildTargetBands kept as an alias for the snapshot-era call sites) and
have both the snapshot builder and the page fallback call it. The page
always computes the client copy from the profile so a still-loading
snapshot frame keeps real bands, then prefers the server numbers once
they arrive. Behaviour-preserving.
2026-06-18 19:02:05 +02:00
Marc-André Bombeck f22e39872e refactor(tz): route coach/verdict/mood day-keys through userDayKey
The coach snapshot tzDayKey, the dashboard verdict dayKeyInTz, and the
mood-entry moodDateKey each carried their own Intl day-key formatter
(en-CA / en-CA / sv-SE). Delegate all three to the canonical userDayKey
so every day-bucket key stays byte-aligned and the locale decision
lives in one place. Behaviour-preserving.
2026-06-18 18:53:07 +02:00
Marc-André Bombeck e15e8ef428 merge(W2): i18n dead-key sweep, reverse-coverage guard, primitives, docs
Remove 177 verified-dead i18n keys across all six locales and add a
reverse-coverage test so orphans cannot re-accumulate. Drop forwardRef
from textarea/native-select (React 19 ref-as-prop), route a consumption
write through toJson(), cap the offline persister snapshot at 2 MB and
surface quota errors instead of swallowing them. Refresh CLAUDE.md drift
(model count, React pin, release line, directory paths, symbol anchors).
2026-06-18 18:48:14 +02:00
Marc-André Bombeck 36090daa70 docs: refresh the contributor-manual drift in one pass
Correct the accreted drift in CLAUDE.md against the current tree: 61→80 Prisma
models (tech table + schema row), the React 19.2.x exact pin (drop the stale
patch digit), and the v1.12.x line marker → v1.18.x. Re-point the two paths
that are now directories — `src/lib/openapi/routes/` (23 modules) and
`src/lib/query-keys/` (16 modules), both with `index.ts` barrels. Replace the
exact line anchors that have since moved (requireAdmin, the rollup
whitelist-splice, isPublicUrl, the Dockerfile pg-boss/prisma-cli install,
api-handler LOC) with symbol references that survive line drift, fix the AI
providers row to cite `provider.ts`/`resolveProvider` plus the
`PROVIDER_CHAIN_TYPES` allowlist, and correct the `apiSuccess(data, status?)`
signature in the request-flow diagram.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck 3465cbe81c fix(pwa): bound the persisted query cache and stop swallowing quota errors
Cap the serialized snapshot at 2 MB and skip the write when it's over — an
oversized blob is the likeliest IndexedDB quota trigger and its offline value
is marginal, so keeping the last good snapshot beats evicting it. When a write
still hits QuotaExceededError, log it and drop the persisted blob so the next
flush has room, instead of silently swallowing the failure and leaving the
offline cache stuck on a stale snapshot the user can't explain.

The allowlist and per-account binding are untouched.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck 151a56fc66 refactor(ui): adopt the React 19 ref-as-prop idiom and the toJson helper
Drop forwardRef from the textarea and native-select primitives — the last two
holdouts under components/ui — so they accept ref as a plain prop like the
input primitive already does. No call site passes a ref to either, and the
React 19 prop path is the forward-looking shape.

Route the inventory-consumption JSON-column write through the shared toJson
helper instead of an inline `as unknown as Prisma.InputJsonValue` cast, keeping
the WHY of the escape hatch in one place.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck d50e277a2b chore(i18n): remove orphaned translation keys and add a reverse-coverage guard
Delete 177 translation keys that no call site reaches — across all six locales
— covering the medications, cycle, labs, notifications, nav, targets, and
admin namespaces that drifted out of use as their surfaces were rebuilt. The
forward call-site guard only catches keys referenced from code that never
shipped to the bundle; nothing watched the reverse, so orphans piled up.

Add a reverse-coverage test that walks messages/en.json and asserts every leaf
is reachable: a literal t() key, a subtree returned by t("ns"), a dynamic key
built inside a t(`…`) template or by string concatenation (the per-metric
${i18nPrefix}.* pages, cycle.calendar.flow${LEVEL}, medications.site${Suffix},
"notifications.event" + type), or an explicit allowlist for DB-driven keys
(mood.tag / mood.tagCategory). A new orphan now prints as a punch list.

Drop the unrendered admin.carrierUnknown and dashboard.bpInTargetShort from the
drift / trend-card guards — both lost their call sites and were removed here.
2026-06-18 18:47:05 +02:00
Marc-André Bombeck b4a3355fd0 merge(W1): backend perf, resilience, timezone correctness
startOfLocalDayInTz DST-safe day anchor (fixes the intake UTC-midnight
off-by-offset bug) + userDayKey/wallClockInTz dedup across scheduling,
consolidation and correlations; illness-insights N+1 collapsed under a
concurrency cap; series-batch per-type isolation with degraded_types
meta; rest-mode episode read memoised per request; targets response
builder extracted to src/lib/targets.
2026-06-18 18:35:45 +02:00
Marc-André Bombeck 0af1fbb9d4 refactor(targets): extract the insights-targets builder into lib
The targets route held a ~1.4k-line domain function in the handler body.
Move buildTargetsResponse into src/lib/targets/build-response so it sits
beside the other targets helpers and can be unit-tested without the route
shell; the route is now auth -> cachedSwr(buildTargetsResponse) -> apiSuccess.

Follow the as-needed compliance source pin to the new file location.
2026-06-18 18:34:27 +02:00
Marc-André Bombeck 075dae9ace perf(illness): dedupe the active-episode read per request
resolveRestMode is resolved from several surfaces in one request — the
snapshot builder, the recovery score, the analytics route, and coincident-
deviation — each firing an identical active-episode findMany for the same
account. Memoise the read per request keyed on (userId, asOf instant); an
explicit client bypasses the memo so transaction and test reads are never
served a default-client cached result.
2026-06-18 18:31:25 +02:00
Marc-André Bombeck 04b0f38537 fix(measurements): isolate per-type failures in the series batch
The batched daily-series endpoint ran every type through one Promise.all
with no per-task catch, so a single type's transient reject 500'd the whole
batch and blanked every preloaded chart in the dashboard row at once.

Wrap each per-type read so a failure degrades to an empty slice for that
type only; the failing chart self-fetches through its existing fallback.
A degraded_types counter rides the wide event.
2026-06-18 18:29:46 +02:00
Marc-André Bombeck 1a73afa7e9 perf(illness): parallelise the insights recovery-gap fan-out
The cross-episode insights route looped up to 24 episodes, each running a
sequential 10-vital scan with two serial reads per vital — ~500 serialised
round-trips on a year of episodes, starving the shared Prisma pool.

Pair the baseline + episode-window reads, fan the per-vital scan out under a
p-limit(4) cap, and run the per-episode correlations concurrently under the
same discipline. Series order and the gated outputs are unchanged.
2026-06-18 18:28:37 +02:00
Marc-André Bombeck cbfc4a36d2 refactor(tz): route day-key copies through the canonical userDayKey
Compliance streaks, the measurement-consolidation day key, and the
correlations weekday helper each carried their own Intl formatter
(en-CA / sv-SE / short-weekday). Delegate them to userDayKey and
wallClockInTz so every day-bucket key stays byte-for-byte aligned and the
locale decision lives in one place.
2026-06-18 18:27:06 +02:00
Marc-André Bombeck 1b354de68b fix(tz): anchor medication-intake day floor at local midnight
The intake aggregator built its day floor from en-US calendar parts pinned
to UTC midnight, so today's window opened off by the zone offset for any
non-UTC user — a 23:30-local reading could land in the wrong day's bucket.

Promote the DST-safe start-of-local-day primitive into src/lib/tz/local-day
as startOfLocalDayInTz and route the intake route, the cadence start-of-day,
and the recurrence day floor through it, removing three divergent copies.
2026-06-18 18:25:03 +02:00
Marc-André Bombeck b474ea1489 fix(pwa): stop the offline cache from persisting sensitive health data
The offline layer wrote far too much to disk and cleared it too rarely.
The query persister used a denylist (only auth/session/admin/tokens), so
every other family — coach, insights, illness, labs, mood, cycle — was
dehydrated to IndexedDB in plaintext; the service worker likewise
prefix-cached GET /api/insights, which serves decrypted Coach
conversations. Neither store carried an account marker and both were
wiped only on explicit logout, so a session that ended by expiry or a
401 left health data on disk for the next account on a shared profile.

Flip the persister to a strict allowlist (dashboard snapshot, per-chart
series, widget layout — the offline-dashboard set only) and stamp each
payload with the live account id, discarding on mismatch. Drop
/api/insights and /api/analytics from the worker allowlist. Wipe the
IndexedDB blob and the data/page caches on every session end — the
401/expiry redirect and the logout path both route through one helper.
2026-06-18 18:18:24 +02:00
Marc-André Bombeck 3cf9a262ca test(e2e): keep route mocks authoritative against the v1.18.6 service worker
The production build registers a service worker whose v1.18.6 data branch
serves allowlisted GET /api/* reads network-first. A worker-originated
fetch is not subject to Playwright's page.route interception, so the
worker re-fetched the real (empty) backend and bypassed every per-spec
route.fulfill — a just-created row never surfaced and read-after-write
assertions failed. Block the worker in the test context so the route
mocks are authoritative; the shipped offline cache is untouched for real
users. Separately, the dashboard now batches chart series into
/api/measurements/series-batch, a sub-path the **/api/measurements* glob
does not match, so it fell through to the empty backend and each chart
painted an empty preloaded slice; stub the endpoint with the same
populated set.
2026-06-18 17:48:57 +02:00
Marc-André Bombeck 9633d66eea fix(pwa): restore persisted query cache as gap-fill, never overwrite live data
restorePersistedQueryCache hydrated the dehydrated snapshot
unconditionally, so a restore that resolves in an effect AFTER the page
has already fetched a fresh result overwrote that live query with the
stale (often empty) on-disk copy — a read opened straight after a
mutation then showed pre-mutation data. Filter the dehydrated state to
keys the live cache does not already hold before hydrate, so restore only
fills gaps; a query already present is by definition fresher than disk.
Scope the post-restore invalidation to the keys actually hydrated so a
live query is never disturbed. Adds an invariant test covering the
clobber case.
2026-06-18 17:48:57 +02:00
Marc-André Bombeck 33dcf39adf fix(a11y): hold revealed state under the reduced-motion backstop
The global prefers-reduced-motion catch-all collapsed every animation to
near-zero duration but left animation-fill-mode at its default, so an
entrance animation whose element rests at opacity:0 and only reveals
through its keyframe snapped back to the hidden base style once the
near-instant animation completed. Under reduced motion that left charts
and list rows permanently invisible. Pin animation-fill-mode:forwards and
zero the stagger delay so the shortened reveal holds its final visible
frame and paints immediately. Per-component blocks that set animation:none
are unaffected (no animation to fill).
2026-06-18 17:48:41 +02:00
Marc-André Bombeck 15a628db47 fix(pwa): SW /api reads network-first (cache only as offline fallback) — restores read-after-write freshness 2026-06-18 17:10:47 +02:00
Marc-André Bombeck 730430728d fix(pwa): serve allowlisted API reads network-first
The offline service worker cached allowlisted GET /api/* reads with
stale-while-revalidate, serving the cached pre-mutation copy first and
revalidating in the background. Online, a list fetched straight after a
create or update rendered the old (or empty) data until the next paint,
breaking read-after-write.

Switch the data branch to network-first: always fetch fresh from the
network online and refresh the cache behind it; fall back to the last
cached copy only when the network fails (offline). The offline fallback,
the allowlist plus auth/token/secret-body boundary, and the
release-versioned cache name are unchanged.

Add a regression guard to the cache-policy test that reads public/sw.js
and asserts the network-first wiring and the absence of the old
stale-while-revalidate handler.
2026-06-18 17:09:56 +02:00
Marc-André Bombeck 36ce82866a chore(release): v1.18.6 2026-06-18 15:35:49 +02:00
Marc-André Bombeck 82c6947633 perf(dashboard): batched rollup series endpoint + server-side target bands + gate-helper split 2026-06-18 15:33:53 +02:00
Marc-André Bombeck 437e501536 refactor(dashboard): thin page orchestrator + consume server bands and batched series
Extract the pure first-paint / skeleton-reservation gates
(resolveDashboardFirstPaintGate, resolveConfiguredTileCount,
resolveChartRowPlaceholderCount + their widget-id sets) into
components/dashboard/dashboard-gates; page.tsx re-exports them so the
existing unit-test imports stay valid. Wire the page to read the
server-computed targetBands when snapshot mode is on (legacy client
compute kept for the snapshot-off fallback) and to issue one
series-batch fetch for the visible non-sleep charts, threading each
chart its slice via preloadedSeries.
2026-06-18 15:29:29 +02:00
Marc-André Bombeck 143aefc709 feat(api): batched rollup-backed series endpoint to coalesce dashboard fetches
Add GET /api/measurements/series-batch returning every requested
MeasurementType's daily series in one response, so the dashboard chart
row stops issuing one self-fetch per chart. Extract the rollup-daily read
(per-source collapse, coverage probe + inline fold, live-SQL fallback)
into a shared readDailySeries reader so the single-type route and the
batch route read through the same path with no SQL duplication; the
single route now delegates to it. Cap the batch's per-type reads with
p-limit to protect the shared pool. Sleep stays on its per-night /series
adapter and is not batched.

HealthChart gains an optional preloadedSeries prop: when the dashboard
hands it a covered slice for the daily window it folds those rows instead
of self-fetching, keyed so a range-tab change re-fetches cleanly. Non-
dashboard mounts are unchanged (empty discriminator keeps the cache tuple
byte-identical). Register the endpoint in the OpenAPI table and regen the
spec; add unit + parity coverage for the batch route.
2026-06-18 15:29:20 +02:00
Marc-André Bombeck bd80557749 perf(dashboard): compute chart band/target math server-side
Move the BP/pulse/body-fat/weight band + target derivation out of the
dashboard client component and into the snapshot DTO. The page recomputed
every band from the user profile on each render via the analytics band
helpers; the snapshot builder now resolves the same numeric structures
once server-side under a new targetBands block, calling the identical
helpers. Emit only the i18n-free numbers — the label-bearing BP target
zones stay client-assembled from the server numbers.

Add a parity test asserting buildTargetBands matches the prior client
derivation for a male, a female, and a no-profile user.
2026-06-18 15:29:09 +02:00
Marc-André Bombeck b5b557f84d feat(pwa): real offline reads — SW stale-while-revalidate allowlist + IDB query persister + offline indicator 2026-06-18 15:19:16 +02:00
Marc-André Bombeck bb08aa42dc feat(pwa): real offline read value via SW data cache + query persistence
An installed PWA opened offline rendered empty skeletons forever: the
service worker hard-excluded every /api/* path and there was no client
query persistence. Close both gaps for read value.

Service worker: add a stale-while-revalidate branch for an allowlist of
safe GET /api/* reads (dashboard snapshot + widgets, measurements,
medications, insights, analytics, version) in a release-versioned
healthlog-data cache. Two-layer refusal — a path deny guard for any
auth/token/session/login segment plus a body-level secret refusal for
hlk_/hlr_/sk- shapes and no-store responses — so a secret-shaped body or
an auth surface can never be persisted. Mutations stay network-only.

Client: add a dependency-free IndexedDB query persister that hydrates the
last successful reads before the first authenticated paint, build-version
and age gated, debounced on cache change, with an auth/session/admin/token
denylist. Cleared on logout alongside the SW page and data caches so a
shared device never leaks one account's data.

Offline banner now states it is showing last-synced data when a cached
snapshot is present. Bump release to v1.18.6.
2026-06-18 15:18:48 +02:00
Marc-André Bombeck ba89f97176 fix(mobile): touch-target floor, reduced-motion backstop, manifest categories, trivy ignore for npm-bundled picomatch 2026-06-18 15:16:23 +02:00
Marc-André Bombeck b9035addf4 ci: ignore the npm-bundled picomatch CVE in the container scan
CVE-2026-33671 (picomatch ReDoS, HIGH) is flagged only in the npm CLI's
own bundled copy inside the Node base image, used at image-build time,
not by our runtime. Our app dependency tree pins picomatch >= 4.0.4 via
a pnpm override, so the running app is not affected. Re-check when the
base image ships an npm whose bundled picomatch is >= 4.0.4.
2026-06-18 15:15:14 +02:00
Marc-André Bombeck 08d25e1cf0 feat(pwa): richer install metadata in the web manifest
Add categories (health, medical, lifestyle) and extend display_override
to allow minimal-ui as a fallback. Maskable icons were already present.
Screenshots are deferred until narrow + wide capture assets exist.
2026-06-18 15:15:14 +02:00
Marc-André Bombeck 722af87382 fix(a11y): add a global prefers-reduced-motion backstop
Per-component motion-reduce gating stays in place with its tailored
static fallbacks; this adds a defensive catch-all so any animation or
transition that misses a per-component guard still collapses to near-
instant under reduced motion. Near-zero rather than zero keeps
animation/transition-end listeners firing so state machines don't
stall.
2026-06-18 15:15:14 +02:00
Marc-André Bombeck 1eb143fad4 fix(a11y): raise sub-44px tap targets to the mobile floor
Bring interactive elements that fell below the 44px iOS/WCAG touch
floor up to min-h-11 on phones (relaxing to the denser sm density on
larger screens), and add visible focus rings where they were missing:

- TimesOfDayChips remove button + API-token copy buttons gain an
  inset hit-area so the visual density stays tight while the tap
  target clears the floor.
- Dose-history row-actions menu trigger and the insights suggested-
  prompt chips raise to min-h-11 sm:min-h-9.
- Sortable table headers and the glucose advanced disclosure toggle
  gain a focus-visible ring and a phone tap floor.
2026-06-18 15:15:01 +02:00
Marc-André Bombeck dd44ddeb70 Merge pull request #340 from MBombeck/release/v1.18.5
v1.18.5 — value-range filter, collapsible advanced settings, titration timeline
2026-06-18 14:52:40 +02:00
Marc-André Bombeck 474e99e0f8 chore(release): v1.18.5 2026-06-18 14:43:47 +02:00
Marc-André Bombeck e0796c5e44 feat(search): numeric value-range filter (valueMin/valueMax) 2026-06-18 14:42:52 +02:00
Marc-André Bombeck cc89695cbd feat(measurements): value-range filter on the management list
Add an optional numeric value-range filter to the measurement list:
readings whose `value` falls within `[valueMin, valueMax]`, either bound
omittable for an open-ended range. Server-authoritative — both bounds are
parsed on `listMeasurementsSchema` (coerced from the query string), an
inverted `valueMin > valueMax` returns 422, and the bounds thread into the
Prisma `where` as a `value: { gte, lte }` range on the raw per-sample list.

The filter rides a new `FilterBarNumberRange` pill that mirrors the existing
date-range control, hidden for the day-grouped and sleep-night types where
the painted value is a synthesised total rather than a stored reading.
2026-06-18 14:42:29 +02:00
Marc-André Bombeck 1cdc87cc36 feat(medications): collapsible advanced settings + injection titration timeline 2026-06-18 14:42:12 +02:00
Marc-André Bombeck e6ea27baf2 feat(medications): collapsible advanced groups + injection titration timeline
Restructure the medication detail advanced surface (Erweitert + API tabs)
into expand/collapse groups: SettingsGroup gains an opt-in collapsible
mode built on the same Radix Collapsible primitive SheetSection uses, so a
long settings wall reads as scannable sections. Lifecycle and external-API
groups default open; Data and the destructive zone default collapsed.

Add a titration timeline to the injection tab — completed and planned
dose-escalation steps rendered as a vertical timeline with a "you are
here" marker. Reads the existing MedicationDoseChange stream (future-dated
rows are planned steps), so no schema change is needed. Neutral palette,
no state-driven colour.
2026-06-18 14:41:14 +02:00
1567 changed files with 89578 additions and 25545 deletions

No files matched your search

+41
View File
@@ -50,6 +50,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Fitbit -- optional integration (classic Web API, per-user BYO-keys)
# -----------------------------------------------------------------------------
# Each user registers their own app at https://dev.fitbit.com (self-serve, no
# brand verification) and pastes the OAuth Client ID + Client Secret into
# Settings (stored encrypted in the DB). There is therefore no
# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE.
#
# FITBIT_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it
# must exactly match the Callback URL registered in the dev.fitbit.com app.
# FITBIT_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
@@ -187,3 +200,31 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
#
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# -----------------------------------------------------------------------------
# IP geolocation lookup -- optional
# -----------------------------------------------------------------------------
# The admin login/audit overview enriches auth events with a coarse
# "City, CC" location + carrier. The default online provider is ip-api.com.
# Override the base URL to point at a different provider; the response must
# match either the ipwho.is shape (`success` + `country_code`) or the
# ip-api.com shape (`status` + `countryCode`). HTTPS only -- a non-HTTPS URL
# is refused (audit-event IPs must never egress over plaintext HTTP). The
# `/<ip>` segment is appended automatically.
# IP_GEO_LOOKUP_URL="https://ip-api.com/json"
#
# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
# IP_GEO_LOOKUP_DISABLED="1"
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+2 -2
View File
@@ -25,10 +25,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
+12 -6
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -33,6 +33,12 @@ jobs:
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
@@ -60,9 +66,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -85,7 +91,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -100,7 +106,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build Docker image
run: docker build -t healthlog:scan .
+3 -2
View File
@@ -46,8 +46,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# prisma generated
/src/generated/prisma
# prisma generated — ignore the whole path so a stray symlink at
# `src/generated` (e.g. from a worktree) can never be committed
/src/generated
# v1.4.43 QoL (L3) — generated at build time by
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
+12
View File
@@ -2,5 +2,17 @@ node_modules
.next
out
build
coverage
# Prisma-generated client.
src/generated
# Lockfile — owned by pnpm.
pnpm-lock.yaml
# Generator-owned API contract. The openapi:check job enforces its exact
# bytes against the Zod registry, so prettier must never rewrite it.
docs/api/openapi.yaml
# Build-generated service worker (the PWA build emits it).
public/sw.js
+14
View File
@@ -0,0 +1,14 @@
# Trivy vulnerability ignore list.
# One CVE id per line. Only suppress a finding that has NO upstream fix we
# can apply; never suppress one with a fixed version available — bump instead.
# Each entry names what it covers and the condition to re-check / remove it.
# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's
# OWN bundled copy that ships inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at
# image-build time by npm, not by our runtime. Our application dependency
# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is
# not affected and the path is not reachable at runtime. Re-check when the
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
# then drop this entry.
CVE-2026-33671
+458 -39
View File
@@ -2,6 +2,424 @@
## [Unreleased]
## [1.21.2.1] — 2026-06-27 — Coach + briefing provider hotfix
A hotfix. No migrations, no breaking changes.
### Fixed
- The Coach and the daily briefing failed for accounts signed in through the ChatGPT provider: the provider rejected the multi-step tool-call request, and the failure surfaced as a server error instead of a graceful message. A provider failure now always degrades to a clear in-chat notice — with the correct reconnect or rate-limit prompt where those apply — rather than a 500, and the provider request follows the documented tool-call wire format so the Coach's multi-round reasoning and the briefing generate again. The provider's own error detail is now captured for diagnosis.
### Changed
- While the Coach is thinking, the bubble shows only the three animated dots — the "Thinking…" word is gone (it remains for screen readers).
## [1.21.2] — 2026-06-27 — Ambient Coach presence
A patch release. The Coach surfaces what it already computes — at the metric, on the score, in the briefing — instead of waiting to be asked. No migrations, no breaking changes.
### Added
- A "Coach read" on every metric page: one line placing today's reading against your own usual range, and — when the data supports it — one line naming the strongest connected signal in plain association language. While your range is still forming it says so rather than guessing.
- Opening the Coach from a metric or card now shows that it is already on that metric, with a tappable opening question; the unscoped Coach opens on the day's most notable signal instead of a blank box.
- The daily briefing recalls the prior period and points ahead.
- The health score surfaces an honest "internal read" when its contributors disagree — good sleep but a rising resting pulse — and notes when a metric has come back inside your usual range after a dip.
### Changed
- The Coach's motivational-interviewing repertoire gained develop-discrepancy and roll-with-resistance moves and a clearer anti-persuasion boundary.
- The prose number-check now runs on the local-model path too, so grounding holds regardless of provider.
## [1.21.1] — 2026-06-26 — Dialog footer reachability
A patch release. No migrations, no breaking changes.
### Fixed
- Pop-up forms now scroll only their body on desktop, keeping the action row in view. A tall form — or one made tall by browser zoom, display scaling, or a longer-language label — could push the primary button below the fold and force a scroll to reach it; the centred dialog now matches the bottom sheet, which already pinned its footer. Every form the app mounts through the shared sheet surface inherits the fix.
## [1.21.0] — 2026-06-26 — Date-format preference, a far more connected Coach, and broad correctness work
A feature release. The Coach reaches every data domain on demand, surfaces the cross-metric patterns the analytics tier already discovers, opens in context from any screen, and speaks with a warmer, forward-looking voice on one shared set of safety thresholds. Dates render in your chosen format everywhere. Two additive migrations (`0193` rollup x-rescale, `0192` date format); no breaking changes.
### Added
- A date-format preference in the profile — automatic (follows the language), day-month-year, month-day-year, or ISO — honoured across the app, including every date and date-time field, which now render in your chosen order regardless of the browser's locale.
- The Coach answers from the full data picture: every metric domain on demand plus cycle and workouts, and it surfaces the discovered cross-metric correlations — medication adherence against symptoms, short sleep against the next morning's vitals, and so on — citing only what the analytics tier actually found.
- Open the Coach in context from any metric page or insight card and it arrives already scoped to what you were looking at, with a relevant opening question.
- "Learn more" pointers on the vitals tiles, the glucose panel, the resilience tile, and the lab biomarker detail link out to the matching guide; the Coach references the same guides and can no longer offer a link that doesn't exist.
- Medication compliance and symptom severity are now first-class signals in the correlation engine, so an adherence dip that tracks a symptom flare can finally be surfaced.
### Changed
- The Coach connects signals into one story instead of listing metrics, looks ahead with gentle, ranged outlooks, ends an action turn by checking your confidence and offering a single doable step, and keeps affirmation earned. A closed acute-symptom clause points to prompt medical attention for crisis signs.
- Critical-threshold numbers — blood pressure, fever, glucose — now come from one source, so the dashboard banner, the Coach, the status cards, and the notifications always state the same thresholds.
- The trend regression is composed on an origin-rescaled, mean-centred basis, removing a floating-point cancellation on long windows; the rollup now matches the live computation to the last digit.
- The daily AI usage budget is provider-aware: usage on your own OpenAI key, ChatGPT plan, or local model is no longer limited by the server-cost ceiling that applies to an operator-provided key.
- The Coach builds its data snapshot once per turn and may take an extra reasoning round for a deep cross-metric question.
- Discovered correlations are held to an effect-size floor and a confidence tier, and sparse personal signals are shrunk toward the baseline, so a real-but-trivial association is no longer stated as a confident driver.
### Fixed
- ChatGPT/OpenAI sign-in users no longer hit a spurious "daily limit reached" after only a couple of messages.
- The symptom journal's recovery-return and the SpO2 red-flag count consecutive calendar days and are order-independent; the "two or more vitals out of band today" flag is keyed to your calendar day rather than UTC.
- Withings activity sync and the medication-intake dedup issue far fewer queries on a long backfill.
- The trend read returns the same boundary day whether served from the rollup or from a live query.
- The native date primitives that ignored the locale are gone — every field routes through the format-aware inputs.
## [1.20.2] — 2026-06-26 — Hardening across insights, safety flags, and integrations
A patch release. A broad pass over the server tier and the AI surfaces: a few correctness and safety fixes, a numerical hardening of the trend tier, and several smaller robustness and cost improvements. No schema changes.
### Added
- The lab-report review screen marks low-confidence rows so an uncertain reading is easy to spot before it is saved.
### Changed
- The trend regression (slope, r² and variability) is composed from mean-centered sums, which removes a floating-point cancellation on long, near-flat windows and keeps the rollup result in line with the live computation. Each accumulator now uses the exact stored sum rather than reconstructing it.
### Fixed
- The symptom journal's "sustained fever" flag — which can raise an urgent prompt to seek care — now counts only consecutive calendar days. A sparse series of isolated febrile entries no longer trips it, so the alert reflects a genuine multi-day run.
- Insight generation no longer reads a full measurement history per metric to compute its 30-day comparisons; it reads only the window those averages need. Generating insights on a long-history account is markedly faster, on both the page request and the overnight pre-generation.
- Photo and PDF scanning is no longer offered for text-only reasoning models that cannot read an image, so a scan attempt fails clearly up front instead of erroring at the provider.
- A notification channel whose stored configuration cannot be read now disables itself immediately with a precise reason, instead of retrying a permanent error several times and ending on a misleading message. This surfaces during an encryption-key rotation gap.
- The Coach cancels its in-flight model call when the browser tab disconnects mid-answer, rather than paying the full cost into a closed connection.
- Local-OCR text structuring reserves a budget proportionate to its real cost instead of the larger vision-scan ceiling, and refunds cleanly on a failed read.
- The admin diagnostic endpoints return a 422 for a malformed query string instead of a 500.
## [1.20.1] — 2026-06-23 — Dashboard render-loop fix
A patch release. One fix; no schema changes.
### Fixed
- A user reported the dashboard crashing on returning to a backgrounded tab. The on-focus snapshot refetch re-rendered the page, and each chart's "data ready" notify — keyed on a handler that the page recreated every render — re-fired on every commit, driving the chart row into an unbounded update loop until the browser tripped its render-depth guard and the page fell back to its error card. The notify now fires once, when a chart's initial query settles, regardless of how often the page re-renders.
## [1.20.0] — 2026-06-22 — Coach on-demand retrieval, deeper recovery insight, Fitbit, and a leaner data tier
A feature release. The Coach fetches what it needs on demand instead of carrying a full snapshot; the recovery-gap learns from the symptom journal and sleep; Fitbit connects over its own Web API; the trend tier serves slope and variability without a live scan; and several built surfaces become discoverable. Two additive migrations (`0190`, `0191`); no breaking changes.
### Added
- The Coach answers from on-demand retrieval: a small base context plus a data inventory, with tools it calls to pull the exact series, lab, sleep, medication or recovery figures a question needs. It cites only what it fetched and says so plainly when there is no data. First-turn cost drops substantially while the grounding and cross-metric reasoning are unchanged.
- The recovery-gap reads the illness journal's own symptom-severity curve and adds a sleep-context line, and it is now relapse-aware — it reports the final sustained return, not the first.
- Fitbit connects experimentally over the Fitbit Web API with PKCE, so a self-hoster can link it with an instant developer app.
- The sleep, steps and glucose dashboard tiles and the preventive-care row are surfaced by default (each self-hides without data), so built features are easier to find.
### Changed
- The AI client layer moves to a structured message format with per-provider prompt-caching, which lowers cost on repeated context and lays the groundwork for retrieval.
- Trend slope, r² and standard deviation are served from the rollup tier via stored regression accumulators instead of a live scan on every request; the result matches the live computation.
### Fixed
- The fever red-flag now reads days in chronological order, so an episode logged from both a thermometer and the journal can no longer raise or hide a sustained-fever flag incorrectly.
- A vital that settles and then shows a single stray out-of-range reading keeps its recovery day instead of reporting none.
- Fitbit sleep segments are placed on the correct day for users outside UTC.
- Concurrent syncs for Withings/WHOOP/Oura/Fitbit no longer park a connection at a spurious reconnect when a one-time-use refresh token is rotated.
- The Coach reply is no longer corrupted into raw JSON on some providers, and a tool request is no longer dropped on an unexpected stop reason.
- The trend tier records when slope is briefly unavailable pending backfill rather than reporting it silently.
### Refactor
- The Coach's per-request reads are shared across the retrieval fan-out instead of repeated per tool.
### Security
- Dependency bumps: next, pg-boss, radix-ui, pg, @testcontainers/postgresql, and the CI actions.
## [1.19.2] — 2026-06-21 — Recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data
A follow-up that folds the illness journal's own symptom curve into the recovery-gap, surfaces Oura resilience, fixes multi-year chart ranges, and widens the heart-rate and Telegram data paths. Two additive migrations (`0188`, `0189`); no breaking changes.
### Added
- The recovery-gap now reads the illness journal's own daily functional-impact curve (and linked symptom severity), not just passive vitals. When symptoms lead the recovery, the card names them — "you tend to feel better before your logged symptoms ease". Sparse journals withhold rather than guess.
- An Oura resilience tile on the recovery surface, showing the latest resilience band with a quiet above/in-line/below-average cue and a calm learning state until enough days are recorded.
- Hourly heart-rate uploads can carry a per-bucket low and high alongside the average, so a high-frequency client no longer loses the within-hour spread.
- A numeric reply to a Telegram measurement reminder is captured as a reading (for example a weight or a glucose value), bound to the linked chat and self-cleaning like the mood path. Blood pressure stays a simple "done" for now, since it needs two values.
### Changed
- A multi-year "All" chart range now renders the whole history, stepped up to a weekly or monthly tier, instead of being silently truncated to the most recent year.
### Fixed
- `telegram_chat_id` is now unique, with a one-time clean-up of any stale duplicate bindings (no account is removed).
- Migration `0187` is rerun-safe.
- The sleep peer-card comment no longer overstates window parity; each card discloses its own night count. Dead streaming state left after the reasoning-disclosure removal is gone.
## [1.19.1] — 2026-06-21 — Coach flow, sleep and mood polish, navigation, recovery-gap
A follow-up to 1.19.0 that refines the Coach conversation flow and cost, adds a sleep average card, completes the mood colour pass, tidies navigation, and sharpens the illness recovery-gap. No schema changes; no breaking changes.
### Added
- A sleep average-per-night card sits beside sleep debt and chronotype as a third peer, computed over the same scorable-night set so the three cannot disagree. It stays calm until enough nights are scored.
- The recovery-gap names the vital someone tends to recover ahead of — for example "you tend to feel better before your resting heart rate settles" — rather than a single bare line.
### Changed
- The Coach opens on a fresh chat by default. It resumes a past conversation only when the Coach has written something unread; an explicit `?c=<id>` deep-link still opens that conversation. This refines the 1.19.0 behaviour, which always reopened the most recent thread.
- The chat view carries an always-visible toolbar with a clear way into past conversations and a new-chat action, and reaching conversations from the dashboard tile now always lands on the conversation list instead of an empty pane.
- The Coach sends the full health snapshot once per conversation (and again only if the conversation grows long enough to scroll the original context out of view); short follow-ups ride the existing transcript. A one-word reply no longer re-pays the whole snapshot, cutting per-message cost on follow-ups by roughly an order of magnitude while keeping the same grounding and cross-metric correlation.
- The streaming reply shows a typing indicator that gives way to the streamed text, replacing the reasoning-status disclosure.
- The settings navigation entry that opens the dashboard layout is labelled "Dashboard" to match where it leads.
- The main sidebar order groups the care surfaces together: medications, preventive care, lab values, illness, then insights, the Coach, and achievements.
### Fixed
- The mood time-of-day chart and the mood calendar now use full colour saturation in line with the other mood surfaces; only no-data cells stay muted.
- The cross-episode recovery-gap baseline counts only days that deviated in the illness-adverse direction, so a neutral reading (such as a weight drift) can no longer pad the sample or be named the recovery driver.
- The sleep average-per-night field is part of the documented sleep-rhythm response contract.
## [1.19.0] — 2026-06-21 — Coach context, a leaner AI pipeline, new device signals, interactive reminders, and wider self-hosting
A feature release. The Coach gains the lab values and a direct way in; the daily AI work is restructured to do less and reuse more; several new device signals are captured; reminders become interactive over Telegram; charts and the cycle and sleep surfaces are corrected and polished; and the project ships ready-made templates for one-click self-hosting. Three additive migrations (`0185`–`0187`); no breaking changes.
### Added
- The Coach can answer about lab results — the most recent value per biomarker (last twelve months) is provided as grounded context, with reference range, in/out-of-range status, and date. Qualitative results are included; the Coach quotes them verbatim and never invents a value.
- The Coach entry points open directly into the most recent conversation, the same thread across devices, rather than always starting blank. A `?c=<id>` deep-link opens a specific conversation; the new-chat start remains.
- The daily briefing is surfaced on the dashboard as a short spotlight strip, lifting its key signals above the fold for a fresh briefing.
- Mood can be logged straight from a Telegram reminder — a 1–5 inline choice or a short written note — and the reminder chat self-cleans about thirty minutes later so old prompts do not linger or bias the next entry. Mood and measurement reminders also carry calm "remind me later" / "done" actions.
- Withings ECG records the device's atrial-fibrillation screening result and the full ECG waveform (stored encrypted at rest).
- Oura cardiovascular age and resilience are recorded.
- A configured multimodal model reads a scanned lab document directly; where the model is text-only, the on-device OCR path still applies.
- Ready-made self-hosting templates: an Unraid Community Applications template and a Portainer stack template, a hardened `docker-compose.yml`, and a NAS quick-start guide.
- A go-forward aggregated heart-rate upload contract (hourly buckets) so high-frequency clients stop accumulating one row per raw sample.
### Changed
- The AI pipeline does less, more coherently: the comprehensive briefing reads a bounded recent window (all-time figures stay accurate); per-domain status summaries generate once on the nightly pass instead of per metric through the day; a shared feature snapshot is computed once and reused; slow movers (weight, BMI) skip regeneration when their inputs are unchanged; illness and cycle state fold into the briefing context at no cost for users without them. Reproducibility, consent, and grounding checks are unchanged throughout.
- Medication remaining-supply is computed server-side and rendered from one canonical value, so the web and the iOS client always agree.
- High-frequency heart rate and blood oxygen are consolidated server-side into daily aggregates (mean with the daily minimum and maximum preserved; a resting-heart-rate value is derived where the source has none), matching the cumulative-metric consolidation already in place.
- Day-level bucketing for recent readings honours the user's timezone, so a late-evening reading lands on the correct local day.
- Sleep debt is a recovering rolling balance over recent nights instead of an ever-growing sum, so it reflects what is owed now and shrinks after a good night.
- The cycle view aligns its columns, uses consistent typography and equally sized toggles, and shows the prediction disclaimer once — clearly, at onboarding — rather than repeated across the feature.
- The sleep card's tiles share one consistent layout, and mood charts paint at full saturation.
- Sleep-night minute values are returned rounded; the notifications page opens on a single heading.
### Fixed
- Charts honour the 90-day and All ranges — they were stuck on roughly the last month regardless of the selected range.
- Medication remaining-supply can no longer display a nonsensical negative figure; the canonical readout is floored and a would-be underflow is recorded for diagnosis.
- Notification settings now label the measurement-reminder, medication-intake-sync, and coach-nudge events instead of showing raw keys.
### Performance
- Achievements and the dashboard summary serve from a stale-while-revalidate cache, the achievements builder precomputes day boundaries, three charts defer their library, and the analytics aggregate folds its canonical self-join into a single shared expression.
### Security
- The ECG waveform is encrypted at rest and covered by the key-rotation script, with a schema-driven test that fails the build if any encrypted column is ever left out of rotation.
- A Telegram interaction is strictly bound to the linked account before it can write, and the chat identifier is redacted from logs.
### Refactor
- Oversized modules are split into focused modules behind a barrel with no change in behaviour: the OpenAPI medication and insights registries, medication validations, the import panel, and the account settings section.
### Deferred
- Garmin: blocked by an external gate (the developer programme is invitation-only and not accepting new applicants), so there is nothing to build until access opens; only the official hosted instance could pursue it.
- Briefing map-reduce over the per-domain summaries, the cycle/sleep companion-line edge cases, multi-year "All" chart ranges, and the Oura resilience tile remain follow-ups.
## [1.18.10] — 2026-06-20 — data-loss fix, measurement consolidation, grounding, and the full-page Coach
A correctness-and-polish release. It closes a silent data-loss path and a stalled measurement-consolidation job, hardens AI output grounding, and acts on a broad round of UI feedback. Two additive migrations (a covering index and a labs setting); no breaking changes.
### Fixed
- Editing one field of a cycle or condition-journal day entry no longer wipes an encrypted note that cannot be decrypted with the current key — the stored value is preserved untouched unless that field is explicitly changed.
- The nightly consolidation of cumulative metrics (walking/running distance, flights climbed, active energy) no longer aborts on the first conflicting day, so raw per-sample history collapses to daily values as intended. A one-time compaction of the existing backlog runs after deploy.
- The daily briefing refreshes when regenerated, and the home screen reflects a saved tile layout without a manual reload.
- The Coach dictation button reports when speech input is unavailable instead of doing nothing.
### Added
- The Coach has a full-width page (no panel-within-a-panel), reachable conversation history, and a clean new-chat start.
- Scan a lab report without a vision model: optional on-device OCR (off by default) reads the image in the browser and sends only the extracted text to the configured AI provider, then the same mandatory per-row review applies. The labs "Add" action now offers "scan a document" or "add a value manually".
- Withings sleep now records heart rate, respiratory rate, blood oxygen, sleep HRV, and the sleep score (previously discarded at sync).
- Oura VO2max is recorded, and the VO2max tile is shown on the dashboard by default.
### Changed
- Preventive-care cards show the cadence on the metric row rather than beside the heading, and the heading opens the measurement history filtered to that metric.
- The danger zone and the daily-briefing card match the rest of the surface; the feedback tool is marked deprecated; the preventive-care and labs settings follow the standard section layout.
- Lab values: delete moved to the value's detail view, the overview tags are quieter, and the reference-range confidence is surfaced when a new biomarker is created.
- Location lookups default to the online resolver; the offline database is optional.
- Mobile: the full-page Coach composer stays clear of the bottom navigation, long navigation labels stay readable, and touch targets meet 44 px.
### Performance and integrity
- Blood-pressure in-target and grade read identically whether served warm or cold (one canonical source per day).
- A per-session statement timeout stops a single stuck query from holding a connection; a soft-delete-aware measurements index; the all-time fallback scan is bounded.
### Security
- Daily-briefing numbers are checked against the underlying signals; the Coach reply is screened for fabricated values or dose instructions; the period narrative is grounded before it is stored; the metric status cards state precomputed trends rather than re-deriving them.
### API
- The labs OCR capability response carries a `mode` (vision or text) and a new local-OCR preference; additional measurement types are recorded server-side. All additive and back-compatible.
## [1.18.9] — 2026-06-20 — Coach surface, lab-report scanning, and fresher dashboards
A broad UI and data release. It adds two migrations (both additive and nullable — qualitative lab results and Coach token columns) and three additive, back-compatible API changes (see the note below).
### Added
- Coach opens on a calm new-chat surface: a centred greeting and a single composer, then each answer streams in word by word with a soft fade, a collapsible thinking disclosure, and a quiet per-message footer showing the tokens used and the model. The quick-access drawer is unchanged.
- Scan a paper lab report. With an AI provider connected, upload a photo or PDF and the reader proposes the measurements and biomarkers it finds. Every row goes through a mandatory review — confirm, edit, or discard each one, with duplicate and reference-range hints — and only what you confirm is saved. The uploaded file is held in memory for the read alone and is never stored.
- Record a qualitative lab result — positive/negative or another text value — where a number does not apply.
- Sort lab values alphabetically, and delete a biomarker directly from the labs list and detail views.
- VO2max shows on the dashboard by default, and Oura's VO2max reading is now ingested alongside the existing sources.
### Changed
- The labs surface defaults to the list view, and its tiles share the medication-card layout. The labs and condition-journal settings now follow the same section pattern as the rest of Settings.
- Preventive-care cards read the last-completed date as "today"/"yesterday" and the next due date as a discreet "today" (green) or "overdue" (orange), and drop the small trend strip.
- Sleep-debt and chronotype sit side by side, and the daily-briefing card aligns with the surrounding type.
### Fixed
- The dashboard reflects new readings without a hard refresh: an added or background-synced measurement now refreshes the home snapshot — including on Withings and Apple Health sync, and on returning to the tab.
- The condition journal opens quickly again. The cross-episode correlation runs only when an entry's analysis is opened, and the typical-recovery-gap insight appears only when there is enough real data to support it.
- The latest-report area shows its age and, when no AI provider is connected, points to where to connect one, instead of presenting an old report as current.
- The Coach quick-access icon uses a softer tone.
### API
- The Coach stream's `done` frame now carries an optional token `usage`, and a Coach message can carry `tokensUsed` and `model`; an optional `reasoning` frame is reserved. New `GET/POST /api/labs/ocr/{capability,extract,commit}` endpoints; rows committed from a scan carry `source: "OCR"`. All additive and back-compatible.
## [1.18.8] — 2026-06-20 — coded condition and lab terminology in the health-record export
An additive export-only release: the FHIR R4 health record now carries standard terminology codes where it can assert them honestly. No migration, no API contract change.
### Changed
- Condition resources for journal entries carry a SNOMED CT category coding per illness type (infection, allergy, injury, mental health, autoimmune, chronic, other) alongside the user's free-text label. These stay broad categories, not diagnoses — the patient-reported, unconfirmed guard rails are unchanged.
- Lab Observations now emit a LOINC code and a canonical UCUM unit for the common biomarkers (HbA1c, lipid panel, ferritin, TSH, vitamin D, creatinine, eGFR, liver enzymes, CRP, fasting glucose, hemoglobin), resolved through an analyte alias table. An unrecognised analyte, or a unit that does not match the canonical UCUM symbol, keeps the honest text-only behaviour rather than asserting a code it cannot validate.
## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish
A broad UI, insights, and AI-efficiency release. It ships database migrations (see the migration note below) and two client-facing contract changes: Coach budget responses now arrive as a stream error rather than a 429, and the measurement-reminder push carries the reminder id.
### Added
- The daily briefing surfaces "signals of the day" — today's readings against your 7- and 30-day trend, emerging slopes and recent outliers — and leads with one concrete, present-focused nudge.
- Preventive-care cards show a quiet seven-day strip of the metric, and their menu jumps straight to the measurement history filtered to that type.
- The Coach reads your active illness state and Rest Mode, can point you to the relevant in-depth guide, and draws on a tiered view of your history (recent days in full, older periods progressively summarised with peaks preserved).
- Dictate to the Coach by voice.
- The share-link section is back as its own Settings entry, next to the health-record export: mint a time-boxed, revocable, read-only link to share your record with a clinician.
- Share links now carry a passphrase second factor. A new link mints a one-time passphrase embedded in a QR code (in the URL fragment, never sent to the server); the public view stays locked until the passphrase is verified. Links created before this release keep working without one.
- Labs, preventive care, and the condition journal each have their settings as a proper section of the Settings shell, in the same layout as every other section, instead of a separate page.
### Changed
- The daily briefing regenerates every day and reads warmer and more motivating.
- The Coach interface is rebuilt around a calm, centred reading column with a collapsible conversation list, a thinking indicator that hands off to streaming, and a quieter scrollbar.
- The health-score rings are flattened to match the chart style — the glow, sheen and pulse are gone.
- Sleep drops the redundant "last night" card; the chronotype reads as a prominent summary with your natural sleep midpoint and an expandable detail.
- Every integration card follows one layout — a short description ending in an inline setup-guide link, with the credential hint above the save button.
- A warm insight cycle now sends far fewer AI requests: the per-metric status assessments are batched into a single call, and the comprehensive path recovers from a malformed reply with one corrective retry instead of failing outright.
- The daily briefing now varies its wording day to day while the underlying findings stay stable, so a quiet day no longer reads as a frozen cache.
- Prompt construction is consolidated: grounding, tone, and the medication-safety rules come from one shared source across the briefing, status cards, narratives, and the Coach.
- Settings cards share one card primitive and a consistent padding step, and touch controls across the app meet a 44-pixel target on phones while staying compact on wider screens.
- The local-AI private-host switch accepts an explicit host allowlist instead of an all-or-nothing flag (the previous `true` still means "any private host").
- Coach conversations are pruned on a schedule, alongside the existing notification-attempt retention.
### Fixed
- The daily briefing no longer goes stale: a fresh daily signal forces regeneration instead of re-stamping an unchanged narrative.
- The sleep assessment no longer shows the "connect an AI provider" prompt when a provider is already connected.
- A medication's supply now updates on the next read: adding a container or registering a new medication evicts the cached list instead of serving the pre-change stock for the cache window, and the card and table refresh together.
- The seed for the public demo no longer produces an impossible blood-pressure reading, and reads as a current, healthy, fully-used account.
### Security
- The Coach budget gate reserves spend atomically before each request, so concurrent requests can no longer slip past the cap together, and tokens burned on an empty or refused reply are still counted.
- New share links require a passphrase to open the record; the passphrase is stored only as an HMAC hash, verified in constant time behind a rate limit, and unlocks a short-lived view cookie scoped to that one link.
- The session and authentication-challenge tables are indexed on their expiry column for the reaper.
- The development key-padding fallback fails closed unless the environment is explicitly development or test, so a missing `NODE_ENV` can no longer silently derive a weak key.
### Migration
- This release adds three migrations: expiry indexes on the session and challenge tables, a daily-briefing reroll marker, and the nullable share-link passphrase hash. Run `prisma migrate deploy` (the entrypoint does this automatically). All three are additive — no backfill, no downtime.
## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups
A small UI and settings patch on top of v1.18.6. No migration, no API contract change.
### Fixed
- The settings and admin left menu now aligns with the top of the first card, instead of sitting slightly offset.
- The Coach conversation scrolls within a bounded area with the composer pinned to the bottom, instead of overflowing the panel.
- The health-record settings section (PDF and FHIR export) is reliably reachable again — its entry no longer disappears from Settings.
### Changed
- The guided tour is first-run only. The per-page and Settings "show tour" triggers are removed.
- Preventive-care cards now mirror the medication card: the cadence sits as a chip beside the name, due and last dates read as text, and "last done" always shows — with an em-dash when there is nothing yet.
## [1.18.6] — 2026-06-18 — offline reads, grounded guidance, and one consistent app
A large release that makes the app genuinely useful offline, gives every metric cited reference context, grounds the AI Coach and Daily Briefing in that same context, adds responsible safety alerts for clinically urgent readings, and brings the newer modules (preventive care, illness, labs) and the whole settings area in line with the established patterns. A guided tour now explains what each module does and how they connect. No breaking changes; all schema changes are additive.
### Added
- **A module-by-module guided tour.** After onboarding, a short tour walks each module — what it does, one capability you might miss, and how it feeds the others — so the whole app makes sense on day one. Replayable any time from Settings, or per module from its page.
- **Cited reference ranges everywhere.** Metrics now carry plain-language explainers and the general reference range they sit in (blood pressure, resting heart rate, glucose, blood oxygen, temperature, and more), framed as general guidance and sourced from recognised guidelines — never a diagnosis. Blood pressure follows the ESH range. The blood-pressure detail now also shows pulse pressure and mean arterial pressure, derived from your readings.
- **A Coach that knows the ranges.** The AI Coach and Daily Briefing are now grounded in those cited ranges, so a comment about a value reflects where it sits in the general range — still general guidance, never a diagnosis, with no third-party attribution.
- **Proactive Coach check-ins you can actually see.** A proactive nudge now appears as a message in your Coach conversation (not only as a push), with a discreet unread dot on the Coach button that clears when you open it — so it works even without notifications set up.
- **Responsible safety alerts.** A confirmed, re-measured reading in a clinically urgent range (very high or low blood pressure, low or high glucose) now raises an alert that says to re-check or contact your doctor — never a diagnosis, and only for the modules you use.
- **A diabetes setting.** An explicit opt-in switches glucose targets to the tighter clinical goal range; it is never inferred from your readings.
- **Per-module settings for preventive care, illness and labs.** Each gets its own settings page to reorder items and switch between card and list view; labs additionally lets you manage biomarkers and choose the sort order.
- **Mark a reminder done.** A measurement reminder can be completed server-side (for the apps), instead of only being dismissed locally.
- **Real offline reads.** Opened without a connection, the installed app shows your last synced dashboard and recent data with a "showing your last synced data" note, rather than blank placeholders. Changes you make offline still save when you reconnect.
### Changed
- **Preventive-care cards match the medication cards.** A clear "measure now" action, the interval as a chip, and progress toward the next due date — with measurement-appropriate wording (no more "intake" for a blood-pressure check) and the same calm, neutral card.
- **One consistent "add" and a settings wrench** across preventive care, illness and labs; the illness journal's resolved entries are collapsed by default.
- **Every Settings and Admin page now has a clear heading and description**, in one shared frame, with the side menu lined up to the content — so the whole area reads as one piece. The AI section is now labelled "AI provider", and module availability has its own admin page.
- **No more AI dead-end.** When no AI provider is connected, onboarding and the Coach/Insights/Briefing screens explain what the feature does and how to set it up, instead of a bare error.
- **Clearer sleep, recovery and insights.** The estimated sleep-stage timeline is shown only when it reflects measured stages (with an honest note otherwise); the chronotype reads as labelled text; the sleep-quality assessment is more useful; the recovery page is de-cluttered; and insight cards link through to their detail pages, including steps.
- **A faster dashboard.** All charts load in one batched request instead of one per chart, with target ranges computed on the server.
- **Fewer disclaimers, one clear acknowledgment.** The repeated "not medical advice" banners are gone in favour of a single acknowledgment at onboarding (reachable any time in Settings).
- **Mobile polish.** Small tap targets were brought up to a comfortable size, and a global reduced-motion safeguard quiets animation when your device asks for it.
### Fixed
- **Reduced motion no longer hides content.** Charts and list rows that animate in now stay visible when the device requests reduced motion, instead of disappearing.
- **Read-after-write.** A reading or medication you just added now reliably appears in its list.
- **A timezone bug** that anchored the local day at UTC midnight for one intake path.
- **Contrast.** The preventive-care action button now meets AA contrast in dark mode.
### Security
- **The offline cache is hardened.** It stores only a safe, read-only allowlist (never anything tied to sign-in or sensitive AI conversations), is bound to the signed-in account, and is wiped when the session ends — not only on explicit sign-out.
- The container scan ignores a known `picomatch` advisory (CVE-2026-33671) that exists only in the npm CLI bundled inside the base image, not in the app's own dependencies (pinned to the fixed version).
### Operator note
- Additive migrations only (`0173`–`0177`: tour progress, diabetes opt-in, disclaimer acknowledgment, Coach last-seen, a Coach-message index). The optional `APNS_CRITICAL_ENTITLEMENT` env var (introduced in 1.18.4) gates native critical alerts; urgent alerts ship as time-sensitive without it. The service-worker cache version bumps with the release, so clients refresh their offline cache automatically on update.
## [1.18.5] — 2026-06-18 — filter by value, a tidier advanced panel, and a titration timeline for injectables
A small polish release. You can now filter your readings by value, the advanced medication settings collapse into sections instead of one long page, and injectable medications show their dose-escalation plan as a timeline. No breaking changes; no migration.
### Added
- **Filter readings by value.** Search now takes a numeric range — show only readings whose value is between two numbers (or just above / just below one), alongside the existing type, source and date filters. Useful for finding outliers or a specific episode.
- **A titration timeline for injectables.** Injectable medications now show their planned dose-escalation (e.g. 2.5 mg → 5 mg → 7.5 mg over the weeks) as a timeline with a "you are here" marker, on the injection tab. Built from the dose-change history already recorded — nothing new to enter.
### Changed
- **The advanced medication settings read more calmly.** The advanced and API panels group into collapsible sections (lifecycle expanded, data and danger areas collapsed by default) instead of one long wall — the settings themselves are unchanged.
## [1.18.4] — 2026-06-18 — urgent alerts that reach you on whatever you've set up, and a stronger free notification path
This release makes notifications work well for self-hosters who don't run Apple push. Genuinely urgent health signals — a sustained low-oxygen or fever pattern flagged by the condition journal — now go out at the strongest level each notification channel you've configured supports, and reach you even with no Apple Developer account: max-priority ntfy, high-urgency Web Push, a webhook, or a time-sensitive iOS alert if you do run push. The installed web app gains real reminder handling: a dose reminder clears itself once you mark it taken, and an app badge counts what's still due. No breaking changes; no migration.
@@ -2216,6 +2634,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe
### Tests
Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11:
- BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline`
- BP with null readings ever → tile NOT emitted
- Weight within 7 days → behaviour unchanged (regression guard)
@@ -2581,7 +3000,7 @@ self-heal via boot-time backfill on first reach.
card; not exposed via any route yet.
- **`computeLongWindowSummary(userId, type, windowDays)`** in
`summaries-slice.ts` — granularity-routed `count / min / max /
mean / sum` aggregate for long-window consumers.
mean / sum` aggregate for long-window consumers.
- **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` /
`readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with
legacy-NULL fallback.
@@ -2611,7 +3030,7 @@ rides the post-deploy window.
- **`/api/measurements?groupBy=day` cumulative path:** consumes
`sum_value` directly; eliminates per-type JS aggregation on
`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE /
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
- **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing
425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type
loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes
@@ -2668,10 +3087,10 @@ false and stranded the whole helper on live SQL across the full
### Fixed
- **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS +
PULSE + WEIGHT`** — the three types the helper actually reads.
PULSE + WEIGHT`** — the three types the helper actually reads.
`isFullyCovered(coverage)` AND-term dropped.
- **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS +
BLOOD_PRESSURE_DIA`**. Same fix.
BLOOD_PRESSURE_DIA`**. Same fix.
- **`health-score-fast-path` gates only on `WEIGHT`**. Same fix.
Coverage-probe semantics unchanged. Each helper now consults the
@@ -2978,7 +3397,7 @@ without changing existing behaviour.
`aps.category = "MEDICATION_REMINDER"` on every medication-reminder
payload so iOS renders the three action-buttons (Genommen / Snooze
15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content
= 1` is set by default so a future Notification Service Extension
= 1` is set by default so a future Notification Service Extension
can hook the payload without a server-side change. The med-reminder
metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min"
action pins to the schedule slot rather than wall-clock delivery
@@ -3177,7 +3596,7 @@ dashboard SQL surfaced.
list through a Zod `refine` chained on the existing
`isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at
the write boundary so the new `to_char(measured_at AT TIME ZONE
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
a corrupted stored value.
### Tests
@@ -3505,7 +3924,7 @@ surfaces city + country when the carrier lookup misses.
buckets + a freshly logged WEIGHT type) falls back to the live
aggregate so no type silently underreports.
- **Daily chart fetches opt into rollup buckets.** `GET
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
from `measurement_rollups` instead of scanning the measurements
table when the window exceeds seven days. The Insights chart
fetchers pass `source=rollup`; iOS and other clients are
@@ -3572,7 +3991,7 @@ surfaces city + country when the carrier lookup misses.
`pickCumulativeDaySum` helper, source-priority-aware in the same
shape SLEEP_DURATION already used.
- **Insights nav strict-gate** flipped (`if (!availability) return
true` → `return false`). Types without measurements no longer
true` → `return false`). Types without measurements no longer
appear in the strip.
- **About → Admin Console.** "Über HealthLog" moved out of the
personal dropdown into a new `/admin/about` slug. The public
@@ -3598,7 +4017,7 @@ surfaces city + country when the carrier lookup misses.
### Schema
- **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL
DEFAULT '{}'`). Migration
DEFAULT '{}'`). Migration
`0068_v1436_insights_exclude_metrics` is idempotent under re-run.
### Refactor
@@ -3608,7 +4027,7 @@ surfaces city + country when the carrier lookup misses.
`applyInsightsExcludeFilter`, `pickCumulativeDaySum`,
`probeRollupCoverage`, `isFullyCovered`. Tested standalone.
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed:
true, rollup_refresh_error: ... }` on failure instead of swallowing
true, rollup_refresh_error: ... }` on failure instead of swallowing
silently. The read path still returns `{ recomputed: false }` so
the response never fails because of a populator hiccup.
- Cumulative metric-key lookup hoisted out of a four-level nested
@@ -3670,7 +4089,7 @@ zero reader-path impact (serial concurrency, runs in the background).
in place. The discovery query finds users with at least one row in
`measurements` and zero rows in `measurement_rollups`, then sends
one job per uncovered account with `singletonKey:
boot-backfill|{userId}` so a fast restart while a fold is queued
boot-backfill|{userId}` so a fast restart while a fold is queued
doesn't pile up duplicates.
- **Best-effort:** any error during discovery is captured in the
return value and logged through `workerLog`; the worker boot never
@@ -3685,7 +4104,7 @@ zero reader-path impact (serial concurrency, runs in the background).
user lands on the queue when a third already-folded user co-exists;
zero jobs enqueued when every account is already covered.
- Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm
typecheck` + `pnpm lint` clean.
typecheck` + `pnpm lint` clean.
## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap
@@ -3703,7 +4122,7 @@ across every analytics surface is planned for a follow-up.
- **New table `measurement_rollups`** keyed on
`(user_id, type, granularity, bucket_start)` with
`count / mean / min_value / max_value / sd / slope / r2 /
computed_at`. Backed by a composite-descending index on
computed_at`. Backed by a composite-descending index on
`(user_id, type, granularity, bucket_start DESC)`. Additive only —
no `ALTER` on existing tables. Migration
`0067_v1434_measurement_rollups` is idempotent under re-run.
@@ -3779,8 +4198,8 @@ viewport-zoom fix surfaced during the mobile-deep pass.
- `tests/integration/withings-oauth.test.ts` covers connect /
callback / disconnect plus state-token replay rejection.
- `tests/integration/passkey-register.test.ts` covers register-options
+ register-verify + tampered-attestation rejection + challenge
single-use.
- register-verify + tampered-attestation rejection + challenge
single-use.
- `tests/integration/cache-invalidation-coverage.test.ts` grids the
v1.4.34 IW-G invalidation matrix across 5 write surfaces.
- Integration suite goes from 197/50 to 222/55. tsc + lint clean.
@@ -3852,7 +4271,7 @@ freeze.
- **Focus rings on mobile + tablet navigation.** The bottom-nav primary
links, the More overflow trigger, and the mobile top-bar user menu
trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
and switch-control users can now see focus on every nav surface.
- **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and
DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px
@@ -4086,7 +4505,7 @@ without leaking userIds.
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
@@ -4337,8 +4756,7 @@ the affected surfaces.
`very_late`.** Defensive fallback when the dose-window
estimator can't resolve a window.
- **`/api/insights/generate` POST gating.** Endpoint now refuses
on a disabled assistant master flag instead of returning a
500.
on a disabled assistant master flag instead of returning a 500.
- **Mood Log overflow on narrow viewports.** Cards no longer
shred their internal grid below 360 CSS px.
- **F13 username readability.** Header username now respects
@@ -4685,7 +5103,7 @@ permission picker and the future Insights nav.
### Added
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
locked in the iOS coordination notes (§12). iOS
emits one row per day per cumulative type via
@@ -4768,11 +5186,12 @@ encounter them in read paths because no source writes them yet, and
the codegen path will pick them up on the next iOS regeneration.
**Cutover sequence.** v1.4.30 ships with the helper + drain script
+ server tolerance for both shapes. The next iOS TestFlight build
adopts `HealthKitStatisticsService.swift` and starts posting daily-
aggregated rows for the five cumulative types. Operator runs the
drain script once after the new TestFlight cuts over; per-sample
row pressure on `Measurement` drops 50-200× for cumulative types.
- server tolerance for both shapes. The next iOS TestFlight build
adopts `HealthKitStatisticsService.swift` and starts posting daily-
aggregated rows for the five cumulative types. Operator runs the
drain script once after the new TestFlight cuts over; per-sample
row pressure on `Measurement` drops 50-200× for cumulative types.
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
@@ -5029,7 +5448,7 @@ internal-only `/api/internal/web-vitals` beacon route).
GLP-1 row painted with a brand icon and a middle-dot separator;
every other row painted two-line without an icon. Both routes
through a shared `<MedicationCardHeader>` now — line 1 is `{name}
{dose}`, line 2 is the class label plus state badges. The GLP-1
{dose}`, line 2 is the class label plus state badges. The GLP-1
outlier shape is gone.
- **HealthScore card came up short below the action+prompts column.**
The card now opts into `flex h-full flex-col` with the disclaimer
@@ -5067,7 +5486,7 @@ internal-only `/api/internal/web-vitals` beacon route).
- **Medications detail-page chrome collapsed to one heading scale.**
Every section on `/medications/[id]` mounts through
`<MedicationDetailSection>` (`text-base font-semibold leading-6
tracking-tight`). DrugLevelChart's standalone header migrates to
tracking-tight`). DrugLevelChart's standalone header migrates to
`<h2>` with the same classes. Micro labels lift from
`text-[10px]` / `text-[11px]` to `text-xs` across Scheduling,
Titration, SideEffects. Three scales survive the page: heading,
@@ -5592,7 +6011,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
`router.replace`s to a clean URL.
- **`/api/version` register button below the tap floor.** The
`/auth/register` submit button promotes to `size="lg" min-h-11
w-full` so the primary action stays finger-tap reachable on
w-full` so the primary action stays finger-tap reachable on
narrow viewports.
- **Workouts pagination broken under canonical dedup.** Pulling the
full filtered set and slicing post-dedup yields the correct
@@ -5688,8 +6107,8 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
contract and the SSR `useIsMobile` first-paint.
- **16 new `metric-availability.test.ts` cases** cover each metric ×
`{has data, no data, undefined summaries, missing summary entry,
BMI-from-WEIGHT derivation, sys-vs-dia independence,
mood/medication overrides}`.
BMI-from-WEIGHT derivation, sys-vs-dia independence,
mood/medication overrides}`.
- **6 new `insights-tab-strip.test.tsx` cases** assert
backward-compat without `availability`, pill-drop when data is
missing, overview pill always renders, mood + medication
@@ -5857,7 +6276,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
- **Coach — native first-party system prompts across six locales.**
Coach + insights system prompts move from EN body + `REPLY
LANGUAGE` footer to native per-locale bodies; the safety contract
LANGUAGE` footer to native per-locale bodies; the safety contract
matrix lives as YAML per locale (single source of truth across
drafting + tests). The refusal-probe matrix in CI catches
cross-locale prompt regressions before they reach the dispatch
@@ -5868,7 +6287,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
- **Personal Records end-to-end — schema, detection worker, badge,
push opt-in.** Migration 0054 introduces the `PersonalRecord`
table + `PersonalRecordDirection` helper; `GET
/api/personal-records` is paginated (default twenty-five, max two
/api/personal-records` is paginated (default twenty-five, max two
hundred). A pg-boss worker sweeps MAX / MIN per metric and the
workout slots on every batch-ingest + a thirty-minute fallback
cron (concurrency five, warmup gate so the very first datapoint
@@ -5939,7 +6358,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
GLP-1 detail page renders the user's current step + remaining
ladder as observational reference, framed as reference-not-advice
and bound by GROUND RULE 15. `GET
/api/medications/[id]/titration-ladder` returns the structured
/api/medications/[id]/titration-ladder` returns the structured
ladder + current step.
- **GLP-1 — pen-and-vial inventory with a 30-day in-use clock.**
Migration 0056 introduces the `MedicationInventoryItem` table +
@@ -5993,7 +6412,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
and the per-row collapse affordance comes off.
- **DELETE `/api/measurements/by-external-ids` for iOS deletion
sync.** Idempotent batch delete keyed on `(user, source,
externalId)` tuples; rate-limited the same way as the batch
externalId)` tuples; rate-limited the same way as the batch
ingest endpoint.
- **Multi-arch Docker image.** GHCR publish workflow builds
`linux/amd64` on `ubuntu-latest` plus `linux/arm64` on
@@ -6001,7 +6420,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
Macs and arm64 clouds now pull native; the previously-stale
README claim is accurate again.
- **GLP-1 endpoint hardening.** `POST
/api/medications/[id]/glp1` now parses through bounded Zod
/api/medications/[id]/glp1` now parses through bounded Zod
schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`)
with length-capped notes, finite-number guards on dose value,
and bounded `effectiveFrom`. Every write produces an audit-log
@@ -6086,7 +6505,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
`src/lib/medications/route-guards.ts` rather than open-coding
the lookup; rate-limit headers across the same surface align
on the option-bag form `apiError(..., { headers:
rateLimitHeaders(rl) })`.
rateLimitHeaders(rl) })`.
### Fixed
@@ -6166,7 +6585,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
single-entry POST stripped it on the Zod boundary. Mirror parity
restored.
- **Personal-records endpoint unpaginated.** `GET
/api/personal-records` now clamps `?limit` (default twenty-five,
/api/personal-records` now clamps `?limit` (default twenty-five,
maximum two hundred); the unbounded read is gone.
- **Withings activity sync bucketed positive-offset users into the
wrong day.** The per-day row was anchored at 23:59:59 UTC, so a
@@ -6285,7 +6704,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
2024 pharmacokinetic reference. The acknowledgment version
re-prompts the user on bump.
- **GLP-1 convenience endpoint hardened.** `POST
/api/medications/[id]/glp1` parses through bounded Zod schemas
/api/medications/[id]/glp1` parses through bounded Zod schemas
(`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`), enforces
length caps on notes, finite-number guards on dose value, and
bounded `effectiveFrom`; every write produces an audit-log row
@@ -6491,7 +6910,7 @@ estimates and source citations, is tracked internally. Headline items:
- **iOS Swift app — P1 through P5.** Login + dashboard + widget
(P1); Apple Health sync (P2); Coach extended for HRV / sleep /
resting HR / steps (P3); per-metric APNs alerts (P4); workouts
+ GeoJSON routes (P5). All server contracts locked in v1.4.25.
- GeoJSON routes (P5). All server contracts locked in v1.4.25.
- **Workout ingest API matching the v1.5 iOS contract.**
Schema shipped this release; endpoint signature finalised
during the iOS sprint.
+53 -51
View File
@@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.20.x.
## Voice and privacy
@@ -32,19 +32,19 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
## Tech stack at a glance
| Layer | Tech | Notes |
|---|---|---|
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
| Layer | Tech | Notes |
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
@@ -71,8 +71,9 @@ handler body Zod parse (`safeParse` + `returnAllZodIssue
│ business logic, `annotate({ action, meta })`,
│ `auditLog()` for sensitive ops.
▼
src/lib/api-response.ts `apiSuccess(data, meta?)` / `apiError(msg, status,
│ meta?)` — every response is `{ data, error, meta? }`.
src/lib/api-response.ts `apiSuccess(data, status?)` → `{ data, error: null }`;
│ `apiError(msg, status, meta?)` threads `meta` on the
│ error path only — `{ data: null, error, meta? }`.
▼
src/proxy.ts security headers attach, request-id echoes.
▼
@@ -87,9 +88,9 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary at `src/lib/api-handler.ts:414-431`, not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see `src/lib/rollups/measurement-rollups.ts:330-343`). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys.ts` is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`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.
@@ -109,23 +110,23 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
## Critical-files map
| Concern | File | What it owns |
|---|---|---|
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | `src/lib/api-handler.ts` (490 LOC) | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider-chain.ts` + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is not registered in `resolveProvider()` — production cannot reach it. |
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (61 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
| Concern | File | What it owns |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
@@ -134,7 +135,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
@@ -180,21 +181,22 @@ The native SwiftUI client lives in a separate repository and rides public beta v
## Pointers
| What | Where |
|---|---|
| Public face | `README.md` |
| Release history | `CHANGELOG.md` |
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | `docs/self-hosting/` |
| Public API contract | `docs/api/openapi.yaml` |
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
| iOS coordination notes | `.planning/ios-coord/` |
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
| What | Where |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public face | `README.md` |
| Release history | `CHANGELOG.md` |
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | `docs/self-hosting/` |
| Public API contract | `docs/api/openapi.yaml` |
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
| iOS coordination notes | `.planning/ios-coord/` |
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
## Agent context hygiene (ECONNRESET / oversized-request prevention)
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.)
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
+21 -3
View File
@@ -33,7 +33,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
## Highlights
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Your devices, one server.** Withings and WHOOP sync over OAuth2, Google Health/Fitbit connects experimentally, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Fitbit connects experimentally over the Fitbit Web API, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
@@ -42,18 +42,36 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
## Quick start
## Quick start (Docker Compose)
```bash
git clone https://github.com/MBombeck/HealthLog.git && cd HealthLog
cp .env.example .env
# Generate the three secrets (each a 64-char hex string = 32 bytes):
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
# LAN / plain-HTTP host (NAS, homelab, Tailscale) — required, or login fails:
echo "SESSION_COOKIE_SECURE=false" >> .env
docker compose up -d
```
Open **http://localhost:3000** — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. Behind a reverse proxy, set `NEXT_PUBLIC_APP_URL` and `APP_URL` first — see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/).
Open **http://localhost:3000** (or `http://<host>:3000`) — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required.
Verify the running version:
```bash
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
```
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
## Self-hosting
+45 -6
View File
@@ -1,3 +1,22 @@
# HealthLog — two-container stack (app + Postgres 16).
#
# First run (clean host):
# cp .env.example .env
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
# echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
# docker compose up -d
# # then open http://<host>:3000 — the first registered user becomes admin.
#
# LAN / plain-HTTP self-hosts (NAS, homelab, Tailscale): set
# SESSION_COOKIE_SECURE=false
# in .env, otherwise the session cookie carries the `Secure` flag over HTTP
# and login silently fails. This is the single most common self-host snag.
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless — GeoLite2 MMDBs are baked into
# the image — so a backup of the database is a backup of everything.
services:
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
# self-host deployments stay working with `docker compose up`.
@@ -39,19 +58,39 @@ services:
# a free connection before erroring.
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
NODE_ENV: production
# Encryption at rest (AES-256-GCM). Generate each secret with
# `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY
# is the single-key path and is all most self-hosts need. The map form
# (ENCRYPTION_KEYS={"v1":"<hex>","v2":"<hex>"} + ENCRYPTION_ACTIVE_KEY_ID=v2)
# is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these
# values stable and backed up: lose them and the encrypted columns are
# unrecoverable.
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
APP_URL: "${APP_URL:-http://localhost:3000}"
# v1.5.1 session-cookie Secure-flag opt-out. Forwarded explicitly so
# an operator setting SESSION_COOKIE_SECURE=false in .env actually
# reaches the Node process (the compose `environment:` block is a
# whitelist — vars not listed here don't propagate from .env into
# the container, even though docker-compose reads .env for
# `${VAR}` substitution at compose-up time).
# Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP
# self-hosts (http://<host>:3000 with no TLS reverse proxy) — otherwise the
# session cookie carries the `Secure` flag, the browser drops it over HTTP,
# and login silently fails. Unset → the flag tracks NODE_ENV=production
# (i.e. on); set to true to force it. Forwarded explicitly so an operator
# value in .env reaches the Node process (the `environment:` block is a
# 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:-}"
# 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
# default — a direct-exposed host must not trust a forgeable header).
# IP_GEO_LOOKUP_URL overrides the default ip-api.com provider (HTTPS
# only); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# Listed here so operator values in .env reach the container (the
# `environment:` block is a whitelist — vars not listed never propagate).
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
+2 -2
View File
@@ -32,7 +32,7 @@ Separate the two naming axes cleanly:
- **UI copy is always localised.** Every visible string resolves through an
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
it / pl`). The user never sees a raw identifier.
it / pl`). The user never sees a raw identifier.
- **Internal identifiers are English.** Route slugs (`/insights/<slug>`), the
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
slug→metric / slug→target maps all use established English terms. English is
@@ -54,7 +54,7 @@ The migration must not break either client-visible boundary:
navigation keep resolving.
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
German tile ids on input. The Zod enum is built from
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids _plus_ the legacy German
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
replacement; this normalisation runs on **both** the read path
+29 -29
View File
@@ -13,16 +13,16 @@ row. The fold only runs when cycle tracking is enabled for the account.
## Day-log fields
| HealthKit category type | CycleDayLog field | Values |
|---|---|---|
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
| HealthKit category type | CycleDayLog field | Values |
| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
@@ -31,21 +31,21 @@ logged-but-unspecified bleeding day is at least light flow).
## Symptom category types → seeded symptom keys
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
sample whose severity is *not present* (codepoint 1) does NOT create a link.
sample whose severity is _not present_ (codepoint 1) does NOT create a link.
| HealthKit category type | CycleSymptom key |
|---|---|
| `AbdominalCramps` | `cramps` |
| `Headache` | `headache` |
| `Bloating` | `bloating` |
| `Acne` | `acne` |
| `BreastPain` | `breast_tenderness` |
| `Fatigue` | `fatigue` |
| `LowerBackPain` | `back_pain` |
| `SleepChanges` | `insomnia` |
| `MoodChanges` | `mood_swings` |
| `AppetiteChanges` | `food_cravings` |
| `Nausea` | `nausea` |
| HealthKit category type | CycleSymptom key |
| ----------------------- | ------------------- |
| `AbdominalCramps` | `cramps` |
| `Headache` | `headache` |
| `Bloating` | `bloating` |
| `Acne` | `acne` |
| `BreastPain` | `breast_tenderness` |
| `Fatigue` | `fatigue` |
| `LowerBackPain` | `back_pain` |
| `SleepChanges` | `insomnia` |
| `MoodChanges` | `mood_swings` |
| `AppetiteChanges` | `food_cravings` |
| `Nausea` | `nausea` |
## Profile-level signals
@@ -64,8 +64,8 @@ the temperature-trend ovulation method directly from `Measurement`.
## Deferred
| HealthKit category type | Status |
|---|---|
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
| HealthKit category type | Status |
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
+1799 -26
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -11,6 +11,7 @@ last_updated: 2026-05-15
## TL;DR
Three submission gates that need the maintainer's hand at ASC:
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
3. Standard metadata (name, description, screenshots, categories, age rating)
@@ -24,9 +25,11 @@ Plus pre-submit final checks.
**Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation.
**Recommended wording in the declaration form**:
> HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians.
**What to check**:
- [ ] Check "No" on the medical-device toggle
- [ ] Add the description above to the medical-device-justification field if Apple shows one
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
@@ -44,22 +47,26 @@ Plus pre-submit final checks.
For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking.
**Health & Fitness**
- Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2)
- Fitness: yes (steps, distance, active energy, workouts)
- Purpose: App Functionality
- Linked to user: yes
**Contact Info**
- Email Address: yes (for account login + GDPR data-subject correspondence)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Sensitive Info**
- Blood pressure, glucose, medications, drug-related observations: yes
- Purpose: App Functionality
- Linked to user: yes
**User Content**
- Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails)
- Photos or Videos: NO
- Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice)
@@ -69,12 +76,14 @@ For each category below: **Linked to user, used for App Functionality + Account
- Linked to user: yes
**Identifiers**
- User ID: yes (server-side user UUID)
- Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Other Data Types**
- Other Data Types: yes (general medical observations not fitting above)
- Purpose: App Functionality
- Linked to user: yes
@@ -97,6 +106,7 @@ For each category below: **Linked to user, used for App Functionality + Account
## Section 3 — Standard Metadata Checklist
### App Information
- [ ] Name: "HealthLog"
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
- [ ] Primary category: Health & Fitness
@@ -105,25 +115,30 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] Age rating: 17+ (because Sensitive Health Info content)
### Description
- [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance
- [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion."
### Screenshots
- [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion).
- [ ] 5.5" iPhone screenshots: same set (Apple's legacy size).
- [ ] iPad screenshots if supporting iPad.
### URLs
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
### App Review Information
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
- [ ] Sign-in required: yes (provide demo credentials)
### Pricing + Availability
- [ ] Free
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
@@ -154,6 +169,7 @@ Run through this list:
---
**Source links**:
- Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research
- Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/
- EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj
+7 -7
View File
@@ -6,13 +6,13 @@ pages as `<img>` tags. Mirrored into `healthlog-docs/src/assets/diagrams/`
and `healthlog-landing/public/diagrams/` so each surface ships its own
copy.
| File | Story it tells |
| ---- | -------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
| File | Story it tells |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
## Editing
+19 -14
View File
@@ -9,12 +9,12 @@ Ollama endpoint can fall back from one to the other automatically.
## Provider matrix
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| -------- | ---------------- | ------------- | -------- | -------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
@@ -134,11 +134,14 @@ API key at rest like every other provider credential.
**SSRF guard.** Local endpoints route through the same validation
as every other outbound URL HealthLog hits. The guard rejects
private-IP-range targets by default — set
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
allow `localhost`, RFC1918, and link-local destinations
(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
instances unless you specifically run an internal endpoint.
private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
opens the escape hatch and accepts two forms: a comma-separated host
allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
which permits only those exact hostnames, or the legacy
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
including the cloud-metadata endpoint, so prefer the host list. Leave
it unset on public-facing instances unless you specifically run an
internal endpoint.
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
@@ -232,9 +235,11 @@ error.
provider in `/settings/ai`, or configure an admin-shared OpenAI
key in the admin panel.
- **Local endpoint rejected as "internal/private host".** Set
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
Leave it unset on internet-facing instances unless you specifically
want to allow private-range targets.
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
(e.g. `ollama.lan`; comma-separate several) in the container
environment, or to `true` to allow any private host. Prefer the host
list; leave it unset on internet-facing instances unless you
specifically want to allow private-range targets.
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
refresh tokens lapse after extended idle periods. Reconnect via
the **Connect ChatGPT** button — HealthLog re-runs the device-code
+13 -13
View File
@@ -97,12 +97,12 @@ they triggered.
The parser folds four XML element types into the existing HealthLog
schema (CHANGELOG v1.4.34):
| XML element | Maps to | Notes |
| ----------- | ------- | ----- |
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
| XML element | Maps to | Notes |
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
@@ -187,13 +187,13 @@ Override per-user via the Sources section of `/settings/thresholds`.
## Failure modes
| Failure | What you see | Recovery |
| ------- | ------------ | -------- |
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
| Failure | What you see | Recovery |
| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
The wide-event log line `import.apple-health.kickoff` (or
`import.apple-health.kickoff.denied` on failure) captures every
+89 -85
View File
@@ -36,8 +36,12 @@ arrays:
```json
{
"measurements": [ /* … */ ],
"moodEntries": [ /* … */ ]
"measurements": [
/* … */
],
"moodEntries": [
/* … */
]
}
```
@@ -48,26 +52,26 @@ skipped and counted under `skipped` in the response.
### Measurement entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
| Field | Required | Type | Notes |
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
### Mood entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
@@ -76,72 +80,72 @@ display per the user's preference. Event-class types record a single
fired event (`value` is `1`) and are normally produced by a device, not
hand-authored.
| Type | Canonical unit |
|---|---|
| `WEIGHT` | kg |
| `BLOOD_PRESSURE_SYS` | mmHg |
| `BLOOD_PRESSURE_DIA` | mmHg |
| `PULSE` | bpm |
| `RESTING_HEART_RATE` | bpm |
| `WALKING_HEART_RATE_AVERAGE` | bpm |
| `AVERAGE_HEART_RATE` | bpm |
| `MAX_HEART_RATE` | bpm |
| `CARDIO_RECOVERY` | bpm |
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
| `HRV_RMSSD` | ms (RMSSD) |
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
| `LEAN_BODY_MASS` | kg |
| `MUSCLE_MASS` | kg |
| `BONE_MASS` | kg |
| `TOTAL_BODY_WATER` | kg |
| `VISCERAL_FAT` | rating |
| `BODY_MASS_INDEX` | kg/m² |
| `BODY_TEMPERATURE` | °C (core) |
| `SKIN_TEMPERATURE` | °C (dermal spot) |
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
| `SLEEP_DURATION` | minutes |
| `SLEEP_NEED` | minutes |
| `SLEEP_PERFORMANCE` | percent (0–100) |
| `SLEEP_EFFICIENCY` | percent (0–100) |
| `SLEEP_CONSISTENCY` | percent (0–100) |
| `SLEEP_DISTURBANCE_COUNT` | count |
| `BREATHING_DISTURBANCES` | count |
| `ACTIVITY_STEPS` | count |
| `FLIGHTS_CLIMBED` | count |
| `WALKING_RUNNING_DISTANCE` | metres |
| `SIX_MINUTE_WALK_DISTANCE` | metres |
| `WALKING_STEP_LENGTH` | metres |
| `WALKING_SPEED` | m/s |
| `STAIR_ASCENT_SPEED` | m/s |
| `STAIR_DESCENT_SPEED` | m/s |
| `WALKING_STEADINESS` | percent (0–100) |
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
| `VASCULAR_AGE` | years |
| `FALL_COUNT` | count |
| `AUDIO_EXPOSURE_ENV` | dBA |
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
| `TIME_IN_DAYLIGHT` | minutes |
| `RECOVERY_SCORE` | score (0–100) |
| `STRESS_SCORE` | score (0–100) |
| `STRAIN_SCORE` | score (0–100) |
| `DAY_STRAIN` | score (0–21) |
| `WORKOUT_STRAIN` | score (0–21) |
| `AUDIO_EXPOSURE_EVENT` | count (event) |
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
| `HIGH_HEART_RATE_EVENT` | event |
| `LOW_HEART_RATE_EVENT` | event |
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
| Type | Canonical unit |
| ------------------------------- | -------------------- |
| `WEIGHT` | kg |
| `BLOOD_PRESSURE_SYS` | mmHg |
| `BLOOD_PRESSURE_DIA` | mmHg |
| `PULSE` | bpm |
| `RESTING_HEART_RATE` | bpm |
| `WALKING_HEART_RATE_AVERAGE` | bpm |
| `AVERAGE_HEART_RATE` | bpm |
| `MAX_HEART_RATE` | bpm |
| `CARDIO_RECOVERY` | bpm |
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
| `HRV_RMSSD` | ms (RMSSD) |
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
| `LEAN_BODY_MASS` | kg |
| `MUSCLE_MASS` | kg |
| `BONE_MASS` | kg |
| `TOTAL_BODY_WATER` | kg |
| `VISCERAL_FAT` | rating |
| `BODY_MASS_INDEX` | kg/m² |
| `BODY_TEMPERATURE` | °C (core) |
| `SKIN_TEMPERATURE` | °C (dermal spot) |
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
| `SLEEP_DURATION` | minutes |
| `SLEEP_NEED` | minutes |
| `SLEEP_PERFORMANCE` | percent (0–100) |
| `SLEEP_EFFICIENCY` | percent (0–100) |
| `SLEEP_CONSISTENCY` | percent (0–100) |
| `SLEEP_DISTURBANCE_COUNT` | count |
| `BREATHING_DISTURBANCES` | count |
| `ACTIVITY_STEPS` | count |
| `FLIGHTS_CLIMBED` | count |
| `WALKING_RUNNING_DISTANCE` | metres |
| `SIX_MINUTE_WALK_DISTANCE` | metres |
| `WALKING_STEP_LENGTH` | metres |
| `WALKING_SPEED` | m/s |
| `STAIR_ASCENT_SPEED` | m/s |
| `STAIR_DESCENT_SPEED` | m/s |
| `WALKING_STEADINESS` | percent (0–100) |
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
| `VASCULAR_AGE` | years |
| `FALL_COUNT` | count |
| `AUDIO_EXPOSURE_ENV` | dBA |
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
| `TIME_IN_DAYLIGHT` | minutes |
| `RECOVERY_SCORE` | score (0–100) |
| `STRESS_SCORE` | score (0–100) |
| `STRAIN_SCORE` | score (0–100) |
| `DAY_STRAIN` | score (0–21) |
| `WORKOUT_STRAIN` | score (0–21) |
| `AUDIO_EXPOSURE_EVENT` | count (event) |
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
| `HIGH_HEART_RATE_EVENT` | event |
| `LOW_HEART_RATE_EVENT` | event |
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
+1
View File
@@ -31,6 +31,7 @@ grant scoped to credentials they control.
and the connection dies when the first access token expires (~1 hour).
The remaining `read:*` scopes are read-only; request all of them so the
user grants once and every sync resource is covered.
4. Save the app. WHOOP issues a **Client ID** and **Client Secret** —
keep the secret out of any chat log; it grants read access to the
linked account's full WHOOP history.
+10 -10
View File
@@ -11,14 +11,14 @@ relay.
The Withings client (`src/lib/withings/client.ts`) maps the following
metric types into `Measurement` rows tagged `source = WITHINGS`:
| Withings meastype | HealthLog `MeasurementType` |
| ----------------- | --------------------------- |
| 1 | `WEIGHT` |
| 5 | `FAT_FREE_MASS` |
| 9 | `BLOOD_PRESSURE_DIA` |
| 10 | `BLOOD_PRESSURE_SYS` |
| 11 | `PULSE` |
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
| Withings meastype | HealthLog `MeasurementType` |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| 1 | `WEIGHT` |
| 5 | `FAT_FREE_MASS` |
| 9 | `BLOOD_PRESSURE_DIA` |
| 10 | `BLOOD_PRESSURE_SYS` |
| 11 | `PULSE` |
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
Activity data (steps, active energy, distance, floors climbed) lands
@@ -136,7 +136,7 @@ Override per-user via the Sources section of `/settings/thresholds`.
- **`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`).
exec app env | grep WITHINGS`).
- **`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
@@ -183,7 +183,7 @@ The Settings page **Disconnect** button:
the database — disconnect does not delete data. Re-linking the
same Withings account merges back onto the same metric
timeline; the upsert path matches on `(userId, type, measuredAt,
source)` so previously-synced rows update in place.
source)` so previously-synced rows update in place.
## Admin probe
+6 -6
View File
@@ -39,11 +39,11 @@ export without polluting the local shell.
Output is one row per declared variable, grouped by section. Three
states:
| Marker | Meaning |
| --------------------- | ---------------------------------------------------------------------------------- |
| `[OK]` | Variable is set and non-empty. |
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
| Marker | Meaning |
| -------------------- | -------------------------------------------------------------- |
| `[OK]` | Variable is set and non-empty. |
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
The grep-friendly format lets a CI pipeline do `grep -q
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
@@ -93,7 +93,7 @@ so changes go through a PR. To add a new variable:
Deferred to v1.4.43:
- GitHub Actions workflow that runs `pnpm check-env --file
.env.production.example` on every PR, blocking changes that
.env.production.example` on every PR, blocking changes that
introduce new required vars without updating the example file.
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
target container and aborts the deploy on exit code 1.
+1
View File
@@ -96,6 +96,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--tombstone-implausible` is refused without `--fix`.
- **Drifted schedule windows are reconciled** to
`min/max(times_of_day)` so the legacy pair stops contradicting the
canonical dose times. Overnight windows (`window_end < window_start`)
+23 -23
View File
@@ -9,8 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
+ linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses.
@@ -43,11 +43,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------- | ------- | ------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `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 |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `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`)
@@ -131,9 +131,9 @@ compose `environment:` block has no effect — they only take hold when
you build your own image. They are intentionally absent from the
compose runtime whitelist for that reason.
| Variable | Default | Effect when set |
| --- | --- | --- |
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
| Variable | Default | Effect when set |
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
To flip one, rebuild the image with the variable exported:
@@ -144,18 +144,18 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
## 7. Where to go next
| Next step | File |
| --------- | ---- |
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
| Withings device sync | `docs/integrations/withings.md` |
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
| Next step | File |
| ------------------------------------------------------------------- | ------------------------------------- |
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
| Withings device sync | `docs/integrations/withings.md` |
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
## Troubleshooting
@@ -168,7 +168,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
`app_settings.singleton.registrationEnabled`. The admin panel can
flip it back if a previous owner closed it.
- **Service unreachable on `localhost:3000`.** Check `docker compose
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
and will mark the container unhealthy if Postgres is still
starting. Tail `docker compose logs db` to confirm Postgres came up
cleanly.
+25 -25
View File
@@ -24,14 +24,14 @@ Apple-platform exclusives the web cannot offer.
## TL;DR — which channel needs what
| Channel | What it needs | Cost | Where it works |
| --- | --- | --- | --- |
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
| Channel | What it needs | Cost | Where it works |
| ------------ | ------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after _Add to Home Screen_) |
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
Web Push is the recommended default — it needs no third-party account,
just a keypair you generate yourself once.
@@ -173,7 +173,7 @@ pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
HealthLog publishes to that topic.
1. Pick a hard-to-guess topic name (the topic *is* the access control on
1. Pick a hard-to-guess topic name (the topic _is_ the access control on
public `ntfy.sh` — anyone who knows the string can read it, so treat
it like a secret).
2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/<topic>`.
@@ -268,14 +268,14 @@ under your own Apple identity. Everything below is that path.
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
| Variable | Purpose |
| --- | --- |
| `APNS_KEY_ID` | The `.p8` key's Key ID |
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
| `APNS_KEY` | OR the raw PEM body inline |
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
| Variable | Purpose |
| --------------------------- | ----------------------------------------------------------------------------------- |
| `APNS_KEY_ID` | The `.p8` key's Key ID |
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
| `APNS_KEY` | OR the raw PEM body inline |
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
APNs is **all-or-none**: either set the three IDs plus exactly one key
@@ -311,14 +311,14 @@ Two gotchas worth knowing before you blame a stale token:
## Which channel should I pick?
| Your setup | Recommended channel |
| --- | --- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
| Your setup | Recommended channel |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
Mixing channels is fine and common — e.g. Web Push on the laptop and
ntfy on the phone. Each user configures their own set from
+49
View File
@@ -0,0 +1,49 @@
# HealthLog on Portainer
HealthLog ships a Portainer **app template** that deploys the app and its
PostgreSQL 16 database as one stack from the project `docker-compose.yml`.
## Add the template
1. In Portainer, go to **Settings → App Templates**.
2. Set the URL to the raw template file:
```
https://raw.githubusercontent.com/MBombeck/HealthLog/main/docs/self-hosting/portainer/templates.json
```
3. Save. **HealthLog** now appears under **App Templates**.
## Deploy
1. Open **App Templates → HealthLog**.
2. Fill in the variables. Generate each secret with `openssl rand -hex 32` (a
64-character hex string):
- `POSTGRES_PASSWORD`
- `ENCRYPTION_KEY` — back this up; losing it makes encrypted data
unrecoverable
- `API_TOKEN_HMAC_KEY`
- `SESSION_COOKIE_SECURE` — **set to `false` for plain-HTTP / LAN access**, or
login silently fails. Set to `true` only behind an HTTPS reverse proxy.
- `NEXT_PUBLIC_APP_URL` and `APP_URL` — how you reach the app, e.g.
`http://nas:3000` or `https://healthlog.example.com`.
3. **Deploy the stack**. The app waits for PostgreSQL, runs database migrations
on first boot, then starts serving.
4. Open the app URL. The first registered user becomes the admin.
Verify the running version:
```bash
curl -s http://<host>:3000/api/version
```
## Notes
- Persistent state lives in one named volume (`pgdata`, the Postgres data dir);
the app container is stateless. Back up the database to back up everything.
- Pin a release tag (`HEALTHLOG_IMAGE_REF=:vX.Y.Z`) for deliberate upgrades, or
track `:latest`. Skim the
[CHANGELOG](https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md) and
back up before upgrading. Migrations are forward-only.
- For passive discovery you can also submit this template to the community
aggregator at https://github.com/Lissy93/portainer-templates.
@@ -0,0 +1,54 @@
{
"version": "3",
"templates": [
{
"type": 3,
"title": "HealthLog",
"name": "healthlog",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories": ["Tools", "Other"],
"platform": "linux",
"logo": "https://raw.githubusercontent.com/MBombeck/HealthLog/main/public/logo-512.png",
"repository": {
"url": "https://github.com/MBombeck/HealthLog",
"stackfile": "docker-compose.yml"
},
"env": [
{
"name": "POSTGRES_PASSWORD",
"label": "PostgreSQL password",
"description": "Database password. Generate with: openssl rand -hex 32"
},
{
"name": "ENCRYPTION_KEY",
"label": "Encryption key (data at rest)",
"description": "AES-256-GCM key. Generate with: openssl rand -hex 32. Back this up — losing it makes encrypted data unrecoverable."
},
{
"name": "API_TOKEN_HMAC_KEY",
"label": "API token HMAC key",
"description": "Generate with: openssl rand -hex 32"
},
{
"name": "SESSION_COOKIE_SECURE",
"label": "Session cookie Secure flag",
"description": "Set to false for plain-HTTP / LAN access, or login fails. Set to true only behind an HTTPS reverse proxy.",
"default": "false"
},
{
"name": "NEXT_PUBLIC_APP_URL",
"label": "Public app URL",
"description": "How users reach HealthLog, e.g. http://nas:3000 or https://healthlog.example.com",
"default": "http://localhost:3000"
},
{
"name": "APP_URL",
"label": "Internal app URL",
"description": "Normally identical to the public app URL.",
"default": "http://localhost:3000"
}
]
}
]
}
@@ -45,7 +45,7 @@ reached:
- **Physical or legal seizure of your own host.** If the machine
running PostgreSQL — your VPS, your home server, your NAS — is seized,
imaged, or compelled, the data is on it. Encryption at rest protects
against a stolen disk or a leaked backup only while the key is *not*
against a stolen disk or a leaked backup only while the key is _not_
also available; if the host is running, or the key is stored next to
the data, that protection is gone.
- **Your hosting provider.** A VPS provider can snapshot your disk and
@@ -77,7 +77,7 @@ record, not speculation:
California privacy statute over the sharing of menstrual-tracking
data; the verdict and the underlying class action are public record.
These are cited as factual public record to explain *why* the
These are cited as factual public record to explain _why_ the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
+12 -12
View File
@@ -11,11 +11,11 @@ the env-var pairing is identical across them.
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| -------- | ------------ | ----- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| Variable | Required for | Notes |
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
@@ -28,12 +28,12 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
| Topology | `TRUST_PROXY_HOPS` |
| -------- | ------------------ |
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | -------------------------- |
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
@@ -132,7 +132,7 @@ operator attention:
The following variables are **build-time only** — they are injected
via `--build-arg` by the `docker-publish` workflow and baked into the
shipped image. Setting them under Coolify's *Environment Variables*
shipped image. Setting them under Coolify's _Environment Variables_
panel has zero effect on the running container, and the stale value
becomes a misleading artefact next time someone audits the deploy
config:
+1 -1
View File
@@ -90,7 +90,7 @@ services:
app:
image: ghcr.io/mbombeck/healthlog:latest
environment:
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
HEALTHLOG_PROCESS_TYPE: web
```
+137
View File
@@ -0,0 +1,137 @@
# HealthLog on Unraid
HealthLog runs on Unraid in two ways. The **Compose Manager** path is the clean,
self-contained install (app + database as one unit) and is recommended. The
**Community Applications (CA)** template gives you the familiar one-app-at-a-time
Unraid UI, but because CA templates describe a single container you install
PostgreSQL separately and wire the two together.
HealthLog stores all persistent state in PostgreSQL — the app container itself is
stateless (the offline geo-IP databases are baked into the image). A backup of
the database is a backup of everything.
## What you need
- Unraid 6.12 or newer.
- The image: `ghcr.io/mbombeck/healthlog:latest` (multi-arch, amd64 + arm64).
- Three secrets, each generated with `openssl rand -hex 32` (a 64-character hex
string). On Unraid open the terminal (the `>_` icon, top-right) and run the
command three times:
- `POSTGRES_PASSWORD` — the database password
- `ENCRYPTION_KEY` — AES-256-GCM key for data at rest
- `API_TOKEN_HMAC_KEY` — API token hashing key
Keep `ENCRYPTION_KEY` safe and backed up. If you lose it, the encrypted columns
become unrecoverable.
---
## Recommended: Compose Manager
This installs the app and its database together, exactly as the project's
`docker-compose.yml` describes.
1. Install **Compose Manager** from Community Applications (search "Compose
Manager", by dcflachs).
2. **Settings → Docker → Compose Manager → Add New Stack**, name it `healthlog`.
3. Paste the project
[`docker-compose.yml`](https://github.com/MBombeck/HealthLog/blob/main/docker-compose.yml)
into the stack's compose file.
4. Edit the stack's `.env` (the "Edit Stack → env" field) and set:
```env
POSTGRES_PASSWORD=<your openssl rand -hex 32>
ENCRYPTION_KEY=<your openssl rand -hex 32>
API_TOKEN_HMAC_KEY=<your openssl rand -hex 32>
SESSION_COOKIE_SECURE=false
NEXT_PUBLIC_APP_URL=http://tower:3000
APP_URL=http://tower:3000
```
Replace `tower` with your server's hostname or IP. `SESSION_COOKIE_SECURE=false`
is required for plain-HTTP LAN access — see the note below.
5. **Compose Up**. The app waits for PostgreSQL, runs database migrations on
first boot, then starts serving.
6. Open `http://tower:3000`. The first account you register becomes the admin.
Verify the running version:
```bash
curl -s http://tower:3000/api/version
```
---
## Alternative: Community Applications template
Use this if you prefer the per-app Unraid UI. You install PostgreSQL yourself,
then add HealthLog pointed at it.
### Step 1 — install PostgreSQL 16
1. In **Apps** (Community Applications), search for a **PostgreSQL** container
(for example jj9987's `postgresql`). Install version 16.
2. Set:
- `POSTGRES_USER` = `healthlog`
- `POSTGRES_PASSWORD` = your generated password
- `POSTGRES_DB` = `healthlog`
3. Map the Postgres data path to a share on the array (e.g.
`/mnt/user/appdata/healthlog-db`) so the database survives container updates.
4. Note the container's IP (or container name if both run on the same custom
Docker network).
### Step 2 — add the HealthLog template
The template lives at
[`docs/self-hosting/unraid/healthlog.xml`](unraid/healthlog.xml). Add it via
**Docker → Add Container → Template** and paste the raw URL, or copy it into
`/boot/config/plugins/dockerMan/templates-user/` and pick it from the template
dropdown.
Fill in the fields:
| Field | Value |
| ----------------------- | -------------------------------------------------------------------------------- |
| WebUI Port | `3000` |
| `DATABASE_URL` | `postgresql://healthlog:<password>@<postgres-host>:5432/healthlog?schema=public` |
| `ENCRYPTION_KEY` | your generated key |
| `API_TOKEN_HMAC_KEY` | your generated key |
| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access |
| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) |
| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` |
Replace `<password>` and `<postgres-host>` with the values from Step 1. Apply,
then open `http://tower:3000` — the first registered user becomes the admin.
The HealthLog container has no persistent volume of its own; all state is in the
PostgreSQL container you installed in Step 1. Back that one up.
---
## The one setting that trips people up
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
(`http://tower:3000`). Left at its default, the session cookie carries the
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
"do nothing" — the page reloads to the login screen with no error.
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
and `APP_URL` at the public `https://` URL.
## Updates
Pin a release tag (`ghcr.io/mbombeck/healthlog:vX.Y.Z`) and bump deliberately,
or track `:latest` and let Unraid's update check re-pull. Skim the
[CHANGELOG](https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md) before
upgrading, and back up the database first. Migrations are forward-only and run
automatically on container start.
## Health check
Both `/api/health` and `/api/version` respond without authentication.
`/api/health` returns `200` when the database and background worker are up
(`503` otherwise) and is the container health probe baked into the image.
`/api/version` returns the version, build SHA, and build timestamp for deploy
verification.
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0"?>
<!--
HealthLog — Unraid Community Applications template.
CA templates describe ONE container. HealthLog needs PostgreSQL 16, so
install a PostgreSQL container FIRST (CA has community PostgreSQL templates),
create a `healthlog` database + user, then point DATABASE_URL below at it.
Full walkthrough: docs/self-hosting/unraid.md.
LAN installs serve plain HTTP (http://tower:3000). Keep SESSION_COOKIE_SECURE
set to false (the default in this template) or login will silently fail.
-->
<Container version="2">
<Name>HealthLog</Name>
<Repository>ghcr.io/mbombeck/healthlog:latest</Repository>
<Registry>https://github.com/MBombeck/HealthLog/pkgs/container/healthlog</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://github.com/MBombeck/HealthLog/issues</Support>
<Project>https://github.com/MBombeck/HealthLog</Project>
<Overview>
Self-hosted personal health tracker (PWA + native iOS client): weight, blood
pressure, pulse, blood glucose, body composition, sleep, mood, menstrual
cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no
vendor cloud, no telemetry.
REQUIRES a separate PostgreSQL 16 container — install one first (CA has
community PostgreSQL templates), create a database and user, then set
DATABASE_URL below. Step-by-step guide:
https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/unraid.md
Generate each required secret with: openssl rand -hex 32
LAN / plain-HTTP installs MUST keep SESSION_COOKIE_SECURE set to false,
otherwise the session cookie carries the Secure flag, the browser drops it
over HTTP, and login silently fails.
</Overview>
<Category>Tools: Productivity: Other:</Category>
<WebUI>http://[IP]:[PORT:3000]/</WebUI>
<Icon>https://raw.githubusercontent.com/MBombeck/HealthLog/main/public/logo-512.png</Icon>
<ExtraParams>--security-opt no-new-privileges:true</ExtraParams>
<DonateText/>
<DonateLink/>
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="http://tower:3000" Mode="" Description="Internal app base URL. Normally identical to NEXT_PUBLIC_APP_URL." Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {&quot;v1&quot;:&quot;&lt;hex&gt;&quot;,&quot;v2&quot;:&quot;&lt;hex&gt;&quot;}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
</Container>
+1 -1
View File
@@ -235,7 +235,7 @@ lists) uses the unified `<FilterBar>` rail from
labelled `<Select>`s and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
+3 -1
View File
@@ -243,7 +243,9 @@ test.describe("Coach guided clarifying questions", () => {
await expect(current).toHaveCount(0);
// Settling the offer (decline via ✕) releases the next question.
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
await adopt
.locator('[data-slot="coach-self-context-adopt-dismiss"]')
.click();
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
current.locator('[data-slot="coach-guided-progress"]'),
+48
View File
@@ -98,10 +98,58 @@ test.describe("authenticated dashboard render", () => {
}),
);
// 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
// sub-path of `/api/measurements`, which the `**/api/measurements*`
// glob below does NOT match (Playwright's `*` does not cross `/`), so
// without an explicit stub the request fell through to the real
// backend. The seed user has no measurements, so the route returned
// every requested type as an EMPTY array — and an empty-but-present
// slice satisfies the chart's preloaded-coverage check, so each chart
// rendered the batched empty slice instead of self-fetching. Mock the
// endpoint with the same populated set so the charts paint.
await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => {
const url = new URL(route.request().url());
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
const series: Record<
string,
Array<{
type: string;
value: number;
measuredAt: string;
count: number;
}>
> = {};
for (const type of types) {
const baseValue =
type === "BLOOD_PRESSURE_SYS"
? 124
: type === "BLOOD_PRESSURE_DIA"
? 80
: type === "PULSE"
? 70
: 78.5;
series[type] = Array.from({ length: 10 }, (_, i) => ({
type,
value: baseValue + (i % 3) - 1,
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
count: 1,
}));
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { series }, error: null }),
});
});
// The chart's per-type fetch reads `json.data.measurements` and
// expects a `meta.total`. Return a small dataset so the chart has
// points to plot — otherwise Recharts mounts a placeholder instead
// of the recharts-wrapper SVG and the assertion below times out.
// (Self-fetch fallback for any chart not covered by the batch above,
// e.g. a range-tab change that drops batched coverage.)
await page.route("**/api/measurements*", (route) => {
const url = new URL(route.request().url());
const type = url.searchParams.get("type") ?? "WEIGHT";
+8 -10
View File
@@ -24,9 +24,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("v1.4.28 — insights scroll restoration", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.fixme(
"returning to /insights from a sub-page lands at the top",
async ({ page }) => {
test.fixme("returning to /insights from a sub-page lands at the top", async ({
page,
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Force the mother page tall enough that scrolling has somewhere
// to go even on a sparse seed; the rendered Insights overview
@@ -42,10 +42,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
await page.waitForURL(/\/insights\//);
// Back to the mother page via the overview pill (pill index 0).
await page
.locator("[data-slot='insights-tab-strip-pill']")
.first()
.click();
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
await page.waitForURL(/\/insights\/?$/);
// The mother page's mount-effect deferred a `scrollTo(0)` to the
@@ -53,8 +50,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
await page.waitForTimeout(50);
const y = await page.evaluate(() => window.scrollY);
expect(y).toBeLessThan(50);
},
);
});
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
page,
@@ -63,7 +59,9 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
const nav = page.locator("[data-slot='insights-tab-strip']");
await expect(nav).toBeVisible();
const touchAction = await nav.evaluate(
(el) => (el as HTMLElement).style.touchAction || getComputedStyle(el).touchAction,
(el) =>
(el as HTMLElement).style.touchAction ||
getComputedStyle(el).touchAction,
);
expect(touchAction).toContain("pan-y");
});
+1 -4
View File
@@ -165,10 +165,7 @@ test.describe("medication wizard — compose-mode", () => {
expect(capture.postBody).toMatchObject({
name: "Insulin",
oneShot: false,
schedules: [
{ rrule: "FREQ=DAILY" },
{ rrule: "FREQ=WEEKLY;BYDAY=WE" },
],
schedules: [{ rrule: "FREQ=DAILY" }, { rrule: "FREQ=WEEKLY;BYDAY=WE" }],
});
await expectMedicationOnList(page, {
+3
View File
@@ -59,6 +59,9 @@ test.describe("medication wizard — daily", () => {
// Step 4 — course window (today by default).
await expectStep(page, 4);
// `DateField` keeps the ISO value on a hidden native date input
// (data-slot), while the visible overlay paints the locale-formatted
// string. Assert the committed ISO value on the hidden input.
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
+7 -3
View File
@@ -221,9 +221,13 @@ 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"]');
await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
timeout: 5_000,
});
await expect(root).toHaveAttribute(
"data-display-step",
String(displayIndex),
{
timeout: 5_000,
},
);
if (totalSteps != null) {
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
}
+9 -1
View File
@@ -62,7 +62,15 @@ test.describe("medication wizard — one-shot", () => {
// Step 4 — course window. Set startsOn to a deterministic date so
// the post body assertion below is stable.
await expectStep(page, 4);
await page.locator('[data-slot="course-window-starts"]').fill("2026-10-15");
// `DateField` rides a visible text overlay (the data-testid) that parses
// a typed ISO string back to the canonical value; the hidden native input
// (data-slot) carries the committed ISO. Type into the overlay, then blur
// so the parse commits.
await page.getByTestId("course-window-starts-field").fill("2026-10-15");
await page.getByTestId("course-window-starts-field").blur();
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue("2026-10-15");
await clickNext(page);
// Step 5 — pick Einmalig. The path compresses to 5 steps the
+3 -1
View File
@@ -159,7 +159,9 @@ test.describe("mobile-viewport smoke", () => {
// trimmed text. A role-based label is more stable across
// re-skins than the raw `innerText` that brittle selectors
// surfaced previously.
const ariaLabel = await btn.getAttribute("aria-label").catch(() => null);
const ariaLabel = await btn
.getAttribute("aria-label")
.catch(() => null);
const innerText = await btn.innerText().catch(() => "");
const label = ariaLabel || innerText || "(no text)";
failures.push(
+19 -12
View File
@@ -48,7 +48,16 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
)
.evaluateAll((els) =>
els.map((el) => {
const rect = el.getBoundingClientRect();
// DateField / DateTimeField front an sr-only native input with a
// formatted overlay; the 44px tap target is the wrapper, while the
// overlay sits inside the wrapper's border (≈42px). Measure the
// wrapper for any input inside one so the target-size reflects the
// real affordance, not the inner content box.
const wrapper = el.closest(
'[data-slot="date-field"],[data-slot="date-time-field"]',
);
const measured = wrapper ?? el;
const rect = measured.getBoundingClientRect();
const style = getComputedStyle(el);
return {
id: el.id || el.getAttribute("name") || "",
@@ -64,17 +73,15 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
}),
)
).filter(
// The avatar profile-photo upload uses the accessible
// visually-hidden <input type="file"> pattern: the input itself
// is sr-only / zero-size and the real 44 px touch target is the
// styled label/button that triggers it. The touch-target sweep
// must measure that visible affordance, not the hidden input, so
// exempt file inputs that are hidden or collapsed to zero size.
(inp) =>
!(
inp.type === "file" &&
(inp.hidden || inp.height === 0 || inp.width === 0)
),
// Some inputs use the accessible visually-hidden pattern: the input
// itself is sr-only / zero-size and the real 44 px touch target is a
// sibling visible affordance — the styled label/button for the avatar
// <input type="file">, and the formatted overlay that fronts the
// sr-only native <input type="date"|"datetime-local"> inside DateField /
// DateTimeField. The touch-target sweep must measure those visible
// affordances, not the hidden input, so exempt any input that is
// visually hidden or collapsed to zero size.
(inp) => !(inp.hidden || inp.height === 0 || inp.width === 0),
);
expect(formInputs.length).toBeGreaterThan(0);
+73
View File
@@ -8,7 +8,79 @@ import type { DataSummary } from "@/lib/analytics/trends";
import type {
DashboardSnapshot,
DashboardLayoutCatalogueEntry,
DashboardTargetBands,
} from "@/lib/dashboard/snapshot";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
buildTrafficLightBands,
buildTrafficRange,
buildWeightBandsFromHeight,
buildWeightRangeFromHeight,
getBodyFatTargetRange,
} from "@/lib/analytics/value-bands";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
// v1.18.6 — resolve the mock's band block from the same pure helpers the
// server builder uses, so the e2e fixture matches the real DTO shape.
function mockTargetBands(): DashboardTargetBands {
const dob = new Date("1990-01-01T00:00:00.000Z");
const bpTargets = getBpTargets(dob);
const pulseTarget = getPersonalizedPulseTarget(
getAgeFromDateOfBirth(dob),
"MALE",
);
const bodyFatRange = getBodyFatTargetRange("MALE");
return {
bpTargets,
bpSysRange: bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null,
bpDiaRange: bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null,
pulseDisplayRange: {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
},
pulseBands: [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((b) => b.max > b.min),
bodyFatRange,
bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
}),
weightRange: buildWeightRangeFromHeight(180),
weightBands: buildWeightBandsFromHeight(180, {
lowerBound: 30,
upperBound: 250,
}),
};
}
/**
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
@@ -138,6 +210,7 @@ export function buildMockSnapshot(
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),
tiles: {
summaries,
lastSeenByType,
+64 -64
View File
@@ -5,17 +5,17 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
* Contracts under test (v1.16.8):
* Contracts under test (v1.18.6, CCH-04):
*
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
* launcher on every authenticated page. An unread Coach-initiated
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
* Coach launcher on every authenticated page. The per-metric inline
* launch icon the sub-page header used to mount was retired in
* CCH-04 — the FAB is now the only entry. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
* unread the FAB renders without the dot. The inline launch the
* sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
* unread the FAB renders without the dot.
*
* 2. Clicking the inline launch opens the Coach drawer — as a
* 2. Clicking the FAB opens the Coach drawer in place — as a
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
*
@@ -27,16 +27,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* tray; it hands off to the full-page route `/coach`.
*
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
* empty-state branch (no data → CTA + Coach launch) and the populated
* branch (with data → full chart + Coach launch in the header).
* empty-state branch (no data → CTA) and the populated branch (with
* data → full chart). The FAB launcher is present on both.
*/
test.describe("Coach launch surfaces on insights sub-pages", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Populated analytics so each sub-page paints the data branch
// (`hasMetricData` true) — the inline Coach launch is mounted in
// the sub-page header by the shell.
// (`hasMetricData` true). The Coach FAB is the launcher on every
// authenticated page regardless of the data branch.
// Regex form matches the slim slice the dashboard split fires
// alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
@@ -126,51 +126,54 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
test(`/insights/${slug} mounts the dot-free Coach FAB on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
"mobile inline-launch branch",
"mobile FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// The header launch icon stays the contextual entry point.
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
// CCH-04 — the per-metric inline launch icon is retired; no header
// launcher renders on the sub-page.
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
// The FAB is a permanent launcher; with no unread nudge it
// The FAB is the permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
});
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop inline-launch branch",
"desktop FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
});
}
test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
test("clicking the Coach FAB opens the drawer as a bottom-sheet on Pixel 5", async ({
page,
}, testInfo) => {
test.skip(
@@ -178,26 +181,26 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer bottom-sheet branch is mobile-only",
);
// Hydration gate — the status query is fired by the sub-page
// boundary itself, so its response proves React has hydrated the
// page and attached the launch icon's click handler. Clicking the
// SSR-painted icon any earlier is silently lost (CI flake).
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached. Clicking the SSR-painted FAB any earlier is silently
// lost (CI flake).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
await fabHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
test("clicking the Coach FAB opens the drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -206,15 +209,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
);
// Hydration gate — see the bottom-sheet branch above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
await fabHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
@@ -252,9 +255,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await expect(fab).toHaveAttribute("data-unread", "true", {
timeout: 10_000,
});
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toBeVisible();
await expect(page.locator('[data-slot="coach-fab-unread"]')).toBeVisible();
await fab.click();
// v1.16.11 — the FAB opens the side drawer in place instead of
@@ -271,9 +272,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(0);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
@@ -281,23 +280,23 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
page,
}, testInfo) => {
// The in-panel history tray is gone from the drawer; the button
// navigates to `/coach` where the conversation list
// renders inline on lg+.
// navigates to `/coach?view=conversations`, where the full page opens
// the conversation list on arrival instead of a blank pane.
test.skip(
testInfo.project.name !== "chromium-desktop",
"exercises the desktop drawer layout",
);
// Hydration gate — see the bottom-sheet test above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
await fabHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
@@ -313,11 +312,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await expect(historyButton).toBeVisible({ timeout: 10_000 });
await historyButton.click();
// No in-panel tray opens — the click navigates to the full view.
await page.waitForURL("**/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
// No in-panel tray opens — the click navigates to the full view, which
// carries the `view=conversations` handoff so the list opens on arrival.
await page.waitForURL(/\/coach(\?|$)/, { timeout: 10_000 });
await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({
timeout: 10_000,
});
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
+1 -3
View File
@@ -148,9 +148,7 @@ test.describe("v1.4.27 — insights empty-state with metric gating", () => {
// title). Pin on the sub-page slot's h1 so we don't accidentally
// match a sidebar / nav element.
await expect(
page.locator(
'[data-slot="insights-subpage"] #insights-subpage-title',
),
page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
).toContainText(headingMatch);
});
}
+4 -4
View File
@@ -38,14 +38,14 @@ test.describe("v1.4.27 — public pages", () => {
// No bounce to the login page.
expect(page.url()).toMatch(/\/about(\?|$)/);
await expect(page.getByRole("heading", { name: /about/i }).first()).toBeVisible();
await expect(
page.getByRole("heading", { name: /about/i }).first(),
).toBeVisible();
// GeoLite2 attribution is the load-bearing reason this page exists.
await expect(page.getByText(/GeoLite2/)).toBeVisible();
await expect(page.getByText(/MaxMind/)).toBeVisible();
await expect(
page.getByText(/Attribution-ShareAlike 4\.0/i),
).toBeVisible();
await expect(page.getByText(/Attribution-ShareAlike 4\.0/i)).toBeVisible();
});
// v1.4.28 backlog: Next.js's `not-found.tsx` may return 200 or 404
+46 -6
View File
@@ -58,9 +58,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
waitUntil: "domcontentloaded",
});
const content = page.locator(
'[data-slot="responsive-sheet-content"]',
);
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible({ timeout: 10_000 });
await expect(content).toHaveAttribute("data-variant", "sheet");
@@ -85,10 +83,52 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
waitUntil: "domcontentloaded",
});
const content = page.locator(
'[data-slot="responsive-sheet-content"]',
);
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible({ timeout: 10_000 });
await expect(content).toHaveAttribute("data-variant", "dialog");
});
// v1.21.1 — the desktop Dialog branch must scroll only its body and
// keep the footer (the primary action) inside the viewport when the
// form is taller than the available height. A short viewport stands in
// for the real-world triggers — browser zoom, OS display scaling, long
// locale strings — that push a form past `max-h-[calc(100dvh-2rem)]`.
test("Desktop Chrome: footer stays in viewport when the form overflows", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop-only branch",
);
// Deliberately short so the Add-Measurement form exceeds the dialog
// height cap and forces the body to scroll.
await page.setViewportSize({ width: 1280, height: 480 });
await page.goto("/measurements?add=WEIGHT", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible({ timeout: 10_000 });
await expect(content).toHaveAttribute("data-variant", "dialog");
// The body owns the scroll — its content is taller than its box.
const body = page.locator('[data-slot="responsive-sheet-body"]');
await expect(body).toBeVisible();
const bodyScrolls = await body.evaluate(
(el) => el.scrollHeight > el.clientHeight + 1,
);
expect(bodyScrolls).toBe(true);
// The footer (primary action) is reachable without scrolling: its
// bottom edge sits within the viewport.
const footer = page.locator('[data-slot="responsive-sheet-footer"]');
await expect(footer).toBeVisible();
const box = await footer.boundingBox();
expect(box).not.toBeNull();
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1);
});
});
+10 -2
View File
@@ -43,8 +43,16 @@ const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component.
const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
// through the wrapper like every component. `src/lib/queries/` is the
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
// (every file is `"use client"`), so its `/api/` reads route through the
// wrapper too.
const ENFORCED_ROOTS = [
"src/components/",
"src/app/",
"src/hooks/",
"src/lib/queries/",
];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
+2 -2
View File
@@ -125,8 +125,8 @@ const queryKeyFactoryRule = {
node.key.type === "Identifier"
? node.key.name
: node.key.type === "Literal"
? node.key.value
: null;
? node.key.value
: null;
if (keyName !== "queryKey" && keyName !== "mutationKey") return;
context.report({
@@ -81,7 +81,7 @@ const safeFetchRequiredRule = {
schema: [],
messages: {
rawFetch:
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
'Raw fetch() bypasses the safeFetch wrapper\'s manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from "@/lib/safe-fetch" and call it instead.',
},
},
create(context) {
+4 -15
View File
@@ -63,21 +63,10 @@
"testcontainers",
"tw-animate-css"
],
"ignoreBinaries": [
"docker",
"docker-compose",
"pg_isready",
"tsx",
"cosign"
],
"ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
"src/components/ui/**": [
"exports",
"types"
],
"src/lib/validations/**": [
"types"
]
"src/components/ui/**": ["exports", "types"],
"src/lib/validations/**": ["types"]
}
}
}
+501 -328
View File
File diff suppressed because it is too large. Load diff
+506 -333
View File
File diff suppressed because it is too large. Load diff
+493 -320
View File
File diff suppressed because it is too large. Load diff
+494 -321
View File
File diff suppressed because it is too large. Load diff
+493 -320
View File
File diff suppressed because it is too large. Load diff
+494 -321
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -140,9 +140,7 @@ const nextConfig: NextConfig = {
return [
{
source: "/:path*",
headers: [
{ key: "Permissions-Policy", value: "unload=()" },
],
headers: [{ key: "Permissions-Policy", value: "unload=()" }],
},
{
source: "/((?!api|_next).*)",
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.18.4",
"version": "1.21.2.1",
"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",
@@ -71,14 +71,14 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next": "16.2.9",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"pg-boss": "^12.20.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"nodemailer": "^9.0.0",
"radix-ui": "^1.4.3",
"radix-ui": "^1.6.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "^7.78.0",
@@ -87,7 +87,8 @@
"sax": "^1.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"undici": "^8.4.1",
"tesseract.js": "^7.0.0",
"undici": "^8.5.0",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
@@ -99,7 +100,7 @@
"@next/bundle-analyzer": "^16.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
"@testcontainers/postgresql": "^12.0.3",
"@types/node": "^25.9.2",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
@@ -114,7 +115,7 @@
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"pg": "^8.22.0",
"prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
+11
View File
@@ -33,6 +33,17 @@ export default defineConfig({
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
// v1.18.6 — block the service worker in the test context. The
// production build registers `/sw.js`, whose v1.18.6 data branch
// serves allowlisted `/api/*` GET reads network-first. A worker-
// originated `fetch` is NOT subject to Playwright's `page.route`
// interception, so the SW would re-fetch the real (empty) backend
// and serve that — bypassing every per-spec `route.fulfill` mock and
// breaking read-after-write assertions (a just-created row never
// surfaced). Blocking the worker keeps the route mocks authoritative
// without weakening the shipped SW behaviour. Real users keep the
// offline data cache; only the test harness opts out.
serviceWorkers: "block",
// HealthLog ships dark mode as the default (Dracula theme) — `globals.css`
// sets `color-scheme: dark` on the root and the `<ThemeProvider>` defaults
// to "system". Playwright's stock context is `colorScheme: "light"`, which
+1008 -738
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
-- v1.18.6 — resumable module-tour progress.
--
-- A single nullable JSONB column on `users` carrying the fine-grained
-- resume point for the module-by-module guided tour. Follows the
-- established per-user JSON-column convention (`dashboard_widgets_json`,
-- `insights_layout_json`, `coach_prefs_json`).
--
-- Shape (validated by `src/lib/onboarding/tour-progress.ts`):
-- { "lastStopId": string | null,
-- "completedStopIds": string[],
-- "status": "in_progress" | "completed" | "skipped",
-- "updatedAt": string (ISO 8601) }
--
-- NULL = the user has not started the module tour. The coarse
-- `onboarding_tour_completed` boolean stays the launcher's auto-launch
-- gate; this column is purely the resume point and is cleared back to
-- NULL on a Settings replay so the tour genuinely restarts.
--
-- Additive + nullable: every existing account keeps NULL and the
-- application resolver treats NULL as "not started".
ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB;
@@ -0,0 +1,15 @@
-- v1.18.6 — diabetes opt-in for clinician glucose targets.
--
-- One additive, defaulted column on `users` so a user can declare "I have
-- diabetes / clinician glucose targets". When `true`, the glucose target
-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130,
-- postprandial < 180) instead of the general non-diabetic normal bands.
--
-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is
-- a user-declared preference that only selects which target band the user is
-- judged against. The default `false` backfills every existing row onto the
-- general bands (the current behaviour), so the migration is a no-op for
-- anyone who never opts in.
ALTER TABLE "users"
ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,18 @@
-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
--
-- Two nullable columns on `users` recording that the user acknowledged, once
-- at the start of onboarding, that HealthLog is a private tracking tool — not
-- a clinical assessment or diagnosis — and that they should talk to a doctor
-- if worried. The `*_version` pins exactly which copy was read (mirrors the
-- `research_mode_acknowledged_*` pair), so a future material wording change
-- can re-prompt without a schema change.
--
-- Both nullable with no default: NULL = never acknowledged. The prompt only
-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never
-- re-walled. This replaces the per-page / per-chart disclaimer banners that
-- were removed app-wide in the same release; the acknowledged legal text
-- stays reachable on the public privacy page.
ALTER TABLE "users"
ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3),
ADD COLUMN "disclaimer_acknowledged_version" TEXT;
@@ -0,0 +1,15 @@
-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot.
--
-- A single nullable timestamp on `users`. The proactive Coach nudge now
-- lands as an ASSISTANT message in the conversation rail (CCH-02); the
-- Coach FAB shows a discreet unread dot when the newest Coach message is
-- newer than this stamp, and clears it once the user opens the Coach
-- (the open writes the current time here via POST
-- /api/insights/coach/seen).
--
-- NULL backfills every existing row to "never opened" — harmless: the
-- dot only paints when an actual newer Coach message exists, so a user
-- with no proactive nudge yet sees nothing. No data migration needed.
ALTER TABLE "users"
ADD COLUMN "coach_last_seen_at" TIMESTAMP(3);
@@ -0,0 +1,24 @@
-- v1.18.6 (L1) — covering index for the Coach assistant-message scans.
--
-- Two hot reads walk a user's assistant messages newest-first:
-- * the global Coach FAB poll (`GET /api/insights/coach/nudge-status`) —
-- the newest assistant message across the caller's conversations, used
-- to decide whether the unread dot paints; and
-- * the proactive-nudge cron's frequency gate (CCH-02 / M1) — the newest
-- `provider_type = 'nudge'` assistant message inside the rolling window.
--
-- The pre-existing `(conversation_id, created_at)` index does not cover the
-- `role` / `provider_type` filter, so Postgres gathered every assistant row
-- in the user's threads and sorted. `coach_messages` grows unbounded, and the
-- FAB poll fires on every authenticated page with refetch-on-focus.
--
-- This index orders by `created_at DESC` after `(conversation_id, role)` so
-- the planner can do an index-backed per-conversation merge for the
-- newest-first reads. Additive; no data touched.
--
-- Reversibility (down):
-- DROP INDEX IF EXISTS "coach_messages_conversation_id_role_created_at_idx";
-- CreateIndex
CREATE INDEX IF NOT EXISTS "coach_messages_conversation_id_role_created_at_idx"
ON "coach_messages"("conversation_id", "role", "created_at" DESC);
@@ -0,0 +1,19 @@
-- v1.18.7 — expiry indexes for the two tables swept by the session reaper.
--
-- `sessions` (sliding-30-day session expiry) and `auth_challenges` (passkey
-- registration / authentication challenges) are both pruned on `expires_at`,
-- but neither carried an index on that column. On a busy multi-user instance
-- the cleanup degraded to a sequential scan. Every sibling expiry table
-- (`refresh_tokens`, `whoop_oauth_states`, `idempotency_keys`,
-- `withings_oauth_states`, …) already indexes its expiry column; this brings
-- the two stragglers in line. Additive; no data touched.
--
-- Reversibility (down):
-- DROP INDEX IF EXISTS "sessions_expires_at_idx";
-- DROP INDEX IF EXISTS "auth_challenges_expires_at_idx";
-- CreateIndex
CREATE INDEX IF NOT EXISTS "sessions_expires_at_idx" ON "sessions"("expires_at");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "auth_challenges_expires_at_idx" ON "auth_challenges"("expires_at");
@@ -0,0 +1,5 @@
-- v1.18.7 — track the daily-briefing phrasing re-roll date.
-- The content-hash gate keeps the briefing findings byte-stable on
-- unchanged data; this column lets the paragraph be re-rolled once per
-- calendar day (UTC) for phrasing variety without re-paying for findings.
ALTER TABLE "users" ADD COLUMN "insights_briefing_reroll_date" TEXT;
@@ -0,0 +1,7 @@
-- v1.18.7 — passphrase second factor for clinician share links.
-- A leaked share URL on its own no longer opens the record: every new
-- link carries a high-entropy passphrase, stored only as its HMAC-SHA256
-- hash (same key as token_hash). The column is NULLABLE so every link
-- minted before this change keeps the possession-only contract and the
-- public view renders for it without a gate.
ALTER TABLE "clinician_share_links" ADD COLUMN "passphrase_hash" TEXT;
@@ -0,0 +1,14 @@
-- v1.18.9 — qualitative lab results (positive/negative/borderline serology).
--
-- A LabResult was numeric-only (`value` NOT NULL). Qualitative findings
-- ("Hepatitis Bs-Antigen: negativ") have no number to store. This migration is
-- ADDITIVE and preserves every existing numeric row:
--
-- 1. `value` becomes nullable so a qualitative row can omit it. Existing rows
-- keep their numeric value untouched (DROP NOT NULL never rewrites data).
-- 2. `value_text` is added (nullable) to hold the qualitative result string.
--
-- A row is QUANTITATIVE when `value IS NOT NULL`, QUALITATIVE when
-- `value_text IS NOT NULL`. The application layer enforces the exclusive-or.
ALTER TABLE "lab_results" ALTER COLUMN "value" DROP NOT NULL;
ALTER TABLE "lab_results" ADD COLUMN "value_text" text;
@@ -0,0 +1,14 @@
-- v1.18.9 — per-message Coach token usage + model.
--
-- The streaming chat endpoint already computes the per-turn token count
-- (`result.tokensUsed`) and the model that produced the reply, but until
-- now neither reached the client. Two additive nullable columns let the
-- quiet per-message token footer survive a conversation reload: the live
-- turn paints from the `done.usage` SSE frame, persisted history paints
-- from these columns.
--
-- Both NULL by default. No backfill: existing rows (and every user turn
-- or refusal, which never carries a token count) simply render no footer.
ALTER TABLE "coach_messages"
ADD COLUMN "tokens_used" INTEGER,
ADD COLUMN "model" TEXT;
@@ -0,0 +1,40 @@
-- v1.18.10 P-5 — covering partial index for soft-delete-filtered reads.
--
-- The hot reads on `measurements` all filter `deleted_at IS NULL` and order
-- `measured_at DESC` per type — the DISTINCT-ON-latest pass, the management
-- list, the series reads, and the per-type count. The base
-- `(user_id, type, measured_at)` index is ASC and does NOT carry
-- `deleted_at`, so those reads pay a heap re-check of `deleted_at` over the
-- type partition for every candidate row.
--
-- Two partial indexes already cover the highest-volume types
-- (`measurements_dense_intraday_retention_idx` for HRV/PULSE, migration 0108;
-- `measurements_legacy_step_consolidation_idx` for ACTIVITY_STEPS, migration
-- 0087). This index closes the gap for every OTHER type: a covering partial
-- index keyed `(user_id, type, measured_at DESC)` over live rows only serves
-- the latest / list / series reads without the heap re-check, and — being
-- partial on `deleted_at IS NULL` — it carries no entries for tombstoned rows.
--
-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column add,
-- no backfill, no rewrite of `measurements`.
--
-- Prisma can't express a partial-predicate index, so it lives here in raw
-- migration SQL and is intentionally omitted from `schema.prisma` (the same
-- treatment migrations 0087 + 0108 use; a comment on the model documents it).
--
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
--
-- Lock note (conscious trade-off): a plain, non-`CONCURRENTLY` build, so it
-- runs inside Prisma's migration transaction and holds an ACCESS EXCLUSIVE
-- lock on `measurements` for the build. On the largest multi-year HealthKit
-- tenants the live row set is large, so deploy at a low-traffic window. A
-- `CONCURRENTLY` build cannot run inside the migration transaction and is not
-- used here to keep the migration atomic with the rest of the release.
--
-- Reversibility:
-- DROP INDEX IF EXISTS "measurements_live_covering_idx";
-- A roll-back loses only the scan acceleration; no data is touched.
CREATE INDEX IF NOT EXISTS "measurements_live_covering_idx"
ON "measurements" ("user_id", "type", "measured_at" DESC)
WHERE "deleted_at" IS NULL;
@@ -0,0 +1,16 @@
-- v1.18.10 — opt-in local (in-browser) OCR for lab-report scans.
--
-- One additive, defaulted column on `users`. When `true`, a user whose AI
-- provider cannot read images (ChatGPT-OAuth/Codex, a text-only model) can
-- still scan a paper report: the image is OCR'd in the browser via tesseract.js
-- and only the extracted TEXT is forwarded to the text-only provider for
-- structuring. The raw image never leaves the device in this mode.
--
-- Less accurate than a native vision model — the mandatory per-row review
-- screen stays the safety backstop, and the in-app copy sets that expectation.
-- The default `false` backfills every existing row onto the current behaviour
-- (vision-only scan, or no scan affordance), so this is a no-op for anyone who
-- never opts in.
ALTER TABLE "users"
ADD COLUMN "labs_local_ocr_enabled" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,70 @@
-- v1.19.0 — full Withings ECG waveform capture.
--
-- v1.18.11 captured only the device's atrial-fibrillation *verdict* per
-- on-device ECG (one IRREGULAR_RHYTHM_NOTIFICATION measurement EVENT row).
-- This adds the raw ECG **waveform** so a real trace can later be rendered.
--
-- One additive table, `ecg_recordings`:
--
-- * `waveform_encrypted` (BYTEA) holds the AES-256-GCM ciphertext of the
-- JSON-encoded micro-volt sample array (Withings Heart `get` `body.signal`)
-- — the `*Encrypted` Bytes convention (the coach_messages.encrypted_content
-- precedent). The waveform NEVER lands as plaintext (fail-closed crypto).
-- * `sampling_frequency` (Hz), `sample_count`, `duration_seconds`, `lead`,
-- and `average_heart_rate` stay plaintext descriptors so a renderer can
-- read them without a per-row decrypt.
-- * `rhythm_classification` snapshots the paired EVENT row's AFib verdict;
-- `measurement_id` FKs back to that row (ON DELETE SET NULL — the trace
-- survives a re-keyed/removed EVENT row).
--
-- Idempotency: a re-sync of the same recording upserts in place via the
-- unique `(user_id, source, external_recording_id)` (Withings `signalid`,
-- or a `ts-<unix>` fallback).
--
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
-- `DO $$`) so reruns are safe on prod.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "ecg_recordings";
-- The waveform store is self-contained — no other domain depends on it.
-- CreateTable
CREATE TABLE IF NOT EXISTS "ecg_recordings" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"source" "measurement_source" NOT NULL DEFAULT 'WITHINGS',
"external_recording_id" TEXT NOT NULL,
"recorded_at" TIMESTAMP(3) NOT NULL,
"waveform_encrypted" BYTEA NOT NULL,
"sampling_frequency" INTEGER NOT NULL,
"sample_count" INTEGER NOT NULL,
"duration_seconds" DOUBLE PRECISION,
"lead" TEXT,
"average_heart_rate" INTEGER,
"rhythm_classification" "rhythm_classification",
"measurement_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ecg_recordings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "ecg_recordings_user_id_source_external_recording_id_key" ON "ecg_recordings"("user_id", "source", "external_recording_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "ecg_recordings_user_id_recorded_at_idx" ON "ecg_recordings"("user_id", "recorded_at" DESC);
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_measurement_id_fkey" FOREIGN KEY ("measurement_id") REFERENCES "measurements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,25 @@
-- v1.19.0 — Oura resilience capture (additive).
--
-- One new `measurement_type` enum value, `RESILIENCE`, so the Oura sync can
-- store the daily resilience level from the `daily_resilience` collection.
--
-- Oura exposes resilience as a categorical band — limited / adequate / solid /
-- strong / exceptional — describing how well the body copes with cumulative
-- load. Rather than introduce a new categorical column + CHECK constraint, the
-- level is ORDINAL-ENCODED into the existing numeric `value`:
--
-- limited = 1, adequate = 2, solid = 3, strong = 4, exceptional = 5
--
-- The 5-level → ordinal mapping is the single source of truth in
-- `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Unit is the level scale
-- (`level`). Stored as `source = OURA`. An unknown / missing level string mints
-- no row (it is skipped, never coerced to 0).
--
-- Purely-additive: one enum extension, no backfill, no existing row touched, no
-- new table or column. Idempotent guard (`IF NOT EXISTS`) so reruns are safe on
-- prod.
--
-- Reversibility (down): Postgres cannot drop a single enum value in place; the
-- value is inert until a row references it, so a down migration is a no-op.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESILIENCE';
@@ -0,0 +1,24 @@
-- v1.19.0 — force-reply correlation for the interactive mood reminder.
-- Maps a bot prompt message to the entry a free-text reply attaches to.
--
-- Idempotent guards (`IF NOT EXISTS`) so a rerun is safe on prod. The
-- semantics are unchanged from the original cut (same table, same columns,
-- same indexes) — v1.19.2 added only the guards.
CREATE TABLE IF NOT EXISTS "telegram_prompt_contexts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"chat_id" TEXT NOT NULL,
"prompt_msg_id" INTEGER NOT NULL,
"kind" TEXT NOT NULL,
"ref_id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "telegram_prompt_contexts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at");
@@ -0,0 +1,33 @@
-- v1.19.2 (iOS #34 extension) — per-bucket heart-rate spread.
--
-- v1.19.0 shipped the hourly heart-rate bucket
-- (`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) carrying the
-- hour's AVERAGE bpm as one PULSE row. This adds the hour's MIN / MAX bpm
-- as two companion columns so a client can render the intra-hour range
-- (low/high band around the mean) without iOS re-uploading raw samples.
--
-- Two additive, nullable columns on `measurements`:
--
-- * `value_min` — the bucket's minimum bpm.
-- * `value_max` — the bucket's maximum bpm.
--
-- Set ONLY on the hourly heart-rate bucket rows; NULL for every other row
-- (per-sample readings, per-day cumulative `stats:` totals, manual
-- entries). The series reader hands them through only for the heart-rate
-- kind, and the batch ingest persists / overwrites them alongside `value`.
--
-- Additive; no existing row touched. The columns default to NULL so every
-- legacy row and every non-HR-bucket write stays unchanged. Idempotent
-- guards (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod.
--
-- Reversibility (down):
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_min";
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_max";
-- The columns are read-additive — dropping them reverts to the avg-only
-- v1.19.0 contract.
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "value_min" DOUBLE PRECISION;
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "value_max" DOUBLE PRECISION;
@@ -0,0 +1,55 @@
-- v1.19.2 — Telegram: one chat ⇄ one account, plus a TELEGRAM measurement
-- source for numeric-reply capture.
--
-- 1. `measurement_source` += `TELEGRAM`. A numeric reply to a measurement
-- reminder is captured as a Measurement that enters through the
-- chat-bound webhook (not the cookie/Bearer client write path), so it
-- carries its own source label. 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, so it is safe to extend the
-- enum in the same step (Postgres only forbids USING a freshly-added
-- value in the same transaction). Mirrors the 0160 source-extension
-- precedent.
--
-- 2. `users.telegram_chat_id` UNIQUE. The inbound webhook resolves the user
-- from `telegram_chat_id` (`findTelegramUser`); a chat id shared across
-- two accounts would route a reply / button tap / numeric capture
-- ambiguously. The index is over a nullable column, so Postgres' default
-- NULL-distinct semantics keep an unlinked account (NULL chat id) from
-- ever colliding — only two accounts both binding the SAME non-NULL chat
-- id are rejected, which is the bug we are closing.
--
-- Defensive pre-clean: should any duplicate non-NULL chat ids already
-- exist (a chat re-pointed between accounts before this constraint
-- landed), keep the most recently updated row's binding and NULL out the
-- stale ones so the unique index can be built without error. The cleared
-- accounts simply fall back to "Telegram not linked" and re-link from
-- Settings; no data beyond the binding is touched.
--
-- Idempotent guards (`IF NOT EXISTS`) make a rerun safe. Forward-only.
--
-- Reversibility: Postgres cannot remove an enum value, so `TELEGRAM` stays
-- (inert with no rows). The unique index drops with
-- `DROP INDEX IF EXISTS "users_telegram_chat_id_key"`.
-- ── 1. measurement_source — append the user-driven webhook source ──────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'TELEGRAM';
-- ── 2. users.telegram_chat_id — one chat binds one account ─────────────
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY telegram_chat_id
ORDER BY updated_at DESC, id DESC
) AS rn
FROM "users"
WHERE telegram_chat_id IS NOT NULL
)
UPDATE "users" u
SET telegram_chat_id = NULL
FROM ranked
WHERE u.id = ranked.id AND ranked.rn > 1;
CREATE UNIQUE INDEX IF NOT EXISTS "users_telegram_chat_id_key"
ON "users" ("telegram_chat_id");
@@ -0,0 +1,66 @@
-- v1.20.0 F6 — rollup-tier completion: per-bucket regression accumulators.
--
-- Up to v1.19.x the windowed slope / r² / sd (slope7/30/90, r²_7/30/90,
-- the 90-day population stddev) still ran as a live `$queryRaw` over the
-- raw `measurements` partition on EVERY warm-rollup request, because the
-- per-bucket `mean` cannot reconstruct an OLS regression over the raw
-- points in a window. The per-bucket `sd / slope / r2` columns are a
-- within-bucket fold and are NOT linearly composable across DAY buckets.
--
-- This adds the four regression-accumulator sums Postgres' REGR_* /
-- STDDEV_POP folds expose, computed per (user, type, granularity,
-- bucketStart, source) over the same canonical-source rows the live
-- query reads. With `n = count` and `Σy = mean·count` already stored,
-- these four close the set: all six terms are ADDITIVE across buckets,
-- so a 7/30/90-day slope / r² / sd composes from the summed accumulators
-- with a bit-identical result to the live REGR_SLOPE / REGR_R2 /
-- STDDEV_POP over the raw rows (Postgres uses the same closed form):
--
-- slope = (n·Σxy − Σx·Σy) / (n·Σxx − Σx²)
-- r² = (n·Σxy − Σx·Σy)² / ((n·Σxx − Σx²)(n·Σyy − Σy²))
-- sd_pop = sqrt(Σyy/n − (Σy/n)²)
--
-- The x-axis is EPOCH DAYS (`EXTRACT(EPOCH FROM measured_at) / 86400.0`),
-- matching the live REGR_* calls in `measurement-rollups.ts`,
-- `summaries-slice.ts`, and `comprehensive-aggregator.ts`. The raw
-- (un-rebased) epoch-day sums are stored so the cross-bucket sum
-- reconstructs the same regression the live query runs.
--
-- Four additive, nullable `DOUBLE PRECISION` columns on
-- `measurement_rollups`:
--
-- * `sum_x` — Σ epoch_days
-- * `sum_xy` — Σ (epoch_days · value)
-- * `sum_xx` — Σ epoch_days²
-- * `sum_yy` — Σ value²
--
-- Nullable keeps the migration additive: existing rows read NULL until
-- the boot-time `rollup-full-backfill` re-fold refills them (mirrors the
-- v1.4.39 `sum_value` rollout). New writes self-populate because the
-- accumulators ride the same aggregate that already recomputes on every
-- write hook. The legacy per-bucket `sd / slope / r2` columns are left
-- in place (harmless) to keep this migration purely additive.
--
-- Additive; no existing row touched. Idempotent guards
-- (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod.
--
-- Reversibility (down):
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_x";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_xy";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_xx";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_yy";
-- Dropping them reverts the readers to the live REGR_* path on a
-- coverage miss; the read-swap falls back automatically when the
-- accumulators are NULL.
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_x" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_xy" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_xx" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_yy" DOUBLE PRECISION;
@@ -0,0 +1,8 @@
-- v1.20.0 — classic Fitbit Web API (api.fitbit.com) authorises with PKCE
-- (Authorization Code + S256). Carry the per-handshake `code_verifier` from the
-- connect route to the callback on the short-lived OAuth-state ledger row.
--
-- Additive + nullable: a legacy in-flight row (or any non-PKCE path) simply
-- carries no verifier, so this is reversible and rerun-safe with no data loss.
-- `IF NOT EXISTS` guards a prod rerun.
ALTER TABLE "fitbit_oauth_states" ADD COLUMN IF NOT EXISTS "code_verifier" TEXT;
@@ -0,0 +1,24 @@
-- v1.21.0 — per-user date display preference.
--
-- The UI locale alone decided whether dates rendered as dd.MM.yyyy or
-- MM/dd/yyyy, and the native date input fell back to the browser locale,
-- which left non-US self-hosters on MM/DD/YYYY with no way out. The new
-- column carries an explicit preference: AUTO follows the locale
-- convention, DMY pins day-month-year, MDY pins month-day-year, YMD pins
-- ISO yyyy-MM-dd. Display-time only — stored instants stay UTC.
--
-- Additive + non-destructive: a new NOT NULL column with a default, no
-- backfill needed. Existing rows read AUTO. Guarded so a re-run is a
-- no-op (the enum create + the column add both skip when present).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'date_format_preference'
) THEN
CREATE TYPE "date_format_preference" AS ENUM ('AUTO', 'DMY', 'MDY', 'YMD');
END IF;
END
$$;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "date_format" "date_format_preference" NOT NULL DEFAULT 'AUTO';
@@ -0,0 +1,148 @@
-- v1.21.0 — rebase the regression accumulators to a fixed recent x-origin.
--
-- Migration 0190 stored the OLS accumulators `sum_x / sum_xy / sum_xx` over a
-- RAW epoch-day x-axis (`EXTRACT(EPOCH FROM measured_at) / 86400.0`). Raw
-- epoch-days sit at x ≈ 20 540, so `x²` lands near 4.2e8 and the per-bucket
-- `SUM(x²)` accumulates past ~1e10. A double carries ~15-16 significant decimal
-- digits; a value ~1e10 has ~10 integer digits, leaving only ~5-6 fractional
-- digits — so the SUB-DAY x detail (the time-of-day fraction) is already lost
-- when `sum_xx` is squared and summed, BEFORE the value is ever stored. No
-- read-side identity (mean-centering included) can recover bits lost at
-- accumulation time. That precision floor is why the windowed slope / r² on a
-- near-flat, ill-conditioned window drifted from the live REGR_* probe past the
-- 1e-9 parity gauge (the rollup-regression-parity DST case).
--
-- The fix is a WINDOW-LOCAL X-RESCALE: accumulate x RELATIVE to a fixed origin
-- so the squared terms stay small and exact. The origin is 2020-01-01
-- (epoch-day 18262); rebased x = epoch_days − 18262 stays in the low thousands
-- for any realistic reading, so `x²` ≤ ~1e7 and `sum_xx` never sheds precision.
--
-- sum_x' = Σ (epoch_days − 18262)
-- sum_xy' = Σ (epoch_days − 18262) · value
-- sum_xx' = Σ (epoch_days − 18262)²
--
-- Slope / r² / population-sd are INVARIANT under an affine x-shift (Sxx, Sxy,
-- Syy are unchanged when a constant is subtracted from every x), so the
-- cross-bucket compose yields the SAME regression as before — only the
-- unreported intercept would move. The live REGR_* probe stays on raw
-- epoch-days and still parity-matches because it, too, is shift-invariant.
-- `sum_yy / sum_value / count / mean / sd / slope / r2` are y-only or
-- shift-invariant and are NOT touched.
--
-- This recomputes the three rebased accumulators DIRECTLY FROM `measurements`,
-- grouped exactly as the writer groups (per user / type / granularity-bucket /
-- source over non-deleted rows). Recompute-from-source makes the migration
-- naturally IDEMPOTENT and self-correcting: a rerun re-derives the same rebased
-- sums regardless of the column's prior state (raw 0190 basis, partially
-- rebased, or already rebased). It only writes rows that already carry the 0190
-- accumulators (`sum_xy IS NOT NULL`), so NULL pre-migration rows stay NULL for
-- the boot re-fold to fill on the new basis. Set-based, one statement per
-- granularity; the `(user_id, type, measured_at)` index bounds the scan.
--
-- Reversibility (down): re-fold the accumulators on the raw epoch-day basis,
-- i.e. drop the `− 18262` from each SUM. There is no column shape change to
-- revert; the legacy basis is recoverable by re-running 0190's populator.
-- DAY buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('day', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('day', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'DAY'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- WEEK buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('week', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('week', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'WEEK'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- MONTH buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('month', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('month', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'MONTH'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- YEAR buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('year', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('year', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'YEAR'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
+284 -6
View File
@@ -71,6 +71,13 @@ model User {
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
// v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
// The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
// but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
// higher, seedless temperature so the prose reads fresh day-over-day
// 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")
// 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
@@ -87,7 +94,12 @@ model User {
// Telegram notifications (per-user)
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
telegramChatId String? @map("telegram_chat_id")
// v1.19.2 — one Telegram chat binds to exactly one HealthLog account.
// The inbound webhook resolves the user from this chat id; a chat shared
// across two accounts would route a reply / button tap ambiguously. The
// unique is partial by Postgres NULL semantics (multiple NULLs allowed),
// so an unlinked account (NULL chat id) never collides. Migration 0189.
telegramChatId String? @unique @map("telegram_chat_id")
telegramEnabled Boolean @default(false) @map("telegram_enabled")
// Withings OAuth credentials (per-user, encrypted)
@@ -193,6 +205,21 @@ model User {
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
// When `true`, the glucose target resolver applies the tighter ADA glycemic
// GOAL bands (fasting 80–130, postprandial < 180) instead of the general
// non-diabetic normal bands. NEVER auto-inferred from values, NEVER a
// diagnosis — purely a user-declared preference that selects which target
// band the user is judged against. Default `false`.
hasDiabetes Boolean @default(false) @map("has_diabetes")
// v1.18.10 — opt-in local (in-browser) OCR for lab-report scans. When `true`,
// a user whose AI provider cannot read images (e.g. ChatGPT-OAuth/Codex, a
// text-only model) can still scan a report: the image is OCR'd in the browser
// via tesseract.js and only the extracted TEXT is sent to the text-only
// provider for structuring. Less accurate than native vision — the mandatory
// review screen remains the safety backstop. Default `false`; vision-capable
// users keep the higher-accuracy native path and never pay the WASM download.
labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
@@ -201,6 +228,11 @@ model User {
// (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
// Display-time only — canonical storage stays UTC instants.
timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
// Date display preference. AUTO follows the active locale convention
// (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year,
// MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only —
// canonical storage stays UTC instants.
dateFormat DateFormatPreference @default(AUTO) @map("date_format")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -256,6 +288,33 @@ model User {
// Settings → Account.
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
// v1.18.6 — resumable module-tour progress. The coarse boolean above
// stays as the "did the user ever finish/dismiss" flag the launcher
// gates on; this column carries the fine-grained resume point so a
// mid-tour reload reopens at the right module. Follows the per-user
// JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson`
// / `coachPrefsJson`). Shape (validated by
// `src/lib/onboarding/tour-progress.ts`):
// { lastStopId: string | null,
// completedStopIds: string[],
// status: "in_progress" | "completed" | "skipped",
// updatedAt: string (ISO) }
// Null = the user has not started the module tour (fresh account or a
// legacy account that only carries the boolean flag). Cleared back to
// null on a Settings replay so the tour genuinely restarts.
onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
// once at the start of onboarding: HealthLog is a private tracking tool,
// not a clinical assessment or diagnosis; if worried, talk to a doctor.
// Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
// which copy the user read, so a future material wording change can ask
// again. Null = never acknowledged (legacy accounts read as never-asked;
// the prompt only gates fresh onboarding, so they are not re-walled). The
// acknowledged legal text stays reachable on the public privacy page.
disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
@@ -430,6 +489,18 @@ model User {
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
modulePreferencesJson Json? @map("module_preferences_json")
// v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
// on the Coach FAB. The proactive nudge now lands as an ASSISTANT
// message in the conversation rail (CCH-02); the FAB shows a discreet
// unread dot when the newest Coach message is newer than this stamp,
// and clears it when the user opens the Coach (drawer or page, which
// writes the current time here via `POST /api/insights/coach/seen`).
// Null = never opened the Coach, so any existing message reads as
// unread once. Server-authoritative so the signal is consistent across
// web + iOS; the FAB keeps a local mirror only as an instant-paint
// optimisation.
coachLastSeenAt DateTime? @map("coach_last_seen_at")
// v1.5.5 — self-hosted avatar storage.
//
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
@@ -596,6 +667,10 @@ model User {
// per-day timeline mirroring the CycleDayLog shape.
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
// 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.
ecgRecordings EcgRecording[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -675,6 +750,9 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
// this index the cleanup degrades to a sequential scan on a busy instance.
@@index([expiresAt])
@@map("sessions")
}
@@ -689,6 +767,9 @@ model AuthChallenge {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// v1.18.7 — challenges are swept by expiry; index the column the reaper
// filters on so the cleanup stays index-backed.
@@index([expiresAt])
@@map("auth_challenges")
}
@@ -834,6 +915,17 @@ enum MeasurementType {
// absolute-reading buckets. Plausible band is small and centred on 0.
BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`.
// ── v1.19.0 — Oura resilience (additive) ──
// Oura's daily resilience level from the `daily_resilience` collection — a
// categorical band describing how well the body is coping with cumulative
// load: limited / adequate / solid / strong / exceptional. Stored ORDINAL-
// ENCODED into the numeric `value` (limited=1, adequate=2, solid=3, strong=4,
// exceptional=5) so it fits the existing numeric Measurement model with no new
// categorical column or CHECK constraint. Unit is the level scale (`level`).
// The 5-level → ordinal mapping is the single source of truth in
// `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`.
RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5).
@@map("measurement_type")
}
@@ -886,6 +978,17 @@ enum TimeFormatPreference {
@@map("time_format_preference")
}
// Date display preference for rendered dates. AUTO defers to the active
// locale's convention; DMY/MDY/YMD pin the field order regardless of locale.
enum DateFormatPreference {
AUTO
DMY
MDY
YMD
@@map("date_format_preference")
}
enum MeasurementSource {
MANUAL
WITHINGS
@@ -920,6 +1023,14 @@ 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.
OURA
// v1.19.2 — measurements captured from a numeric reply to a Telegram
// measurement reminder. Unlike the server-owned integrations above, this
// is a user-driven write, but it enters through the webhook (chat-bound)
// rather than the cookie/Bearer client write path, so it carries its own
// source label. The client batch + single-POST surfaces still reject it
// (server-resolved only); iOS DTO mirrors this spelling so the
// /api/sync/changes delta feed decodes the rows.
TELEGRAM
@@map("measurement_source")
}
@@ -944,6 +1055,16 @@ model Measurement {
userId String @map("user_id")
type MeasurementType
value Float
// v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row.
// Set ONLY on the hourly heart-rate bucket
// (`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`), where `value`
// is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a
// client can render the intra-hour range without re-uploading raw
// samples. NULL for every other row (per-sample readings, per-day
// cumulative `stats:` totals, manual entries) — the series reader hands
// them through only for the heart-rate kind.
valueMin Float? @map("value_min")
valueMax Float? @map("value_max")
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
@@ -1003,6 +1124,10 @@ model Measurement {
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
// paired with this EVENT row. SET NULL on the FK side keeps the trace if
// the EVENT row is re-keyed / removed.
ecgRecordings EcgRecording[]
// Original measuredAt-based dedup — kept for legacy Withings ingest
// and the manual UI's "no duplicate at the same wall-clock" guard.
@@ -1054,9 +1179,84 @@ model Measurement {
// — see migration 0108. It accelerates the v1.10.0 dense intra-day
// retention drain's discovery + per-user scan. Same Prisma-can't-
// express-a-partial-predicate reason it is omitted here.
// `measurements_live_covering_idx` is a covering partial index over
// `(user_id, type, measured_at DESC) WHERE deleted_at IS NULL` — see
// migration 0183 (v1.18.10 P-5). It serves the DISTINCT-ON-latest, list,
// 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.
@@map("measurements")
}
// ─── ECG waveform recordings (v1.19.0) ─────────────────────
//
// v1.18.11 captured only the device's AFib *verdict* for each on-device
// ECG (one IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row). This model
// stores the full raw ECG **waveform** so a real trace can later be rendered.
//
// The waveform is sensitive health data, so the sample array is held
// AES-256-GCM at rest in `waveformEncrypted` (Bytes) — the same Bytes-codec
// precedent as `CoachMessage.encryptedContent`. The plaintext is the JSON
// encoding of the integer micro-volt sample array returned by the Withings
// Heart `get` endpoint (`body.signal`); decrypt → `JSON.parse` → number[].
// It NEVER lands as plaintext (fail-closed crypto in `src/lib/crypto.ts`).
//
// The non-sensitive descriptors stay plaintext so a future renderer can read
// `samplingFrequency` / `sampleCount` / `durationSeconds` without a per-row
// decrypt: `samplingFrequency` (Hz) pairs with the sample index to recover
// each sample's time offset; `sampleCount` is the array length; `durationSeconds`
// is `sampleCount / samplingFrequency`; `lead` is the recording lead/pathway
// when the source reports it; `averageHeartRate` mirrors the source's reported
// average BPM for the strip when present.
//
// `rhythmClassification` snapshots the same AFib verdict captured on the paired
// Measurement EVENT row so the waveform self-describes without a join, and
// `measurementId` links back to that row (SET NULL — the trace survives a
// re-keyed/removed EVENT row).
//
// Idempotency: `externalRecordingId` is the source's stable per-recording id
// (Withings `signalid`, or a `ts-<unix>` fallback when absent). The unique
// `(userId, source, externalRecordingId)` lets a re-sync overwrite the same
// recording in place rather than inserting a duplicate.
//
// RENDERING UI IS OUT OF SCOPE FOR v1.19.0 — this is ingest-only. A trace
// view (and any analytics over the waveform) is a follow-up.
model EcgRecording {
id String @id @default(cuid())
userId String @map("user_id")
source MeasurementSource @default(WITHINGS)
/// Source's stable per-recording id (Withings `signalid`, or `ts-<unix>`).
externalRecordingId String @map("external_recording_id")
/// When the strip was recorded on-device (source timestamp).
recordedAt DateTime @map("recorded_at")
/// AES-256-GCM ciphertext of the JSON-encoded micro-volt sample array.
waveformEncrypted Bytes @map("waveform_encrypted")
/// Sampling rate of the signal in Hz (sample index → time offset).
samplingFrequency Int @map("sampling_frequency")
/// Number of samples in the (decrypted) waveform array.
sampleCount Int @map("sample_count")
/// `sampleCount / samplingFrequency` in seconds; NULL when frequency is 0.
durationSeconds Float? @map("duration_seconds")
/// Recording lead / pathway label when the source reports it (else NULL).
lead String?
/// Source-reported average heart rate (BPM) for the strip, when present.
averageHeartRate Int? @map("average_heart_rate")
/// Snapshot of the device AFib verdict for the paired EVENT row.
rhythmClassification RhythmClassification? @map("rhythm_classification")
/// FK to the IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row, when known.
measurementId String? @map("measurement_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
measurement Measurement? @relation(fields: [measurementId], references: [id], onDelete: SetNull)
// Re-sync overwrites in place rather than inserting a duplicate.
@@unique([userId, source, externalRecordingId])
@@index([userId, recordedAt(sort: Desc)])
@@map("ecg_recordings")
}
// ─── Measurement rollups (v1.5.0) ──────────────────────────
//
// Persistent rollup table — second cache tier behind the 60-s LRU.
@@ -1108,6 +1308,23 @@ model MeasurementRollup {
/// to keep the migration additive — the boot backfill fills NULLs
/// for existing rows; new rows always carry the sum.
sumValue Float? @map("sum_value")
/// v1.20.0 F6 — per-bucket OLS regression accumulators over the same
/// canonical-source raw rows the live REGR_* query reads. With
/// `n = count` and `Σy = mean·count` these close the additive set so a
/// windowed slope / r² / sd composes from summed accumulators across DAY
/// buckets with a bit-identical result to live REGR_SLOPE / REGR_R2 /
/// STDDEV_POP. The x-axis is epoch days REBASED to 2020-01-01
/// (`EXTRACT(EPOCH FROM measured_at) / 86400.0 − 18262`; v1.21.0 migration
/// 0193) so `sum_xx` stays O(1e7) instead of O(1e10) and never sheds
/// precision squaring a ~20 540 raw epoch-day. Slope / r² / sd are
/// invariant under the x-shift, so the live (raw-epoch) REGR_* probe still
/// parity-matches the cross-bucket compose. Nullable to keep migration 0190
/// additive — the boot backfill fills NULLs for pre-migration rows; new
/// rows always carry them (they ride the same aggregate as mean / sum).
sumX Float? @map("sum_x")
sumXy Float? @map("sum_xy")
sumXx Float? @map("sum_xx")
sumYy Float? @map("sum_yy")
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2112,6 +2329,33 @@ model TelegramScheduledDeletion {
@@map("telegram_scheduled_deletions")
}
// v1.19.0 — force-reply correlation for the interactive mood reminder.
//
// When a user taps "Notiz…" on a mood prompt the bot sends a force_reply
// message; the user's free-text reply then arrives as a normal `message`
// whose `reply_to_message.message_id` points back at that prompt. This
// row maps `(chatId, promptMessageId)` to the mood entry the note should
// attach to, so the inbound note handler can resolve "what was this a
// reply to" without trusting any client-supplied id. Rows are short-lived
// (`expiresAt`) and best-effort deleted once consumed; the deletion sweep
// is keyed on `expiresAt` so an unanswered note prompt self-cleans.
model TelegramPromptContext {
id String @id @default(cuid())
userId String @map("user_id")
chatId String @map("chat_id")
// The bot's prompt message the user is expected to reply to.
promptMsgId Int @map("prompt_msg_id")
// What the reply attaches to. "mood_note" carries `refId = moodEntryId`.
kind String
refId String @map("ref_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([chatId, promptMsgId])
@@index([expiresAt])
@@map("telegram_prompt_contexts")
}
// ─── API Tokens (for external ingest) ──────────────────────
model ApiToken {
@@ -2433,11 +2677,20 @@ model FitbitConnection {
/// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth
/// state check, identical in shape + lifecycle to `WhoopOAuthState`.
///
/// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE
/// (Authorization Code + S256), so the connect route mints a `code_verifier`
/// alongside the nonce and stashes it here; the callback reads it back to present
/// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path)
/// simply carries no verifier.
model FitbitOAuthState {
nonce String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
nonce String @id
userId String @map("user_id")
/// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no
/// PKCE handshake is in flight (legacy rows).
codeVerifier String? @map("code_verifier")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -3332,7 +3585,14 @@ model LabResult {
panel String?
/// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
analyte String
value Float
/// Numeric reading. NULL for a qualitative result (see `valueText`). Exactly
/// one of `value` / `valueText` is set per row: `value IS NOT NULL` marks a
/// quantitative reading, `valueText IS NOT NULL` a qualitative one.
value Float?
/// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" /
/// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The
/// reference bounds + range verdict are meaningless for a qualitative row.
valueText String? @map("value_text")
unit String
/// Optional reference-range bounds as reported by the lab.
referenceLow Float? @map("reference_low")
@@ -3577,6 +3837,11 @@ model CoachMessage {
metricSourceJson String? @map("metric_source_json")
providerType String? @map("provider_type")
promptVersion String? @map("prompt_version")
// v1.18.9 — per-turn token usage + model, persisted so the quiet
// per-message token footer survives a conversation reload. Both
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@ -3586,6 +3851,12 @@ model CoachMessage {
feedback RecommendationFeedback[]
@@index([conversationId, createdAt])
// v1.18.6 (L1) — the FAB nudge-status poll and the cron's persisted-nudge
// frequency gate both scan a user's assistant messages newest-first
// (optionally filtered to provider_type = 'nudge'). This index lets the
// planner do an index-backed per-conversation merge instead of gathering +
// sorting every assistant row in the user's threads on each global poll.
@@index([conversationId, role, createdAt(sort: Desc)])
@@map("coach_messages")
}
@@ -3791,6 +4062,13 @@ model ClinicianShareLink {
userId String @map("user_id")
/// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`).
tokenHash String @unique @map("token_hash")
/// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as
/// `tokenHash`). NULL for legacy links minted before the passphrase gate;
/// those keep the possession-only contract (the public view renders without
/// a gate). A non-null value forces the unlock gate before the record paints.
/// The raw passphrase is returned exactly once at create time and stored only
/// as this hash — there is no recovery path.
passphraseHash String? @map("passphrase_hash")
/// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded.
label String
+11 -4
View File
@@ -6,7 +6,8 @@
"scope": "/",
"start_url": "/",
"display": "standalone",
"display_override": ["standalone"],
"display_override": ["standalone", "minimal-ui"],
"categories": ["health", "medical", "lifestyle"],
"theme_color": "#282a36",
"background_color": "#282a36",
"icons": [
@@ -40,19 +41,25 @@
"name": "Add measurement",
"short_name": "Add",
"url": "/measurements?add=WEIGHT&source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
},
{
"name": "Today",
"short_name": "Today",
"url": "/?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
},
{
"name": "Medications",
"short_name": "Meds",
"url": "/medications?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
}
]
}
+127 -4
View File
@@ -36,11 +36,72 @@ 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.18.4";
/* @sw-version-fallback */ "v1.21.2.1";
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/*`
// reads. Network-first: online callers always get the fresh server response
// (and the cache is refreshed behind it), so a read-after-write never serves a
// stale pre-mutation list. Offline (the network fetch throws) the LAST cached
// dashboard/series is served instead of empty skeletons forever.
//
// v1.18.6.x — reverted from stale-while-revalidate, which served the cached
// pre-mutation copy first and only revalidated in the background, so a list
// read straight after a create/update showed the old (or empty) data until the
// next paint. Network-first restores read-after-write freshness while keeping
// the offline fallback.
const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
const MAX_DATA_ENTRIES = 60;
// Safe-GET read allowlist. ONLY idempotent reads that render the core views
// and carry NO secret-shaped body. Auth, mutations and token endpoints are
// never listed — and `isCacheableApiResponse` is a second, body-level guard
// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future
// endpoint that slipped onto the list can still never persist a secret.
// Matched by exact path or `<path>/` / `<path>?` prefix.
// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`)
// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat
// endpoint into the disk cache, and neither belongs on a self-hosted health
// PWA's durable storage. Only the dashboard snapshot, the widget layout, the
// measurement reads (incl. the batched daily series), the medication reads,
// and the version probe are eligible.
const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
"/api/dashboard/widgets",
"/api/measurements",
"/api/medications",
"/api/version",
];
// Defence in depth: even an allowlisted path is refused if its pathname looks
// like an auth/token/secret surface.
const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i;
function isAllowlistedApiRead(pathname) {
if (API_DENY_RE.test(pathname)) return false;
return API_READ_ALLOWLIST.some(
(p) =>
pathname === p ||
pathname.startsWith(p + "/") ||
pathname.startsWith(p + "?"),
);
}
// Body-level secret refusal: never persist a body carrying a token/secret-
// shaped pattern even if the path was allowlisted by mistake.
const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/;
function isCacheableApiResponse(response, bodyText) {
if (!response || !response.ok) return false;
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) {
return false;
}
return true;
}
// App shell files to precache on install
const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"];
@@ -64,7 +125,10 @@ self.addEventListener("activate", (event) => {
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
.filter(
(k) =>
k !== STATIC_CACHE && k !== PAGE_CACHE && k !== DATA_CACHE,
)
.map((k) => caches.delete(k)),
),
),
@@ -87,8 +151,16 @@ self.addEventListener("fetch", (event) => {
// Only handle same-origin GET requests
if (request.method !== "GET" || url.origin !== self.location.origin) return;
// API calls — network only (never serve stale data)
if (url.pathname.startsWith("/api/")) return;
// API calls. Allowlisted safe GET reads use network-first so the installed
// PWA always renders fresh server data online (and falls back to the last
// cached copy only when the network fails); everything else (auth,
// mutations, anything not on the list) stays network-only.
if (url.pathname.startsWith("/api/")) {
if (isAllowlistedApiRead(url.pathname)) {
event.respondWith(networkFirstApi(request));
}
return;
}
// Next.js static assets — cache-first (immutable hashed filenames)
if (url.pathname.startsWith("/_next/static/")) {
@@ -126,6 +198,57 @@ async function cacheFirst(request, cacheName) {
return response;
}
/**
* Network-first for allowlisted safe GET `/api/*` reads.
*
* Always tries the network first so an online caller gets the fresh server
* response — critical for read-after-write: a list fetched straight after a
* create/update must reflect the mutation, not a stale pre-mutation copy. On a
* cacheable response the data cache is refreshed behind it. Only when the
* network fails (offline) is the last cached copy served; with neither, a JSON
* 503 lets the client's TanStack Query cache / IndexedDB persister supply data.
*
* Caching is gated twice: `isAllowlistedApiRead` already filtered the path,
* and `isCacheableApiResponse` inspects the actual body so a `no-store` or
* secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same
* refusal discipline the idempotency cache uses.
*/
async function networkFirstApi(request) {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(request);
// Read the body once to body-screen it, then reconstruct a fresh Response
// so both the cache.put and the return value have an unconsumed body.
const bodyText = await response
.clone()
.text()
.catch(() => undefined);
if (isCacheableApiResponse(response, bodyText)) {
const toStore = new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
await cache.put(request, toStore);
trimCache(DATA_CACHE, MAX_DATA_ENTRIES);
}
return response;
} catch {
// Network failed (offline) — serve the last cached copy if we have one.
const cached = await cache.match(request);
if (cached) return cached;
return new Response(
JSON.stringify({ data: null, error: "offline", meta: { offline: true } }),
{
status: 503,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
}
}
/**
* Privacy gate for the navigation page cache. Most app routes are
* client-fetch-only shells with no server-rendered PII (health JSON loads
@@ -9,11 +9,7 @@
* `self.__APP_VERSION__` (falling back to a literal).
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
readFileSync,
rmSync,
} from "node:fs";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, afterAll } from "vitest";
+6 -2
View File
@@ -59,7 +59,9 @@ async function main(): Promise<void> {
const dryRun = !args.confirm;
if (dryRun) {
console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.");
console.log(
"[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.",
);
} else {
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
}
@@ -92,7 +94,9 @@ async function main(): Promise<void> {
}
}
console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`);
console.log(
`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`,
);
await prisma.$disconnect();
}
+3 -6
View File
@@ -66,12 +66,9 @@ async function main(): Promise<void> {
for (const [index, user] of users.entries()) {
const userStarted = Date.now();
try {
const { rowsUpserted, durationMs } = await recomputeUserRollups(
user.id,
{
granularities: ALL_GRANULARITIES,
},
);
const { rowsUpserted, durationMs } = await recomputeUserRollups(user.id, {
granularities: ALL_GRANULARITIES,
});
totalRowsUpserted += rowsUpserted;
console.log(
`[${index + 1}/${users.length}] user=${user.username} ` +
+2 -7
View File
@@ -158,11 +158,7 @@ export function checkEnv(
// All-or-none groups: surface a synthetic row when the group is
// partially populated. This catches the v1.4.40 AP-2 pattern even
// when the group itself is `required: false`.
if (
group.allOrNone &&
presentInGroup > 0 &&
absentInGroup > 0
) {
if (group.allOrNone && presentInGroup > 0 && absentInGroup > 0) {
results.push({
group: group.name,
variable: "<all-or-none>",
@@ -205,8 +201,7 @@ function renderResults(results: CheckResult[]): {
);
} else if (r.required) {
console.log(
` [MISSING-REQUIRED] ${r.variable}` +
(r.note ? ` — ${r.note}` : ""),
` [MISSING-REQUIRED] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
);
missingRequired++;
} else {
+8 -2
View File
@@ -163,11 +163,17 @@
"required": false,
"allOrNone": true,
"variables": [
{ "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
{
"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_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
{
"name": "BACKUP_ENCRYPTION_KEY",
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
}
]
}
]
+3 -1
View File
@@ -53,4 +53,6 @@ const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
// One-line log so the build output shows the version we baked in.
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
process.stdout.write(
`sw-version.js → ${version} (sw.js fallback re-anchored)\n`,
);
+23 -23
View File
@@ -30,28 +30,28 @@ on both ends** so secret columns can be NULLed and column order is pinned.
## What moves (FK-parent-first, 20 tables)
| # | Table | Scope predicate |
|---|---|---|
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
| 3 | `cycle_profiles` | `user_id = demo` |
| 4 | `menstrual_cycles` | `user_id = demo` |
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
| 9 | `medications` | `user_id = demo` |
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
| 13 | `medication_inventory_items` | `user_id = demo` |
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
| 15 | `medication_side_effects` | `user_id = demo` |
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
| 17 | `personal_records` | `user_id = demo` |
| 18 | `user_achievements` | `user_id = demo` |
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
| # | Table | Scope predicate |
| --- | ----------------------------- | --------------------------------------------------------------------------------- |
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
| 3 | `cycle_profiles` | `user_id = demo` |
| 4 | `menstrual_cycles` | `user_id = demo` |
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
| 9 | `medications` | `user_id = demo` |
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
| 13 | `medication_inventory_items` | `user_id = demo` |
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
| 15 | `medication_side_effects` | `user_id = demo` |
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
| 17 | `personal_records` | `user_id = demo` |
| 18 | `user_achievements` | `user_id = demo` |
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
## Secret / credential columns NULLed on the `users` row
@@ -142,7 +142,7 @@ be a write-capable admin. Keep DEMO_MODE enabled.
## Caveats / things to confirm before running
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
tables on edge01, not just what this run inserted. On a clean
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
accounts, treat a non-zero value as a warning to inspect, not an automatic
+5 -1
View File
@@ -151,7 +151,11 @@ async function updateCoolifyEnv(
appUuid: string,
value: string,
): Promise<void> {
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
const list = await coolifyRequest(
base,
token,
`/applications/${appUuid}/envs`,
);
if (!list.ok) {
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
}
+14 -9
View File
@@ -100,7 +100,10 @@ const TAG = "[repair-intake-anomalies]";
// LOCAL time by default; this parser pins them back to UTC so every
// Date this script handles is the true instant. (OID 1114 = timestamp
// without time zone.)
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
types.setTypeParser(
1114,
(value: string) => new Date(`${value.replace(" ", "T")}Z`),
);
/**
* Serialise a Date for a `timestamp without time zone` comparison /
@@ -1056,11 +1059,7 @@ async function main(): Promise<void> {
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
WHERE "id" = $1 AND "starts_on" = $3::timestamp
`,
[
p.medicationId,
utcParam(p.firstRow),
utcParam(p.startsOn),
],
[p.medicationId, utcParam(p.firstRow), utcParam(p.startsOn)],
);
summary.startsOnAdjusted += res.rowCount ?? 0;
}
@@ -1105,11 +1104,17 @@ async function main(): Promise<void> {
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
console.log(
` implausible rows tombstoned: ${summary.implausibleTombstoned}`,
);
console.log(
` window/times drift schedules: ${summary.windowDriftSchedules}`,
);
console.log(` windows reconciled: ${summary.windowsReconciled}`);
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
console.log(
` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`,
);
console.log(` era-inference candidates: ${summary.eraCandidates}`);
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
+19 -6
View File
@@ -207,7 +207,11 @@ async function main() {
),
);
results.push(
await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
await rotateStringColumn(
"WhoopConnection",
field,
prisma.whoopConnection,
),
);
results.push(
await rotateStringColumn(
@@ -349,11 +353,7 @@ async function main() {
await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
);
results.push(
await rotateBytesColumn(
"Biomarker",
"contextEncrypted",
prisma.biomarker,
),
await rotateBytesColumn("Biomarker", "contextEncrypted", prisma.biomarker),
);
results.push(
await rotateBytesColumn(
@@ -370,6 +370,19 @@ async function main() {
),
);
// ───── v1.19.0 ECG waveform (Bytes column) ─────
// "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the
// same `encrypt()` ciphertext-string-as-UTF-8 shape the Coach columns use
// (see src/lib/withings/ecg-waveform-codec.ts), so the generic Bytes
// rotation re-stamps it without decoding the waveform.
results.push(
await rotateBytesColumn(
"EcgRecording",
"waveformEncrypted",
prisma.ecgRecording,
),
);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
Loaded 100 of 1567 files, more files were not shown because too many files have changed in this diff. Show more