1205 Commits
Author SHA1 Message Date
Marc-André Bombeck fe099acd67 Merge pull request #248 from MBombeck/release/v1.10.2
v1.10.2 — honest AI connection test, consistent insights, retention re-enabled
v1.10.2
2026-06-03 09:27:33 +02:00
Marc-André Bombeck 44028b2a6e chore(release): v1.10.2 — honest AI connection test, consistent insights, retention re-enabled 2026-06-03 09:18:55 +02:00
Marc-André Bombeck 9bdf76ea11 fix(settings): localise AI connection-test failure reasons
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck d8f623aea3 fix(measurements): close residual P2002 in the dense intra-day fold
Resolve the canonical daily row deterministically: prefer the row already
carrying the target stats: externalId before falling back to the row on the
canonical local-noon instant, with a stable orderBy tiebreaker. Adopting a
sibling and stamping the target externalId onto it could otherwise collide on
the (userId, type, source, externalId) index with the real canonical row.

Re-anchor the adopted row to local-noon only when that slot is free, so an
externalId row at a tz/DST-shifted instant cannot collide on the measured_at
composite during adopt.

Guard the create against a concurrent insert on the same canonical composite:
catch P2002 outside the transaction (a caught error inside leaves the tx in
the aborted state) and retry the fold once, which then resolves and adopts the
winning row instead of creating a duplicate.

Add a real-Postgres case covering a row already on the canonical instant under
the target stats: externalId plus a fresh out-of-window batch: the fold adopts
in place, no P2002, one canonical row with the correctly folded mean.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck 8456d3bbc3 chore: correct body-cap comments, dockerignore licence posture, stale log copy
- workouts/batch + safeJson: the body cap is a DoS ceiling, not a bound
  the worst-case payload always fits under; an extreme GPS-heavy backlog
  can exceed it and falls back to smaller batches client-side. Note that
  request.text() still buffers the raw body, so the cap bounds the parse
  cost, not the initial read.
- .dockerignore: keep LICENSE / LICENSE.md in the image — AGPL-3.0
  conveys with the software.
- reminder-worker: the dense walk flag is an operator kill-switch now
  that the fold is reworked, not a pending-rework gate.
2026-06-03 09:10:46 +02:00
Marc-André Bombeck 33358c7960 test(medications): pin the GLP-1 card clock off the schedule window
Two assertions depended on the live wall-clock: the card suppresses the
upcoming-injection line while now sits inside a schedule window, so a CI
run during the 08:00-20:00 window rendered the pill instead of the
Next/Recommended-next line. Pin to 07:00 Berlin on a non-scheduled day.
2026-06-03 08:59:31 +02:00
Marc-André Bombeck 0ec5f7552f chore(api): regenerate OpenAPI for the capabilities endpoint 2026-06-03 08:59:31 +02:00
Marc-André Bombeck dbdc99a4a1 fix(insights): return values sub-page back-link to the originating metric
The show-all-readings link now carries the originating metric page as a
sanitised from param, so a weight to show-all drill-in returns to weight
rather than the Insights overview. The param is constrained to an internal
/insights/<slug> path so a crafted value cannot redirect off-site.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 732e85e4df fix(insights): add scores drill-down back-link and unify back-link styling
The composite-score detail page (/insights/scores/[metric]) gains a leading
back-link to the Insights overview, matching the other drill-down surfaces.
Align the medication detail + history back-links onto the canonical ghost
recipe (-ml-2 w-fit, mr-1 size-4 icon) used by the values and workout-detail
pages, replacing the divergent -ml-2 gap-1 variant.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 692b091637 feat(insights): unify range controls and part set across bespoke metric pages
Mount MetricRangeControls on the measurement-series bespoke pages (weight,
blood-pressure, pulse, bmi, sleep) so the range selector no longer vanishes
when navigating from a HealthKit metric. Mood, workouts, and medications are
event-driven rather than MeasurementType series, so they document the
omission inline.

