Commit Graph
1191 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 78c8177cd3 feat(insights): fold a confidence-gated derived signal into the daily briefing
When a derived wellness score (readiness or recovery) crosses a notability gate
— outside the green band AND confidence at least medium — the insights
generator now appends a SYSTEM CONTEXT block so the briefing can narrate the
shift observationally. Mirrors the GLP-1 plateau detector's append mechanism:
reads the one derived contract, returns null (the common case) at zero token
cost, and instructs the model to frame the signal as an observed pattern, never
a diagnosis or a dose recommendation. Add the readiness/recovery sourceMetric
enum values plus their briefing href + icon + trend-chart mappings.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 8c24f3ec2a feat(insights): add a wellness-score summary to the doctor report and FHIR export
Surface the server-derived nightly scores (recovery / stress / strain) in the
health-record export without polluting the clinical layer. The aggregator now
emits a wellnessScores summary (latest / avg / min / max / count per score over
the window) built from the already-fetched COMPUTED rows. The PDF renders them
in a clearly-labelled "Wellness summary" section, separate from the clinical
vitals table, with a "descriptive, not a clinical assessment" disclaimer. The
FHIR bundle emits one Observation per score under the survey category with a
text-only concept and the same disclaimer as an Annotation note, so a viewer
never mistakes a band for a diagnosis.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 3487bdaec8 feat(coach): fold the derived wellness layer into the snapshot as compact summaries
Add a "derived" block to the Coach snapshot: one tiny object per high-signal
derived metric (readiness, recovery, sleep, stress, strain, HRV balance,
cardio-fitness band, vascular-age delta) carrying value + band + confidence +
historyDays — never the raw series. A fully-populated block is ~250 tokens,
comfortably inside the snapshot budget. Insufficient metrics are omitted so the
model never sees no-data noise; each metric's compute is fault-isolated so a
transient failure on one never sinks the turn. Gated on the underlying signals
staying in scope. The system prompt now documents the block and frames the
bands as descriptive composites, not clinical findings.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck d9405e17b9 feat(dashboard): surface the wellness scores as a data-gated ring strip
Add a wellness-scores section to the vitals dashboard: a row of score rings for
Readiness, Recovery, Sleep, Stress, and Strain. Every ring is
data-availability-gated — a score with no underlying data simply does not
render, so the strip never shows an apologetic empty ring. Readiness and Sleep
are tappable and route to their existing score-anatomy detail pages; the
persisted nightly scores render read-only. Every ring reads the one
/api/insights/derived route through the shared useDerivedMetric hook.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck a8daef7376 feat(insights): read the persisted wellness scores through the derived contract
Register RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE as derived metrics that
read the nightly-persisted COMPUTED Measurement rows — a passthrough, never a
recompute. The dispatcher routes the three ids to a thin reader that returns
the latest score, a trailing-window trend, and a direction-aware band (stress
and strain invert, so a high stress reads red). This lets every surface — the
dashboard, the Coach snapshot, the doctor report — consume the scores through
the one Derived<T> route + hook rather than each growing its own read path.
Regenerate the OpenAPI enum, which picks up the three ids from the registry.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 02c50acd46 feat(insights): unify the derived-metric query hook onto one factory key
Two parallel loaders for the generic derived-metric route had grown — a
components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a
3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix
with different tuple shapes, which silently poisons the TanStack cache between
surfaces. Collapse to the single components-local hook (auth-gated, 8 s client
ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is
always present so the metric prefix can no longer carry two shapes.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck c5ba38cebb feat(insights): register the stress, strain, and dense-retention jobs
Wire the two nightly score passes and the dense intra-day retention drain
into the worker. The stress (04:50) and strain (04:55) crons each get a
queue, a schedule, and a single-flight handler; the dense-retention drain
gets a per-user backfill queue with boot discovery (like mean-consolidation)
and folds its steady-state nightly walk onto the existing drain-cumulative
tick, after the daily-mean fold.

A queue not registered in allQueues silently never drains, so a source-grep
guard pins every registration site for all three jobs.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 6935feda15 feat(insights): compute and store a nightly strain score from workout load
Derive a daily cardio-load proxy from the per-workout heart-rate series
plus active energy. Each workout's HR series scores via Banister's
gender-weighted exponential TRIMP over the fractional heart-rate reserve
(Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals
map to a 0-100 saturating scale. Days with no usable HR series but a
non-trivial active-energy total fall back to an energy-only proxy, with the
path recorded in the result.

Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated
when neither a usable HR series nor active energy is present.

Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP);
Tanaka, Monahan & Seals 2001 (HRmax).
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 57d9cc1674 feat(insights): compute and store a nightly stress score from intra-day HRV
Apple Watch has no electrodermal sensor, so there is no direct stress
measurement to read — what HealthKit does give is discrete SDNN samples
through the day. Derive an honest HRV-through-day PROXY: a suppressed,
below-baseline intra-day SDNN is a recognised marker of higher sympathetic
load. The provenance and label say "HRV-derived proxy" and never imply a
galvanic/EDA reading.

The engine reuses the shared vitals-baseline math (the same SDNN baseline
the wellness index reads), maps the day's below-baseline suppression to a
0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a
client can never POST it. It gates and writes nothing below the minimum
intra-day sample floor or without a usable baseline.

Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012
(neurovisceral integration). Idempotent per user per day via the nightly job.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck ce2b745cc3 feat(measurements): add a bounded dense intra-day retention tier for HRV/HR
Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep
their intra-day shape long enough for the stress engine to read them, but
the raw per-sample volume must stay bounded. Add a retention tier that
keeps the last 14 days of raw HRV/HR samples and folds everything older to
one daily-mean row via the proven mean-consolidation drain (same idempotent
stats:<HK>:<day> fold, soft-delete, and rollup recompute).

The two dense-tier types are deliberately EXEMPT from the destructive
daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would
collapse the intra-day shape and soft-delete the raw rows. A regression test
pins both exclusions so a future edit that adds either type to the drain
allowlist fails loudly.

Migration 0108 adds only the partial index that keeps the retention drain's
discovery and per-user scan cheap; the retention bound lives in code so
tuning it never needs a migration.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 56fc9cefbe docs(meta): sharpen the base SEO description
Replace the generic page description with the self-hosted positioning —
Withings + Apple Health sync, transparent derived wellness metrics, and
AI Insights you own — so the search-result snippet matches the README
and landing copy. OpenGraph and Twitter card descriptions are unchanged.
2026-06-02 22:39:40 +02:00
Marc-André Bombeck 4ddf385211 docs(readme): add wearable-ecosystem positioning and v1.10 derived metrics
Add an honest "Versus WHOOP / Oura / Fitbit ecosystems" subsection that
positions HealthLog on self-hosting, data ownership, multi-provider
aggregation, and the blood-pressure / glucose / medication context the
wrist bands don't hold — without claiming to be or match a wearable
device. Refresh the comparison table with BP+glucose, medication
compliance, derived metrics, and FHIR rows.

Fold the v1.10 derived-metrics tier into the status line, Key Features,
and roadmap: fitness-age, vascular-age delta, HRV balance, sleep score,
readiness and the stored recovery score, and the coincident-deviation
early-strain flag — each computed from the user's own measurements,
citing its inputs, degrading to "not enough data" rather than
fabricating a value. Stress and strain scores stay out of the copy: the
types are reserved but no engine computes them in v1.10.
2026-06-02 22:39:40 +02:00
Marc-André Bombeck 3dcec54726 feat(insights): compute and store a nightly recovery score
Add a recovery-score engine that turns a user's already-stored nightly
signals into a stored 0-100 score. It reuses the readiness blend — the
resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation
from the personal baseline, with the null-redistribution the health
score already uses — so the stored series and the live readiness surface
can never drift. The lineage is the recovery-marker literature (Plews
2013, Buchheit 2014): a higher score reads as more recovered. It is a
descriptive wellness proxy, not a clinical or training-recovery
assessment, and it is only stored when enough components are present —
below that floor no row is written rather than a headline from one
signal.

A nightly job computes each eligible user's score and upserts it as a
COMPUTED RECOVERY_SCORE row, idempotent per user per day on the
externalId day key, so a re-fired tick overwrites in place instead of
duplicating. The score is never computed on a page visit. The queue is
registered in the worker's queue inventory, scheduled, and wired to a
handler; a grep guard pins all four points so it cannot silently never
run.
2026-06-02 22:31:21 +02:00
Marc-André Bombeck 33b75361f3 feat(measurements): add a COMPUTED source and first-class score types
Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
2026-06-02 22:31:21 +02:00