Commit Graph
28 Commits
Author SHA1 Message Date
Marc-André Bombeck 6f718121c7 test(api): use the real caps in the limiter mocks, and drop a stale pointer
Three consent-surface tests mocked the shared AI-consent bucket at 60 where it
is 20, and the analytics-read and MCP mocks named 60 against real caps of 120.
Nothing asserts the number today, so nothing was wrong on the wire — but the
mocked verdict now carries `limit` all the way to `X-RateLimit-Limit`, and the
first test that reads that header off a mock would pin the fiction.

The response-shape file's header pointed at a document that is not in the
repo; it says what it means directly instead. It also now records the one
thing the gate cannot see: `meta` is a loose object on purpose, so undeclared
keys inside it survive the parse and the strip comparison does not reach them.
2026-09-10 05:07:15 +02:00
Marc-André Bombeck 08100b6207 fix(api): put Retry-After and the rate-limit triple on every 429
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.

Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.

`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.

The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
2026-09-10 03:55:46 +02:00
Marc-André Bombeck 8ffa9cd87a fix(insights): scan one discovery matrix from every correlation surface
Four surfaces assembled the correlation-discovery matrix and three carried a
different channel set. The Coach `get_correlations` tool had neither the
environmental-exposure channels nor the custom-metric ones, so asking it
whether air pressure relates to your sleep met a matrix the pair had never
been in. Its own file header said the opposite - "the SAME full-matrix scan
the route runs" - and had said it since the weather channels arrived in
v1.25. The period narrative was missing those two families and the
compliance / symptom pair on top.

`src/lib/insights/discovery-matrix.ts` is now the one place a matrix is
built. v1.30.3 hoisted the fetch discipline into a shared module for exactly
this reason and left the assembly duplicated behind; this finishes it. The
route, the per-metric card, the Coach tool and the period narrative all call
`assembleDiscoveryMatrix` and get the same families. What still differs is
declared as an option with its reason at the declaration: the measurement
read mode (a read-cost choice, left per surface), the extra metric types the
narrative's delta beat needs, and the RATED mood factors.

Mood factors stay on the narrative alone. That capture kind is frozen, the
two surfaces that persist a discovered pair write its channel key into a
plaintext column, and a custom factor's key is an opaque id - so admitting
them elsewhere is a decision about what to ask, not a parity fix.

Restore the per-metric filter on the assessment card's relations. It was
written in place of the pattern-dismissal filter rather than chained with it,
so a weight card's prompt had been receiving every surviving pair in the
record while the function name, the docstring and the early-return guard all
still said one metric.

`discovery-matrix-guard.test.ts` freezes who may fetch a channel, who may
construct one, and who may scan. Each matcher asserts a non-zero match count,
so a matcher that has stopped matching fails rather than agreeing with the
allowlist.

The statistics are untouched - same Pearson, same exact p-value, same
Benjamini-Hochberg, same floors. The Coach's family is larger now, which is
the point: it is corrected over the same family the insights page is.
2026-08-23 17:38:14 +02:00
Marc-André Bombeck f9ac7a3327 Pin the rollup swap's parity, fallback, and eligibility contracts
Both paths read identical fixtures — per-(day, source) DAY rollup rows
derived from the same raw rows the fallback reads — and must produce
equivalent daily series: the plain daily-mean case, the multi-source
day (the all-source weighted mean, never a ladder-collapsed single
source), and the pre-sum_value mean-times-count compose. A far-from-UTC
profile timezone must force the raw path so day keys stay profile-tz
keyed; the near-UTC midnight band's single-day attribution shift is
pinned explicitly so it stays a documented decision rather than drift.
Coverage misses and empty in-window bucket reads fall back per channel;
sleep and the cumulative types never enter the rollup query.

Every assertion family was broken on purpose and observed failing with
the site named before restoring green; the record sits in the test's
doc comment. The route test mocks the new coverage probe and bucket
read as empty so every channel rides the raw path unchanged there.
2026-08-13 22:06:10 +02:00
Marc-André Bombeck 8390b4d24e Serve correlation-discovery spot channels from the DAY rollup tier
The discovery scan read every raw measurement in the 180-day window for
all nine measurement channels and bucketed them into daily means in JS,
paying tens of thousands of rows on every cache miss (a CGM glucose
channel alone can exceed the read cap). The DAY rollup tier already
stores those per-day aggregates, so eligible spot channels now read one
pre-aggregated row per day per source and compose the all-source
count-weighted mean — the exact reduction toDailyMeans applies.

Read-swap replaces the raw read per channel, with fallback-on-miss:
structural ineligibility (SLEEP_DURATION's per-night reconstruction,
the cumulative types' device-type collapse axis the rollup rows do not
carry), a far-from-UTC profile timezone (the v1.4.38 W-A near-UTC
guard), a coverage-probe miss, or an empty in-window bucket read all
route that channel to the existing raw path. The SWR cache cell stays
in front; the swap only lowers the cost of a cache miss.
2026-08-13 22:00:27 +02:00
Marc-André Bombeck ac96e5e975 Cache the correlations discovery read and keep writes off the hit path
GET /api/insights/correlations was the most expensive uncached read in
the app: a 180-day window, ~10 parallel series reads, Pearson + FDR
in-process, plus two syncAcceptedPatterns DB writes — all paid on every
mount. The built body now reads through the analytics bucket's SWR cell
(60 s fresh / 1 h stale, keyed userId|correlations|locale), and the
pattern-sync writes live inside the builder, so a cache hit performs no
write on the read path. Eviction truth comes free: measurement, mood,
medication, illness and custom-metric writes already sweep the user's
prefix in that bucket, and a pattern dismiss sweeps it through
invalidateUserCorrelationPatterns.

The route test pins the contract: a second GET inside the fresh window
re-runs neither the measurement window read nor the pattern sync, and
serves a byte-equal body.
2026-08-13 18:52:07 +02:00
Marc-André Bombeck d86358f3e4 fix: recover managed sharing boundaries 2026-08-06 00:19:35 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.

The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.

A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André BombeckandClaude Opus 4.8 04320c415d fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.

Route every correlation narrative through the server translator:

- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
  the reader's locale, resolved from the correlations route the same way
  other insight generators resolve it. Sentence templates, the direction
  word, and every channel label live under a new insights.correlation.*
  namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
  (BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
  localised the same way; the analytics cache key now includes the locale
  so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
  causal framing ("not a cause" / "an association, never a cause") and the
  faint/moderate/high hedging tiers are preserved in every language.

English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 6c2eb15f05 fix(insights): hoist the correlation window fetch so every reader shares one grain
The asc+take(20000) window-cap bug (fixed on the correlations route
alone) and the mixed-grain toDailyMeans bug (fixed on three of four
readers) both survived on the Coach get_correlations tool, the
per-metric assessment card, and the period narrative. Dense accounts
saw the cap drop the newest rows on the coach/card readers and drop
the CURRENT period on the narrative — backwards for a recent-window
and a current-vs-prior surface respectively; the narrative's own local
toDailyMeans twin also averaged sleep stage-segments and blended
per-sample step chunks with drained daily totals instead of summing.

Hoists the fetch + grain resolution into
fetchMeasurementWindowSeries/fetchMoodWindowSeries
(correlation-channel-series.ts) — desc+cap+resort, full column select
for buildMeasurementDailySeries's per-type grain — and routes the
route, the coach tool, the per-metric card, and the period narrative
through it, deleting every local copy.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 5169be5887 fix(insights): order the correlations read desc so a hit cap keeps recent rows
The FDR discovery read capped a dense account's 180-day window at
orderBy asc + take, so exceeding the cap silently dropped the NEWEST
rows. The early-detection pass exists specifically to see the recent
window, so a capped read gave it nothing to work with.

Order desc for both the measurement and mood reads, re-sort ascending
in JS afterward (every downstream day-bucketing pass groups by day
key, so pre-sort order doesn't matter to them), and annotate when a
read actually hit its cap.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 8902d3985c fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):

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

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

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

Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck cdee3e3efc feat(environment): register env signals and wire weather into correlation discovery
Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
2026-06-28 16:58:30 +02:00
Marc-André Bombeck 0aaaaac081 feat(insights): surface early-detection and lab correlations to the correlations route and coach
Thread the rolling early-detection pass and the labs -> outcome pass through the
correlations route and the coach correlations reader, both of which already
build the same daily series. The route response gains an optional `emerging`
block (recent-window pairs not yet established, flagged provisional) and a
`labCorrelations` block; the coach reader gains matching `emerging` and
`labDrivers` fields. Regenerate the OpenAPI contract for the new response shape.
2026-06-27 13:34:43 +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 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 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 261f3cea1d test(insights): walk the insights AI tree in the module-route gate inventory
Add `src/app/api/insights` to the inventory's walked trees so every
`route.ts` beneath it must classify: the 14 AI-narrative routes as
insights-module-gated, the Coach sub-tree as coach-delegated, and the
AI-settings / provider-chain / tile-layout / feedback / targets /
glp1-timeline routes as explicit infra-and-config EXEMPT entries with
documented reasons. A future ungated insights AI route now fails the
inventory BY NAME rather than leaking the surface over a Bearer token when
the account turned insights off.

Add representative insights-off → 403 module.disabled coverage to the
blood-pressure-status and correlations route tests (alongside the existing
insights-on → 200 path).
2026-06-16 02:45:23 +02:00
Marc-André Bombeck 4cf923647d feat(insights): gate every AI-narrative insights route on the insights module
Require the `insights` module (in addition to the existing assistant-surface
gate and any per-metric data-module gate) on all 14 narrative insights
routes: blood-pressure / bmi / pulse / weight / medication-compliance /
mood / metric / derived / derived-batch status, cards, correlations,
narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope
now precedes any provider or cache work when the account has insights off.

The per-metric sleep/glucose/recovery/mood gates and the
requireAssistantSurface("insightStatus"|"correlations") checks are kept;
this adds the module layer on top of them.

Route tests that now resolve the gate mock @/lib/modules/gate
default-enabled so their existing data-shape and assistant-flag assertions
ride through unchanged.
2026-06-16 02:40:38 +02:00
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 298e4c34b4 perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
2026-06-10 12:55:56 +02:00
Marc-André Bombeck 4e6599d85f feat(mood-insights): promote mood to a discovery outcome and surface it
Two changes that turn the descriptive relations surfaces into a
statistically-gated discovery view on the mood page.

Mood as an outcome: the FDR-controlled all-pairs correlation discovery
engine treated mood only as a behaviour (a lag source). Promote it to an
outcome channel too, so the scan also surfaces "behaviour today → next-day
mood" relations (e.g. more daylight today → better mood tomorrow) alongside
the existing "mood today → next-day outcome" direction. The self-pair is
skipped and Benjamini-Hochberg already controls the larger pair family this
opens up. The discovery route now feeds the shared mood daily-mean series
into both roles.

Surface the discovery slice: the engine was computed but never rendered.
The mood Insights page now shows the mood-involving pairs that survive the
false-discovery correction, each phrased observationally with its paired-day
count, r, and BH q-value, plus the honest "X of N tested" footer. The
surface self-fetches the correlation route and degrades to nothing when the
operator has disabled it. Observational only, with the standing disclaimer.
2026-06-04 18:09:07 +02:00
Marc-André Bombeck 95a2a7692f feat(insights): add composite scores, score-anatomy view, and correlation discovery
Build the composite tier of the derived-metrics layer on the shared seam:

- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
  consistency, timing and composition sub-scores over the per-stage sleep
  rows, reweighting around any sub-score the data cannot support (a legacy
  asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
  blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
  deviation-from-baseline components; returns insufficient below two present
  components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
  the latest day and fires when two or more coincide, listing the
  contributing vitals as possible factors, never a cause or a diagnosis.

Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.

Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.

Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.

Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
2026-06-02 21:19:56 +02:00
Marc-André Bombeck 5ea813e20c chore(release): v1.4.31
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
2026-05-16 14:19:57 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 1f5ad1729d feat(api): aggregator endpoints for headless API clients
Adds seven read/write endpoints that aggregate or adapt existing data
into shapes more practical for non-browser consumers. None of the
pre-existing endpoints are touched.

  • `GET /api/dashboard/summary` — greeting + streak + today compliance
    + per-metric latest value with 7-day sparkline. Single round-trip
    for a "home" view instead of fanning out across 6 endpoints.
  • `GET /api/measurements/series?kind=&days=` — pre-aggregated series +
    stats (mean, min, max, stdDev, count) reusing
    `src/lib/analytics/trends.ts`.
  • `GET/PATCH /api/integrations/healthkit` — opt-in per-metric sync
    config (entries[] of `{ id, kind, direction, enabled }`). Stored in
    the new `users.healthkit_config_json` column.
  • `POST /api/devices` — APNs/FCM token registration only; no push
    sender wired up here. Upsert on `token`.
  • `GET /api/medications/intake?scope=today|compliance` — top-level
    aggregator over all of a user's medications, plus a sibling
    `POST /api/medications/intake` accepting `{ intakeId, status, takenAt }`
    for clients that don't want to maintain medication-id state.
  • `GET /api/insights/cards` — adapter that maps the rule-based alerts
    from `/api/insights/comprehensive` into card-shaped `Insight[]`.
  • `GET /api/insights/correlations` — placeholder array today; flagged
    in audit log as `insights.correlations.empty` until a real engine
    lands.

All endpoints honour `requireAuth()` (cookie or bearer) and the existing
audit/Wide-Event pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:16 +02:00