Apply the canonical part set where the data supports it: blood-pressure and
sleep gain the diversity nudge + show-all-values foot link. Blood pressure
omits the single-series stat strip (it is sys+dia) and bmi omits the strip /
nudge / show-all entry (it is derived from WEIGHT), each with an inline note.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 73255f817d refactor(insights): extract shared MetricRangeControls from HealthKit page
Lift the time-range pills + period-over-period delta block out of
HealthKitMetricPage into a standalone MetricRangeControls component that
owns the range pref, the range read, and the direction-sentiment lookup.
The HealthKit scaffold now consumes the shared piece so the bespoke metric
pages can render the identical control.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 5b5ab5795c fix(settings): guard AI test and regenerate against non-JSON responses
The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 564d7dec77 fix(ai): never 5xx from the connection test and report a categorised reason
The connection-test route mapped a provider 5xx to a 502, which a reverse
proxy rewrites to its own HTML error page; the browser's res.json() then
threw "Unexpected token '<'". Every provider-call failure now returns
HTTP 200 with a secret-free { ok:false, providerType, reason }. Reasons
are categorised: credential/auth (401/403, or a 5xx whose body signals an
invalidated/expired session), rate-limit (429), provider server error
(other 5xx) and network/timeout.

The empty-override "test my saved config" path now resolves through the
same provider chain generation uses, instead of the legacy codex->admin
probe that could test a different provider than the one generation runs.

The chain runner already advances past an auth-class failure to the next
enabled provider; add explicit coverage for that on the raw generation
path (401 -> next succeeds, fallback count incremented).
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 192fe45469 feat(api): add GET /api/meta/capabilities discovery endpoint
Expose the server's live id vocabularies (derived-metric ids,
vitals-baseline types, insights layout tile-ids, metric-status ids,
the HealthKit ingest mapping, computed-score types, write allowlist) and
the FHIR coding constants, plus the running contract version, behind
requireAuth (cookie or Bearer, not admin).

Every list is sourced from its canonical server constant / registry, so
the endpoint cannot drift from what the routes actually accept and emit —
retiring the doc-vs-server enum-drift class for the native client. Export
the FHIR ATC/SNOMED URIs and a shared german-ATC default-locale set so the
export route and the discovery surface share one source.

Register the route in the OpenAPI table; drift-guard tests pin
derivedMetricIds to the registry and writeAllowlist to the constant.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 5ae7aa7570 feat(insights): add load, error, and empty states to the vitals dashboard
Render a skeleton row matching the resolved tile height while the derived
batch loads so the grid reserves its final footprint with no layout shift.
Hide the section heading until there is content (or a skeleton placeholder)
under it, removing the stranded-heading layout shift on empty data. Surface
an inline error message plus a Retry that refetches the shared batch query
when the batch fails, instead of the whole surface silently vanishing. Mark
the tiles container aria-busy while loading and aria-live polite, matching
the insights a11y pattern.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 786454ee2f fix(ui): repoint medication + admin status colours to theme tokens for light-mode AA
The intake-history taken badge and the admin reminders status glyphs used
bare Tailwind -400/-500 shades chosen for the dark theme. On the white
Alucard light card those land well below WCAG AA (green-400 1.74:1,
red-400 2.77:1, yellow-400 1.53:1). Repoint to the success / destructive /
warning theme tokens (light values 6.1:1 / 5.0:1 / 5.8:1 on white) and pin
the original shades behind dark: so the dark rendering stays identical.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 48f20f4622 feat(api): cap request-body size on batch and export ingest routes
Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch
and export routes that lacked one. The cap measures the raw text
before JSON.parse, so an over-limit payload returns a clean 413
without buffering onto the heap.

- workouts/batch: reuse the existing 5 MB ceiling so chunked uploads
  with no Content-Length are bounded too.
- measurements/batch: 4 MB — an order of magnitude above a 500-entry
  batch's few-hundred-KB worst case.
- export/health-record: 64 KB — the selection payload is small and
  bounded.

Each cap is a DoS ceiling sized well above any legitimate payload,
never a tight bound on a real batch.
2026-06-03 08:56:19 +02:00
tarantila 81b9606f6f fix(docs): improve README language 2026-06-03 08:11:07 +02:00
tarantila 2795a50eef fix(docker): harden Docker build context 2026-06-03 08:11:05 +02:00
Marc-André Bombeck 64965162c7 feat(measurements): re-enable the dense intra-day retention drain
The v1.10.0.2 kill-switch defaulted the drain OFF after the P2002 fold
collision. With the fold reworked to coexist with the canonical daily row
and integration-tested against real rows, flip the default back ON
(DENSE_INTRADAY_RETENTION_ENABLED=true unless explicitly set to false). The
boot-discovery still defers past the startup storm via the boot-delay so it
never contends for the connection pool on a data-heavy tenant; the nightly
03:50 walk and the per-user handler run again.
2026-06-03 07:41:20 +02:00
Marc-André Bombeck f049eeb8b1 fix(measurements): rework dense intra-day fold to coexist with the canonical daily row
The dense intra-day retention fold minted its daily-mean stats: row with
an externalId-keyed upsert. On real data a row already occupies the
canonical local-noon instant — a sibling daily stats: row or a per-sample
HRV/PULSE reading that fell on noon — so the upsert lookup missed (the
existing row carries a different externalId) and the INSERT then violated
the (user_id, type, measured_at, source, sleep_stage) unique index with
P2002. The mocked unit tests could not surface it.

Resolve the canonical row by the measured_at composite the collision is on
(sleep_stage IS NULL for these continuous types, matched via the
NULLS NOT DISTINCT index) and adopt it in place — refresh the mean, stamp
the canonical stats: externalId, un-tombstone — or create it when absent.
Exclude the adopted row id from the per-sample soft-delete so a noon-instant
sample becomes the daily row instead of being tombstoned.

Add a testcontainer integration test that seeds the colliding shape (a
pre-existing canonical-instant row plus out-of-window per-sample rows) and
pins no-P2002, coexistence, the noon-sample case, idempotency, and the
retention bound. Update the unit coverage for the adopt-in-place path.
2026-06-03 07:41:20 +02:00
Marc-André Bombeck e6ade6b03d Merge pull request #247 from MBombeck/release/v1.10.1
v1.10.1 — consistent medication cards
v1.10.1
2026-06-03 07:22:47 +02:00
Marc-André Bombeck b7b7e327ce chore(release): v1.10.1 — consistent medication cards 2026-06-03 07:17:01 +02:00
Marc-André Bombeck 2a584f6210 test(medications): pin card next/last slot symmetry + equal height
Extend the symmetry suite to assert both cards render the byte-identical
next/last slot wrapper (same reserved min-height, spacing, muted colour,
next-then-last order, no em-dash placeholder, no brighter foreground
token) and a constant-height bottom-pinned body across oral / injection
/ GLP-1 / PRN / one-shot, including the compliance-skeleton path. Update
the dose-accent assertions onto text-dose-accent and the GLP-1 rotation
test onto the folded-into-line nudge. Clock stays pinned.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck b3e8f43ab8 fix(medications): make the card identical across every type
Route both the generic and GLP-1 cards through the shared next/last
slot, ending the divergence that made two meds of different types read
differently in the same grid row:

- the GLP-1 card showed last-then-next; both now show next-then-last;
- its next line painted text-foreground/85; both now use the muted token;
- its inner spacing was space-y-1; both now use space-y-3.5;
- it always rendered the next line and printed a literal em-dash when
  there was no next injection; both now gate the line on an actual next
  and drop the placeholder.

Make CardContent a flex column and pin the action row with mt-auto.
Combined with the reserved next/last slot and the constant-height
compliance skeleton, every card body is now equal height, so the pin
aligns the take/skip buttons across a grid row without opening the void
that an unequal-height pin produced before.

Fold the GLP-1 injection-site rotation nudge into the upcoming-injection
line instead of a separate bordered block, so the GLP-1 card keeps the
same row inventory and height as the generic card.

Repoint both cards' dose accent onto text-dose-accent, drop the GLP-1
compliance query's active-only enabled guard so both cards make the same
calls, and share the weekday-label helper.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck 0255dbad7d fix(medications): repoint dose accent onto an AA-safe theme token
The medication cards stamped the upcoming schedule dose with Tailwind
stock text-purple-400, which reads ~3.1:1 on the white Alucard light
card and fails WCAG AA for body text. Add a theme-aware --dose-accent
token: the exact text-purple-400 oklch in dark mode (so the dark card
stays pixel-identical) and a darker AA-safe purple on the light card,
mirroring how the semantic feedback tokens land each tone above AA in
both themes. The cards switch to text-dose-accent in the follow-up
commit.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck e4bec11c2f feat(medications): add shared next/last slot + compliance skeleton card-parts
Extract the medication-card middle slot into a shared
<MedicationNextLastSlot> so the generic and GLP-1 cards render an
identical next/last region — same order (next then last), same
text-muted-foreground colour, same space-y-3.5 spacing, same reserved
min-height, and the same gating (the caller passes a next line only when
one should show; the part never prints a placeholder). A shared
useWeekdayLabel helper collapses the two weekday i18n key families onto
the abbreviated form so the same weekday no longer renders 'Monday' on
one card and 'Mon' on the other.

Add a MedicationComplianceSkeleton that reserves the compliance block's
footprint while the per-card query is in flight or returns null, so a
card without compliance is no longer ~5rem shorter than its grid-row
sibling.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck e1ea0f6645 Merge pull request #246 from MBombeck/fix/disable-dense-retention
v1.10.0.2 — disable intra-day retention drain (P2002 fold collision)
v1.10.0.2
2026-06-03 03:45:37 +02:00
Marc-André Bombeck 7bd66615b6 fix(jobs): disable the dense intra-day retention drain (P2002 fold collision) 2026-06-03 03:39:01 +02:00
Marc-André Bombeck 1bd668ae88 Merge pull request #245 from MBombeck/fix/dense-retention-boot-defer
v1.10.0.1 — defer intra-day retention drain past startup
v1.10.0.1
2026-06-03 02:29:26 +02:00
Marc-André Bombeck 3080828fb7 fix(jobs): defer the dense intra-day retention boot drain past startup to avoid pool exhaustion 2026-06-03 02:22:35 +02:00
Marc-André Bombeck 1fe70b27f5 Merge pull request #244 from MBombeck/release/v1.10.0
v1.10.0 — derived wellness metrics, device-event awareness, deeper ingestion
v1.10.0
2026-06-03 01:17:14 +02:00
Marc-André Bombeck 56e9816def test(medications): pin the card clock so window-status and day labels are deterministic 2026-06-03 01:07:36 +02:00
Marc-André Bombeck fbab8cfd30 fix(jobs): break consolidation import cycle that crashed the worker at boot
The reminder worker failed to start in the production bundle with
`ReferenceError: Cannot access 'l' before initialization`, taking every
nightly pg-boss job down with it (score jobs + dense-intraday retention).

Root cause is a value-level import cycle between `consolidation-base.ts`
and `drain-per-sample-cumulative.ts`: each imported values from the other.
The bundler merges the tightly-cyclic pair into one module body and hoists
`DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of
the constant it reads, so the eager read hits a temporal dead zone. It only
surfaced in the production build because dev/test run the modules unbundled.

Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow`
shape, and the timezone day-math helpers — into a new dependency-free module
`consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle
at its root. Re-export the moved names from their former homes so every
existing import site resolves unchanged.
2026-06-03 01:01:21 +02:00
Marc-André Bombeck d105849026 fix(fhir): resolve the medication-administration cap per call so an operator override takes effect 2026-06-03 01:00:04 +02:00
Marc-André Bombeck e1d4182a03 test(medications): pin the clock for the next-due day-label assertion 2026-06-03 01:00:04 +02:00
Marc-André Bombeck 8dbfc6989a refactor(measurements): drop the unused score-type set and rhythm-classification enum 2026-06-03 01:00:04 +02:00
Marc-André Bombeck 56e14aa9fa Merge remote-tracking branch 'origin/main' into release/v1.10.0
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
2026-06-03 00:40:41 +02:00
Marc-André Bombeck d43cbf3a3b chore(release): v1.10.0 — derived wellness metrics, device-event awareness, deeper ingestion 2026-06-03 00:08:22 +02:00
Marc-André Bombeck d369b406ac test(insights): align trend-sentiment + gate-inventory tests with the changes
The semantic-token swap means the trend arrow/delta now emit
text-success/text-warning (pixel-identical in dark, AA-safe in light), and
the batched derived route joins the insightStatus allowlist alongside the
single route. Update the trend-card sentiment-colour assertions and the
coach-route gate inventory accordingly.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 551d352581 feat(insights): surface inputs, method and a cited standard for all 11 derived metrics
Only Sleep and Readiness reached a provenance surface; the other nine
shipped a number with the cited standard buried in a code comment. Give
every derived metric a path to its inputs + method + cited standard:

- Recovery, Stress and Strain get a ScoreAnatomyView detail page and a
  tappable ring (they were read-only dead-ends). Stress surfaces its
  'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain
  cites the TRIMP load model; Recovery the HRV-guided recovery literature.
- The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age)
  gain an info explainer affordance carrying the method + standard, since
  they render as tiles with no detail page.
- The coincident-deviation flag gains a dashboard tile (shown only when it
  fired) with the same explainer (median ± k·MAD basis).
- A single standards map (src/components/insights/derived/standards.ts)
  pins each metric's method copy key, standard name and a real linkable URL
  (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source.

Add the per-metric method/caveat/description copy across all six locales.
Map storage units to display symbols so a baseline tile reads '36.6 °C',
not '36.6 celsius'. Show the band word under the strip rings so a value
reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline
placeholder when no trailing series exists rather than reserving dead
space, and give the tile a series + provenance slot for when one does.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck b92d5cec82 feat(insights): batch the derived-metric reads behind one route
The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.

Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 3f8c8e750c fix(i18n): translate the device-notification namespace for es/fr/it/pl
The entire insights.rhythmEvents namespace — section title, intro, the
load-error, and the medical-adjacent device-detection disclaimer — shipped
the English string verbatim in es/fr/it/pl. The locale-integrity guard only
checks key shape + placeholders, so the copy-paste passed CI while rendering
English on a Spanish/French/Italian/Polish device-notifications card,
disclaimer included. Translate the four keys into real localised copy.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 60c0ba4483 fix(insights): point derived score-band tokens at the AA-corrected semantic colours
The band primitives (score ring, coverage dots, contributor bars,
provenance chips + standard link, sparkline tint, trend arrow/delta)
painted straight from the raw `--dracula-*` variables, which carry no
`:root.light` override. On the white card the Alucard light theme uses,
the green/orange/yellow cleared roughly 1.1-1.5:1 — under AA — and the
`bg-dracula-yellow` confidence dot read near-invisible. The file comment
claimed they 'track automatically'; they did not.

Point BAND_NUMBER/PROGRESS/BORDER/VAR, the coverage-meter dot tint, the
provenance cyan chips, the sparkline stroke, and sentimentColorClass at
the semantic `--success`/`--warning`/`--destructive`/`--info` tokens
instead. Those alias the same Dracula tones in dark mode (pixel-identical
there, the primary target) but carry the darker AA-safe values under
`:root.light`. Clamp the score-ring provisional caption to one line so a
longer localised string cannot wrap over the ring arc.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck ecf93317dd docs: document the wellness-summary report section and correct the computed-scores note 2026-06-02 23:43:22 +02:00
Marc-André BombeckandClaude Opus 4.8 f170198a83 fix(measurements): validate single-POST source against the writable allowlist
WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it
gated the single-entry POST, but the schema only refined out COMPUTED —
WITHINGS and IMPORT stayed client-writable, so a client could attribute a row
to a source the server owns (the Withings webhook / CSV importer) and pollute
the per-source canonical picker. Wire createMeasurementSchema.source to a Zod
enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum
subsumes the old COMPUTED refine. Regenerate the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 dabdfdf68e fix(insights): confidence reflects real history depth; readiness source folds sleep + mood
The readiness and coincident-deviation composites passed historyDays =
windowDays (a constant) into deriveCoverage, pinning historyFraction to 1 so
a 7-day and a 30-day blend reported identical confidence. Pass the real
distinct-history-days backing the composite (the depth of the
best-supported present contributor) so confidence tracks actual depth.

The readiness provenance source was derived only from RHR/HRV/respiratory, so
a blend backed solely by sleep + mood could mislabel as DAY. Fold the present
sleep + mood provenance in so the source chip matches what actually backed
the headline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 fc6924d52f fix(insights): circular sleep-midpoint distance and explicit efficiency overlap
The sleep-score consistency + timing sub-scores measured the midpoint as a
linear minutes-of-day difference, so a sleeper whose midpoint straddles
midnight (and any night across a DST shift) read a spurious ~24-hour spread
that inflated the SD and timing distance. Measure the midpoint on the
circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint
and a circular distance for the SD and timing offset.

Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to
100, masking a data problem. Handle the overlap explicitly: a small overshoot
caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross
overshoot drops the sub-score because the in-bed denominator is not usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 cd8e34dac7 fix(insights): place HRV balance against the recent trend, not the band center
computeHrvBalance read recentAvg = baseline.value.center, which is the band
midpoint by construction and therefore always inside [low, high] — so the
"low" and "unbalanced" arms were dead and the card could only ever read
"balanced". Compute recentAvg from the last <= 7 DAY means (the recent
trend) and place that against the longer-window personal band, so a
suppressed recent trend flags "low" and an elevated one flags "unbalanced".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 148e6cd600 fix(insights): score the previous day in the nightly score engines
The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55
Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours
old: Strain missed the just-completed day's workouts and Stress saw a
near-empty intra-day SDNN set and falsely gated as insufficient. Score the
day that just ended (now - 1 day) across all three engines and their
measuredAt / externalId day stamps.

Resolve the previous-day key in one place by extracting the shared score-row
helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt /
scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch
tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into
the baseline engine (the three byte-identical profile loaders + the partial
strain variant now route through it). The derived route should adopt
loadBaselineProfile too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00