mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe099acd67 | ||
|
|
44028b2a6e | ||
|
|
9bdf76ea11 | ||
|
|
d8f623aea3 | ||
|
|
8456d3bbc3 | ||
|
|
33358c7960 | ||
|
|
0ec5f7552f | ||
|
|
dbdc99a4a1 | ||
|
|
732e85e4df | ||
|
|
692b091637 | ||
|
|
73255f817d | ||
|
|
5b5ab5795c | ||
|
|
564d7dec77 | ||
|
|
192fe45469 | ||
|
|
5ae7aa7570 | ||
|
|
786454ee2f | ||
|
|
48f20f4622 | ||
|
|
81b9606f6f | ||
|
|
2795a50eef | ||
|
|
64965162c7 | ||
|
|
f049eeb8b1 | ||
|
|
e6ade6b03d | ||
|
|
b7b7e327ce | ||
|
|
2a584f6210 | ||
|
|
b3e8f43ab8 | ||
|
|
0255dbad7d | ||
|
|
e4bec11c2f | ||
|
|
e1ea0f6645 | ||
|
|
7bd66615b6 | ||
|
|
1bd668ae88 | ||
|
|
3080828fb7 | ||
|
|
1fe70b27f5 | ||
|
|
56e9816def | ||
|
|
fbab8cfd30 | ||
|
|
d105849026 | ||
|
|
e1d4182a03 | ||
|
|
8dbfc6989a | ||
|
|
56e14aa9fa | ||
|
|
d43cbf3a3b | ||
|
|
d369b406ac | ||
|
|
551d352581 | ||
|
|
b92d5cec82 | ||
|
|
3f8c8e750c | ||
|
|
60c0ba4483 | ||
|
|
ecf93317dd | ||
|
|
f170198a83 | ||
|
|
dabdfdf68e | ||
|
|
fc6924d52f | ||
|
|
cd8e34dac7 | ||
|
|
148e6cd600 | ||
|
|
78c8177cd3 | ||
|
|
8c24f3ec2a | ||
|
|
3487bdaec8 | ||
|
|
d9405e17b9 | ||
|
|
a8daef7376 | ||
|
|
02c50acd46 | ||
|
|
c5ba38cebb | ||
|
|
6935feda15 | ||
|
|
57d9cc1674 | ||
|
|
ce2b745cc3 | ||
|
|
56fc9cefbe | ||
|
|
4ddf385211 | ||
|
|
3dcec54726 | ||
|
|
33b75361f3 | ||
|
|
51fc48f93c | ||
|
|
e893a43005 | ||
|
|
9990815efe | ||
|
|
95064b405a | ||
|
|
135141a750 | ||
|
|
a45325af52 | ||
|
|
b06b1cada4 | ||
|
|
635e64ace7 | ||
|
|
30be0c4e0d | ||
|
|
d247f6966a | ||
|
|
14d79ab453 | ||
|
|
440f24bff2 | ||
|
|
95a2a7692f | ||
|
|
663a3ee09d | ||
|
|
789eef35c3 | ||
|
|
e7f406ecc7 | ||
|
|
0c8506d7d4 | ||
|
|
7b3094eeef | ||
|
|
26fb461cc7 | ||
|
|
32bc11f968 | ||
|
|
aa281d6649 | ||
|
|
4ad65a5b7d | ||
|
|
6dac56b659 | ||
|
|
744e8e37ec | ||
|
|
e2917cb012 | ||
|
|
9106489196 | ||
|
|
cc927f7921 | ||
|
|
ad471a5110 | ||
|
|
fc7386a364 | ||
|
|
879833b372 |
No files matched your search
+50
-8
@@ -7,30 +7,41 @@
|
||||
# compose `environment:` block at boot — `.env` should never ship.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
.env.example
|
||||
.env.production.example
|
||||
|
||||
# Version control + CI metadata.
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.gitleaks.toml
|
||||
|
||||
# Build / install output that gets regenerated inside the builder stage.
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
playwright/.cache
|
||||
playwright.config.ts
|
||||
vitest.config.mts
|
||||
vitest.integration.config.mts
|
||||
tests
|
||||
tests/
|
||||
e2e
|
||||
e2e/
|
||||
e2e-results
|
||||
.eslintcache
|
||||
eslint-plugins
|
||||
eslint-plugins/
|
||||
knip.json
|
||||
|
||||
# Persisted session state from e2e runs.
|
||||
e2e/setup/storageState.json
|
||||
|
||||
# Planning scratch + per-release audit notes (operator-local).
|
||||
.planning
|
||||
docs/audit
|
||||
# Generated local cache files
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
public/sw-version.js
|
||||
|
||||
# Captures from local debugging sessions.
|
||||
*.har
|
||||
@@ -43,3 +54,34 @@ docs/audit
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs / Debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Docker compose / local ops files
|
||||
docker-compose*.yml
|
||||
compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Documentation not needed for runtime image
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
CONTRIBUTING-AI.md
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
|
||||
# the software, so the licence text ships inside the image.
|
||||
AGENTS.md
|
||||
.planning
|
||||
docs
|
||||
docs/*.md
|
||||
docs/**/*.md
|
||||
@@ -62,6 +62,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# FHIR health-record export -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# Per-export ceiling on the number of MedicationAdministration rows in the
|
||||
# FHIR Bundle. The report window already scopes the set; this is a coarse
|
||||
# safety ceiling on the serialised Bundle size. Bounded 1..50000; any unset
|
||||
# or out-of-range value falls back to the default of 5000. Raise it only for
|
||||
# an unusual multi-year, many-medication workload.
|
||||
# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+108
-254
@@ -1,270 +1,124 @@
|
||||
# HealthLog v1.4.24 — roadmap
|
||||
# HealthLog — roadmap
|
||||
|
||||
Milestone: **v1.4.24 — pre-iOS final polish** (not yet kicked off)
|
||||
Latest tag at start: v1.4.23 (live in prod)
|
||||
Current line: **v1.9.0** (shipping — Insights time-ranges, deeper mood, medication
|
||||
drug-coding into FHIR, advanced-settings rebuild, connection-pool stability).
|
||||
Trunk: `main`, always releasable. Release model in CLAUDE.md.
|
||||
|
||||
Carry-over candidates already captured in
|
||||
`.planning/v1423-backlog.md`:
|
||||
|
||||
- Pearson incomplete-beta replacement — v1.4.23 W5 H6 raised
|
||||
`MIN_PAIRED_N` from 14 → 20 as a conservative surfacing-gate
|
||||
patch. Land the rigorous incomplete-beta or normal-approx
|
||||
replacement before v1.5/v1.6 auto-correlation discovery ships.
|
||||
- OpenAPI drift gate flip from warn-only to hard-fail — generator
|
||||
covers ~880 lines vs the legacy hand-maintained spec's 5 468;
|
||||
complete registry coverage in parallel so the lockstep happens
|
||||
before the iOS DTO codegen is downloading the spec
|
||||
automatically.
|
||||
- Settings-cog vs per-message-controls UX consolidation — Design
|
||||
pushback during W6 raised concern that the dual surface
|
||||
duplicates intent. Wait on first-week thumbs data, decide
|
||||
whether per-user prompt prefs drift from per-message ratings.
|
||||
- `coach-prefs.test.ts` integration `NextRequest` URL mock
|
||||
regression — pre-existing failure that predates v1.4.23.
|
||||
Investigate the harness's `cookies()` mock interaction with
|
||||
`NextRequest`'s URL parsing.
|
||||
- Sec-MED-1 + LOW cluster — intra-batch dedup accounting
|
||||
(Sec-LOW-1), idempotency 422 retry hint in the OpenAPI 422
|
||||
description (Sec-LOW-2), APNs key-file path redaction in
|
||||
wide-event meta (Sec-LOW-3), refresh-token failure audit
|
||||
`userId` extension (Sec-LOW-4).
|
||||
- S-05 simplify deferral (Session B) — touched too many call
|
||||
sites for a single reconcile commit.
|
||||
|
||||
Reserved next strategic milestones:
|
||||
|
||||
- **v1.5** — iOS app + Apple Health integration + per-metric APNs
|
||||
alerts. Refreshed strategic plan with concrete file paths at
|
||||
`.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a
|
||||
~5-day iOS Swift sprint because every server-side contract
|
||||
landed in v1.4.23. Per-user timezone foundation
|
||||
(`.planning/feature-user-timezone.md`, issue #167) is now scoped
|
||||
into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch
|
||||
prep into the v1.4.25 polish window instead of slipping it.
|
||||
- **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach
|
||||
full-page route at `/insights/coach`, conversation-driven goal
|
||||
setting.
|
||||
Next milestone: **v1.10.0 — derived / synthesized wellness metrics + provenance + docs**.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.23 (completed 2026-05-11)
|
||||
## v1.10.0 — derived metrics, provenance, docs, discoverability
|
||||
|
||||
| Wave | Goal | State |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 1 | Research — Apple Health type mapping, APNs library decision (`@parse/node-apn`), OpenAPI tooling choice (`zod-openapi` + `yaml@^2`) | done |
|
||||
| 2 | Apple Health foundation — F1 `MeasurementType` extension + sleep-stage enum + composite dedup index, F2 `APPLE_HEALTH` source + UI badge, F3 `POST /api/measurements/batch` + sleep-stage analytics aggregation | done |
|
||||
| 3 | APNs scaffolding — F4 `@parse/node-apn` sender + dispatcher cascade rewire, `Device` columns + paired CHECK constraint, `POST /api/devices` apnsToken acceptance + cross-user-hijack guard | done |
|
||||
| 4 | OpenAPI generator + Coach metric slot + native-auth hardening + Coolify runbook — F5 `zod-openapi` registry + warn-only CI gate, F6 nine HealthKit categories in strict schema + PROMPT_VERSION 4.23.0, F7 per-device refresh-token replay-detection + device-management endpoints, F8 Coolify runbook + `::notice::` line | done |
|
||||
| 5 | Hygiene (H1-H7) — sentinel parser malformed-enum annotation, analytics chunked-paged aggregate, drawer controlled-prop refactor, per-user prompt prefs surface, `medication_schedules.days_of_week` deploy, Pearson n≥20 gate, Coach feedback observation view | done |
|
||||
| 6 | Multi-agent QA + reconcile — six reviews (code, security, design, senior-dev, simplify, product-lead); 1 CRIT + 9 HIGH + 4 simplify + 3 MED applied; LOWs triaged into v1423-backlog | done |
|
||||
| 7 | Release v1.4.23 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy, /api/version=1.4.23, smoke, GH release, docs + landing sync, brief | done |
|
||||
The headline is derived wellness metrics that are honest on the data HealthLog
|
||||
actually has — daily snapshots, not continuous streams. The governing constraint:
|
||||
HRV is a single nightly SDNN value, there is no continuous HR, no EDA, no raw ECG/PPG,
|
||||
and the nightly drain tombstones per-sample rows to a daily mean. So anything built on
|
||||
intraday physiology is out of scope; day-scale deviation-from-personal-baseline
|
||||
statistics are the sweet spot. Full synthesis in
|
||||
`.planning/v1.10-derived-metrics-PROPOSAL.md`, implementation plan in
|
||||
`.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0, not
|
||||
1.9.1), research reports under `.planning/v1.10-research/`.
|
||||
|
||||
Milestone completed 2026-05-11 — v1.4.23 LIVE in prod.
|
||||
Release brief: `docs/audit/v1423-summary.md`.
|
||||
Backlog seeded to `.planning/v1423-backlog.md`. v1.5 strategic plan refresh
|
||||
at `.planning/phase-W6-v1423-product-lead-review.md`.
|
||||
### Ordering (data-availability first)
|
||||
|
||||
A derived metric appears only when its required inputs exist; composites degrade
|
||||
gracefully and never present a headline number secretly computed from one of N signals.
|
||||
Precedent: the Personal Health Score null-redistributes missing pillars.
|
||||
(`.planning/v1.10-research/DESIGN-PRINCIPLE-data-availability.md`.)
|
||||
|
||||
1. **Per-metric Vitals dashboard** (flagship) — each available signal shows its own
|
||||
personal-baseline card; absent signals don't render, so it scales 1→30 metrics with
|
||||
no data assumptions. Pure rolling stats over inputs we 100% have, plus a multi-signal
|
||||
early-strain flag. Build this first.
|
||||
2. **Pre-computed easy wins** — age/sex-adjusted reference ranges (cross-cutting
|
||||
enabler: `dateOfBirth` / `gender` are stored but unused for norms), Fitness Age /
|
||||
cardio band from VO2max, Vascular Age framing from PWV. Render only when the device
|
||||
value exists.
|
||||
3. **Composites** — Sleep Score and a Readiness / Wellness index (extending the Personal
|
||||
Health Score), each with a minimum-inputs threshold, reweighting around missing
|
||||
inputs, a visible coverage / confidence indicator, and a "track BP/HRV to sharpen
|
||||
this" nudge.
|
||||
4. **Broader correlation discovery** — expand the fixed-hypothesis correlation engine
|
||||
into an FDR-controlled behaviour↔outcome discovery engine, surfacing only pairs with
|
||||
enough paired n (the existing n≥20 gate pattern).
|
||||
|
||||
Out of scope by construction (require sensing we deliberately don't retain): Body
|
||||
Battery, all-day / real-time Stress, Strain / Training Load, a faithful Recovery score,
|
||||
AFib / ECG derivation, breathing-disturbance / AHI, Oura Resilience, minute-by-minute
|
||||
time-in-zone. State this plainly so we never over-promise; surface a device-computed
|
||||
value as a passthrough where one exists, never re-derive it.
|
||||
|
||||
### Provenance / transparency (acceptance criterion, not an afterthought)
|
||||
|
||||
Every derived metric openly states what it's based on and links the basis:
|
||||
- exact inputs + method / formula (plain-language + the math),
|
||||
- why this method (rationale),
|
||||
- a citation / link to the underlying authority (IEEE / RFC / WHO / LOINC /
|
||||
peer-reviewed studies / Wikipedia — whichever is right),
|
||||
- confidence / limitations stated honestly (SDNN≠RMSSD, snapshot-not-continuous,
|
||||
consumer-sensor validity caveats),
|
||||
- rendered through the existing collapsible provenance / explainer pattern — no
|
||||
black-box number.
|
||||
|
||||
Needs a shared provenance / citation data model so each derived metric carries its
|
||||
sources, surfaced via the explainer component everywhere it appears.
|
||||
(`.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`.)
|
||||
|
||||
### Documentation audit + continuation
|
||||
|
||||
- Audit the docs site (`healthlog-docs`), in-repo `docs/`, README, and the OpenAPI
|
||||
contract for accuracy vs the shipped state — much drifted across the v1.8.x → v1.9.0
|
||||
arc.
|
||||
- Continue / extend docs to cover the new derived metrics with the same provenance:
|
||||
meaning, computation, linked standard, required data, graceful-degradation behaviour.
|
||||
- This `ROADMAP.md` / `STATE.md` refresh folds into the audit.
|
||||
|
||||
### Discoverability / growth
|
||||
|
||||
A discoverability / growth workstream stands alongside the milestone (scope to be
|
||||
shaped during planning).
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.22 (completed 2026-05-10T22:43:50+02:00)
|
||||
## Standing deferred items
|
||||
|
||||
| Wave | Goal | State |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, metric token leaks, Targets page brainstorm); health-coach prompt research | done |
|
||||
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | done |
|
||||
| 3 | Coach polish — B1 prompt rewrite (PROMPT_VERSION 4.20.2 → 4.22.0), B2 collapsible evidence, B3 Gravatar parity, B4 disclaimer move, B5 settings cog removal | done |
|
||||
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte sparkline + Δ-vs-last-month, C2 api-tokens 5th attempt, C3 Coolify auto-deploy runbook, C4 AuthShell flicker → proxy.ts, C5 node-26-alpine deferred, D backlog wave (12 items) | done |
|
||||
| 5 | Multi-agent QA + Product-Lead — code, security, design, senior-dev, simplify, product-lead; 0 CRIT, 7 HIGH all applied inline, ~6 MED applied, rest deferred | done |
|
||||
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, host-side retag deploy (Coolify secrets still missing), /api/version=1.4.22, smoke, GH release, docs+landing sync, brief | done |
|
||||
Carried forward; pick up as they become relevant or fold into a milestone.
|
||||
|
||||
Milestone completed 2026-05-10T22:43:50+02:00 — v1.4.22 LIVE in prod.
|
||||
Release brief: `docs/audit/v1422-summary.md`.
|
||||
Image digest:
|
||||
`sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb`.
|
||||
Backlog seeded to `.planning/v1422-backlog.md`. v1.5 strategic plan at
|
||||
`.planning/phase-W5-v1422-product-lead-review.md`.
|
||||
- **BYOK AI fallback** — resilience gap: no provider fallback is configured, so a single
|
||||
provider outage stalls assessment generation.
|
||||
- **`MedicationAdministration` per-export cap tuning** — the per-dose administration
|
||||
emission added in v1.9.0 needs a sensible cap for high-volume histories.
|
||||
- **SNOMED route / site upgrade** — route-of-administration + injection-site could carry
|
||||
SNOMED codes rather than the current local enum.
|
||||
- **German bfarm ATC URI behind a locale toggle** — the export codes ATC against the WHO
|
||||
system; a German deployment may prefer the bfarm URI.
|
||||
|
||||
---
|
||||
|
||||
## Previous patch — v1.4.21 (completed 2026-05-10T17:46+00:00)
|
||||
## Shipped (coarse — CHANGELOG.md is the record)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| F | Same-day patch on top of v1.4.20 — Daily Briefing regenerate fix, Coach day-level snapshot, duplicate streaming bubble, drawer header, scope picker UI | done |
|
||||
| E | Release v1.4.21 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag fallback, /api/version=1.4.21, smoke, GH release, brief | done |
|
||||
|
||||
Patch completed 2026-05-10T17:46+00:00 — v1.4.21 LIVE in prod.
|
||||
Release brief: `docs/audit/v1421-summary.md`.
|
||||
Image digest:
|
||||
`sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.20 (completed 2026-05-10T16:49:25Z)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| F0 | Bootstrap — commit dangling v1.4.19 reports, scaffold STATE+ROADMAP | done |
|
||||
| F1 | Branch model — long-lived `develop` from `main` HEAD; GHCR builds on `main` + `v*` only | done |
|
||||
| F2 | Document branch + release model — CONTRIBUTING.md + docs site mirror page | done |
|
||||
| FX | User-facing artifact cleanup — PII (no real names, no health figures), internal jargon, German leaks; CHANGELOG + docs/audit + GH releases + docs + landing | done |
|
||||
| F5 | Best-practice GitHub repo audit — CODE_OF_CONDUCT.md, issue + PR templates, dependabot expansion, package.json metadata | done |
|
||||
| F6 | Multi-agent QA on new docs — 38 docs pages cross-checked vs deployed state, CRIT + HIGH inline | done |
|
||||
| B1 | Hero strip + Daily Briefing + Suggested-prompts | done |
|
||||
| B2 | AI Coach drawer + SSE streaming + encrypted persistence | done |
|
||||
| B3 | Correlation discovery + Trends row with AI annotations | done |
|
||||
| B4 | Weekly Report + Storyboard markers + Mobile passes | done |
|
||||
| B5 | Personal Health Score (composite 0–100, 3 bands) | done |
|
||||
| D | Multi-agent QA — code, security, design, senior-dev, simplify, product-lead; 13 HIGH + 6 MED + 5 simplify-yes inline; 22 MED + 16 LOW deferred | done |
|
||||
| E | Release v1.4.20 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag deploy fallback, /api/version=1.4.20, smoke, docs+landing sync | done |
|
||||
|
||||
Milestone completed 2026-05-10T16:49:25Z — v1.4.20 LIVE in prod.
|
||||
Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to
|
||||
`.planning/v1421-backlog.md`. v1.5 strategic plan at
|
||||
`.planning/phase-D-v1420-product-lead-review.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.19 (completed 2026-05-10T12:39:59Z)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.19 | done |
|
||||
| A1 | BD-Zielbereich constant 50% (4th attempt) — live-DB root-cause + integration test | done |
|
||||
| A2 | Charts mobile audit — universal X-tick density helper, mobile-first stacked headers across HealthChart / MoodChart / MedicationComplianceChart | done |
|
||||
| A3 | `/insights` polish — Comparison-toggle relocated, single page-level refresh, BP/Weight tile strip removed, raw-token leak guarded | done |
|
||||
| A4 | AI prompt — GROUND RULE 7 forbids default-positivity opener, PROMPT_VERSION 4.16.1 → 4.19.0 | done |
|
||||
| A5 | Settings/Integrations status-UI — `<IntegrationStatusPill>` consolidates Withings + Mood Log, divider parity, locale-aware relative-time | done |
|
||||
| A6 | Settings mobile audit — input heights equalised at 36 px, action buttons standardised, Sprache select hoisted to its own row | done |
|
||||
| A7 | Admin polish — feedback tab strip scrollbar, api-tokens 4th-attempt truncate+tooltip, Einklappen toggle removed, Zielwerte i18n | done |
|
||||
| A8 | Quality-of-life audit — 78 findings prioritised CRITICAL / HIGH / MED / LOW | done |
|
||||
| B | Apply A8 — 6/6 CRITICAL + 21/25 HIGH inline; 31 MED + 16 LOW carried to v1.4.20 backlog | done |
|
||||
| D | Multi-agent QA (5 reviewers) + Product-Lead → v1.5 redesign plan filed | done |
|
||||
| E1-E3 | Release v1.4.19 — tag, GHCR build green, host-side retag deploy via fallback (Coolify deploy hung, retried via SSH), GH release, docs+landing sync | done |
|
||||
|
||||
Milestone completed 2026-05-10T12:39:59Z — v1.4.19 LIVE in prod.
|
||||
Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to
|
||||
`.planning/v1420-backlog.md` (carry-over) and v1.5 strategic items in
|
||||
`.planning/v15-backlog.md` + `.planning/phase-D-v1419-product-lead-review.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18 | done |
|
||||
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | done |
|
||||
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual offender | done |
|
||||
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | done |
|
||||
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | done |
|
||||
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design, senior, simplify, product | done |
|
||||
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | done |
|
||||
|
||||
Marathon completed 2026-05-10T11:45+02:00 — v1.4.18 LIVE in prod.
|
||||
Full report: `docs/audit/v1418-summary.md`. Backlog seeded to
|
||||
`.planning/v1419-backlog.md` (tactical) and `.planning/v15-backlog.md`
|
||||
|
||||
- `.planning/phase-D-v1418-product-lead-review.md` (strategic).
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.17 hotfix (live 2026-05-10T07:58+00:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Hotfix | `/insights` TypeError on legacy cached blob — `isLegacyInsightPayload()` flag + advisor card short-circuit | done |
|
||||
| Audit | `git grep -nE '\.replace\(' src/` — 82 hits scanned, crash site is the only fragile case | done |
|
||||
| E | Release v1.4.17 — bump, tag, GHCR, host-side retag deploy, GH release | done |
|
||||
|
||||
Hotfix completed 2026-05-10T07:58+00:00 — v1.4.17 LIVE in prod.
|
||||
Detailed report: `.planning/phase-v1417-hotfix-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.16 (completed 2026-05-10T04:05+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.16 | done |
|
||||
| A1 | Sidebar admin-expand bug — Admin nav from non-admin route + Gravatar dropdown side-effects | done |
|
||||
| A2 | BD-Zielbereich real-fix (regression from v1.4.15 A4) — root-cause + E2E | done |
|
||||
| A3 | `/admin/api-tokens` table responsive — card-list mobile fallback | done |
|
||||
| A4 | "7-Tage-Schnitt" → "7-Tage-Trend" DE + indicator on ALL charts | done |
|
||||
| A5 | Top-tile-selector real-fix — widget-id enum drift fixed | done |
|
||||
| A6 | Medication-chart 7d-trend + target-range — match other charts | done |
|
||||
| A7 | AI Generator rate-limit 10/h + cache-invalidate-on-new | done |
|
||||
| A8 | Umlaute encoding bug + login-overview UTF-8 audit + long-window split-half delta | done |
|
||||
| B1a | Charts visual leap — gradients, baseline, rich tooltip, mood emoji glyphs | done |
|
||||
| B1b | Insights surface visual leap — page hero, recs grid, summary typography, dashboard preview | done |
|
||||
| B2 | AI provider settings UX cleanup — single pulldown-driven form, fallback chain editor | done |
|
||||
| B3 | Admin System-Status host-load chart — CPU/memory/disk-io graph last 2h | done |
|
||||
| B4 | Admin logs visibility deepening — filterable audit log + structured wide-event tail | done |
|
||||
| B5a | Medical-reference grounding — AHA / ESC / ESH / WHO bundle + UI citations | done |
|
||||
| B5b | Multi-provider redundancy — try-each-on-hard-failure, configurable fallback order | done |
|
||||
| B5c | Per-recommendation explainability — WHY / WINDOW / CITATIONS card with mini-chart | done |
|
||||
| B5d | Confidence score per recommendation — deterministic 0-100 from sufficiency / recency / signal | done |
|
||||
| B5e | User-feedback loop — thumbs persisted to RecommendationFeedback + daily aggregator | done |
|
||||
| B6 | Settings naming-audit — stringency, consistency, no double naming | done |
|
||||
| B7 | Settings → Export menu (Arztbrief consolidated) — new `/settings/export` route | done |
|
||||
| B8 | Extended comparison views — Vormonat / Vorjahr overlay across charts + tiles + insights | done |
|
||||
| C | Catch-up — 5 of 8 deferred HIGH + 3 of 5 mobile MED + docker-publish drop qemu-arm64 | done |
|
||||
| D | Multi-agent QA + Product-Lead — code-review, security, design, senior, simplify, product-lead | done |
|
||||
| E1-E3 | Release v1.4.16 — bump, tag, GHCR (both green), host-side retag deploy, GH release, docs+landing sync | done |
|
||||
|
||||
Marathon completed 2026-05-10T04:05+02:00 — v1.4.16 LIVE in prod.
|
||||
Full report: `docs/audit/v1416-summary.md`. v1.5 backlog seeded at
|
||||
`.planning/v15-backlog.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.15 (completed 2026-05-09T22:50+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.15 | done |
|
||||
| A1 | Nav conditionals + sidebar context-awareness | done |
|
||||
| A2 | `/admin` overview redesign + `/admin/api-tokens` responsive | done |
|
||||
| A3 | Quick-add labels + Stimmung-Card mobile + onboarding flicker | done |
|
||||
| A4 | Dashboard analytics fixes (BD-Zielbereich, Medikamente graph, Stimmung agg, 7-Tage-Trend, top-tile selector) | done |
|
||||
| A5 | Mobile UX audit + chart scroll-lockup fix | done |
|
||||
| B-mobile | Mobile audit fix-application (5 commits, 2/2 CRITICAL, 6/8 HIGH) | done |
|
||||
| B1 | Backup completeness — restore, download, upload, audit, docs | done |
|
||||
| B2 | Withings + moodLog sync robustness — status UI, refresh rotation, telegram on fail | done |
|
||||
| B3 | Notification reliability — auto-disable on 410, exp backoff, status visible | done |
|
||||
| B4 | Achievements UI — `/achievements` page + dashboard card | done |
|
||||
| B5 | Onboarding tour first-run — spotlight, Esc/arrows, restart from Settings | done |
|
||||
| B6 | Doctor-report v2 — configurable date range, practice-name on cover | done |
|
||||
| C1 | AI/Codex hardening — provider abstraction, JSON schema, citations, scope, slug-drift defense | done |
|
||||
| C2 | Auto-deployment — Coolify webhook on GHCR push, failure-notify | done |
|
||||
| C3 | CI/e2e reliability audit — root-cause flaky specs, docker-publish cache fix | done |
|
||||
| C4 | i18n coverage audit — EN+DE parity, non-empty test guards | done |
|
||||
| C5 | Empty-states audit — 13 surfaces upgraded | done |
|
||||
| D | Multi-agent QA (5 reviewers + reconcile) | done |
|
||||
| E1–E3 | Release v1.4.15 — bump, tag, GHCR, host-side retag deploy, docs+landing sync, Marc-Brief | done |
|
||||
|
||||
Marathon completed 2026-05-09T22:50+02:00 — v1.4.15 LIVE in prod.
|
||||
Full report: `docs/audit/v1415-summary.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.5 (relabelled v1.4.14, completed 2026-05-09)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP, git/CI/prod sanity, single chore commit | done |
|
||||
| 1 | Verify Codex-OAuth end-to-end (device-start shape, prod logs, gpt-5.3-codex slug) | done |
|
||||
| 2 | v1.4.6 deferred backlog — T2.1..T2.7 (wipe scope, DST math, 5xx → 422/503, status-card test, redact regex, Backups view, /admin/users) | done |
|
||||
| 3 | End-to-end test coverage — authed dashboard, add-measurement, KI flow, doctor PDF, insights, mobile smoke, axe-core extended | done |
|
||||
| 4 | Performance audit — Playwright capture, `docs/audit/v15-performance.md`, top-3 wins inline | done |
|
||||
| 4b | Admin Panel refactor — `/admin/[section]` dynamic routes mirror Settings; sidebar Admin group; legacy hash redirects; bundle-size win | done |
|
||||
| 5 | UX polish — design-review top-10 friction points, CRITICAL/HIGH triage | done |
|
||||
| 6 | Multi-agent QA (parallel) — code-reviewer, security, visual/UX, simplify | done |
|
||||
| 7 | Pre-release verification — typecheck / lint / format / test / integration / build / e2e | done |
|
||||
| 8 | Release v1.4.14 — rebrand patch deploy (host-side retag, GHCR `:1.4.14`, image digest `0ced46004a54…`) | done |
|
||||
| 9 | Docs + landing site sync to v1.4.14 | done |
|
||||
| 10 | Backfill GitHub releases v1.4.7..v1.4.13 from CHANGELOG | done |
|
||||
| 11 | Final summary `docs/audit/v1414-summary.md` with Marc-Brief | done |
|
||||
|
||||
Marathon completed 2026-05-09T18:35+02:00 — v1.4.14 LIVE in prod.
|
||||
Full report: `docs/audit/v1414-summary.md`.
|
||||
- **v1.9.0** (2026-06-02) — Insights time-ranges + period-over-period deltas, deeper
|
||||
mood insights, medication ATC + RxNorm into the FHIR export (+ MedicationAdministration
|
||||
per dose, Coverage-from-bare-KVNR, Composition narrative), advanced-settings rebuild,
|
||||
inline targets (`/targets` retired), connection-pool sizing fix, decoupled insight
|
||||
warm. Migration `0103`.
|
||||
- **v1.8.7.1** (2026-06-02) — assessment on every HealthKit metric page, one-tap
|
||||
pre-generation.
|
||||
- **v1.8.x** (2026-05-31 → 06-02) — Insights big release (reliable assessments, graded
|
||||
compression, embedded targets, explainers, English slugs), compliance + intake-slot
|
||||
medical fixes, injection-site tracking, FHIR codesystem + Coverage, instant
|
||||
assessments, GLP-1 blood-level chart.
|
||||
- **v1.7.x** (2026-05-31) — health-record PDF + FHIR R4 export, PRN + cyclic schedules,
|
||||
unified dashboard snapshot + nightly insight pre-gen, offline sync delta feed, full
|
||||
HealthKit chart coverage, Coach data clustering.
|
||||
- **v1.6.0** (2026-05-30) — medication editor overhaul + route of administration,
|
||||
one-time injection, today-tile read-flip, profile-photo upload.
|
||||
- **v1.5.x** (2026-05-24 → 29) — native iOS client public beta + Apple Health sync,
|
||||
medication scheduling (RRULE / rolling / one-shot), step consolidation, `safeFetch`
|
||||
egress hardening, avatar storage, `SESSION_COOKIE_SECURE` through compose.
|
||||
- **v1.4.24 → v1.4.50** (2026-05-11 → 24) — rollup tiers + perf, APNs + `push_attempts`,
|
||||
reminder suppression, self-healing stale-shell, localisation, MoodLog reverse-sync.
|
||||
- **v1.4.23** (2026-05-11) — pre-iOS backend foundation + hygiene (Apple Health enum +
|
||||
batch ingest + APNs scaffolding + OpenAPI generator). Earlier history archived.
|
||||
</content>
|
||||
+95
-223
@@ -1,245 +1,117 @@
|
||||
# v1.4.23 marathon — state log
|
||||
# State log — where we are now
|
||||
|
||||
Status: SHIPPED — v1.4.23 LIVE in prod
|
||||
Last update: 2026-05-11 (release wave)
|
||||
Status: **v1.9.0 shipping** — on branch `release/v1.9.0`, Draft PR #240 → `main`,
|
||||
about to tag + docker-publish + deploy.
|
||||
Last update: 2026-06-02
|
||||
|
||||
> Previous milestone: v1.4.22 live (image digest
|
||||
> `sha256:865154614303…`, `/api/version=1.4.22`).
|
||||
> v1.4.23 = pre-iOS prep + hygiene. Backend foundation so the iOS
|
||||
> Health app can ship in v1.5 without contract churn, plus 7
|
||||
> hygiene items from `.planning/v1422-backlog.md`.
|
||||
> Single trunk: `main` is always releasable. `develop` is retired (archived as the
|
||||
> tag `archive/develop-pre-v1.5`; never recreate it). Releases cut on a short-lived
|
||||
> `release/vX.Y.Z` branch → PR → merge → tag → `docker-publish.yml` → manual deploy →
|
||||
> verify `/api/version` on every target. See CLAUDE.md for the full model.
|
||||
|
||||
## Wave 1 — Research (single agent, three streams)
|
||||
---
|
||||
|
||||
- [ ] Apple Health `HKQuantityTypeIdentifier` → `MeasurementType`
|
||||
mapping, with units + canonical aggregation rules
|
||||
- [ ] APNs Node library decision (`apn` vs `@parse/node-apn` vs
|
||||
raw HTTP/2) with maintenance / type-safety / footprint pros/cons
|
||||
- [ ] OpenAPI 3.1 generator tooling for Next.js routes
|
||||
(`@asteasolutions/zod-to-openapi`, `ts-rest`, `next-rest`,
|
||||
hand-rolled)
|
||||
- Detailed report: `.planning/phase-W1-v1423-research.md`
|
||||
## v1.9.0 — what it carries
|
||||
|
||||
## Wave 2 — Apple Health foundation (F1+F2+F3)
|
||||
Insights time-ranges + period-over-period deltas, deeper mood insights, medication
|
||||
drug-coding into the FHIR export, an advanced-settings rebuild, an inline-targets
|
||||
move, and a stability fix to the connection pool.
|
||||
|
||||
### F1 — `MeasurementType` enum extension
|
||||
### Added
|
||||
|
||||
- [x] Added `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
|
||||
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
|
||||
`WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`
|
||||
(W1 recommended reusing `ACTIVITY_STEPS` + `SLEEP_DURATION`
|
||||
verbatim; the iOS DTO already shipped `BODY_TEMPERATURE` so
|
||||
that joined the additive set — net new = 7)
|
||||
- [x] Prisma migration `0036_apple_health_measurement_types`
|
||||
- [x] Sleep unit shifted from hours to minutes; new `SleepStage`
|
||||
enum + nullable `Measurement.sleepStage` column (CHECK
|
||||
constraint scopes it to SLEEP_DURATION rows)
|
||||
- [x] Composite unique index `(user_id, type, source, external_id)`
|
||||
— Apple Health batch dedup key
|
||||
- [x] Unit conversions in
|
||||
`src/lib/measurements/apple-health-mapping.ts` (16 unit tests)
|
||||
- **Selectable time ranges on the Insights pages** — week / month / quarter / year
|
||||
range pills per metric, with a period-over-period delta (the change in the average,
|
||||
stated plainly with its direction).
|
||||
- **Deeper mood insights** — time-of-day pattern (highest / lowest), a stability read,
|
||||
and correlation cards against weight and blood pressure. Every correlation is
|
||||
paired-n gated (only shown when there are enough paired days to mean anything).
|
||||
- **Standard drug codes on a medication** — an optional ATC and RxNorm code (entered,
|
||||
never guessed; migration `0103` adds the nullable `atc_code` / `rxnorm_code`).
|
||||
These flow into the health-record export: the `MedicationStatement` codes the drug
|
||||
with the WHO ATC system + RxNorm alongside the plain name, and the export adds a
|
||||
`MedicationAdministration` for every dose actually taken or skipped.
|
||||
- **`scripts/assert-deploy.ts`** — a deploy-verification script that checks a target
|
||||
reports the expected version after a release.
|
||||
|
||||
### F2 — `MeasurementSource` enum expansion
|
||||
### Changed
|
||||
|
||||
- [x] `APPLE_HEALTH` appended to the enum
|
||||
- [x] UI badge in measurement-list (Dracula-pink chip) + bilingual
|
||||
`measurements.sourceAppleHealth` keys + admin restore-route
|
||||
enum guard sync
|
||||
- **Medication advanced-settings page rebuilt** — import / intake-import / export in
|
||||
their own group; the external-API endpoints listed one by one with collapsible
|
||||
request examples; tidier layout.
|
||||
- **Targets edited inline** — the standalone `/targets` page is retired; a metric's
|
||||
target range is set from the metric itself.
|
||||
- **Health-record export emits insurer Coverage from a bare KVNR** (not only with a
|
||||
full insurer organisation present) and carries a top-level Composition narrative
|
||||
summary.
|
||||
|
||||
### F3 — `POST /api/measurements/batch`
|
||||
### Fixed
|
||||
|
||||
- [x] New route at `src/app/api/measurements/batch/route.ts`,
|
||||
idempotent via `Idempotency-Key`
|
||||
- [x] Validates batch ≤ 500 entries (returns 422
|
||||
`coach.batch.too_large`)
|
||||
- [x] Dedupes via `(userId, type, source, externalId)` composite
|
||||
- [x] Returns per-entry status (`inserted` / `duplicate` /
|
||||
`skipped`) + idempotency-replay-safe
|
||||
- [x] Integration test (`tests/integration/measurements-batch.test.ts`,
|
||||
6 cases including idempotency replay)
|
||||
- [x] Sleep-stage aggregation in `/api/analytics`:
|
||||
per-Berlin-day summary + `sleepStages` block
|
||||
(`tests/integration/analytics-sleep-stages.test.ts`)
|
||||
- Detailed report: `.planning/phase-W2-v1423-report.md`
|
||||
- **App stays responsive under load** — `DB_CONNECTION_LIMIT` is now sized for real
|
||||
concurrency (the 9 default could starve while a background insight warm and a
|
||||
foreground request competed, surfacing as the self-recovering "server not
|
||||
responding"); tunable for larger self-hosts.
|
||||
- **Background insight warm decoupled + sync-burst invalidation debounced** — a warm
|
||||
no longer blocks a page, and a burst of Apple Health / Withings sync no longer
|
||||
triggers a storm of regenerations.
|
||||
- **Medication card rows hold an equal height** ("last dose" / "next dose" alignment).
|
||||
- **Glucose entered in mmol/L converts correctly** in the editor.
|
||||
- **Scrollbar-gutter reserved** — admin + global layout no longer shifts when a
|
||||
scrollbar appears.
|
||||
- **Six legacy `*-status` routes documented** in the OpenAPI contract.
|
||||
|
||||
## Wave 3 — APNs scaffolding (F4)
|
||||
---
|
||||
|
||||
- [x] Library install + lock-file (`@parse/node-apn@8.1.0`,
|
||||
W1 stream-2 decision)
|
||||
- [x] `src/lib/notifications/senders/apns.ts` — `sendApnsPush()`
|
||||
one-shot helper plus `sendViaApns(userId, payload)` dispatcher
|
||||
entry mirroring the web-push contract
|
||||
- [x] Env-var contract: `APNS_KEY_ID`, `APNS_TEAM_ID`,
|
||||
`APNS_BUNDLE_ID`, one of `APNS_KEY` / `APNS_KEY_FILE`,
|
||||
optional `APNS_PRODUCTION` — documented in `.env.example`,
|
||||
all-or-none guard with one-shot warning when partially set
|
||||
- [x] `Device` Prisma model extension via migration
|
||||
`0037_apns_device_columns`: nullable `apnsToken` +
|
||||
`apnsEnvironment`, paired CHECK constraint, single-column
|
||||
index on `apns_token` for the dispatcher fan-out lookup
|
||||
- [x] Dispatcher wiring: `APNS` joins the cascade as channel-type
|
||||
4; cascade order is now deterministic
|
||||
(APNs → Telegram → ntfy → Web Push)
|
||||
- [x] Mock provider at `src/lib/notifications/senders/__mocks__/apns.ts`
|
||||
with queued per-token responses + recorded calls
|
||||
- [x] Integration test
|
||||
`tests/integration/apns-dispatch.test.ts` — round-trip,
|
||||
hard-reject + cleanup, cascade fall-through (3 tests)
|
||||
- [x] `POST /api/devices` accepts paired `apnsToken` +
|
||||
`apnsEnvironment`, enforces hex format, applies the
|
||||
cross-user-hijack guard at the APNs-token layer
|
||||
(409 `apns_token_owned_by_other_user`)
|
||||
- Detailed report: `.planning/phase-W3-v1423-report.md`
|
||||
## What is live (the release arc since v1.4.23)
|
||||
|
||||
## Wave 4 — OpenAPI + Coach schema + native auth + Coolify
|
||||
Coarse summary; CHANGELOG.md is the accurate per-release record.
|
||||
|
||||
### F5 — OpenAPI 3.1 generator + CI gate
|
||||
- **v1.4.24 → v1.4.50** — perf + rollup tiers (measurement / mood / compliance /
|
||||
cumulative-sum rollups, read-swap with live fallback, auto-converging boot
|
||||
backfill), APNs delivery + per-channel test endpoint + `push_attempts` ledger,
|
||||
server-side reminder suppression (`clientManaged`), self-healing stale-shell after
|
||||
deploy, full localisation push, MoodLog reverse-sync.
|
||||
- **v1.5.x** — native iOS client public beta + Apple Health sync (`POST /api/measurements/batch`,
|
||||
per-day cumulative `stats:` overwrite), medication scheduling (RRULE / rolling /
|
||||
one-shot lifecycle, creation wizard → modal-dialog compose-mode), per-day-cumulative
|
||||
step consolidation, `safeFetch` egress hardening, self-hosted avatar storage,
|
||||
`SESSION_COOKIE_SECURE` plumbed through compose.
|
||||
- **v1.6.0** — medication editor overhaul + route of administration (`deliveryForm`,
|
||||
migration `0088` ORAL backfill), one-time injection, today-tile read-flip onto the
|
||||
canonical recurrence engine, profile-photo upload.
|
||||
- **v1.7.x** — health-record export (enriched PDF + HL7 FHIR R4 bundle), PRN + cyclic
|
||||
schedules, unified dashboard snapshot + nightly insight pre-generation, offline sync
|
||||
delta feed (tombstones), full HealthKit chart coverage + display-unit preference,
|
||||
Coach data clustering, multi-time compliance fixes.
|
||||
- **v1.8.x** — Insights big release (reliable data-driven assessments, graded
|
||||
compression, embedded targets, explainers, English slugs + tile-id aliases),
|
||||
multi-time compliance, one-intake-row-per-dose-slot medical fix, injection-site
|
||||
tracking, FHIR codesystem for HealthKit-only metrics, FHIR `Coverage`, instant
|
||||
assessments (stale-while-revalidate), GLP-1 blood-level chart.
|
||||
- **v1.8.7.1** — a plain-language assessment on every HealthKit metric page (~30
|
||||
metrics), one-tap pre-generation of every assessment.
|
||||
|
||||
- [x] Tool wired (W1 decision — `zod-openapi` (samchungy) + `yaml@^2`)
|
||||
- [x] `pnpm openapi:generate` script emits
|
||||
`docs/api/openapi.yaml` from Zod schemas
|
||||
- [x] CI step compares generated vs committed; PR warns on drift
|
||||
(`continue-on-error: true` — flips to hard-fail in v1.4.24+)
|
||||
- [x] Legacy hand-maintained spec preserved at
|
||||
`docs/api/openapi-v1422-legacy.yaml` during the incremental
|
||||
migration
|
||||
apps01 + demo are LIVE on the latest tag; v1.9.0 deploy is pending the tag.
|
||||
|
||||
### F6 — Coach + Daily Briefing schema slot for new metrics
|
||||
---
|
||||
|
||||
- [x] `aiInsightResponseSchema.dailyBriefing.keyFindings[].sourceMetric`
|
||||
enum extends to include the 9 Apple Health categories
|
||||
(hrv, sleep, resting_hr, steps, active_energy, flights,
|
||||
distance, vo2_max, body_temp)
|
||||
- [x] `trendAnnotations` schema mirrors the same additive enum
|
||||
- [x] `coach/snapshot.ts` queries the new measurement types when
|
||||
scope toggles them on; web-only accounts pay zero extra SQL
|
||||
- [x] `CoachProvenance.metrics` + `counts` extended symmetrically
|
||||
- [x] PROMPT_VERSION 4.22.0 → 4.23.0 with new GROUND RULE 12
|
||||
("treat Apple Health categories as silent when absent")
|
||||
- [x] EN + DE prompt bodies, OUTPUT FORMAT block, and i18n strings
|
||||
all updated
|
||||
## Deferred set (carry-forward from v1.9.0)
|
||||
|
||||
### F7 — Native API hardening (refresh-token per-device)
|
||||
| Item | Where it goes | Note |
|
||||
|---|---|---|
|
||||
| Derived / synthesized wellness metrics | **v1.10.0** | per-metric Vitals dashboard first, then composites with coverage/confidence gating. Plans in `.planning/v1.10-derived-metrics-PROPOSAL.md` + `.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0) + `.planning/v1.10-research/*`. |
|
||||
| Docs / provenance audit | **v1.10.0** | docs site + in-repo `docs/` + README + OpenAPI drifted across the v1.8.x → v1.9.0 arc; audit + continue, and surface every derived metric's sources via the explainer pattern. Requirement: `.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`. |
|
||||
| Discoverability / growth workstream | **v1.10.0** | standing alongside the derived-metrics milestone. |
|
||||
| BYOK AI fallback | open | resilience gap — no provider fallback is configured, so a single provider outage stalls assessment generation. |
|
||||
| `MedicationAdministration` per-export cap tuning | open | the per-dose administration emission needs a sensible cap for high-volume histories. |
|
||||
| SNOMED route / site upgrade | open | route-of-administration + injection-site could carry SNOMED codes rather than the current local enum. |
|
||||
| German bfarm ATC URI behind a locale toggle | open | the export codes ATC against the WHO system; a German deployment may prefer the bfarm URI — gate behind a locale toggle. |
|
||||
|
||||
- [x] Refresh-token reuse-detection switches from "revoke all" to
|
||||
per-device-token revocation (legacy null-deviceId tokens fall
|
||||
back to user-wide revoke — safety hatch)
|
||||
- [x] New route `GET /api/auth/me/devices` lists active devices
|
||||
with last-seen, label, channels, isCurrent marker
|
||||
- [x] New route `DELETE /api/auth/me/devices/[id]` revokes one device
|
||||
(refresh + access tokens + Device row)
|
||||
- [x] Alternate `DELETE /api/devices/[id]` for the iOS token-rotation
|
||||
cleanup path (mirror of the auth/me variant)
|
||||
- [x] Ownership-boundary 404 on cross-user attempts
|
||||
---
|
||||
|
||||
### F8 — Coolify auto-deploy fix for real
|
||||
## Next milestone — v1.10.0
|
||||
|
||||
- [x] `COOLIFY_WEBHOOK` + `COOLIFY_TOKEN` GitHub repo secrets
|
||||
documented (maintainer action; see
|
||||
`.planning/coolify-auto-deploy-howto.md` for the verbatim
|
||||
recipe)
|
||||
- [x] Coolify "Watch image registry for new digests" UI toggle
|
||||
documented as the load-bearing piece (maintainer action — can't
|
||||
be flipped from CI)
|
||||
- [x] Workflow step gains a `::notice::` line so future runs surface
|
||||
the deploy timestamp + sha without opening the verbose log
|
||||
- [x] Verification recipe (`curl /api/version | jq .data.version`) + host-side fallback documented inline
|
||||
|
||||
## Wave 5 — Hygiene (H1-H7)
|
||||
|
||||
- [x] H1 sentinel parser malformed-enum hardening
|
||||
(`SentinelParseResult.malformedEntries[]` + per-line typed
|
||||
reasons; chat route splits `coach.keyvalues.parse_partial` from
|
||||
the full-block `coach.keyvalues.parse_failed`)
|
||||
- [x] H2 analytics-route unbounded `findMany` pagination
|
||||
(cursor-paged 5 000-row chunks via `fetchBpSeriesChunked`,
|
||||
`analytics.bp_in_target.row_count` wide-event meta for slow-
|
||||
query attribution; integration test seeds 6 000 rows across a
|
||||
chunk boundary)
|
||||
- [x] H3 `<CoachDrawer key={prefill}>` controlled-prop refactor
|
||||
(`useResettableValue` hook + pure `nextResettableValue` helper;
|
||||
`key={prefill}` removed from the parent mount)
|
||||
- [x] H4 per-user prompt-tuning surface (Coach settings cog return)
|
||||
(`User.coachPrefsJson` migration `0038_coach_prefs`; new
|
||||
`GET/PUT /api/auth/me/coach-prefs`; settings cog opens a
|
||||
right-edge `<Sheet>`; system-prompt prepends a per-user
|
||||
OVERRIDE; snapshot reads prefs BEFORE measurement queries so
|
||||
`excludeMetrics` filters before the snapshot lands)
|
||||
- [x] H5 schema drift on `medication_schedules.days_of_week`
|
||||
(decision: deploy — column is referenced by 9 source files
|
||||
across 4 user-visible surfaces; migration
|
||||
`0039_medication_schedule_days_of_week`, NULL = daily)
|
||||
- [x] H6 Pearson p-value normal-approx replacement
|
||||
(raised `MIN_PAIRED_N` from 14 → 20 — conservative patch;
|
||||
rigorous incomplete-beta queued as a v1.4.24 candidate)
|
||||
- [x] H7 Coach helpful/unhelpful first-week observation view
|
||||
(polymorphic `RecommendationFeedback.target_type` migration
|
||||
`0040_recommendation_feedback_target_type`; new POST
|
||||
`/api/insights/chat/messages/:id/feedback`; aggregator slice
|
||||
`buildCoachFeedbackBuckets` by (promptVersion, tone, verbosity);
|
||||
admin section `/admin/coach-feedback` with the H7 i18n bundle
|
||||
both EN + DE)
|
||||
|
||||
W5 commits (8): 58ae9bc, fa07748, 9413d29, 3f60c81, 0eda1de, 1faee95,
|
||||
05c7f14, plus this STATE tick.
|
||||
|
||||
Test deltas: 2191 → 2223 unit (+32). Integration suite added 3
|
||||
files: `coach-prefs`, `coach-feedback`, plus the 6000-row chunk
|
||||
boundary regression in the existing `bp-in-target` test.
|
||||
|
||||
OpenAPI registry coverage: +3 routes (GET / PUT
|
||||
`/api/auth/me/coach-prefs`, POST
|
||||
`/api/insights/chat/messages/{id}/feedback`).
|
||||
|
||||
## Wave 6 — Multi-agent QA + Product-Lead review
|
||||
|
||||
- [x] code-reviewer
|
||||
- [x] security review
|
||||
- [x] design / UX review
|
||||
- [x] senior-dev review
|
||||
- [x] simplify
|
||||
- [x] Product Lead — v1.5 P1 (iOS first launch) plan refresh with
|
||||
concrete file paths now that backend contracts exist
|
||||
- [x] Reconcile — 1 CRITICAL + all 9 HIGH + 4 of 5 simplify + 3 MED
|
||||
applied; remainder triaged into `.planning/v1423-backlog.md`
|
||||
|
||||
W6 reconcile commits (11): 5486507, 13977bc, d5202e5, a2dfc5e (Session A
|
||||
— CRIT + HIGH 1-3); 3ee6dab, dfffe6b, d25e50e (Session B — HIGH 4 +
|
||||
S-02-S-04 simplify + HIGH 6); deadc73 (Session D — HIGH 5); 650f150
|
||||
(Session E — HIGH 7); 1a46bfe (Session F — MED cluster); plus this
|
||||
STATE tick.
|
||||
|
||||
Detailed reconcile report: `.planning/phase-W6-v1423-reconcile-report.md`.
|
||||
Deferred items: `.planning/v1423-backlog.md` (settings-cog vs
|
||||
per-message-controls debate, rigorous Pearson incomplete-beta, surplus
|
||||
MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
|
||||
issue, sandbox `git commit` no-op).
|
||||
|
||||
Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts`
|
||||
|
||||
- `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
|
||||
Session F added the forged-X-Device-Id regression — final at 2236 unit
|
||||
(+45 across the W5+W6 reconcile arc).
|
||||
|
||||
## Wave 7 — Release v1.4.23
|
||||
|
||||
- [x] Pre-release verify (typecheck / lint / openapi:check / 2236
|
||||
unit / 110 of 112 integration — 2 failures are the pre-existing
|
||||
`coach-prefs.test.ts` `NextRequest` URL mock carryover from
|
||||
v1.4.22, documented in `v1423-backlog.md`, non-blocking)
|
||||
- [x] Format sweep commit (`f1e6630` prettier sweep on `develop`) +
|
||||
W1 research + W5/W6 review packs commit (`766a9ae`) +
|
||||
OpenAPI regen (`4183552`)
|
||||
- [x] Bump `package.json` 1.4.22 → 1.4.23 + CHANGELOG (`d2331d9`
|
||||
`chore(release): v1.4.23`)
|
||||
- [x] Release-merge develop → main (`0dc0e16 Release v1.4.23`)
|
||||
- [x] Tag + push v1.4.23
|
||||
- [x] GHCR build green
|
||||
- [x] Coolify deploy + `/api/version=1.4.23` confirmed
|
||||
- [x] Production smoke (gated routes 307; `/api/version=1.4.23`)
|
||||
- [x] GH release published
|
||||
- [x] Docs site + landing site sync (image pins 1.4.22 → 1.4.23 +
|
||||
ai-insights v1.4.23 callout; softwareVersion JSON-LD bump)
|
||||
- [x] `docs/audit/v1423-summary.md` release brief
|
||||
- [x] v1.5 P1 plan refresh recorded
|
||||
(`.planning/phase-W6-v1423-product-lead-review.md` section C)
|
||||
Derived / synthesized wellness metrics, honest on the daily-snapshot data HealthLog
|
||||
actually stores; full provenance + standards-linking; full docs audit;
|
||||
discoverability / growth. See `ROADMAP.md`.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The AI connection test now tells you what actually went wrong.** A failed test used to surface a cryptic parse error ("unexpected token") because the server answered with a gateway-level error page the settings screen could not read; worse, the test probed a different provider than the one your nightly insights are generated with, so it could report a problem with a fallback you do not even use. The test now always returns a readable, translated reason — rejected credentials, rate-limited, a provider server error, or unreachable — and it checks the same provider your insights run on, so the result is the truth about your setup.
|
||||
- **Every insight metric page carries the same time-range controls.** The core-metric pages (weight, blood pressure, pulse, BMI, sleep) were missing the period selector that the other metric pages already had; they now match. Drill-down back-links are consistent and a "show all values" link returns you to the metric you came from.
|
||||
- **The derived-metrics dashboard no longer collapses or jumps while loading.** It shows a placeholder row at the right height, keeps its heading hidden until there is something to show, and offers an inline retry if the data fails to load instead of silently vanishing.
|
||||
- **More status colours meet contrast on the light theme** across the medication and admin surfaces.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The intra-day retention drain is back on.** The storage optimisation behind the stress reading was switched off in 1.10.0.2 after a daily-summary collision kept the app cycling post-deploy. The fold now adopts the existing daily row deterministically and recovers cleanly if a concurrent write reaches the same slot first — verified against real data — and the start-up defer that protects the connection pool stays in place.
|
||||
- **The batch-ingest and health-record export endpoints cap request-body size**, rejecting an oversized payload before parsing it.
|
||||
|
||||
### Added
|
||||
|
||||
- **A capability endpoint for API clients.** `GET /api/meta/capabilities` returns the server's live metric, tile, ingest and FHIR vocabularies alongside a contract version, so a client renders what it recognises and ignores what it does not — no more transcribing each release's new identifiers by hand.
|
||||
|
||||
## [1.10.1] — 2026-06-03 — Consistent medication cards
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every medication card looks the same now, whatever the type.** Oral, injection, GLP-1, as-needed, cyclic and one-time medications previously drew their "next / last intake" line, dose accent, spacing and compliance bars slightly differently, so cards in the same row could sit at different heights with the action buttons misaligned. The cards now share one layout: the same intake line, a reserved-height compliance area, and the take/skip buttons pinned to the bottom — so a row of mixed medications lines up cleanly. The dose accent also meets contrast on the light theme.
|
||||
|
||||
## [1.10.0.2] — 2026-06-03 — Disable the intra-day retention drain
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stops the post-deploy restart loop for good.** The intra-day retention drain (a storage optimisation behind the new stress reading) folded older samples into a daily summary, but on real data that summary collided with an existing daily row and the drain failed on every run, which kept the app cycling after a deploy. The drain is now switched off until it is reworked and tested against real data; the nightly pass is skipped, nothing is queued at start-up, and any work already queued completes harmlessly. No user-facing feature is lost — the stress reading simply falls back to its sparse-data behaviour.
|
||||
|
||||
## [1.10.0.1] — 2026-06-03 — Defer the intra-day retention drain past startup
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app no longer turns unresponsive shortly after a deploy or restart.** The new intra-day retention drain ran its catch-up pass the moment the background worker started, at the same time as the database migration, the other start-up backfills, and the first health checks — all sharing one connection pool. On an instance with a lot of history this exhausted the pool, so the app stopped answering until it restarted, then repeated. The catch-up now waits until start-up has settled before it runs; it is unchanged otherwise and the nightly pass is unaffected.
|
||||
|
||||
## [1.10.0] — 2026-06-03 — Derived wellness metrics, device-event awareness, deeper ingestion
|
||||
|
||||
### Added
|
||||
|
||||
- **Derived wellness metrics, each shown with how it is worked out.** From data you already sync, the Insights surface now derives a personal typical range for a vital, a cardio-fitness band, a vascular-age delta, an HRV (SDNN) balance read, body-mass index, a sleep score, a readiness index, and a coincident-deviation flag that notices when several vitals sit off their personal range at once. Every metric states its inputs, its method in plain language, and the published standard it is grounded in, with an honest read of how much data backs it — never a bare number. A metric appears only when there is enough history to mean something.
|
||||
- **Recovery, Stress, and Strain scores, computed each night.** Recovery blends your resting-heart-rate, HRV, sleep and respiratory trends against your own baseline; Strain reads the heart-rate load of your workouts; Stress is an HRV-derived proxy and is labelled as exactly that — not a sensor-measured stress reading. Each is stored as a first-class value you can chart, and each opens to a breakdown of its contributors and its cited basis.
|
||||
- **More of what your watch already measures.** Cardio recovery, overnight wrist temperature, fall count, six-minute-walk distance, stair ascent and descent speed, and the sleep breathing-disturbance index are now ingested and surfaced.
|
||||
- **An awareness timeline for device-flagged events.** Irregular-rhythm, high- and low-heart-rate, walking-steadiness, and breathing-disturbance notifications your device produced are shown as a timeline — strictly as awareness of your own device's certified result, never re-assessed and never a HealthLog diagnosis, with a permanent disclaimer to that effect. The section stays hidden until there is something to show.
|
||||
- **A per-workout heart-rate series**, so an indoor workout with no GPS still yields a training-load signal.
|
||||
- **A wellness summary in the doctor report.** The derived scores and metrics appear in their own section, kept out of the clinical vitals table, labelled descriptive rather than clinical, and exported to FHIR as `survey`-category observations with the same note — so a clinician's system never mistakes them for a vital sign or a diagnosis.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The health-record export carries richer medication codings.** A `MedicationAdministration` now adds a SNOMED route and injection-site coding alongside the existing plain text, a German-locale export can append the German BfArM ATC coding next to the unchanged WHO ATC entry, and the number of administrations an export can carry is raised and made configurable by the operator.
|
||||
- **The Insights overview reads its metrics through a single request** instead of one per tile, so opening the page no longer fans out into a burst of concurrent reads.
|
||||
- **Score-band colours meet contrast on the light theme**, and the device-notification copy — including its medical disclaimer — is translated across every supported language.
|
||||
|
||||
## [1.9.3] — 2026-06-02 — Medication card layout polish
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The medication cards look right again.** A recent alignment change pushed each card's action buttons to the very bottom of the cell, which left an empty gap between a shorter card's content and its buttons when it sat next to a taller card in the grid. The buttons now sit directly under the card's content again; the cards still share a height and the dose rows still line up, without the gap.
|
||||
|
||||
## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication compliance endpoint is now in the public API contract.** `GET /api/medications/{id}/compliance` was missing from the OpenAPI document, so a client generating its types from the contract could mistake the separate cadence endpoint (a different shape) for it. The route is now documented with its exact response — the 7- and 30-day adherence summaries, the per-day compliance grid, and the two-row display block — with the field a client should read for the headline 30-day rate and how to draw the per-day history. No behaviour change; the handler was already correct.
|
||||
|
||||
## [1.9.1] — 2026-06-02 — No assessment warm on a page visit
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Opening the Insights overview no longer regenerates every assessment.** A returning visit fired a one-per-session background warm that asked the provider to regenerate the comprehensive briefing and every category and metric assessment at once — a burst that could run for minutes and make the whole app feel unresponsive while it ran, recovering on its own once it finished. Assessments are kept fresh by the nightly pass, and each metric's text refreshes gently on its own when you open it; the "prepare assessments" button still regenerates everything on demand. A page visit now only reads the cached text.
|
||||
|
||||
## [1.9.0] — 2026-06-02 — Insights time-ranges, deeper mood, medication coding, stability
|
||||
|
||||
### Added
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
|
||||
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
|
||||
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.10 — a derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), each computed from your own measurements and citing its inputs, on top of the v1.7 health-record export (PDF + FHIR R4), flexible medication schedules, and full HealthKit metric coverage. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
|
||||
|
||||
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
|
||||
|
||||
@@ -60,12 +60,26 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
| Open source | AGPL-3.0 | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
|
||||
| Blood pressure + glucose | First-class | BP only | Manual | No | Manual |
|
||||
| Medication compliance | Cadence-aware | No | No | No | Manual |
|
||||
| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| No |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
|
||||
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
|
||||
| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a |
|
||||
| Subscription required | No | For some metrics| No | Yes | No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
|
||||
|
||||
### Versus WHOOP / Oura / Fitbit ecosystems
|
||||
|
||||
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
|
||||
|
||||
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
|
||||
- **It aggregates across providers instead of locking you to one.** Withings devices sync over OAuth2, an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, an Apple Watch, and a glucometer land on one timeline instead of three apps.
|
||||
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
|
||||
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
|
||||
|
||||
HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
@@ -86,6 +100,8 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
|
||||
|
||||
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
|
||||
@@ -488,7 +504,8 @@ For a single-process default the same container hosts both the web and worker (`
|
||||
| ------------ | ----- |
|
||||
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
|
||||
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
|
||||
| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
|
||||
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
|
||||
| **v1.8 – v1.10** (current) | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
|
||||
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
|
||||
|
||||
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
@@ -513,7 +530,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client
|
||||
### What the phone unlocks
|
||||
|
||||
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
|
||||
- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app.
|
||||
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
|
||||
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
|
||||
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
|
||||
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
|
||||
|
||||
@@ -50,6 +50,11 @@ services:
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
||||
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
||||
# an operator override in .env actually reaches the container (the
|
||||
# `environment:` block is a whitelist).
|
||||
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
+1038
-21
File diff suppressed because it is too large.
Load diff
@@ -44,6 +44,25 @@ PDF in a new tab without preserving the filename or fails the blob save).
|
||||
`500` if rendering fails.
|
||||
- Audit action: `doctor-report.pdf.generate`.
|
||||
|
||||
## Wellness summary (derived scores)
|
||||
|
||||
Since v1.10.0 the report carries a **Wellness summary** section below the
|
||||
clinical content, holding the server-derived wellness scores (Recovery,
|
||||
Stress, Strain) and the other derived metrics where present. It is kept
|
||||
deliberately separate from the clinical vitals table:
|
||||
|
||||
- Each entry is labelled **descriptive, not a clinical assessment**, and the
|
||||
scores never enter the vitals table or the clinical aggregations.
|
||||
- The Stress score is shown as an HRV-derived proxy, not a sensor-measured
|
||||
stress value.
|
||||
- In the FHIR export the same scores are emitted as `survey`-category
|
||||
`Observation`s (text-only `code`, no fabricated LOINC) with a per-Observation
|
||||
non-clinical note — so a downstream system never mistakes them for a vital
|
||||
sign or a diagnosis.
|
||||
|
||||
Each derived metric states its inputs, method, and the cited standard it is
|
||||
grounded in (the same provenance the in-app explainer shows).
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
|
||||
|
||||
@@ -251,10 +251,20 @@ export async function expectMedicationOnList(
|
||||
page: Page,
|
||||
{ name, withNextDueChip }: { name: string; withNextDueChip: boolean },
|
||||
): Promise<void> {
|
||||
// Pin the clock to a fixed afternoon before rendering the list. The
|
||||
// recurring stub schedules an 08:00–09:00 window; the card derives its
|
||||
// header from the real clock, so when CI happens to run inside that
|
||||
// window the card shows the "take now" (in_window) pill and the
|
||||
// "Nächste Einnahme:" next-due line is correctly suppressed — making the
|
||||
// chip assertion below depend on the wall-clock. A fixed afternoon puts
|
||||
// the morning window in the past so the next-due line always renders.
|
||||
await page.clock.setFixedTime(new Date("2026-06-04T13:00:00Z"));
|
||||
await page.goto("/medications", { waitUntil: "domcontentloaded" });
|
||||
const main = page.locator("main");
|
||||
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
|
||||
if (withNextDueChip) {
|
||||
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible();
|
||||
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Trend-Diagramme",
|
||||
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
|
||||
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
|
||||
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog."
|
||||
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
|
||||
"wellnessTitle": "Wellness-Zusammenfassung",
|
||||
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
|
||||
"wellnessRecovery": "Erholung",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Belastung"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} Einzelwerte)",
|
||||
"expandDay": "Einzelwerte dieses Tages anzeigen",
|
||||
"collapseDay": "Einzelwerte dieses Tages ausblenden",
|
||||
"loadError": "Messwerte konnten nicht geladen werden."
|
||||
"loadError": "Messwerte konnten nicht geladen werden.",
|
||||
"typeCardioRecovery": "Erholungspuls",
|
||||
"typeWristTemperature": "Handgelenktemperatur",
|
||||
"typeFallCount": "Stürze",
|
||||
"typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke",
|
||||
"typeStairAscentSpeed": "Treppensteig-Tempo",
|
||||
"typeStairDescentSpeed": "Treppab-Tempo",
|
||||
"typeBreathingDisturbances": "Atemaussetzer im Schlaf",
|
||||
"typeIrregularRhythmNotification": "Hinweis auf unregelmäßigen Rhythmus",
|
||||
"typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz",
|
||||
"typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz",
|
||||
"typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität",
|
||||
"typeBreathingDisturbanceEvent": "Atemstörung",
|
||||
"typeRecoveryScore": "Erholungs-Score",
|
||||
"typeStressScore": "Stress-Score",
|
||||
"typeStrainScore": "Belastungs-Score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Blutzucker",
|
||||
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
|
||||
"skinTemperatureTitle": "Hauttemperatur",
|
||||
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln."
|
||||
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
|
||||
"cardioFitnessTitle": "Cardiofitness (VO₂max)",
|
||||
"cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der aussagekräftigste Laborwert für die Ausdauerfitness; ein höherer oder steigender Wert spricht für eine gute Herz-Kreislauf-Gesundheit.",
|
||||
"cardioRecoveryTitle": "Erholungspuls",
|
||||
"cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.",
|
||||
"wristTemperatureTitle": "Handgelenktemperatur",
|
||||
"wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen. Apple zeigt sie als Abweichung von deinem persönlichen Ausgangswert; ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.",
|
||||
"fallsTitle": "Stürze",
|
||||
"fallsBody": "Hier werden die von deiner Apple Watch erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.",
|
||||
"sixMinuteWalkTitle": "6-Minuten-Gehstrecke",
|
||||
"sixMinuteWalkBody": "Dies ist Apples Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Eine gleichbleibende oder steigende Strecke ist ein gutes Zeichen.",
|
||||
"stairAscentSpeedTitle": "Treppensteig-Tempo",
|
||||
"stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
|
||||
"stairDescentSpeedTitle": "Treppab-Tempo",
|
||||
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
|
||||
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
|
||||
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Zusammenfassende Statistik",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
|
||||
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
|
||||
},
|
||||
"valuesBack": "Zurück zu Insights"
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Deine Stimmung war merklich schwankend",
|
||||
"bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} von {required} Eingaben · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basiert auf {count} Tagen Verlauf",
|
||||
"missing": "Erfasse diese, um den Wert zu schärfen: {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wert {score} von 100, {band}",
|
||||
"ariaProvisional": "Wert noch nicht verfügbar — zu wenig Daten",
|
||||
"provisionalCaption": "Noch zu wenig Daten",
|
||||
"band": {
|
||||
"green": "gut",
|
||||
"yellow": "mittel",
|
||||
"red": "niedrig"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "So wird das berechnet",
|
||||
"trigger": "So wird das berechnet",
|
||||
"inputsLabel": "Eingaben",
|
||||
"noInputs": "Noch keine Eingaben für diesen Wert vorhanden.",
|
||||
"methodLabel": "Methode",
|
||||
"window": "Aus deinen letzten {days} Tagen · {source}-Daten",
|
||||
"asOf": "Stand {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Deine Vitalwerte",
|
||||
"loadingLabel": "Vitalwerte werden geladen",
|
||||
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
|
||||
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
|
||||
"typicalRange": "Typisch für dich: {low}–{high}",
|
||||
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
|
||||
"fitnessOlder": "Ausdauer ~{years} J. über deinem Alter",
|
||||
"fitnessTypical": "Ausdauer typisch für dein Alter",
|
||||
"fitnessNoNorm": "Alter und Geschlecht ergänzen, um dies einzuordnen",
|
||||
"vascularBelow": "{years} J. unter deinem Alter",
|
||||
"vascularAbove": "{years} J. über deinem Alter",
|
||||
"vascularMatch": "Im Einklang mit deinem Alter",
|
||||
"vascularNoAge": "Geburtsdatum ergänzen, um dies einzuordnen",
|
||||
"hrvBand": {
|
||||
"balanced": "Für dich ausgewogen",
|
||||
"unbalanced": "Über deinem üblichen Bereich",
|
||||
"low": "Unter deinem üblichen Bereich"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Untergewicht (WHO)",
|
||||
"normal": "Normalbereich (WHO)",
|
||||
"overweight": "Übergewicht (WHO)",
|
||||
"obese": "Adipositas (WHO)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Faktoren",
|
||||
"contributorValue": "{value} · {weight}% Gewicht",
|
||||
"contributorMissing": "keine Daten",
|
||||
"disclaimer": "Richtwert — keine klinische Bewertung.",
|
||||
"insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Schlaf-Score",
|
||||
"description": "Ein transparenter Schlafqualitäts-Score mit allen Teilwerten.",
|
||||
"method": "Eine 0–100-Mischung aus Schlafmenge (Schlaf vs. altersbasierter Bedarf), Effizienz (Schlaf ÷ Bettzeit), Konstanz (wie stabil deine Schlafmitte ist), Timing (vs. deine gewohnte Schlafmitte) und Zusammensetzung (REM- + Tiefschlafanteil). Teilwerte werden umgewichtet, wenn deine Daten einen nicht stützen; nichts wird erfunden.",
|
||||
"sub": {
|
||||
"sufficiency": "Schlafmenge",
|
||||
"efficiency": "Effizienz",
|
||||
"consistency": "Konstanz",
|
||||
"timing": "Timing",
|
||||
"composition": "Zusammensetzung"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Bereitschaft",
|
||||
"description": "Ein täglicher Wohlbefindens-Indikator daraus, wie deine Werte zu deiner eigenen Baseline stehen.",
|
||||
"method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität — jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.",
|
||||
"component": {
|
||||
"rhr": "Ruhepuls",
|
||||
"hrv": "HRV (SDNN)",
|
||||
"sleep": "Schlaf",
|
||||
"respiratory": "Atemfrequenz",
|
||||
"mood": "Stimmungsstabilität"
|
||||
},
|
||||
"caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "Dein jüngster HRV-(SDNN-)Durchschnitt im Vergleich zu deiner eigenen gleitenden Baseline (Median ± robuste Streuung). Ausgeglichen heißt: er liegt in deinem persönlichen Bereich; das Band stammt aus deinen Daten, nie aus einem Bevölkerungswert."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis — es unterscheidet nicht Muskel von Fett."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts — hier nie neu berechnet."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "Die Gefäßalter- bzw. Pulswellengeschwindigkeits-Schätzung deines Geräts im Vergleich zu deinem chronologischen Alter, als Differenz in Jahren. Die Schätzung stammt vom Gerät; HealthLog ordnet sie nur ein.",
|
||||
"caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet — beschreibend, keine klinische Gefäßbewertung.",
|
||||
"description": "Wie die Gefäßalter-Schätzung deines Geräts zu deinem chronologischen Alter steht."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "Ein Hinweis, wenn zwei oder mehr deiner Vitalwerte am selben Tag außerhalb ihres persönlichen Normalbereichs liegen. Jedes Band ist der robuste Median ± k·MAD deiner eigenen Verlaufsdaten und passt sich an dich an, nicht an einen Bevölkerungswert.",
|
||||
"caveat": "Ein Hinweis aus deinen eigenen Baselines — beschreibend, keine Diagnose und kein klinischer Alarm."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.",
|
||||
"caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.",
|
||||
"description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.",
|
||||
"caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.",
|
||||
"description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.",
|
||||
"caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.",
|
||||
"description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wellness-Werte",
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
"strain": "Belastung"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Mehrere Vitalwerte außerhalb der Baseline",
|
||||
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
|
||||
"vitals": "Außerhalb des Bereichs: {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Fitness",
|
||||
"description": "Deine VO₂max im Zeitverlauf — das wichtigste Maß für die Ausdauerfitness.",
|
||||
"chartTitle": "VO₂max (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Fitnessdaten",
|
||||
"description": "VO₂max-Werte erscheinen hier, sobald Apple Health sie aus deinen Workouts synchronisiert."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Fitness",
|
||||
"cardioRecovery": {
|
||||
"title": "Erholungspuls",
|
||||
"description": "Wie stark dein Puls eine Minute nach maximaler Belastung abfällt.",
|
||||
"chartTitle": "Erholungspuls (bpm)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Erholungspuls-Daten",
|
||||
"description": "Erholungspuls-Werte erscheinen hier, sobald Apple Health sie aus einem Workout synchronisiert."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Erholungspuls",
|
||||
"wristTemperature": {
|
||||
"title": "Handgelenktemperatur",
|
||||
"description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.",
|
||||
"chartTitle": "Handgelenktemperatur (°C)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Handgelenktemperatur-Daten",
|
||||
"description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Handgelenk-Temp.",
|
||||
"falls": {
|
||||
"title": "Stürze",
|
||||
"description": "Von deiner Apple Watch erkannte schwere Stürze, pro Tag gezählt.",
|
||||
"chartTitle": "Stürze pro Tag",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Sturzdaten",
|
||||
"description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel."
|
||||
}
|
||||
},
|
||||
"navFalls": "Stürze",
|
||||
"sixMinuteWalk": {
|
||||
"title": "6-Minuten-Gehtest",
|
||||
"description": "Apples Schätzung, wie weit du in sechs Minuten gehen könntest.",
|
||||
"chartTitle": "6-Minuten-Gehstrecke (m)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine 6-Minuten-Gehtest-Daten",
|
||||
"description": "Schätzwerte erscheinen hier, sobald Apple Health synchronisiert."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "6-Min-Gehtest",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Treppensteig-Tempo",
|
||||
"description": "Wie schnell du Treppen hinaufgehst, gemessen von iPhone oder Watch.",
|
||||
"chartTitle": "Treppensteig-Tempo (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Daten zum Treppensteig-Tempo",
|
||||
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Treppe hoch",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Treppab-Tempo",
|
||||
"description": "Wie schnell du Treppen hinabgehst, gemessen von iPhone oder Watch.",
|
||||
"chartTitle": "Treppab-Tempo (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Daten zum Treppab-Tempo",
|
||||
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Treppe runter",
|
||||
"breathingDisturbances": {
|
||||
"title": "Atemaussetzer",
|
||||
"description": "Wie oft deine Atmung im Schlaf gestört war.",
|
||||
"chartTitle": "Atemaussetzer im Schlaf",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Daten zu Atemaussetzern",
|
||||
"description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Atmung",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Gesundheitshinweise des Geräts",
|
||||
"sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an — es erkennt, bestätigt oder interpretiert sie nicht.",
|
||||
"loadError": "Die Gerätehinweise konnten nicht geladen werden.",
|
||||
"disclaimer": "Dies sind Hinweise, die dein Gerät mit seiner eigenen integrierten Erkennung erzeugt hat. Sie werden hier nur zu deiner Information angezeigt. Dies ist keine medizinische Bewertung durch HealthLog, und HealthLog stellt keine Diagnose. Wenn dich ein Hinweis beunruhigt, sprich mit deiner Ärztin oder deinem Arzt.",
|
||||
"event": {
|
||||
"irregularRhythm": "Hinweis auf unregelmäßigen Rhythmus",
|
||||
"highHeartRate": "Hinweis auf hohe Herzfrequenz",
|
||||
"lowHeartRate": "Hinweis auf niedrige Herzfrequenz",
|
||||
"walkingSteadiness": "Hinweis zur Gehstabilität",
|
||||
"breathingDisturbance": "Atemstörung während des Schlafs"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Dein Gerät hat einen möglichen unregelmäßigen Rhythmus erkannt.",
|
||||
"notDetected": "Dein Gerät hat keinen unregelmäßigen Rhythmus erkannt.",
|
||||
"inconclusive": "Dein Gerät konnte keine Messung vornehmen.",
|
||||
"low": "Dein Gerät hat eine niedrige Gehstabilität erkannt.",
|
||||
"veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.",
|
||||
"fired": "Dein Gerät hat dieses Ereignis erkannt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Aktiven Provider testen",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test fehlgeschlagen: {message}",
|
||||
"testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.",
|
||||
"testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.",
|
||||
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
|
||||
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
|
||||
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
|
||||
"disableCoach": {
|
||||
"title": "Coach ausblenden",
|
||||
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Trend charts",
|
||||
"aiSummaryTitle": "AI summary — not clinically validated",
|
||||
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
|
||||
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog."
|
||||
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
|
||||
"wellnessTitle": "Wellness summary",
|
||||
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
|
||||
"wellnessRecovery": "Recovery",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Strain"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} samples)",
|
||||
"expandDay": "Show samples for this day",
|
||||
"collapseDay": "Hide samples for this day",
|
||||
"loadError": "Could not load measurements."
|
||||
"loadError": "Could not load measurements.",
|
||||
"typeCardioRecovery": "Cardio recovery",
|
||||
"typeWristTemperature": "Wrist temperature",
|
||||
"typeFallCount": "Falls",
|
||||
"typeSixMinuteWalkDistance": "Six-minute walk distance",
|
||||
"typeStairAscentSpeed": "Stair ascent speed",
|
||||
"typeStairDescentSpeed": "Stair descent speed",
|
||||
"typeBreathingDisturbances": "Sleep breathing disturbances",
|
||||
"typeIrregularRhythmNotification": "Irregular rhythm notification",
|
||||
"typeHighHeartRateEvent": "High heart rate notification",
|
||||
"typeLowHeartRateEvent": "Low heart rate notification",
|
||||
"typeWalkingSteadinessEvent": "Walking steadiness alert",
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Blood glucose",
|
||||
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.",
|
||||
"skinTemperatureTitle": "Skin temperature",
|
||||
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase."
|
||||
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.",
|
||||
"cardioFitnessTitle": "Cardio fitness (VO₂ max)",
|
||||
"cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort. It is the single best lab marker of aerobic fitness, and a higher or rising value is a strong sign of cardiovascular health.",
|
||||
"cardioRecoveryTitle": "Cardio recovery",
|
||||
"cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.",
|
||||
"wristTemperatureTitle": "Wrist temperature",
|
||||
"wristTemperatureBody": "Wrist temperature is measured while you sleep. Apple shows it as the difference from your personal baseline; a steady reading is normal, while a sustained rise can hint at illness, intense training, or a cycle change.",
|
||||
"fallsTitle": "Falls",
|
||||
"fallsBody": "This counts the hard falls your Apple Watch detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.",
|
||||
"sixMinuteWalkTitle": "Six-minute walk distance",
|
||||
"sixMinuteWalkBody": "This is Apple's estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. A steady or rising distance is a good sign.",
|
||||
"stairAscentSpeedTitle": "Stair ascent speed",
|
||||
"stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.",
|
||||
"stairDescentSpeedTitle": "Stair descent speed",
|
||||
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
|
||||
"breathingDisturbancesTitle": "Sleep breathing disturbances",
|
||||
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Summary statistics",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
|
||||
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
|
||||
},
|
||||
"valuesBack": "Back to insights"
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Your mood has been noticeably variable",
|
||||
"bandVeryVariable": "Your mood has swung a lot lately"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} of {required} inputs · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Based on {count} days of history",
|
||||
"missing": "Track these to sharpen this: {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} out of 100, {band}",
|
||||
"ariaProvisional": "Score not available yet — not enough data",
|
||||
"provisionalCaption": "Not enough data yet",
|
||||
"band": {
|
||||
"green": "good",
|
||||
"yellow": "fair",
|
||||
"red": "low"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "How this is computed",
|
||||
"trigger": "How this is computed",
|
||||
"inputsLabel": "Inputs",
|
||||
"noInputs": "No inputs backed this value yet.",
|
||||
"methodLabel": "Method",
|
||||
"window": "From your last {days} days · {source} data",
|
||||
"asOf": "as of {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Your vitals",
|
||||
"loadingLabel": "Loading your vitals",
|
||||
"loadError": "Could not load your vitals right now.",
|
||||
"building": "Building your typical range — {count} of {target} days",
|
||||
"typicalRange": "Typical for you: {low}–{high}",
|
||||
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
|
||||
"fitnessOlder": "Cardio fitness ~{years} yr above your age",
|
||||
"fitnessTypical": "Cardio fitness typical for your age",
|
||||
"fitnessNoNorm": "Add your age and sex to frame this",
|
||||
"vascularBelow": "{years} yr below your age",
|
||||
"vascularAbove": "{years} yr above your age",
|
||||
"vascularMatch": "In line with your age",
|
||||
"vascularNoAge": "Add your date of birth to frame this",
|
||||
"hrvBand": {
|
||||
"balanced": "Balanced for you",
|
||||
"unbalanced": "Above your usual range",
|
||||
"low": "Below your usual range"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Underweight (WHO)",
|
||||
"normal": "Normal range (WHO)",
|
||||
"overweight": "Overweight (WHO)",
|
||||
"obese": "Obese (WHO)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Contributors",
|
||||
"contributorValue": "{value} · {weight}% weight",
|
||||
"contributorMissing": "no data",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"insufficient": "Not enough data yet to score this. Keep tracking and it will appear here."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Sleep score",
|
||||
"description": "A transparent sleep-quality score with every sub-score shown.",
|
||||
"method": "A 0–100 blend of sufficiency (asleep vs your age-based need), efficiency (asleep ÷ in-bed), consistency (how steady your sleep midpoint is), timing (vs your habitual midpoint) and composition (REM + deep share). Sub-scores reweight around any that your data cannot support; nothing is fabricated.",
|
||||
"sub": {
|
||||
"sufficiency": "Sufficiency",
|
||||
"efficiency": "Efficiency",
|
||||
"consistency": "Consistency",
|
||||
"timing": "Timing",
|
||||
"composition": "Composition"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Readiness",
|
||||
"description": "A daily wellness proxy from how your signals sit against your own baseline.",
|
||||
"method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability — each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.",
|
||||
"component": {
|
||||
"rhr": "Resting heart rate",
|
||||
"hrv": "HRV (SDNN)",
|
||||
"sleep": "Sleep",
|
||||
"respiratory": "Respiratory rate",
|
||||
"mood": "Mood stability"
|
||||
},
|
||||
"caveat": "A wellness proxy, not a recovery or clinical score."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "Your recent HRV (SDNN) average placed against your own rolling baseline (median ± a robust spread). Balanced means it sits inside your personal range; the band is built from your data, never a population cut-off."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only — it does not distinguish muscle from fat."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate — never recomputed here."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "Your device's vascular-age / pulse-wave-velocity estimate compared with your chronological age, expressed as a delta in years. The estimate is the device's; HealthLog only re-frames it.",
|
||||
"caveat": "Your device's own estimate, re-framed — descriptive, not a clinical vascular assessment.",
|
||||
"description": "How your device's vascular-age estimate sits against your chronological age."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "A flag raised when two or more of your vitals sit outside their personal typical range on the same day. Each band is the robust median ± k·MAD of your own history, so it adapts to you rather than to a population cut-off.",
|
||||
"caveat": "An awareness flag from your own baselines — descriptive, not a diagnosis or a clinical alert."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.",
|
||||
"caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.",
|
||||
"description": "A nightly recovery proxy from your HRV, resting heart rate and sleep."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.",
|
||||
"caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.",
|
||||
"description": "A nightly stress proxy from the shape of your intra-day HRV."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
|
||||
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
|
||||
"description": "A nightly activity-load proxy from your heart-rate-weighted effort."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wellness scores",
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
"strain": "Strain"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Several vitals off baseline",
|
||||
"summary": "{count} of your vitals sat outside their personal range today.",
|
||||
"vitals": "Outside range: {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Cardio Fitness",
|
||||
"description": "Your VO₂ max over time — the headline measure of aerobic fitness.",
|
||||
"chartTitle": "VO₂ max (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "No cardio-fitness data yet",
|
||||
"description": "VO₂ max readings appear here once Apple Health syncs from your workouts."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Cardio Fitness",
|
||||
"cardioRecovery": {
|
||||
"title": "Cardio Recovery",
|
||||
"description": "How far your heart rate drops one minute after peak exercise.",
|
||||
"chartTitle": "Cardio recovery (bpm)",
|
||||
"emptyState": {
|
||||
"title": "No cardio-recovery data yet",
|
||||
"description": "Cardio-recovery readings appear here once Apple Health syncs from a workout."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Cardio Recovery",
|
||||
"wristTemperature": {
|
||||
"title": "Wrist Temperature",
|
||||
"description": "Your overnight wrist temperature, tracked against your own baseline.",
|
||||
"chartTitle": "Wrist temperature (°C)",
|
||||
"emptyState": {
|
||||
"title": "No wrist-temperature data yet",
|
||||
"description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Wrist Temp",
|
||||
"falls": {
|
||||
"title": "Falls",
|
||||
"description": "Hard falls your Apple Watch detected, counted per day.",
|
||||
"chartTitle": "Falls per day",
|
||||
"emptyState": {
|
||||
"title": "No fall data yet",
|
||||
"description": "Detected falls appear here once Apple Health syncs. No falls is the goal."
|
||||
}
|
||||
},
|
||||
"navFalls": "Falls",
|
||||
"sixMinuteWalk": {
|
||||
"title": "Six-Minute Walk",
|
||||
"description": "Apple's estimate of how far you could walk in six minutes.",
|
||||
"chartTitle": "Six-minute walk distance (m)",
|
||||
"emptyState": {
|
||||
"title": "No six-minute-walk data yet",
|
||||
"description": "Six-minute-walk estimates appear here once Apple Health syncs."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "6-Min Walk",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Stair Ascent Speed",
|
||||
"description": "How fast you climb stairs, measured by your iPhone or Watch.",
|
||||
"chartTitle": "Stair ascent speed (m/s)",
|
||||
"emptyState": {
|
||||
"title": "No stair-ascent-speed data yet",
|
||||
"description": "Stair-ascent-speed readings appear here once Apple Health syncs."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Stair Ascent",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Stair Descent Speed",
|
||||
"description": "How fast you go down stairs, measured by your iPhone or Watch.",
|
||||
"chartTitle": "Stair descent speed (m/s)",
|
||||
"emptyState": {
|
||||
"title": "No stair-descent-speed data yet",
|
||||
"description": "Stair-descent-speed readings appear here once Apple Health syncs."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Stair Descent",
|
||||
"breathingDisturbances": {
|
||||
"title": "Breathing Disturbances",
|
||||
"description": "How often your breathing was disturbed during sleep.",
|
||||
"chartTitle": "Sleep breathing disturbances",
|
||||
"emptyState": {
|
||||
"title": "No breathing-disturbance data yet",
|
||||
"description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Breathing",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Device health notifications",
|
||||
"sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.",
|
||||
"loadError": "Could not load your device notifications.",
|
||||
"disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.",
|
||||
"event": {
|
||||
"irregularRhythm": "Irregular rhythm notification",
|
||||
"highHeartRate": "High heart rate notification",
|
||||
"lowHeartRate": "Low heart rate notification",
|
||||
"walkingSteadiness": "Walking steadiness alert",
|
||||
"breathingDisturbance": "Breathing disturbance during sleep"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Your device flagged a possible irregular rhythm.",
|
||||
"notDetected": "Your device did not flag an irregular rhythm.",
|
||||
"inconclusive": "Your device could not make a reading.",
|
||||
"low": "Your device flagged low walking steadiness.",
|
||||
"veryLow": "Your device flagged very low walking steadiness.",
|
||||
"fired": "Your device flagged this event."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Hide Coach",
|
||||
"description": "Hides the Coach button and drawer everywhere.",
|
||||
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Gráficos de tendencia",
|
||||
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
|
||||
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
|
||||
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog."
|
||||
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
|
||||
"wellnessTitle": "Resumen de bienestar",
|
||||
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
|
||||
"wellnessRecovery": "Recuperación",
|
||||
"wellnessStress": "Estrés",
|
||||
"wellnessStrain": "Esfuerzo"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} muestras)",
|
||||
"expandDay": "Mostrar las muestras de este día",
|
||||
"collapseDay": "Ocultar las muestras de este día",
|
||||
"loadError": "No se pudieron cargar las mediciones."
|
||||
"loadError": "No se pudieron cargar las mediciones.",
|
||||
"typeCardioRecovery": "Recuperación cardiaca",
|
||||
"typeWristTemperature": "Temperatura de muñeca",
|
||||
"typeFallCount": "Caídas",
|
||||
"typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos",
|
||||
"typeStairAscentSpeed": "Velocidad subiendo escaleras",
|
||||
"typeStairDescentSpeed": "Velocidad bajando escaleras",
|
||||
"typeBreathingDisturbances": "Alteraciones respiratorias del sueño",
|
||||
"typeIrregularRhythmNotification": "Irregular rhythm notification",
|
||||
"typeHighHeartRateEvent": "High heart rate notification",
|
||||
"typeLowHeartRateEvent": "Low heart rate notification",
|
||||
"typeWalkingSteadinessEvent": "Walking steadiness alert",
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Glucosa en sangre",
|
||||
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
|
||||
"skinTemperatureTitle": "Temperatura de la piel",
|
||||
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual."
|
||||
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.",
|
||||
"cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)",
|
||||
"cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo. Es el mejor marcador de laboratorio de la forma aeróbica, y un valor más alto o en aumento es una buena señal de salud cardiovascular.",
|
||||
"cardioRecoveryTitle": "Recuperación cardiaca",
|
||||
"cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.",
|
||||
"wristTemperatureTitle": "Temperatura de muñeca",
|
||||
"wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes. Apple la muestra como la diferencia respecto a tu valor de referencia; un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.",
|
||||
"fallsTitle": "Caídas",
|
||||
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu Apple Watch. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.",
|
||||
"sixMinuteWalkTitle": "Distancia en marcha de 6 minutos",
|
||||
"sixMinuteWalkBody": "Es la estimación de Apple de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. Una distancia estable o en aumento es una buena señal.",
|
||||
"stairAscentSpeedTitle": "Velocidad subiendo escaleras",
|
||||
"stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.",
|
||||
"stairDescentSpeedTitle": "Velocidad bajando escaleras",
|
||||
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
|
||||
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
|
||||
"breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Estadísticas resumidas",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
|
||||
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
|
||||
},
|
||||
"valuesBack": "Volver a Insights"
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Tu estado de ánimo ha sido notablemente variable",
|
||||
"bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} de {required} entradas · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basado en {count} días de historial",
|
||||
"missing": "Registra estos para afinar el valor: {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Puntuación {score} de 100, {band}",
|
||||
"ariaProvisional": "Puntuación aún no disponible — datos insuficientes",
|
||||
"provisionalCaption": "Datos aún insuficientes",
|
||||
"band": {
|
||||
"green": "bueno",
|
||||
"yellow": "medio",
|
||||
"red": "bajo"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "Cómo se calcula esto",
|
||||
"trigger": "Cómo se calcula esto",
|
||||
"inputsLabel": "Entradas",
|
||||
"noInputs": "Aún no hay entradas que respalden este valor.",
|
||||
"methodLabel": "Método",
|
||||
"window": "De tus últimos {days} días · datos de {source}",
|
||||
"asOf": "a fecha de {date}",
|
||||
"standardLink": "Estándar: {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Tus constantes",
|
||||
"loadingLabel": "Cargando tus constantes",
|
||||
"loadError": "No se pudieron cargar tus constantes en este momento.",
|
||||
"building": "Creando tu rango habitual — {count} de {target} días",
|
||||
"typicalRange": "Habitual para ti: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven",
|
||||
"fitnessOlder": "Forma cardiovascular ~{years} años por encima de tu edad",
|
||||
"fitnessTypical": "Forma cardiovascular típica para tu edad",
|
||||
"fitnessNoNorm": "Añade tu edad y sexo para enmarcar esto",
|
||||
"vascularBelow": "{years} años por debajo de tu edad",
|
||||
"vascularAbove": "{years} años por encima de tu edad",
|
||||
"vascularMatch": "Acorde con tu edad",
|
||||
"vascularNoAge": "Añade tu fecha de nacimiento para enmarcar esto",
|
||||
"hrvBand": {
|
||||
"balanced": "Equilibrado para ti",
|
||||
"unbalanced": "Por encima de tu rango habitual",
|
||||
"low": "Por debajo de tu rango habitual"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Bajo peso (OMS)",
|
||||
"normal": "Rango normal (OMS)",
|
||||
"overweight": "Sobrepeso (OMS)",
|
||||
"obese": "Obesidad (OMS)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Factores",
|
||||
"contributorValue": "{value} · {weight}% de peso",
|
||||
"contributorMissing": "sin datos",
|
||||
"disclaimer": "Indicativo — no es una evaluación clínica.",
|
||||
"insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Puntuación de sueño",
|
||||
"description": "Una puntuación de calidad del sueño transparente con todos sus subvalores.",
|
||||
"method": "Una mezcla de 0–100 de suficiencia (sueño frente a tu necesidad por edad), eficiencia (sueño ÷ tiempo en cama), consistencia (qué tan estable es tu punto medio de sueño), horario (frente a tu punto medio habitual) y composición (proporción de REM + sueño profundo). Los subvalores se reponderan en torno a los que tus datos no pueden sustentar; nada se inventa.",
|
||||
"sub": {
|
||||
"sufficiency": "Suficiencia",
|
||||
"efficiency": "Eficiencia",
|
||||
"consistency": "Consistencia",
|
||||
"timing": "Horario",
|
||||
"composition": "Composición"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Disposición",
|
||||
"description": "Un indicador diario de bienestar según cómo se sitúan tus señales frente a tu propia base.",
|
||||
"method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo — cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.",
|
||||
"component": {
|
||||
"rhr": "Frecuencia cardíaca en reposo",
|
||||
"hrv": "VFC (SDNN)",
|
||||
"sleep": "Sueño",
|
||||
"respiratory": "Frecuencia respiratoria",
|
||||
"mood": "Estabilidad del ánimo"
|
||||
},
|
||||
"caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "Tu media reciente de VFC (SDNN) frente a tu propia línea base móvil (mediana ± dispersión robusta). Equilibrada significa que está dentro de tu rango personal; la banda se construye con tus datos, nunca con un umbral poblacional."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Peso ÷ altura², clasificado según las categorías de la OMS para adultos (bajo peso / normal / sobrepeso / obesidad). Solo es una proporción de cribado: no distingue músculo de grasa."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Tu VO₂máx situado frente a percentiles de forma cardiorrespiratoria por edad y sexo, expresado como una diferencia de edad de forma física. El VO₂máx es la estimación de tu dispositivo, nunca se recalcula aquí."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "La estimación de edad vascular / velocidad de onda de pulso de tu dispositivo comparada con tu edad cronológica, expresada como una diferencia en años. La estimación es del dispositivo; HealthLog solo la reinterpreta.",
|
||||
"caveat": "La propia estimación de tu dispositivo, solo reinterpretada: descriptiva, no una evaluación vascular clínica.",
|
||||
"description": "Cómo se sitúa la estimación de edad vascular de tu dispositivo frente a tu edad cronológica."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "Un aviso que aparece cuando dos o más de tus constantes quedan fuera de su rango típico personal el mismo día. Cada banda es la mediana robusta ± k·MAD de tu propio historial, así que se adapta a ti y no a un umbral poblacional.",
|
||||
"caveat": "Un aviso a partir de tus propias líneas base: descriptivo, no un diagnóstico ni una alerta clínica."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.",
|
||||
"caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.",
|
||||
"description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.",
|
||||
"caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.",
|
||||
"description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.",
|
||||
"caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.",
|
||||
"description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Puntuaciones de bienestar",
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
"strain": "Esfuerzo"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Varias constantes fuera de la base",
|
||||
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
|
||||
"vitals": "Fuera de rango: {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Condición física",
|
||||
"description": "Tu VO₂ máx a lo largo del tiempo, la medida principal de la forma aeróbica.",
|
||||
"chartTitle": "VO₂ máx (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de condición física",
|
||||
"description": "Los valores de VO₂ máx aparecen aquí cuando Apple Health los sincroniza desde tus entrenamientos."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Condición física",
|
||||
"cardioRecovery": {
|
||||
"title": "Recuperación cardiaca",
|
||||
"description": "Cuánto baja tu frecuencia cardiaca un minuto después del esfuerzo máximo.",
|
||||
"chartTitle": "Recuperación cardiaca (lpm)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de recuperación cardiaca",
|
||||
"description": "Los valores de recuperación cardiaca aparecen aquí cuando Apple Health los sincroniza desde un entrenamiento."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Recuperación cardiaca",
|
||||
"wristTemperature": {
|
||||
"title": "Temperatura de muñeca",
|
||||
"description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.",
|
||||
"chartTitle": "Temperatura de muñeca (°C)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de temperatura de muñeca",
|
||||
"description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Temp. muñeca",
|
||||
"falls": {
|
||||
"title": "Caídas",
|
||||
"description": "Caídas fuertes que detectó tu Apple Watch, contadas por día.",
|
||||
"chartTitle": "Caídas por día",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de caídas",
|
||||
"description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo."
|
||||
}
|
||||
},
|
||||
"navFalls": "Caídas",
|
||||
"sixMinuteWalk": {
|
||||
"title": "Marcha de 6 minutos",
|
||||
"description": "La estimación de Apple de cuánto podrías caminar en seis minutos.",
|
||||
"chartTitle": "Distancia en 6 minutos (m)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de marcha de 6 minutos",
|
||||
"description": "Las estimaciones aparecen aquí cuando Apple Health se sincroniza."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "Marcha 6 min",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Velocidad subiendo escaleras",
|
||||
"description": "Lo rápido que subes escaleras, medido por tu iPhone o Watch.",
|
||||
"chartTitle": "Velocidad de subida (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de subida de escaleras",
|
||||
"description": "Los valores aparecen aquí cuando Apple Health se sincroniza."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Subir escaleras",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Velocidad bajando escaleras",
|
||||
"description": "Lo rápido que bajas escaleras, medido por tu iPhone o Watch.",
|
||||
"chartTitle": "Velocidad de bajada (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de bajada de escaleras",
|
||||
"description": "Los valores aparecen aquí cuando Apple Health se sincroniza."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Bajar escaleras",
|
||||
"breathingDisturbances": {
|
||||
"title": "Alteraciones respiratorias",
|
||||
"description": "Con qué frecuencia se alteró tu respiración durante el sueño.",
|
||||
"chartTitle": "Alteraciones respiratorias del sueño",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de alteraciones respiratorias",
|
||||
"description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Respiración",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Notificaciones de salud del dispositivo",
|
||||
"sectionIntro": "Notificaciones que tu reloj envió por su cuenta. HealthLog solo las muestra — no las detecta, confirma ni interpreta.",
|
||||
"loadError": "No se pudieron cargar las notificaciones de tu dispositivo.",
|
||||
"disclaimer": "Estas son alertas que tu dispositivo generó con su propia detección integrada. Se muestran aquí solo para tu información. No es una evaluación médica de HealthLog, y HealthLog no diagnostica ninguna afección. Si una notificación te preocupa, habla con tu médico.",
|
||||
"event": {
|
||||
"irregularRhythm": "Irregular rhythm notification",
|
||||
"highHeartRate": "High heart rate notification",
|
||||
"lowHeartRate": "Low heart rate notification",
|
||||
"walkingSteadiness": "Walking steadiness alert",
|
||||
"breathingDisturbance": "Breathing disturbance during sleep"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Your device flagged a possible irregular rhythm.",
|
||||
"notDetected": "Your device did not flag an irregular rhythm.",
|
||||
"inconclusive": "Your device could not make a reading.",
|
||||
"low": "Your device flagged low walking steadiness.",
|
||||
"veryLow": "Your device flagged very low walking steadiness.",
|
||||
"fired": "Your device flagged this event."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Ocultar el Coach",
|
||||
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
|
||||
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Graphiques de tendance",
|
||||
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
|
||||
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
|
||||
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog."
|
||||
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
|
||||
"wellnessTitle": "Résumé de bien-être",
|
||||
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
|
||||
"wellnessRecovery": "Récupération",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Effort"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} échantillons)",
|
||||
"expandDay": "Afficher les échantillons de ce jour",
|
||||
"collapseDay": "Masquer les échantillons de ce jour",
|
||||
"loadError": "Impossible de charger les mesures."
|
||||
"loadError": "Impossible de charger les mesures.",
|
||||
"typeCardioRecovery": "Récupération cardiaque",
|
||||
"typeWristTemperature": "Température du poignet",
|
||||
"typeFallCount": "Chutes",
|
||||
"typeSixMinuteWalkDistance": "Distance de marche de 6 minutes",
|
||||
"typeStairAscentSpeed": "Vitesse en montée d'escaliers",
|
||||
"typeStairDescentSpeed": "Vitesse en descente d'escaliers",
|
||||
"typeBreathingDisturbances": "Troubles respiratoires du sommeil",
|
||||
"typeIrregularRhythmNotification": "Irregular rhythm notification",
|
||||
"typeHighHeartRateEvent": "High heart rate notification",
|
||||
"typeLowHeartRateEvent": "Low heart rate notification",
|
||||
"typeWalkingSteadinessEvent": "Walking steadiness alert",
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Glycémie",
|
||||
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
|
||||
"skinTemperatureTitle": "Température cutanée",
|
||||
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel."
|
||||
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.",
|
||||
"cardioFitnessTitle": "Capacité cardio (VO₂ max)",
|
||||
"cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur marqueur de laboratoire de la forme aérobie, et une valeur plus élevée ou en hausse est un bon signe de santé cardiovasculaire.",
|
||||
"cardioRecoveryTitle": "Récupération cardiaque",
|
||||
"cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.",
|
||||
"wristTemperatureTitle": "Température du poignet",
|
||||
"wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil. Apple l'affiche comme l'écart par rapport à votre valeur de référence ; une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.",
|
||||
"fallsTitle": "Chutes",
|
||||
"fallsBody": "Ceci compte les chutes lourdes détectées par votre Apple Watch. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.",
|
||||
"sixMinuteWalkTitle": "Distance de marche de 6 minutes",
|
||||
"sixMinuteWalkBody": "C'est l'estimation d'Apple de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. Une distance stable ou en hausse est un bon signe.",
|
||||
"stairAscentSpeedTitle": "Vitesse en montée d'escaliers",
|
||||
"stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.",
|
||||
"stairDescentSpeedTitle": "Vitesse en descente d'escaliers",
|
||||
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
|
||||
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
|
||||
"breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statistiques récapitulatives",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
|
||||
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
|
||||
},
|
||||
"valuesBack": "Retour aux Insights"
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Votre humeur a été nettement variable",
|
||||
"bandVeryVariable": "Votre humeur a beaucoup fluctué récemment"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} entrées sur {required} · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basé sur {count} jours d'historique",
|
||||
"missing": "Enregistre ceci pour affiner la valeur : {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} sur 100, {band}",
|
||||
"ariaProvisional": "Score pas encore disponible — données insuffisantes",
|
||||
"provisionalCaption": "Données encore insuffisantes",
|
||||
"band": {
|
||||
"green": "bon",
|
||||
"yellow": "moyen",
|
||||
"red": "faible"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "Comment c'est calculé",
|
||||
"trigger": "Comment c'est calculé",
|
||||
"inputsLabel": "Entrées",
|
||||
"noInputs": "Aucune entrée ne soutient encore cette valeur.",
|
||||
"methodLabel": "Méthode",
|
||||
"window": "Sur tes {days} derniers jours · données {source}",
|
||||
"asOf": "au {date}",
|
||||
"standardLink": "Norme : {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Vos constantes",
|
||||
"loadingLabel": "Chargement de vos constantes",
|
||||
"loadError": "Impossible de charger vos constantes pour le moment.",
|
||||
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
|
||||
"typicalRange": "Habituel pour vous : {low}–{high}",
|
||||
"fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune",
|
||||
"fitnessOlder": "Forme cardio ~{years} ans au-dessus de votre âge",
|
||||
"fitnessTypical": "Forme cardio typique pour votre âge",
|
||||
"fitnessNoNorm": "Ajoutez votre âge et votre sexe pour situer cela",
|
||||
"vascularBelow": "{years} ans en dessous de votre âge",
|
||||
"vascularAbove": "{years} ans au-dessus de votre âge",
|
||||
"vascularMatch": "En phase avec votre âge",
|
||||
"vascularNoAge": "Ajoutez votre date de naissance pour situer cela",
|
||||
"hrvBand": {
|
||||
"balanced": "Équilibré pour vous",
|
||||
"unbalanced": "Au-dessus de votre plage habituelle",
|
||||
"low": "En dessous de votre plage habituelle"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Insuffisance pondérale (OMS)",
|
||||
"normal": "Plage normale (OMS)",
|
||||
"overweight": "Surpoids (OMS)",
|
||||
"obese": "Obésité (OMS)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Facteurs",
|
||||
"contributorValue": "{value} · {weight}% du poids",
|
||||
"contributorMissing": "aucune donnée",
|
||||
"disclaimer": "Indicatif — pas une évaluation clinique.",
|
||||
"insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Score de sommeil",
|
||||
"description": "Un score de qualité du sommeil transparent avec chaque sous-score affiché.",
|
||||
"method": "Un mélange de 0–100 entre suffisance (sommeil vs votre besoin selon l’âge), efficacité (sommeil ÷ temps au lit), régularité (stabilité de votre milieu de sommeil), timing (vs votre milieu habituel) et composition (part de REM + sommeil profond). Les sous-scores sont repondérés autour de ceux que vos données ne peuvent pas étayer ; rien n’est inventé.",
|
||||
"sub": {
|
||||
"sufficiency": "Suffisance",
|
||||
"efficiency": "Efficacité",
|
||||
"consistency": "Régularité",
|
||||
"timing": "Timing",
|
||||
"composition": "Composition"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Disponibilité",
|
||||
"description": "Un indicateur quotidien de bien-être selon la position de vos signaux par rapport à votre propre référence.",
|
||||
"method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur — chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.",
|
||||
"component": {
|
||||
"rhr": "Fréquence cardiaque au repos",
|
||||
"hrv": "VFC (SDNN)",
|
||||
"sleep": "Sommeil",
|
||||
"respiratory": "Fréquence respiratoire",
|
||||
"mood": "Stabilité de l’humeur"
|
||||
},
|
||||
"caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "Votre moyenne récente de VFC (SDNN) comparée à votre propre référence glissante (médiane ± dispersion robuste). Équilibrée signifie qu’elle se situe dans votre plage personnelle ; la bande est construite à partir de vos données, jamais d’un seuil de population."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Poids ÷ taille², classé selon les catégories adultes de l’OMS (maigreur / normal / surpoids / obésité). Un simple ratio de dépistage : il ne distingue pas le muscle de la graisse."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Votre VO₂max situé par rapport aux percentiles de condition cardiorespiratoire selon l’âge et le sexe, exprimé comme un écart d’âge de forme. Le VO₂max est l’estimation de votre appareil, jamais recalculée ici."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "L’estimation d’âge vasculaire / vitesse de l’onde de pouls de votre appareil comparée à votre âge chronologique, exprimée comme un écart en années. L’estimation vient de l’appareil ; HealthLog ne fait que la reformuler.",
|
||||
"caveat": "La propre estimation de votre appareil, simplement reformulée : descriptive, pas une évaluation vasculaire clinique.",
|
||||
"description": "Comment l’estimation d’âge vasculaire de votre appareil se situe par rapport à votre âge chronologique."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "Un indicateur qui se déclenche lorsque deux de vos constantes ou plus sortent de leur plage typique personnelle le même jour. Chaque bande est la médiane robuste ± k·MAD de votre propre historique : elle s’adapte à vous et non à un seuil de population.",
|
||||
"caveat": "Un indicateur issu de vos propres références : descriptif, ni un diagnostic ni une alerte clinique."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.",
|
||||
"caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.",
|
||||
"description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.",
|
||||
"caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.",
|
||||
"description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.",
|
||||
"caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.",
|
||||
"description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Scores de bien-être",
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
"strain": "Effort"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Plusieurs constantes hors référence",
|
||||
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
|
||||
"vitals": "Hors plage : {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Condition physique",
|
||||
"description": "Votre VO₂ max au fil du temps — la principale mesure de la forme aérobie.",
|
||||
"chartTitle": "VO₂ max (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de condition physique",
|
||||
"description": "Les valeurs de VO₂ max apparaissent ici dès qu'Apple Santé les synchronise depuis vos entraînements."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Condition physique",
|
||||
"cardioRecovery": {
|
||||
"title": "Récupération cardiaque",
|
||||
"description": "De combien votre fréquence cardiaque baisse une minute après l'effort maximal.",
|
||||
"chartTitle": "Récupération cardiaque (bpm)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de récupération cardiaque",
|
||||
"description": "Les valeurs de récupération cardiaque apparaissent ici dès qu'Apple Santé les synchronise depuis un entraînement."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Récupération cardiaque",
|
||||
"wristTemperature": {
|
||||
"title": "Température du poignet",
|
||||
"description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.",
|
||||
"chartTitle": "Température du poignet (°C)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de température du poignet",
|
||||
"description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Temp. poignet",
|
||||
"falls": {
|
||||
"title": "Chutes",
|
||||
"description": "Chutes lourdes détectées par votre Apple Watch, comptées par jour.",
|
||||
"chartTitle": "Chutes par jour",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de chutes",
|
||||
"description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute."
|
||||
}
|
||||
},
|
||||
"navFalls": "Chutes",
|
||||
"sixMinuteWalk": {
|
||||
"title": "Marche de 6 minutes",
|
||||
"description": "L'estimation d'Apple de la distance que vous pourriez parcourir en six minutes.",
|
||||
"chartTitle": "Distance sur 6 minutes (m)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de marche de 6 minutes",
|
||||
"description": "Les estimations apparaissent ici dès qu'Apple Santé se synchronise."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "Marche 6 min",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Vitesse en montée d'escaliers",
|
||||
"description": "À quelle vitesse vous montez les escaliers, mesurée par votre iPhone ou Watch.",
|
||||
"chartTitle": "Vitesse de montée (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de montée d'escaliers",
|
||||
"description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Montée escaliers",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Vitesse en descente d'escaliers",
|
||||
"description": "À quelle vitesse vous descendez les escaliers, mesurée par votre iPhone ou Watch.",
|
||||
"chartTitle": "Vitesse de descente (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de descente d'escaliers",
|
||||
"description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Descente escaliers",
|
||||
"breathingDisturbances": {
|
||||
"title": "Troubles respiratoires",
|
||||
"description": "À quelle fréquence votre respiration a été perturbée pendant le sommeil.",
|
||||
"chartTitle": "Troubles respiratoires du sommeil",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de troubles respiratoires",
|
||||
"description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Respiration",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Notifications de santé de l’appareil",
|
||||
"sectionIntro": "Notifications que votre montre a envoyées d’elle-même. HealthLog se contente de les afficher — il ne les détecte, ne les confirme ni ne les interprète.",
|
||||
"loadError": "Impossible de charger les notifications de votre appareil.",
|
||||
"disclaimer": "Ce sont des alertes que votre appareil a produites avec sa propre détection intégrée. Elles sont affichées ici uniquement à titre informatif. Il ne s’agit pas d’une évaluation médicale de HealthLog, et HealthLog ne pose aucun diagnostic. Si une notification vous inquiète, parlez-en à votre médecin.",
|
||||
"event": {
|
||||
"irregularRhythm": "Irregular rhythm notification",
|
||||
"highHeartRate": "High heart rate notification",
|
||||
"lowHeartRate": "Low heart rate notification",
|
||||
"walkingSteadiness": "Walking steadiness alert",
|
||||
"breathingDisturbance": "Breathing disturbance during sleep"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Your device flagged a possible irregular rhythm.",
|
||||
"notDetected": "Your device did not flag an irregular rhythm.",
|
||||
"inconclusive": "Your device could not make a reading.",
|
||||
"low": "Your device flagged low walking steadiness.",
|
||||
"veryLow": "Your device flagged very low walking steadiness.",
|
||||
"fired": "Your device flagged this event."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Masquer le Coach",
|
||||
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
|
||||
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Grafici di tendenza",
|
||||
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
|
||||
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
|
||||
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog."
|
||||
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
|
||||
"wellnessTitle": "Riepilogo del benessere",
|
||||
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
|
||||
"wellnessRecovery": "Recupero",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Sforzo"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} campioni)",
|
||||
"expandDay": "Mostra i campioni di questa giornata",
|
||||
"collapseDay": "Nascondi i campioni di questa giornata",
|
||||
"loadError": "Impossibile caricare le misurazioni."
|
||||
"loadError": "Impossibile caricare le misurazioni.",
|
||||
"typeCardioRecovery": "Recupero cardiaco",
|
||||
"typeWristTemperature": "Temperatura del polso",
|
||||
"typeFallCount": "Cadute",
|
||||
"typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti",
|
||||
"typeStairAscentSpeed": "Velocità in salita scale",
|
||||
"typeStairDescentSpeed": "Velocità in discesa scale",
|
||||
"typeBreathingDisturbances": "Disturbi respiratori nel sonno",
|
||||
"typeIrregularRhythmNotification": "Irregular rhythm notification",
|
||||
"typeHighHeartRateEvent": "High heart rate notification",
|
||||
"typeLowHeartRateEvent": "Low heart rate notification",
|
||||
"typeWalkingSteadinessEvent": "Walking steadiness alert",
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Glicemia",
|
||||
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
|
||||
"skinTemperatureTitle": "Temperatura cutanea",
|
||||
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale."
|
||||
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.",
|
||||
"cardioFitnessTitle": "Forma cardio (VO₂ max)",
|
||||
"cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo. È il miglior marcatore di laboratorio della forma aerobica e un valore più alto o in aumento è un buon segno di salute cardiovascolare.",
|
||||
"cardioRecoveryTitle": "Recupero cardiaco",
|
||||
"cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.",
|
||||
"wristTemperatureTitle": "Temperatura del polso",
|
||||
"wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi. Apple la mostra come differenza dal tuo valore di riferimento; un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.",
|
||||
"fallsTitle": "Cadute",
|
||||
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo Apple Watch. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.",
|
||||
"sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti",
|
||||
"sixMinuteWalkBody": "È la stima di Apple di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Una distanza stabile o in aumento è un buon segno.",
|
||||
"stairAscentSpeedTitle": "Velocità in salita scale",
|
||||
"stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.",
|
||||
"stairDescentSpeedTitle": "Velocità in discesa scale",
|
||||
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
|
||||
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
|
||||
"breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statistiche riepilogative",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
|
||||
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
|
||||
},
|
||||
"valuesBack": "Torna a Insights"
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Il tuo umore è stato notevolmente variabile",
|
||||
"bandVeryVariable": "Il tuo umore ha oscillato molto di recente"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} di {required} input · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basato su {count} giorni di cronologia",
|
||||
"missing": "Registra questi per affinare il valore: {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Punteggio {score} su 100, {band}",
|
||||
"ariaProvisional": "Punteggio non ancora disponibile — dati insufficienti",
|
||||
"provisionalCaption": "Dati ancora insufficienti",
|
||||
"band": {
|
||||
"green": "buono",
|
||||
"yellow": "medio",
|
||||
"red": "basso"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "Come viene calcolato",
|
||||
"trigger": "Come viene calcolato",
|
||||
"inputsLabel": "Input",
|
||||
"noInputs": "Nessun input supporta ancora questo valore.",
|
||||
"methodLabel": "Metodo",
|
||||
"window": "Dai tuoi ultimi {days} giorni · dati {source}",
|
||||
"asOf": "al {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "I tuoi parametri vitali",
|
||||
"loadingLabel": "Caricamento dei tuoi parametri vitali",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
|
||||
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
|
||||
"typicalRange": "Abituale per te: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno",
|
||||
"fitnessOlder": "Forma cardio ~{years} anni oltre la tua età",
|
||||
"fitnessTypical": "Forma cardio tipica per la tua età",
|
||||
"fitnessNoNorm": "Aggiungi età e sesso per inquadrare questo dato",
|
||||
"vascularBelow": "{years} anni sotto la tua età",
|
||||
"vascularAbove": "{years} anni sopra la tua età",
|
||||
"vascularMatch": "In linea con la tua età",
|
||||
"vascularNoAge": "Aggiungi la data di nascita per inquadrare questo dato",
|
||||
"hrvBand": {
|
||||
"balanced": "Equilibrato per te",
|
||||
"unbalanced": "Sopra il tuo intervallo abituale",
|
||||
"low": "Sotto il tuo intervallo abituale"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Sottopeso (OMS)",
|
||||
"normal": "Intervallo normale (OMS)",
|
||||
"overweight": "Sovrappeso (OMS)",
|
||||
"obese": "Obesità (OMS)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Fattori",
|
||||
"contributorValue": "{value} · {weight}% di peso",
|
||||
"contributorMissing": "nessun dato",
|
||||
"disclaimer": "Indicativo — non è una valutazione clinica.",
|
||||
"insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Punteggio del sonno",
|
||||
"description": "Un punteggio di qualità del sonno trasparente con ogni sotto-punteggio mostrato.",
|
||||
"method": "Una combinazione 0–100 di sufficienza (sonno vs il tuo bisogno in base all’età), efficienza (sonno ÷ tempo a letto), costanza (quanto è stabile il punto medio del tuo sonno), orario (vs il tuo punto medio abituale) e composizione (quota di REM + sonno profondo). I sotto-punteggi vengono ripesati attorno a quelli che i tuoi dati non possono supportare; nulla è inventato.",
|
||||
"sub": {
|
||||
"sufficiency": "Sufficienza",
|
||||
"efficiency": "Efficienza",
|
||||
"consistency": "Costanza",
|
||||
"timing": "Orario",
|
||||
"composition": "Composizione"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Prontezza",
|
||||
"description": "Un indicatore quotidiano di benessere in base a come i tuoi segnali si collocano rispetto alla tua baseline.",
|
||||
"method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore — ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.",
|
||||
"component": {
|
||||
"rhr": "Frequenza cardiaca a riposo",
|
||||
"hrv": "HRV (SDNN)",
|
||||
"sleep": "Sonno",
|
||||
"respiratory": "Frequenza respiratoria",
|
||||
"mood": "Stabilità dell’umore"
|
||||
},
|
||||
"caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "La tua media recente di VFC (SDNN) confrontata con la tua linea di base mobile (mediana ± dispersione robusta). Equilibrata significa che rientra nel tuo intervallo personale; la banda è costruita sui tuoi dati, mai su una soglia di popolazione."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Peso ÷ altezza², classificato secondo le categorie OMS per adulti (sottopeso / normale / sovrappeso / obesità). Solo un rapporto di screening: non distingue il muscolo dal grasso."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Il tuo VO₂max collocato rispetto ai percentili di fitness cardiorespiratoria per età e sesso, espresso come scarto di età di forma. Il VO₂max è la stima del tuo dispositivo, qui mai ricalcolata."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "La stima di età vascolare / velocità dell’onda di polso del tuo dispositivo confrontata con la tua età anagrafica, espressa come scarto in anni. La stima è del dispositivo; HealthLog si limita a reinquadrarla.",
|
||||
"caveat": "La stima del tuo dispositivo, solo reinquadrata: descrittiva, non una valutazione vascolare clinica.",
|
||||
"description": "Come la stima di età vascolare del tuo dispositivo si colloca rispetto alla tua età anagrafica."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "Un avviso che compare quando due o più dei tuoi parametri vitali escono dal loro intervallo tipico personale nello stesso giorno. Ogni banda è la mediana robusta ± k·MAD del tuo storico, quindi si adatta a te e non a una soglia di popolazione.",
|
||||
"caveat": "Un avviso dalle tue linee di base: descrittivo, non una diagnosi né un allarme clinico."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.",
|
||||
"caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.",
|
||||
"description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.",
|
||||
"caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.",
|
||||
"description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.",
|
||||
"caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.",
|
||||
"description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Punteggi di benessere",
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
"strain": "Sforzo"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Più parametri fuori dalla base",
|
||||
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
|
||||
"vitals": "Fuori intervallo: {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Forma fisica",
|
||||
"description": "Il tuo VO₂ max nel tempo: la misura principale della forma aerobica.",
|
||||
"chartTitle": "VO₂ max (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulla forma fisica",
|
||||
"description": "I valori di VO₂ max compaiono qui quando Apple Health li sincronizza dai tuoi allenamenti."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Forma fisica",
|
||||
"cardioRecovery": {
|
||||
"title": "Recupero cardiaco",
|
||||
"description": "Di quanto cala la frequenza cardiaca un minuto dopo lo sforzo massimo.",
|
||||
"chartTitle": "Recupero cardiaco (bpm)",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sul recupero cardiaco",
|
||||
"description": "I valori di recupero cardiaco compaiono qui quando Apple Health li sincronizza da un allenamento."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Recupero cardiaco",
|
||||
"wristTemperature": {
|
||||
"title": "Temperatura del polso",
|
||||
"description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.",
|
||||
"chartTitle": "Temperatura del polso (°C)",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulla temperatura del polso",
|
||||
"description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Temp. polso",
|
||||
"falls": {
|
||||
"title": "Cadute",
|
||||
"description": "Cadute forti rilevate dal tuo Apple Watch, contate al giorno.",
|
||||
"chartTitle": "Cadute al giorno",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulle cadute",
|
||||
"description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo."
|
||||
}
|
||||
},
|
||||
"navFalls": "Cadute",
|
||||
"sixMinuteWalk": {
|
||||
"title": "Cammino di 6 minuti",
|
||||
"description": "La stima di Apple di quanto potresti camminare in sei minuti.",
|
||||
"chartTitle": "Distanza in 6 minuti (m)",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sul cammino di 6 minuti",
|
||||
"description": "Le stime compaiono qui quando Apple Health si sincronizza."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "Cammino 6 min",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Velocità in salita scale",
|
||||
"description": "Quanto velocemente sali le scale, misurato da iPhone o Watch.",
|
||||
"chartTitle": "Velocità in salita (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulla salita delle scale",
|
||||
"description": "I valori compaiono qui quando Apple Health si sincronizza."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Salita scale",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Velocità in discesa scale",
|
||||
"description": "Quanto velocemente scendi le scale, misurato da iPhone o Watch.",
|
||||
"chartTitle": "Velocità in discesa (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulla discesa delle scale",
|
||||
"description": "I valori compaiono qui quando Apple Health si sincronizza."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Discesa scale",
|
||||
"breathingDisturbances": {
|
||||
"title": "Disturbi respiratori",
|
||||
"description": "Quanto spesso la tua respirazione è stata disturbata durante il sonno.",
|
||||
"chartTitle": "Disturbi respiratori nel sonno",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sui disturbi respiratori",
|
||||
"description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Respirazione",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Notifiche sulla salute del dispositivo",
|
||||
"sectionIntro": "Notifiche che il tuo orologio ha inviato autonomamente. HealthLog si limita a mostrarle — non le rileva, conferma né interpreta.",
|
||||
"loadError": "Impossibile caricare le notifiche del dispositivo.",
|
||||
"disclaimer": "Questi sono avvisi generati dal tuo dispositivo con il proprio rilevamento integrato. Sono mostrati qui solo a scopo informativo. Non è una valutazione medica da parte di HealthLog, e HealthLog non diagnostica alcuna condizione. Se una notifica ti preoccupa, parla con il tuo medico.",
|
||||
"event": {
|
||||
"irregularRhythm": "Irregular rhythm notification",
|
||||
"highHeartRate": "High heart rate notification",
|
||||
"lowHeartRate": "Low heart rate notification",
|
||||
"walkingSteadiness": "Walking steadiness alert",
|
||||
"breathingDisturbance": "Breathing disturbance during sleep"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Your device flagged a possible irregular rhythm.",
|
||||
"notDetected": "Your device did not flag an irregular rhythm.",
|
||||
"inconclusive": "Your device could not make a reading.",
|
||||
"low": "Your device flagged low walking steadiness.",
|
||||
"veryLow": "Your device flagged very low walking steadiness.",
|
||||
"fired": "Your device flagged this event."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Nascondi il Coach",
|
||||
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
|
||||
|
||||
+284
-4
@@ -158,7 +158,12 @@
|
||||
"chartsTitle": "Wykresy trendów",
|
||||
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
|
||||
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
|
||||
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog."
|
||||
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
|
||||
"wellnessTitle": "Podsumowanie dobrostanu",
|
||||
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
|
||||
"wellnessRecovery": "Regeneracja",
|
||||
"wellnessStress": "Stres",
|
||||
"wellnessStrain": "Obciążenie"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -425,7 +430,22 @@
|
||||
"dailyTotalCaption": "({count} pomiarów)",
|
||||
"expandDay": "Pokaż pomiary z tego dnia",
|
||||
"collapseDay": "Ukryj pomiary z tego dnia",
|
||||
"loadError": "Nie udało się załadować pomiarów."
|
||||
"loadError": "Nie udało się załadować pomiarów.",
|
||||
"typeCardioRecovery": "Regeneracja serca",
|
||||
"typeWristTemperature": "Temperatura nadgarstka",
|
||||
"typeFallCount": "Upadki",
|
||||
"typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego",
|
||||
"typeStairAscentSpeed": "Prędkość wchodzenia po schodach",
|
||||
"typeStairDescentSpeed": "Prędkość schodzenia po schodach",
|
||||
"typeBreathingDisturbances": "Zaburzenia oddychania we śnie",
|
||||
"typeIrregularRhythmNotification": "Irregular rhythm notification",
|
||||
"typeHighHeartRateEvent": "High heart rate notification",
|
||||
"typeLowHeartRateEvent": "Low heart rate notification",
|
||||
"typeWalkingSteadinessEvent": "Walking steadiness alert",
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -1994,7 +2014,23 @@
|
||||
"bloodGlucoseTitle": "Poziom glukozy we krwi",
|
||||
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
|
||||
"skinTemperatureTitle": "Temperatura skóry",
|
||||
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego."
|
||||
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.",
|
||||
"cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)",
|
||||
"cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, a wyższa lub rosnąca wartość świadczy o dobrym zdrowiu układu krążenia.",
|
||||
"cardioRecoveryTitle": "Regeneracja serca",
|
||||
"cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.",
|
||||
"wristTemperatureTitle": "Temperatura nadgarstka",
|
||||
"wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu. Apple pokazuje ją jako różnicę względem Twojej wartości bazowej; stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.",
|
||||
"fallsTitle": "Upadki",
|
||||
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez Apple Watch. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.",
|
||||
"sixMinuteWalkTitle": "Dystans marszu 6-minutowego",
|
||||
"sixMinuteWalkBody": "To szacunek Apple, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Stały lub rosnący dystans to dobry znak.",
|
||||
"stairAscentSpeedTitle": "Prędkość wchodzenia po schodach",
|
||||
"stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.",
|
||||
"stairDescentSpeedTitle": "Prędkość schodzenia po schodach",
|
||||
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
|
||||
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
|
||||
"breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem."
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statystyki podsumowujące",
|
||||
@@ -2011,7 +2047,9 @@
|
||||
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
|
||||
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
|
||||
},
|
||||
"valuesBack": "Powrót do Insights"
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
@@ -2460,6 +2498,243 @@
|
||||
"bandVariable": "Twój nastrój był zauważalnie zmienny",
|
||||
"bandVeryVariable": "Twój nastrój ostatnio mocno się wahał"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} z {required} danych · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Na podstawie {count} dni historii",
|
||||
"missing": "Zapisuj te dane, aby uściślić wartość: {list}"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wynik {score} na 100, {band}",
|
||||
"ariaProvisional": "Wynik jeszcze niedostępny — za mało danych",
|
||||
"provisionalCaption": "Wciąż za mało danych",
|
||||
"band": {
|
||||
"green": "dobry",
|
||||
"yellow": "średni",
|
||||
"red": "niski"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"title": "Jak to jest obliczane",
|
||||
"trigger": "Jak to jest obliczane",
|
||||
"inputsLabel": "Dane wejściowe",
|
||||
"noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.",
|
||||
"methodLabel": "Metoda",
|
||||
"window": "Z ostatnich {days} dni · dane {source}",
|
||||
"asOf": "na dzień {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Twoje parametry życiowe",
|
||||
"loadingLabel": "Wczytywanie parametrów życiowych",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
|
||||
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
|
||||
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
|
||||
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
|
||||
"fitnessOlder": "Kondycja ~{years} lat powyżej Twojego wieku",
|
||||
"fitnessTypical": "Kondycja typowa dla Twojego wieku",
|
||||
"fitnessNoNorm": "Dodaj wiek i płeć, aby to ująć w kontekście",
|
||||
"vascularBelow": "{years} lat poniżej Twojego wieku",
|
||||
"vascularAbove": "{years} lat powyżej Twojego wieku",
|
||||
"vascularMatch": "Zgodnie z Twoim wiekiem",
|
||||
"vascularNoAge": "Dodaj datę urodzenia, aby to ująć w kontekście",
|
||||
"hrvBand": {
|
||||
"balanced": "Zrównoważone dla Ciebie",
|
||||
"unbalanced": "Powyżej Twojego zwykłego zakresu",
|
||||
"low": "Poniżej Twojego zwykłego zakresu"
|
||||
},
|
||||
"bmiCategory": {
|
||||
"underweight": "Niedowaga (WHO)",
|
||||
"normal": "Zakres prawidłowy (WHO)",
|
||||
"overweight": "Nadwaga (WHO)",
|
||||
"obese": "Otyłość (WHO)"
|
||||
}
|
||||
},
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Czynniki",
|
||||
"contributorValue": "{value} · {weight}% wagi",
|
||||
"contributorMissing": "brak danych",
|
||||
"disclaimer": "Orientacyjne — to nie ocena kliniczna.",
|
||||
"insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj."
|
||||
},
|
||||
"composite": {
|
||||
"SLEEP_SCORE": {
|
||||
"title": "Wynik snu",
|
||||
"description": "Przejrzysty wynik jakości snu z każdym podwynikiem.",
|
||||
"method": "Mieszanka 0–100 z wystarczalności (sen vs zapotrzebowanie według wieku), wydajności (sen ÷ czas w łóżku), regularności (jak stabilny jest środek twojego snu), pory (vs twój zwykły środek snu) i struktury (udział REM + snu głębokiego). Podwyniki są ważone na nowo wokół tych, których dane nie wspierają; nic nie jest zmyślane.",
|
||||
"sub": {
|
||||
"sufficiency": "Wystarczalność",
|
||||
"efficiency": "Wydajność",
|
||||
"consistency": "Regularność",
|
||||
"timing": "Pora",
|
||||
"composition": "Struktura"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
"title": "Gotowość",
|
||||
"description": "Codzienny wskaźnik samopoczucia oparty na tym, jak twoje sygnały wypadają względem twojej własnej bazy.",
|
||||
"method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju — każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.",
|
||||
"component": {
|
||||
"rhr": "Tętno spoczynkowe",
|
||||
"hrv": "HRV (SDNN)",
|
||||
"sleep": "Sen",
|
||||
"respiratory": "Częstość oddechu",
|
||||
"mood": "Stabilność nastroju"
|
||||
},
|
||||
"caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny."
|
||||
},
|
||||
"VITALS_BASELINE": {
|
||||
"method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie."
|
||||
},
|
||||
"HRV_BALANCE": {
|
||||
"method": "Twoja niedawna średnia HRV (SDNN) na tle Twojej własnej ruchomej linii bazowej (mediana ± odporne rozproszenie). Zrównoważone oznacza, że mieści się w Twoim osobistym zakresie; pasmo budowane jest z Twoich danych, nigdy z progu populacyjnego."
|
||||
},
|
||||
"BMI": {
|
||||
"method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy — nie odróżnia mięśni od tłuszczu."
|
||||
},
|
||||
"FITNESS_AGE": {
|
||||
"method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia — tutaj nigdy nie jest przeliczany ponownie."
|
||||
},
|
||||
"VASCULAR_AGE_DELTA": {
|
||||
"method": "Szacunek wieku naczyniowego / prędkości fali tętna z Twojego urządzenia porównany z Twoim wiekiem metrykalnym, wyrażony jako różnica w latach. Szacunek pochodzi z urządzenia; HealthLog jedynie go przedstawia.",
|
||||
"caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony — opisowy, nie kliniczna ocena naczyniowa.",
|
||||
"description": "Jak szacunek wieku naczyniowego Twojego urządzenia wypada na tle Twojego wieku metrykalnego."
|
||||
},
|
||||
"COINCIDENT_DEVIATION": {
|
||||
"method": "Sygnał pojawiający się, gdy co najmniej dwa Twoje parametry życiowe wykraczają tego samego dnia poza swój osobisty typowy zakres. Każde pasmo to odporna mediana ± k·MAD Twojej historii, więc dostosowuje się do Ciebie, a nie do progu populacyjnego.",
|
||||
"caveat": "Sygnał z Twoich własnych linii bazowych — opisowy, nie diagnoza ani alarm kliniczny."
|
||||
},
|
||||
"RECOVERY_SCORE": {
|
||||
"method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.",
|
||||
"caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.",
|
||||
"description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu."
|
||||
},
|
||||
"STRESS_SCORE": {
|
||||
"method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.",
|
||||
"caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.",
|
||||
"description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.",
|
||||
"caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.",
|
||||
"description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem."
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wyniki dobrostanu",
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
"strain": "Obciążenie"
|
||||
},
|
||||
"coincident": {
|
||||
"label": "Kilka parametrów poza bazą",
|
||||
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
|
||||
"vitals": "Poza zakresem: {list}"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
"title": "Wydolność",
|
||||
"description": "Twoje VO₂ max w czasie — główna miara wydolności tlenowej.",
|
||||
"chartTitle": "VO₂ max (mL/(kg·min))",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o wydolności",
|
||||
"description": "Wartości VO₂ max pojawią się tutaj, gdy Apple Health zsynchronizuje je z Twoich treningów."
|
||||
}
|
||||
},
|
||||
"navCardioFitness": "Wydolność",
|
||||
"cardioRecovery": {
|
||||
"title": "Regeneracja serca",
|
||||
"description": "O ile spada tętno minutę po szczytowym wysiłku.",
|
||||
"chartTitle": "Regeneracja serca (bpm)",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o regeneracji serca",
|
||||
"description": "Wartości regeneracji serca pojawią się tutaj, gdy Apple Health zsynchronizuje je z treningu."
|
||||
}
|
||||
},
|
||||
"navCardioRecovery": "Regeneracja serca",
|
||||
"wristTemperature": {
|
||||
"title": "Temperatura nadgarstka",
|
||||
"description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.",
|
||||
"chartTitle": "Temperatura nadgarstka (°C)",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o temperaturze nadgarstka",
|
||||
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie."
|
||||
}
|
||||
},
|
||||
"navWristTemperature": "Temp. nadgarstka",
|
||||
"falls": {
|
||||
"title": "Upadki",
|
||||
"description": "Poważne upadki wykryte przez Apple Watch, liczone na dzień.",
|
||||
"chartTitle": "Upadki na dzień",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o upadkach",
|
||||
"description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków."
|
||||
}
|
||||
},
|
||||
"navFalls": "Upadki",
|
||||
"sixMinuteWalk": {
|
||||
"title": "Marsz 6-minutowy",
|
||||
"description": "Szacunek Apple, jak daleko możesz przejść w sześć minut.",
|
||||
"chartTitle": "Dystans w 6 minut (m)",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o marszu 6-minutowym",
|
||||
"description": "Szacunki pojawią się tutaj, gdy Apple Health się zsynchronizuje."
|
||||
}
|
||||
},
|
||||
"navSixMinuteWalk": "Marsz 6 min",
|
||||
"stairAscentSpeed": {
|
||||
"title": "Prędkość wchodzenia po schodach",
|
||||
"description": "Jak szybko wchodzisz po schodach, mierzone przez iPhone'a lub Watch.",
|
||||
"chartTitle": "Prędkość wchodzenia (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o wchodzeniu po schodach",
|
||||
"description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje."
|
||||
}
|
||||
},
|
||||
"navStairAscentSpeed": "Wchodzenie",
|
||||
"stairDescentSpeed": {
|
||||
"title": "Prędkość schodzenia po schodach",
|
||||
"description": "Jak szybko schodzisz po schodach, mierzone przez iPhone'a lub Watch.",
|
||||
"chartTitle": "Prędkość schodzenia (m/s)",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o schodzeniu po schodach",
|
||||
"description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje."
|
||||
}
|
||||
},
|
||||
"navStairDescentSpeed": "Schodzenie",
|
||||
"breathingDisturbances": {
|
||||
"title": "Zaburzenia oddychania",
|
||||
"description": "Jak często Twój oddech był zakłócany podczas snu.",
|
||||
"chartTitle": "Zaburzenia oddychania we śnie",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o zaburzeniach oddychania",
|
||||
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch."
|
||||
}
|
||||
},
|
||||
"navBreathingDisturbances": "Oddychanie",
|
||||
"rhythmEvents": {
|
||||
"sectionTitle": "Powiadomienia zdrowotne urządzenia",
|
||||
"sectionIntro": "Powiadomienia, które zegarek wysłał samodzielnie. HealthLog jedynie je wyświetla — nie wykrywa ich, nie potwierdza ani nie interpretuje.",
|
||||
"loadError": "Nie udało się wczytać powiadomień urządzenia.",
|
||||
"disclaimer": "To alerty wygenerowane przez Twoje urządzenie za pomocą jego wbudowanego wykrywania. Są tu pokazywane wyłącznie dla Twojej informacji. To nie jest ocena medyczna wykonana przez HealthLog, a HealthLog nie diagnozuje żadnych schorzeń. Jeśli powiadomienie Cię niepokoi, skontaktuj się z lekarzem.",
|
||||
"event": {
|
||||
"irregularRhythm": "Irregular rhythm notification",
|
||||
"highHeartRate": "High heart rate notification",
|
||||
"lowHeartRate": "Low heart rate notification",
|
||||
"walkingSteadiness": "Walking steadiness alert",
|
||||
"breathingDisturbance": "Breathing disturbance during sleep"
|
||||
},
|
||||
"verdict": {
|
||||
"irregular": "Your device flagged a possible irregular rhythm.",
|
||||
"notDetected": "Your device did not flag an irregular rhythm.",
|
||||
"inconclusive": "Your device could not make a reading.",
|
||||
"low": "Your device flagged low walking steadiness.",
|
||||
"veryLow": "Your device flagged very low walking steadiness.",
|
||||
"fired": "Your device flagged this event."
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -2984,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Ukryj Coacha",
|
||||
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- v1.10.0 — extend `measurement_type` with seven additive HealthKit
|
||||
-- signals that previously sat in the deferred set.
|
||||
--
|
||||
-- Background: each identifier below was emitted by the iOS client but
|
||||
-- mapped to no `MeasurementType`, so the batch route ack-200'd a
|
||||
-- per-entry `skipped:"unmappable_identifier"` and the sample was lost.
|
||||
-- They are now wired end-to-end (mapping + plausibility range + the
|
||||
-- generic metric-status assessment surface).
|
||||
--
|
||||
-- CARDIO_RECOVERY ← HKQuantityTypeIdentifierHeartRateRecoveryOneMinute
|
||||
-- WRIST_TEMPERATURE ← HKQuantityTypeIdentifierAppleSleepingWristTemperature
|
||||
-- FALL_COUNT ← HKQuantityTypeIdentifierNumberOfTimesFallen
|
||||
-- SIX_MINUTE_WALK_DISTANCE ← HKQuantityTypeIdentifierSixMinuteWalkTestDistance
|
||||
-- STAIR_ASCENT_SPEED ← HKQuantityTypeIdentifierStairAscentSpeed
|
||||
-- STAIR_DESCENT_SPEED ← HKQuantityTypeIdentifierStairDescentSpeed
|
||||
-- BREATHING_DISTURBANCES ← HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances
|
||||
--
|
||||
-- Unit convention: every one of these ships raw on the wire and passes
|
||||
-- through `convertToDbUnit` as identity — none is a 0..1 fraction, so the
|
||||
-- ×100 percent-scaling path the gait-percent metrics use does NOT apply.
|
||||
-- The convention block at the top of
|
||||
-- `src/lib/measurements/apple-health-mapping.ts` spells the split out.
|
||||
--
|
||||
-- Pure additive — no existing row carries any of these values. The
|
||||
-- `IF NOT EXISTS` guard keeps the migration idempotent so the runner can
|
||||
-- replay it on a partially-applied environment without failing on the
|
||||
-- duplicate-enum-value error code. Forward-only; enum values cannot be
|
||||
-- dropped in PostgreSQL, so there is no automatic down path.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_RECOVERY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WRIST_TEMPERATURE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FALL_COUNT';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SIX_MINUTE_WALK_DISTANCE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_ASCENT_SPEED';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_DESCENT_SPEED';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCES';
|
||||
@@ -0,0 +1,95 @@
|
||||
-- v1.10.0 — categorical EVENT row class + device rhythm classification.
|
||||
--
|
||||
-- Adds a discrete EVENT row class to the existing `Measurement` model,
|
||||
-- following the `sleep_stage` / `glucose_context` nullable-context
|
||||
-- precedent (migration 0021 + 0036) — an additive nullable column on the
|
||||
-- existing table, NOT a new storage paradigm. An EVENT row stores ONE
|
||||
-- on-device notification the user's wearable already produced (Apple Watch
|
||||
-- irregular-rhythm / high-HR / low-HR / walking-steadiness, ScanWatch AFib
|
||||
-- screening, sleep breathing-disturbance). `value` is always 1; the
|
||||
-- device's own verdict / severity rides in the new `rhythm_classification`
|
||||
-- column.
|
||||
--
|
||||
-- ── Regulatory framing (load-bearing) ──────────────────────────────────
|
||||
-- HealthLog stores ONLY the classification RESULT the device's
|
||||
-- FDA-cleared / CE-marked on-device algorithm emitted. It never ingests a
|
||||
-- raw ECG waveform, never re-classifies, and never produces a HealthLog
|
||||
-- diagnosis. The surface is awareness/screening of the DEVICE's decision.
|
||||
--
|
||||
-- Two schema objects:
|
||||
--
|
||||
-- 1. Five new `measurement_type` enum values (the EVENT classes).
|
||||
-- 2. A new `rhythm_classification` enum + a nullable column carrying the
|
||||
-- device verdict / severity, NULL for every continuous measurement.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "measurements" DROP CONSTRAINT IF EXISTS "measurements_rhythm_classification_requires_event_type";
|
||||
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "rhythm_classification";
|
||||
-- DROP TYPE IF EXISTS "rhythm_classification";
|
||||
-- Postgres does not support removing an enum value, so the five new
|
||||
-- `measurement_type` members stay; with no rows carrying them after a
|
||||
-- column drop they are inert.
|
||||
|
||||
-- ── 1. measurement_type — append the five EVENT classes ────────────────
|
||||
-- Pure additive; no existing row carries any of these values. The
|
||||
-- `IF NOT EXISTS` guard keeps the migration idempotent across a
|
||||
-- partially-applied environment.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'IRREGULAR_RHYTHM_NOTIFICATION';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HIGH_HEART_RATE_EVENT';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LOW_HEART_RATE_EVENT';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS_EVENT';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCE_EVENT';
|
||||
|
||||
-- ── 2. rhythm_classification — the device verdict / severity enum ──────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'rhythm_classification') THEN
|
||||
CREATE TYPE "rhythm_classification" AS ENUM (
|
||||
'IRREGULAR',
|
||||
'NOT_DETECTED',
|
||||
'INCONCLUSIVE',
|
||||
'LOW',
|
||||
'VERY_LOW',
|
||||
'FIRED'
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Nullable column. NULL for every continuous measurement; non-NULL only
|
||||
-- for the EVENT classes. Mirrors the `sleep_stage` additive-column shape.
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN IF NOT EXISTS "rhythm_classification" "rhythm_classification";
|
||||
|
||||
-- Enforce: rhythm_classification is only set for the EVENT-class rows.
|
||||
-- Mirrors the `measurements_sleep_stage_requires_type` CHECK (migration
|
||||
-- 0036) and the glucose_context CHECK (migration 0021).
|
||||
--
|
||||
-- The comparison casts `type` to text so the predicate references the
|
||||
-- newly-added enum members as string literals. Postgres forbids using an
|
||||
-- enum value in the same transaction that adds it (the ALTER TYPE ... ADD
|
||||
-- VALUE statements above run in this same migration transaction); the
|
||||
-- `::text` cast sidesteps that restriction without weakening the check.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'measurements_rhythm_classification_requires_event_type'
|
||||
) THEN
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_rhythm_classification_requires_event_type"
|
||||
CHECK (
|
||||
"rhythm_classification" IS NULL
|
||||
OR "type"::text IN (
|
||||
'IRREGULAR_RHYTHM_NOTIFICATION',
|
||||
'HIGH_HEART_RATE_EVENT',
|
||||
'LOW_HEART_RATE_EVENT',
|
||||
'WALKING_STEADINESS_EVENT',
|
||||
'BREATHING_DISTURBANCE_EVENT'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.10.0 — computed scores (WX-C): COMPUTED source + the three `*_SCORE`
|
||||
-- measurement types.
|
||||
--
|
||||
-- Two purely-additive enum extensions, no new columns and no backfill:
|
||||
--
|
||||
-- 1. A new `measurement_source` value `COMPUTED` for server-derived rows.
|
||||
-- A `COMPUTED` Measurement is minted by a nightly engine from the user's
|
||||
-- already-stored signals (the Recovery score is the first); it is NEVER
|
||||
-- ingested from a client. The client-facing batch + single-POST write
|
||||
-- surfaces reject it exactly like `WITHINGS` / `IMPORT`. It is part of
|
||||
-- the enum so the read/response shapes (and the iOS decoder) can decode
|
||||
-- the rows it surfaces.
|
||||
--
|
||||
-- 2. Three new `measurement_type` values — the server-derived wellness
|
||||
-- scores `RECOVERY_SCORE`, `STRESS_SCORE`, `STRAIN_SCORE` (0–100, unit
|
||||
-- `score`), each minted nightly by its own engine. These are NOT
|
||||
-- clinical vitals: they stay out of the clinical vitals table and
|
||||
-- surface only in a clearly-labelled, descriptive-not-clinical wellness
|
||||
-- summary (and as FHIR `survey`-category observations).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- Postgres does not support removing an enum value, so the four new
|
||||
-- members stay; with no rows carrying them (a `COMPUTED` source / a
|
||||
-- `*_SCORE` type) after the nightly job is disabled they are inert.
|
||||
|
||||
-- ── 1. measurement_source — append the COMPUTED server-owned source ────
|
||||
-- Pure additive; no existing row carries this value. The `IF NOT EXISTS`
|
||||
-- guard keeps the migration idempotent across a partially-applied environment.
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'COMPUTED';
|
||||
|
||||
-- ── 2. measurement_type — append the three computed-score classes ──────
|
||||
-- Pure additive; no existing row carries any of these values.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RECOVERY_SCORE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_SCORE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRAIN_SCORE';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- v1.10.0 — per-workout heart-rate series (route-independent).
|
||||
--
|
||||
-- A companion to `workout_routes` that holds the per-sample HR series
|
||||
-- WITHOUT requiring GPS geometry. The route table only exists when a
|
||||
-- workout has a GeoJSON LineString, so an INDOOR session (treadmill,
|
||||
-- trainer, strength) had nowhere to store its heart-rate samples even
|
||||
-- though HealthKit ships them. That series feeds the training-strain
|
||||
-- engine (TRIMP / time-in-HR-zone), so decoupling it from the route is
|
||||
-- what unblocks indoor strain.
|
||||
--
|
||||
-- Shape rationale:
|
||||
-- * `samples` JSONB — `[{ t: ISO string, hr?: int, speedMs?, power?,
|
||||
-- cadence? }]`, one entry per recorded sample. Same JSONB decision
|
||||
-- as `workout_routes.geometry` / `sample_timestamps`: no PostGIS
|
||||
-- extension dependency on a self-hosted Postgres install, and the
|
||||
-- blob mirrors HealthKit's per-sample shape directly.
|
||||
-- * `sample_count` — denormalised `samples.length` so the strain
|
||||
-- reader + observability can size the series without deserialising
|
||||
-- the JSONB blob. The application caps the array length on write
|
||||
-- (MAX_WORKOUT_HR_SAMPLES in src/lib/validations/workout.ts) so the
|
||||
-- per-workout payload — and the largest new workout-side write
|
||||
-- stream — stays bounded.
|
||||
--
|
||||
-- 1:1 with the workout: `workout_id` is UNIQUE with an ON DELETE CASCADE
|
||||
-- FK, exactly like `workout_routes`. A re-submitted batch that wins the
|
||||
-- workout-row race but loses the sample-row race is an idempotent no-op
|
||||
-- (the ingest path uses `skipDuplicates`), not a hard error.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- DROP TABLE IF EXISTS "workout_samples";
|
||||
-- A roll-back loses the captured per-workout HR series; the workout row,
|
||||
-- its aggregates (avg/max/min HR), and the route survive untouched.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workout_samples" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE,
|
||||
-- JSONB array: [{ t: ISO string, hr?: int, speedMs?, power?, cadence? }]
|
||||
"samples" JSONB NOT NULL,
|
||||
-- Denormalised samples.length — sized without parsing the blob.
|
||||
"sample_count" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- v1.10.0 — dense intra-day retention tier for daytime HRV / heart-rate.
|
||||
--
|
||||
-- The Stress engine reads the intra-day SDNN (and daytime heart-rate)
|
||||
-- SHAPE across the day, not a single daily mean. Apple Health ships those
|
||||
-- as discrete per-sample rows, so the engine needs the raw samples to
|
||||
-- survive long enough to read. Two facts make that safe:
|
||||
--
|
||||
-- 1. `HEART_RATE_VARIABILITY` and `PULSE` are NOT in the destructive
|
||||
-- daily-mean drain's allowlist (`HIGH_FREQUENCY_MEAN_TYPES` in
|
||||
-- src/lib/measurements/apple-health-mapping.ts), so the nightly
|
||||
-- mean-consolidation never collapses them — the intra-day shape is
|
||||
-- preserved by construction. (PULSE was already excluded for the
|
||||
-- correlation/scatter readers; HRV is exempted for the same reason
|
||||
-- the Stress proxy needs it.)
|
||||
--
|
||||
-- 2. A BOUNDED retention window contains the volume: per-sample HRV / HR
|
||||
-- rows older than the window are folded to one daily-mean `stats:` row
|
||||
-- (the same idempotent fold + soft-delete the mean-consolidation uses)
|
||||
-- and the raw rows are tombstoned. Within the window the raw samples
|
||||
-- stay, so the Stress engine always has its intra-day inputs for the
|
||||
-- days it scores.
|
||||
--
|
||||
-- This migration adds ONLY the partial index that keeps the dense-tier
|
||||
-- drain's discovery + per-user scan cheap. No new column, no backfill, no
|
||||
-- table rewrite. The retention bound itself lives in application code
|
||||
-- (DENSE_INTRADAY_RETENTION_DAYS in
|
||||
-- src/lib/measurements/dense-intraday-retention.ts) so tuning it never
|
||||
-- needs a migration.
|
||||
--
|
||||
-- The index is keyed on `(user_id, measured_at)` and partial over the two
|
||||
-- dense-tier types on live rows only, so it shrinks toward the in-window
|
||||
-- working set as out-of-window samples are folded away — it carries no
|
||||
-- steady-state cost once a tenant's history is past the retention window.
|
||||
--
|
||||
-- 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. The partial predicate
|
||||
-- (two types, `deleted_at IS NULL`) keeps the index small on a typical
|
||||
-- self-host, so the build is fast and the lock is not felt; the largest
|
||||
-- multi-year HealthKit tenants should deploy at a low-traffic window. A
|
||||
-- `CONCURRENTLY` build cannot run inside the migration transaction and is
|
||||
-- not warranted given the partial-index size on the vast majority of
|
||||
-- deployments.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- DROP INDEX IF EXISTS "measurements_dense_intraday_retention_idx";
|
||||
-- A roll-back loses only the scan acceleration; no data is touched.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_dense_intraday_retention_idx"
|
||||
ON "measurements" ("user_id", "measured_at")
|
||||
WHERE "type" IN ('HEART_RATE_VARIABILITY', 'PULSE')
|
||||
AND "deleted_at" IS NULL;
|
||||
+143
-11
@@ -532,10 +532,74 @@ enum MeasurementType {
|
||||
// and pass through `convertToDbUnit` as identity.
|
||||
WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m.
|
||||
WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s.
|
||||
// v1.10.0 — additive HealthKit signals (WX-A)
|
||||
// Seven previously-deferred quantity identifiers, wired end-to-end with
|
||||
// the generic metric-status assessment treatment. Each rides the same
|
||||
// server-side `convertToDbUnit` + plausibility-range path as the rest of
|
||||
// the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`.
|
||||
CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better).
|
||||
WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot).
|
||||
FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better.
|
||||
SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better.
|
||||
STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better.
|
||||
STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better.
|
||||
BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better.
|
||||
// v1.10.0 — categorical events (WX-B)
|
||||
// Device-flagged EVENT rows. These are NOT continuous measurements —
|
||||
// each row records a discrete on-device notification the user's wearable
|
||||
// produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one
|
||||
// fired event); the device's verdict / severity lands in the
|
||||
// `rhythmClassification` column (see the `RhythmClassification` enum and
|
||||
// the CHECK constraint in migration 0105). HealthLog never re-derives or
|
||||
// re-classifies the underlying signal — it only stores and surfaces the
|
||||
// result the device's own certified algorithm already emitted.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis.
|
||||
HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive.
|
||||
LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold.
|
||||
WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`.
|
||||
BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis.
|
||||
// v1.10.0 — computed scores (WX-C)
|
||||
// Server-derived wellness scores (0–100, unit `score`). Each is minted by a
|
||||
// nightly engine from the user's already-stored signals and persisted as a
|
||||
// `COMPUTED`-source Measurement row — never ingested from a client. Only
|
||||
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
|
||||
// defined now so the later engines that compute them need no schema change.
|
||||
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
|
||||
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment.
|
||||
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
|
||||
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// v1.10.0 — categorical events (WX-B)
|
||||
/// The device's own classification RESULT / severity for an EVENT row.
|
||||
/// Non-NULL only for the categorical EVENT `MeasurementType`s
|
||||
/// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT,
|
||||
/// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every
|
||||
/// continuous measurement — mirroring the `GlucoseContext` / `SleepStage`
|
||||
/// nullable-context precedent.
|
||||
///
|
||||
/// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked
|
||||
/// on-device algorithm already produced. It never re-classifies a raw
|
||||
/// signal and never emits a diagnosis of its own. `IRREGULAR` means the
|
||||
/// device flagged a possible irregular rhythm; `NOT_DETECTED` /
|
||||
/// `INCONCLUSIVE` carry the device's own non-positive verdicts so the
|
||||
/// awareness timeline can be honest rather than only ever showing alarms.
|
||||
/// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the
|
||||
/// neutral "this event occurred" verdict for the high/low-HR + breathing
|
||||
/// events that have no finer device-side grading.
|
||||
enum RhythmClassification {
|
||||
IRREGULAR // device flagged a possible irregular rhythm (AFib screening)
|
||||
NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm
|
||||
INCONCLUSIVE // device could not classify (poor signal / out-of-range HR)
|
||||
LOW // walking-steadiness: low
|
||||
VERY_LOW // walking-steadiness: very low
|
||||
FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance)
|
||||
|
||||
@@map("rhythm_classification")
|
||||
}
|
||||
|
||||
/// Context for glucose measurements — affects which target range applies.
|
||||
/// NULL for non-glucose measurements.
|
||||
enum GlucoseContext {
|
||||
@@ -552,6 +616,14 @@ enum MeasurementSource {
|
||||
WITHINGS
|
||||
IMPORT
|
||||
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
|
||||
// v1.10.0 — computed scores (WX-C)
|
||||
// Server-derived rows. A `COMPUTED` measurement is minted by a nightly
|
||||
// engine from the user's already-stored signals (the Recovery score is the
|
||||
// first); it is NEVER ingested from a client. Server-owned / read-only:
|
||||
// the client-facing batch + single-POST write surfaces reject it exactly
|
||||
// like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode
|
||||
// the rows it queries / charts.
|
||||
COMPUTED
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
@@ -596,6 +668,15 @@ model Measurement {
|
||||
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
|
||||
// pattern. A single night produces ~5 rows (one per stage).
|
||||
sleepStage SleepStage? @map("sleep_stage")
|
||||
// v1.10.0 — categorical events (WX-B). The device's own classification
|
||||
// verdict / severity for an EVENT row. NULL for every continuous
|
||||
// measurement; non-NULL only for the categorical EVENT `MeasurementType`s.
|
||||
// Mirrors the `sleepStage` / `glucoseContext` nullable-context columns. A
|
||||
// CHECK constraint (migration 0105) enforces the type pairing so a
|
||||
// continuous row can never carry a classification. NOT part of any unique
|
||||
// index — dedup stays on `(userId, type, source, externalId)` keyed by the
|
||||
// device's `HKSample.uuid`, so a re-posted event collapses to a duplicate.
|
||||
rhythmClassification RhythmClassification? @map("rhythm_classification")
|
||||
// v1.4.25 W8c — device-type tag for the cross-source canonical
|
||||
// picker. Stored as a free-text string (kept simple — Prisma enum
|
||||
// would require coordinated client + server bumps every time we
|
||||
@@ -655,6 +736,11 @@ model Measurement {
|
||||
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
|
||||
// discovery scan. Prisma can't express a partial-predicate index, so
|
||||
// it lives in raw migration SQL and is intentionally omitted here.
|
||||
// `measurements_dense_intraday_retention_idx` is a partial index over
|
||||
// `type IN ('HEART_RATE_VARIABILITY','PULSE') AND deleted_at IS NULL`
|
||||
// — 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.
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
@@ -758,21 +844,28 @@ model Workout {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
samples WorkoutSamples?
|
||||
|
||||
/// Ingest dedup. NULL externalId is distinct so two manual
|
||||
/// entries on the same day never collide here.
|
||||
///
|
||||
/// TODO(v1.5 iOS): cross-source workout dedup. The current key is
|
||||
/// (userId, source, externalId) — so the SAME workout ingested from
|
||||
/// MANUAL plus the iOS HealthKit passthrough lands two rows because
|
||||
/// `source` differs. Measurements already solved this via
|
||||
/// `pickCanonicalSourceRows()` (the read-time canonical-source
|
||||
/// picker in src/lib/analytics/source-priority.ts); workouts need
|
||||
/// the symmetric helper before the dashboard "recent workouts" tile
|
||||
/// can show one row per workout instead of one per source. Tracked
|
||||
/// for v1.5 P1 alongside the iOS app.
|
||||
/// Cross-source dedup (the SAME logical workout arriving from two
|
||||
/// paired sensors — e.g. Apple Watch + Withings ScanWatch — under
|
||||
/// different `source`/`externalId` pairs) is handled in two layers,
|
||||
/// mirroring the measurement-side `pickCanonicalSourceRows()`:
|
||||
/// * WRITE-time — `dedupeWorkoutBatch()` in
|
||||
/// src/lib/workouts/canonical-rows.ts groups an inbound batch by
|
||||
/// (userId, activityType, startedAt ± 90 s) and keeps one
|
||||
/// canonical row per logical workout BEFORE the insert, so the
|
||||
/// duplicate never reaches storage.
|
||||
/// * READ-time — `pickCanonicalWorkoutRows()` in
|
||||
/// src/lib/measurements/pick-canonical-workout-rows.ts collapses
|
||||
/// any cross-batch twins (e.g. one source's row from Monday's
|
||||
/// batch + the other's from Tuesday's backfill) so the dashboard
|
||||
/// "recent workouts" tile shows one row per logical workout.
|
||||
/// Both pickers resolve the user's `sourcePriorityJson` ladder.
|
||||
@@unique([userId, source, externalId])
|
||||
/// Dashboard "recent workouts" query path.
|
||||
@@index([userId, startedAt])
|
||||
@@ -801,6 +894,45 @@ model WorkoutRoute {
|
||||
@@map("workout_routes")
|
||||
}
|
||||
|
||||
// ─── Per-workout heart-rate (+ optional speed/power/cadence) series ──
|
||||
//
|
||||
// v1.10.0 — a route-independent companion to `WorkoutRoute`.
|
||||
//
|
||||
// The route table only exists when a workout has GPS geometry, so an
|
||||
// INDOOR session (treadmill, bike trainer, strength) had no home for
|
||||
// its per-sample heart-rate series even though HealthKit ships one.
|
||||
// That series is the input the training-strain engine (TRIMP /
|
||||
// time-in-HR-zone) needs, so decoupling it from the route is what
|
||||
// unblocks indoor strain.
|
||||
//
|
||||
// `samples` is a JSONB array `[{ t, hr?, speedMs?, power?, cadence? }]`
|
||||
// — `t` is an ISO timestamp, `hr` beats/min. Same JSONB-over-PostGIS
|
||||
// decision as `WorkoutRoute` (no extension dependency on a self-hosted
|
||||
// Postgres). 1:1 with the workout (FK `workout_id` UNIQUE) so a
|
||||
// re-ingested batch that wins the workout-row race but loses the
|
||||
// sample-row race is an idempotent no-op, exactly like the route table.
|
||||
//
|
||||
// `sample_count` is denormalised so the strain reader + observability
|
||||
// can size the series without deserialising the JSONB blob. The ingest
|
||||
// path caps the count (see MAX_WORKOUT_HR_SAMPLES in
|
||||
// src/lib/validations/workout.ts) to keep the per-workout payload — and
|
||||
// the largest new workout-side write stream — bounded.
|
||||
model WorkoutSamples {
|
||||
id String @id @default(cuid())
|
||||
workoutId String @unique @map("workout_id")
|
||||
/// JSONB array of `{ t: ISO string, hr?: int, speedMs?, power?,
|
||||
/// cadence? }`. One entry per recorded sample; ordered by `t`
|
||||
/// ascending at ingest. Capped at MAX_WORKOUT_HR_SAMPLES.
|
||||
samples Json
|
||||
/// Denormalised `samples.length` — sized without parsing the blob.
|
||||
sampleCount Int @map("sample_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("workout_samples")
|
||||
}
|
||||
|
||||
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
|
||||
//
|
||||
// MAX vs MIN direction is stored on the row so the future detection
|
||||
|
||||
@@ -65,11 +65,27 @@ function jsonRequest(body: unknown): Request {
|
||||
});
|
||||
}
|
||||
|
||||
interface TestFailureEnvelope {
|
||||
data: {
|
||||
ok: false;
|
||||
providerType: string;
|
||||
reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable";
|
||||
reason: string;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
|
||||
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
||||
// directly to the client, leaking provider URLs / partial keys / internal
|
||||
// headers. Server now logs full details via annotate() and responds with
|
||||
// a categorised, generic message.
|
||||
describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
// a categorised, secret-free reason.
|
||||
//
|
||||
// This route MUST NEVER return a 5xx: a 5xx origin response is rewritten
|
||||
// by Cloudflare to its own HTML error page, so the browser's res.json()
|
||||
// crashes with `Unexpected token '<', "<!DOCTYPE "`. Every provider-call
|
||||
// failure returns HTTP 200 with `{ ok:false, reason }` the client shows
|
||||
// verbatim.
|
||||
describe("POST /api/ai/test — provider error leak guard + non-5xx contract", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
@@ -81,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
||||
it("never returns a 5xx and does not echo provider err.message back to the client", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
||||
{
|
||||
@@ -92,49 +108,67 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
|
||||
// doesn't replace our JSON body with its HTML error page — that
|
||||
// rewrite was the root cause of the "Unexpected token '<'…" client
|
||||
// crash the maintainer hit when typing a bad OpenAI key.
|
||||
expect(response.status).toBe(422);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
||||
expect(body.error).toBe("Provider rejected the credentials");
|
||||
// Always JSON-parseable, never a 5xx (the Cloudflare HTML-rewrite
|
||||
// root cause of "Unexpected token '<'…").
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.ok).toBe(false);
|
||||
expect(body.data.reasonCode).toBe("credentials");
|
||||
// The stable machine code must itself be secret-free.
|
||||
expect(body.data.reasonCode).not.toMatch(/sk-/);
|
||||
expect(body.data.reason).not.toMatch(/sk-/);
|
||||
expect(body.data.reason).not.toMatch(/api\.openai\.com/);
|
||||
expect(body.data.reason).not.toMatch(/invalid api key/i);
|
||||
expect(body.data.reason).toMatch(/re-authenticate/i);
|
||||
});
|
||||
|
||||
it("returns the 429-categorised message when the provider rate-limits", async () => {
|
||||
it("reclassifies a 5xx whose body signals an invalidated session as a credential failure", async () => {
|
||||
// The shape an operator hit: re-auth, gateway answers 500 with
|
||||
// "authentication token has been invalidated" instead of a 401.
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("upstream 500"), {
|
||||
httpStatus: 500 as const,
|
||||
bodyExcerpt:
|
||||
'{"error":{"message":"Your authentication token has been invalidated. Please try signing in again."}}',
|
||||
}),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.ok).toBe(false);
|
||||
expect(body.data.reasonCode).toBe("credentials");
|
||||
expect(body.data.reason).toMatch(/re-authenticate/i);
|
||||
});
|
||||
|
||||
it("categorises a 429 rate-limit", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
|
||||
// the React Query mutation can read the JSON body instead of
|
||||
// tripping over Cloudflare's HTML 502.
|
||||
expect(response.status).toBe(429);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider rate-limited the request",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("rate_limited");
|
||||
expect(body.data.reason).toMatch(/rate-limited/i);
|
||||
});
|
||||
|
||||
it("returns the 5xx-categorised message when the provider has a server error", async () => {
|
||||
it("categorises a plain 5xx provider server error (no auth signal)", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("server_error");
|
||||
expect(body.data.reason).toMatch(/server error/i);
|
||||
});
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
it("categorises a network/timeout failure", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("unreachable");
|
||||
expect(body.data.reason).toMatch(/could not reach/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// V3 audit: do not return provider error message + bodyExcerpt to the
|
||||
// client (leaks provider URL / partial keys / internal headers).
|
||||
// Log full details server-side for the operator and respond with a
|
||||
// categorised, generic message.
|
||||
// categorised, generic reason.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -91,28 +91,72 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ai_test_provider: provider.type,
|
||||
},
|
||||
});
|
||||
// Map upstream status to a *client-side* status code that won't be
|
||||
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
|
||||
// the body for its own HTML error page — `await res.json()` in the
|
||||
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
|
||||
// which is exactly how the maintainer encountered this when typing a bad
|
||||
// OpenAI key. 4xx codes pass through untouched.
|
||||
const status = err.httpStatus ?? 0;
|
||||
let outboundStatus: number;
|
||||
let safeMessage: string;
|
||||
if (status === 401 || status === 403) {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider rejected the credentials";
|
||||
} else if (status === 429) {
|
||||
outboundStatus = 429;
|
||||
safeMessage = "Provider rate-limited the request";
|
||||
} else if (status >= 500) {
|
||||
outboundStatus = 502;
|
||||
safeMessage = "Provider returned a server error";
|
||||
} else {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider connection failed";
|
||||
}
|
||||
return apiError(safeMessage, outboundStatus);
|
||||
// This route MUST NEVER return a 5xx. A 5xx origin response is
|
||||
// rewritten by Cloudflare / the reverse proxy to its own HTML error
|
||||
// page, so the browser's `res.json()` crashes with
|
||||
// `Unexpected token '<', "<!DOCTYPE "` (Safari: "The string did not
|
||||
// match the expected pattern"). That is exactly the failure an
|
||||
// operator hit re-authenticating a provider whose token came back as
|
||||
// an invalidated-session 500. We always reply 200 with a categorised,
|
||||
// secret-free `{ ok:false, reason }` the client can show verbatim.
|
||||
const { reasonCode, reason } = classifyTestFailure(err);
|
||||
return apiSuccess({
|
||||
ok: false,
|
||||
providerType: provider.type,
|
||||
reasonCode,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, machine-readable failure categories. The client maps each to a
|
||||
* localised string; the English `reason` stays as a fallback for any
|
||||
* legacy / unmapped code. Secret-free by construction.
|
||||
*/
|
||||
type TestFailureCode =
|
||||
| "credentials"
|
||||
| "rate_limited"
|
||||
| "server_error"
|
||||
| "unreachable";
|
||||
|
||||
/**
|
||||
* Map an upstream provider failure to a human-readable, secret-free
|
||||
* reason string. Distinguishes credential/auth, rate-limit, provider
|
||||
* server-error and network/timeout classes. A 5xx whose body carries an
|
||||
* auth signal (invalidated session token, expired key, "sign in again")
|
||||
* is reclassified as a credential failure — that is the shape the
|
||||
* operator's re-auth produced, where the gateway answered 500 instead of
|
||||
* the expected 401.
|
||||
*/
|
||||
function classifyTestFailure(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
): { reasonCode: TestFailureCode; reason: string } {
|
||||
const status = err.httpStatus ?? 0;
|
||||
const haystack = `${err.message} ${err.bodyExcerpt ?? ""}`;
|
||||
const looksLikeAuth =
|
||||
/authentication|invalidated|expired|sign in again|api key/i.test(haystack);
|
||||
|
||||
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
|
||||
return {
|
||||
reasonCode: "credentials",
|
||||
reason: "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
reasonCode: "rate_limited",
|
||||
reason: "Provider rate-limited the request — try again shortly.",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
reasonCode: "server_error",
|
||||
reason: "The AI provider returned a server error.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
reasonCode: "unreachable",
|
||||
reason: "Could not reach the AI provider.",
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,10 @@ import {
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
buildFhirDocumentBundle,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
exportSelectionSchema,
|
||||
toDoctorReportPrefs,
|
||||
@@ -58,7 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
// The selection payload is small and bounded — format, range,
|
||||
// section toggles, a few flags, an optional practice-name string.
|
||||
// 64 KB is far above any legitimate selection while still rejecting
|
||||
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
|
||||
// ceiling, not a tight bound.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = exportSelectionSchema.safeParse(body);
|
||||
@@ -90,6 +100,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
request.headers.get("accept-language") ?? "",
|
||||
);
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
const germanAtc =
|
||||
selection.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
resolveUserTimezone(user.id),
|
||||
@@ -136,7 +152,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (selection.format === "fhir") {
|
||||
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
);
|
||||
const json = JSON.stringify(bundle);
|
||||
annotate({
|
||||
meta: { format: "fhir", bytes: json.length, days: range.days },
|
||||
@@ -178,7 +199,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
);
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,13 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
// v1.10.0 — generic derived-wellness-metric route. Pure compute over
|
||||
// the rollup tier; gates on the same `insightStatus` sub-flag as the
|
||||
// assessment routes (no Coach prose).
|
||||
"src/app/api/insights/derived/route.ts",
|
||||
// v1.10.0 — batched derived-metric route (the dashboard fan-out fix).
|
||||
// Same pure compute + `insightStatus` sub-flag as the single route.
|
||||
"src/app/api/insights/derived/batch/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
|
||||
// same `insightStatus` sub-flag as the seven specialised status routes.
|
||||
@@ -49,6 +56,11 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// `coach`: a user with assessments enabled but Coach disabled can warm.
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
// v1.10.0 — device-flagged event awareness timeline (categorical
|
||||
// events, WX-B). Pure DB read of the device's own verdicts; gates on
|
||||
// the same `insightStatus` sub-flag as the assessment routes (no Coach
|
||||
// prose).
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -47,6 +49,16 @@ beforeEach(() => {
|
||||
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
// v1.10.0 — the discovery engine reads the profile timezone + the
|
||||
// measurement / mood series. Default to an empty corpus so the engine
|
||||
// returns no discovered pairs (the n ≥ 20 gate trivially fails).
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns an empty array as the placeholder", async () => {
|
||||
it("returns the discovery result shape (no pairs on an empty corpus)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[] };
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes the empty-state audit log", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq());
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"insights.correlations.empty",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { discovered: unknown[]; pairsTested: number; fdrQ: number };
|
||||
};
|
||||
expect(body.data.discovered).toEqual([]);
|
||||
expect(body.data.pairsTested).toBe(0);
|
||||
expect(body.data.fdrQ).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns 403 + errorCode when the correlations flag is off", async () => {
|
||||
|
||||
@@ -1,29 +1,142 @@
|
||||
/**
|
||||
* GET /api/insights/correlations — placeholder iOS endpoint.
|
||||
* GET /api/insights/correlations — FDR-controlled correlation discovery.
|
||||
*
|
||||
* The full correlations engine is part of `/api/insights/comprehensive`
|
||||
* and is intentionally not re-exposed yet — iOS only needs a stable
|
||||
* shape to render the empty state. We return `[]` and emit an audit log
|
||||
* so the gap is visible until Phase 7 fills it in.
|
||||
* v1.10.0 — promotes the former placeholder to the real discovery engine.
|
||||
* Scans a curated behaviour × outcome matrix (daylight / mood / glucose /
|
||||
* BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour
|
||||
* day to the NEXT day's outcome, runs Pearson with the exact Student-t
|
||||
* p-value, and applies Benjamini-Hochberg FDR control across every tested
|
||||
* pair so only statistically-defensible patterns surface. Every surfaced
|
||||
* pair carries n, r, p, and the BH-adjusted q, framed descriptive — never
|
||||
* causal.
|
||||
*
|
||||
* Reads daily series bounded to a trailing window, day-keyed in the user's
|
||||
* display timezone (late-night readings mis-bucket under UTC). Pure compute
|
||||
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
|
||||
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Trailing window for the discovery scan (days). */
|
||||
const WINDOW_DAYS = 180;
|
||||
|
||||
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = getLocalDateParts(at, tz);
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** Collapse rows to per-day means keyed in the user's tz. */
|
||||
function toDailyMeans(
|
||||
rows: Array<{ value: number; at: Date }>,
|
||||
tz: string,
|
||||
): DailySeriesPoint[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.value)) continue;
|
||||
const day = tzDayKey(r.at, tz);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += r.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.4.31 — operator can hide the correlation narration tile.
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
annotate({ action: { name: "insights.correlations" } });
|
||||
|
||||
await auditLog("insights.correlations.empty", {
|
||||
userId: user.id,
|
||||
details: { reason: "phase7_pending" },
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
// Behaviour channels are measurement-backed except MOOD (mood entries).
|
||||
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
|
||||
(k) => k !== "MOOD",
|
||||
) as MeasurementType[];
|
||||
const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[];
|
||||
|
||||
const [measurements, moodEntries] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
type: { in: [...behaviourTypes, ...outcomeTypes] },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: 20000,
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
take: 5000,
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
|
||||
for (const m of measurements) {
|
||||
const list = measurementsByType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
measurementsByType.set(m.type, list);
|
||||
}
|
||||
|
||||
const series: NamedSeries[] = [];
|
||||
for (const key of DISCOVERY_BEHAVIOURS) {
|
||||
const points =
|
||||
key === "MOOD"
|
||||
? toDailyMeans(
|
||||
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
|
||||
tz,
|
||||
)
|
||||
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "behaviour", points });
|
||||
}
|
||||
for (const key of DISCOVERY_OUTCOMES) {
|
||||
series.push({
|
||||
key,
|
||||
role: "outcome",
|
||||
points: toDailyMeans(measurementsByType.get(key) ?? [], tz),
|
||||
});
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
meta: {
|
||||
pairs_tested: result.pairsTested,
|
||||
discovered: result.discovered.length,
|
||||
fdr_q: result.fdrQ,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess([]);
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the compute layer so the route test stays isolated.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
computeDerivedMetric: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(metric?: string, type?: string): NextRequest {
|
||||
const url = new URL("http://localhost/api/insights/derived");
|
||||
if (metric !== undefined) url.searchParams.set("metric", metric);
|
||||
if (type !== undefined) url.searchParams.set("type", type);
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/derived", () => {
|
||||
it("returns 200 + a flat Derived envelope for a valid metric", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metric: string;
|
||||
status: string;
|
||||
value: { center: number } | null;
|
||||
coverage: { historyDays: number };
|
||||
confidence: { band: string } | null;
|
||||
provenance: { source: string };
|
||||
reason: string | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.metric).toBe("VITALS_BASELINE");
|
||||
expect(body.data?.status).toBe("ok");
|
||||
expect(body.data?.value?.center).toBe(55);
|
||||
expect(body.data?.confidence?.band).toBe("high");
|
||||
expect(body.data?.provenance.source).toBe("DAY");
|
||||
expect(body.data?.reason).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows userId from the session into the compute call (never a query field)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", metric: "VITALS_BASELINE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on an unknown metric without calling compute", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("NOT_A_METRIC"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing metric param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("flattens an insufficient result with a null value + reason", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
|
||||
status: "insufficient",
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
reason: "no_readings_in_window",
|
||||
} as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
|
||||
};
|
||||
expect(body.data?.status).toBe("insufficient");
|
||||
expect(body.data?.value).toBeNull();
|
||||
expect(body.data?.confidence).toBeNull();
|
||||
expect(body.data?.reason).toBe("no_readings_in_window");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the compute layer; keep the real registry + loadBaselineProfile.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({
|
||||
status: "ok",
|
||||
value: { metric: args.metric, n: 1 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(metrics?: string): NextRequest {
|
||||
const url = new URL("http://localhost/api/insights/derived/batch");
|
||||
if (metrics !== undefined) url.searchParams.set("metrics", metrics);
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/derived/batch", () => {
|
||||
it("resolves several metrics into one keyed map", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(
|
||||
makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Record<string, { metric: string; status: string }> };
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(Object.keys(body.data.metrics).sort()).toEqual([
|
||||
"BMI",
|
||||
"READINESS",
|
||||
"VITALS_BASELINE:WEIGHT",
|
||||
]);
|
||||
expect(body.data.metrics["VITALS_BASELINE:WEIGHT"].status).toBe("ok");
|
||||
expect(body.data.metrics.READINESS.metric).toBe("READINESS");
|
||||
});
|
||||
|
||||
it("loads the profile once regardless of metric count", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("READINESS,BMI,FITNESS_AGE,HRV_BALANCE"));
|
||||
// One User read for the whole batch — the pool-contention mitigation.
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("narrows userId from the session into every compute call", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("READINESS"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", metric: "READINESS" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a duplicate token so it cannot inflate the fan-out", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("BMI,BMI,BMI"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("422s when any token is an unknown metric id", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("READINESS,NOT_A_METRIC"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing metrics param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an empty metrics list", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq(" , , "));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq("READINESS"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* v1.10.0 — batched derived-wellness-metric route.
|
||||
*
|
||||
* `GET /api/insights/derived/batch?metrics=<id,id,…>[&types=<type,…>]`
|
||||
* resolves several derived metrics in ONE request and returns a map keyed
|
||||
* by the per-request key (`<metric>` or `<metric>:<type>` for a
|
||||
* VITALS_BASELINE sub-target). It exists to collapse the Insights cold
|
||||
* mount's 14+ independent `GET /api/insights/derived` requests — each
|
||||
* sharing one Prisma pool — into a single request that fans out
|
||||
* server-side under a bounded `p-limit`, the exact pool-starvation class
|
||||
* that bit v1.4.49.1 / v1.4.40 (the "app hangs then recovers" symptom).
|
||||
*
|
||||
* Honors the same contract as the single route: closed registry enum
|
||||
* (unknown id → 422), cookie OR Bearer auth, `userId` narrowed from the
|
||||
* session (never a query field), pure compute over the rollup tier (no
|
||||
* provider call, no cache table). The profile is loaded ONCE via the
|
||||
* shared `loadBaselineProfile` and threaded into every compute, so the
|
||||
* batch never re-reads the `User` row per metric.
|
||||
*
|
||||
* The single-metric route stays for the anatomy detail pages (one metric
|
||||
* per page); this route backs the dashboard's one-shot grid read.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import pLimit from "p-limit";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
isDerivedMetricId,
|
||||
type DerivedMetricId,
|
||||
} from "@/lib/insights/derived";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Bounded internal fan-out — matches the W-POOL `p-limit(4)` discipline
|
||||
// v1.4.40 applied to the per-type analytics walk so a batch of 14 metrics
|
||||
// never floods the shared Prisma pool.
|
||||
const BATCH_CONCURRENCY = 4;
|
||||
// Defensive ceiling — the dashboard requests ~11 metrics + ~6 baseline
|
||||
// sub-targets; cap the list so a crafted query can't fan out unbounded.
|
||||
const MAX_METRICS = 24;
|
||||
|
||||
/**
|
||||
* A single batch request item: a registered metric id with an optional
|
||||
* VITALS_BASELINE sub-target. The wire form is `metric` or `metric:type`
|
||||
* inside the `metrics` CSV; an entry's `type` is parsed off the colon.
|
||||
*/
|
||||
interface BatchItem {
|
||||
metric: DerivedMetricId;
|
||||
type: string | null;
|
||||
/** The map key the response is keyed by (mirrors the request token). */
|
||||
key: string;
|
||||
}
|
||||
|
||||
const batchQuerySchema = z.object({
|
||||
metrics: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.describe("CSV of derived-metric tokens (`metric` or `metric:type`)."),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse the `metrics` CSV into validated items. An entry `metric:type`
|
||||
* sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type.
|
||||
* Unknown ids are returned as `invalid` so the route 422s the whole
|
||||
* request rather than silently dropping a metric (the closed-enum rule).
|
||||
* Duplicate keys collapse to one item (last write wins) so a repeated
|
||||
* token can't inflate the fan-out.
|
||||
*/
|
||||
function parseBatchItems(csv: string): {
|
||||
items: BatchItem[];
|
||||
invalid: string[];
|
||||
} {
|
||||
const seen = new Map<string, BatchItem>();
|
||||
const invalid: string[] = [];
|
||||
for (const raw of csv.split(",")) {
|
||||
const token = raw.trim();
|
||||
if (token === "") continue;
|
||||
const colon = token.indexOf(":");
|
||||
const metric = colon === -1 ? token : token.slice(0, colon);
|
||||
const type = colon === -1 ? null : token.slice(colon + 1) || null;
|
||||
if (!isDerivedMetricId(metric)) {
|
||||
invalid.push(metric);
|
||||
continue;
|
||||
}
|
||||
const key = type ? `${metric}:${type}` : metric;
|
||||
seen.set(key, { metric, type, key });
|
||||
}
|
||||
return { items: [...seen.values()], invalid };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = batchQuerySchema.safeParse({
|
||||
metrics: request.nextUrl.searchParams.get("metrics"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch.invalid-query" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { items, invalid } = parseBatchItems(parsed.data.metrics);
|
||||
if (invalid.length > 0) {
|
||||
// Mirror the single route's closed-enum rule — an unknown id is a
|
||||
// contract violation, not a silent drop. The id name is the caller's
|
||||
// own input, never user health data, so it is safe to echo.
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch.invalid-metric" },
|
||||
meta: { invalid_count: invalid.length },
|
||||
});
|
||||
return apiError(
|
||||
`Unknown derived metric id(s): ${invalid.join(", ")}`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
if (items.length === 0 || items.length > MAX_METRICS) {
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch.invalid-count" },
|
||||
meta: { requested: items.length, max: MAX_METRICS },
|
||||
});
|
||||
return apiError(
|
||||
items.length === 0
|
||||
? "metrics must name at least one registered derived metric"
|
||||
: `metrics must name at most ${MAX_METRICS} entries`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Profile read ONCE — the pool-contention mitigation; the same shared
|
||||
// loader the nightly score jobs use, so the batch never re-reads the
|
||||
// User row per metric.
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
|
||||
// Fan out under a bounded limiter so a 17-item dashboard read resolves
|
||||
// four computes at a time against the shared pool, not seventeen.
|
||||
const limit = pLimit(BATCH_CONCURRENCY);
|
||||
const now = new Date();
|
||||
const results = await Promise.all(
|
||||
items.map((item) =>
|
||||
limit(async () => {
|
||||
const derived = await computeDerivedMetric({
|
||||
metric: item.metric,
|
||||
userId: user.id,
|
||||
profile,
|
||||
type: item.type,
|
||||
now,
|
||||
});
|
||||
return {
|
||||
key: item.key,
|
||||
payload: {
|
||||
metric: item.metric,
|
||||
status: derived.status,
|
||||
value: derived.status === "ok" ? derived.value : null,
|
||||
coverage: derived.coverage,
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason:
|
||||
derived.status === "insufficient" ? derived.reason : null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const map: Record<string, (typeof results)[number]["payload"]> = {};
|
||||
for (const r of results) map[r.key] = r.payload;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch" },
|
||||
meta: {
|
||||
requested: items.length,
|
||||
ok: results.filter((r) => r.payload.status === "ok").length,
|
||||
},
|
||||
});
|
||||
|
||||
// `metrics` is a map keyed by the per-request token so the client reads
|
||||
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
|
||||
return apiSuccess({ metrics: map });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* v1.10.0 — generic derived-wellness-metric route.
|
||||
*
|
||||
* `GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<MeasurementType>]`
|
||||
* serves the compute-once `Derived<T>` value for any metric registered
|
||||
* in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route
|
||||
* precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed
|
||||
* registry enum (unknown id → 422 via `returnAllZodIssues`), cookie OR
|
||||
* Bearer auth, `userId` narrowed from the session/Bearer (never a query
|
||||
* field).
|
||||
*
|
||||
* The `data` payload is the flat `Derived<T>` union so iOS can decode +
|
||||
* combine values across metrics: `{ metric, status, value?, coverage,
|
||||
* confidence?, provenance, reason? }`. Numbers are pure compute over the
|
||||
* rollup tier (Tier-1, no narrative) — no provider call, no cache table.
|
||||
*
|
||||
* Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids
|
||||
* return a `not_implemented` insufficient until their compute lands in a
|
||||
* later wave (never a 500, never a fabricated value).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
DERIVED_METRIC_IDS,
|
||||
type DerivedMetricId,
|
||||
} from "@/lib/insights/derived";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Closed enum derived from the registry so the route + registry cannot
|
||||
// drift — an id added to the registry is accepted automatically, an
|
||||
// unknown id 422s rather than silently resolving to no metric.
|
||||
const derivedQuerySchema = z.object({
|
||||
metric: z.enum(DERIVED_METRIC_IDS as [string, ...string[]]),
|
||||
// Optional sub-target for metrics that baseline a single chosen vital
|
||||
// (VITALS_BASELINE). Validated against the engine's supported set in
|
||||
// the dispatcher; an unsupported value yields an `insufficient`, not a
|
||||
// 422, so the contract stays forgiving for iOS combinations.
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
type: request.nextUrl.searchParams.get("type") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.derived.invalid-metric" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const metric = parsed.data.metric as DerivedMetricId;
|
||||
|
||||
// Profile read once via the shared loader (the same one the batch route
|
||||
// and the nightly score jobs use), passed into the pure compute function
|
||||
// — never re-fetched per metric.
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
|
||||
const derived = await computeDerivedMetric({
|
||||
metric,
|
||||
userId: user.id,
|
||||
profile,
|
||||
type: parsed.data.type ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.derived" },
|
||||
meta: { metric, status: derived.status },
|
||||
});
|
||||
|
||||
// Flatten the discriminated union for the wire: `metric` tags it,
|
||||
// `value`/`reason` are nullable so iOS decodes one stable shape.
|
||||
return apiSuccess({
|
||||
metric,
|
||||
status: derived.status,
|
||||
value: derived.status === "ok" ? derived.value : null,
|
||||
coverage: derived.coverage,
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
detectGlp1Plateau,
|
||||
buildGlp1PlateauPrompt,
|
||||
} from "@/lib/insights/glp1-plateau";
|
||||
import {
|
||||
detectDerivedBriefingSignals,
|
||||
buildDerivedBriefingPrompt,
|
||||
} from "@/lib/insights/derived-briefing";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
@@ -199,6 +204,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// LLM never sees the excluded blocks.
|
||||
insightsExcludeMetrics: true,
|
||||
locale: true,
|
||||
// v1.10.0 — profile for the derived-signal briefing detector.
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -397,6 +406,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (plateauContext) {
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
|
||||
}
|
||||
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
|
||||
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
|
||||
// Reads the one derived contract; null (the common case) costs nothing.
|
||||
const derivedSex =
|
||||
dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
|
||||
? (dbUser.gender as "MALE" | "FEMALE")
|
||||
: null;
|
||||
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
|
||||
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
|
||||
sex: derivedSex,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
});
|
||||
if (derivedBriefing) {
|
||||
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
|
||||
}
|
||||
|
||||
let result;
|
||||
let workingProviderType: string;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.10.0 — categorical events (WX-B). `GET /api/insights/rhythm-events`.
|
||||
*
|
||||
* The route serves the device-flagged event awareness timeline. The
|
||||
* load-bearing behaviour under test: it narrows the query to the EVENT
|
||||
* MeasurementTypes for the authenticated user, returns the device's own
|
||||
* verdict per row, and reports `hasEvents: false` (the data-availability
|
||||
* gate the UI keys off) when the user has none.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(new URL("http://localhost/api/insights/rhythm-events"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/rhythm-events", () => {
|
||||
it("returns hasEvents:false with an empty list when the user has no events", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { events: unknown[]; hasEvents: boolean };
|
||||
};
|
||||
expect(body.data.hasEvents).toBe(false);
|
||||
expect(body.data.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("narrows the query to the EVENT MeasurementTypes for the session user only", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
await callGet(makeReq());
|
||||
const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]
|
||||
?.where as {
|
||||
userId: string;
|
||||
type: { in: string[] };
|
||||
deletedAt: null;
|
||||
};
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.type.in).toEqual(
|
||||
expect.arrayContaining([
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
"HIGH_HEART_RATE_EVENT",
|
||||
"LOW_HEART_RATE_EVENT",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
]),
|
||||
);
|
||||
// Never a continuous metric — only the five event classes.
|
||||
expect(where.type.in).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("maps each row to the device verdict + occurrence timestamp", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "evt_1",
|
||||
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
rhythmClassification: "IRREGULAR",
|
||||
measuredAt: new Date("2026-06-01T09:15:00.000Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
hasEvents: boolean;
|
||||
events: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
classification: string | null;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(body.data.hasEvents).toBe(true);
|
||||
expect(body.data.events).toEqual([
|
||||
{
|
||||
id: "evt_1",
|
||||
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
classification: "IRREGULAR",
|
||||
occurredAt: "2026-06-01T09:15:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness route (categorical events,
|
||||
* WX-B).
|
||||
*
|
||||
* `GET /api/insights/rhythm-events` returns the authenticated user's
|
||||
* timeline of device-flagged EVENT rows — irregular-rhythm /
|
||||
* high-HR / low-HR / walking-steadiness / breathing-disturbance
|
||||
* notifications the user's wearable (Apple Watch / Withings ScanWatch)
|
||||
* already produced and synced.
|
||||
*
|
||||
* Regulatory framing (load-bearing): this surface is AWARENESS / SCREENING
|
||||
* of the DEVICE's own decision. HealthLog stores and reflects ONLY the
|
||||
* classification RESULT the device's FDA-cleared / CE-marked on-device
|
||||
* algorithm emitted. It never ingests a raw ECG waveform, never
|
||||
* re-classifies, and never produces a HealthLog diagnosis. The verdict the
|
||||
* client renders (`rhythmClassification`) is the device's, verbatim.
|
||||
*
|
||||
* Data-availability-gated by construction: an account with no event rows
|
||||
* gets `{ events: [], hasEvents: false }`, and the client un-mounts the
|
||||
* whole surface rather than painting an empty / alarming card.
|
||||
*
|
||||
* Follows the `metric-status` / `derived` route precedent: `apiHandler`
|
||||
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session
|
||||
* (never a query field), the `insightStatus` assistant-surface gate (no AI
|
||||
* provider call — this is a pure DB read).
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cap the returned timeline. A healthy account produces a handful of
|
||||
// these a year; the cap is a defensive bound, not an expected ceiling.
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: EVENT_TYPE_LIST },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
rhythmClassification: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: MAX_EVENTS,
|
||||
});
|
||||
|
||||
const events = rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
classification: r.rhythmClassification,
|
||||
occurredAt: r.measuredAt.toISOString(),
|
||||
source: r.source,
|
||||
deviceType: r.deviceType,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.rhythm-events" },
|
||||
meta: { count: events.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
events,
|
||||
hasEvents: events.length > 0,
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
// Body-size ceiling backstopping the per-entry cap. A worst-case entry
|
||||
// (max-length hkIdentifier + unit + externalId + two ISO dates + the
|
||||
// numeric fields) serialises to well under 1 KB, so a legitimate
|
||||
// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of
|
||||
// magnitude of headroom above any real batch while still rejecting a
|
||||
// multi-megabyte payload before it reaches `JSON.parse` and the heap.
|
||||
// The cap is a DoS ceiling, not a tight bound.
|
||||
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
|
||||
// batches per user per minute. Healthy iOS sync drains its
|
||||
// HealthKit observer queue in well under one batch per minute (the
|
||||
@@ -90,6 +99,12 @@ const batchEntrySchema = z.object({
|
||||
startDate: z.iso.datetime({ offset: true }),
|
||||
endDate: z.iso.datetime({ offset: true }),
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
// v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue`
|
||||
// codepoint for an EVENT-class category sample (irregular-rhythm,
|
||||
// high/low-HR, walking-steadiness, breathing-disturbance). Carries the
|
||||
// device's own verdict / severity; resolved to a `rhythmClassification`
|
||||
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
|
||||
categoryValue: z.number().int().min(0).max(20).optional(),
|
||||
externalId: z.string().min(1).max(120),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.8.6 W6 — optional per-entry source tag. Defaults to
|
||||
@@ -171,7 +186,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the
|
||||
@@ -215,6 +232,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
sleepStage: entry.sleepStage,
|
||||
categoryValue: entry.categoryValue,
|
||||
});
|
||||
|
||||
if (!mapped) {
|
||||
@@ -254,6 +272,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
sleepStage: mapped.sleepStage ?? null,
|
||||
// v1.10.0 — categorical events (WX-B). The device's own verdict /
|
||||
// severity for an EVENT row; null for every continuous reading.
|
||||
rhythmClassification: mapped.rhythmClassification ?? null,
|
||||
// v1.4.25 W8c — pass the iOS-supplied device-type through to
|
||||
// the row. Stays null for pre-W8c iOS builds; the canonical
|
||||
// picker treats null as `unknown` so legacy ingest keeps
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* GET /api/meta/capabilities route tests.
|
||||
*
|
||||
* This endpoint exists to retire the "doc says N, server ships M" enum-drift
|
||||
* class for the native client — so the drift-guard assertions ARE the point:
|
||||
*
|
||||
* 1. 401 when unauthenticated.
|
||||
* 2. `derivedMetricIds` is exactly the derived registry's id set (same
|
||||
* length AND members) — the endpoint can never silently diverge from
|
||||
* the registry it claims to mirror.
|
||||
* 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES`
|
||||
* constant — a client reads the live write allowlist, not a copy.
|
||||
* 4. Every quantity-mapping row + the FHIR constants + the contract version
|
||||
* are sourced from the canonical server constants, not hand-typed here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The route transitively imports `@/lib/insights/derived/wellness-scores`,
|
||||
// which imports the Prisma client; mock it so the module graph loads without
|
||||
// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain
|
||||
// object), never the client.
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
|
||||
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
|
||||
import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
type CapabilitiesBody = {
|
||||
data: {
|
||||
apiContractVersion: string;
|
||||
derivedMetricIds: string[];
|
||||
vitalsBaselineTypes: string[];
|
||||
layoutTileIds: string[];
|
||||
metricStatusIds: string[];
|
||||
ingest: {
|
||||
quantityTypes: { type: string; hk: string; unit: string }[];
|
||||
eventTypes: string[];
|
||||
computedScores: string[];
|
||||
writeAllowlist: string[];
|
||||
};
|
||||
fhir: {
|
||||
atcSystem: string;
|
||||
snomedRoute: string;
|
||||
germanAtcDefaultLocales: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function call(): Promise<Response> {
|
||||
return (GET as unknown as () => Promise<Response>)();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/meta/capabilities — auth", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/meta/capabilities — drift guards", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
it("derivedMetricIds is exactly the derived registry's id set", async () => {
|
||||
const res = await call();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
// Length AND membership — the endpoint cannot silently drift from the
|
||||
// registry (the whole reason it exists).
|
||||
expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length);
|
||||
expect([...body.data.derivedMetricIds].sort()).toEqual(
|
||||
[...DERIVED_METRIC_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(body.data.ingest.writeAllowlist).toEqual([
|
||||
...WRITABLE_MEASUREMENT_SOURCES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("computedScores are the three persisted wellness scores", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect([...body.data.ingest.computedScores].sort()).toEqual(
|
||||
["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"],
|
||||
);
|
||||
});
|
||||
|
||||
it("fhir constants mirror the FHIR builder source", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM);
|
||||
expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM);
|
||||
expect(body.data.fhir.germanAtcDefaultLocales).toEqual([
|
||||
...GERMAN_ATC_DEFAULT_LOCALES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
// Every emitted quantity row must trace back to a real mapping entry —
|
||||
// never an entry the ingest path doesn't actually accept.
|
||||
const byHk = new Map(
|
||||
Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]),
|
||||
);
|
||||
expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0);
|
||||
for (const q of body.data.ingest.quantityTypes) {
|
||||
const mapping = byHk.get(q.hk);
|
||||
expect(mapping).toBeDefined();
|
||||
expect(q.type).toBe(mapping!.measurementType);
|
||||
expect(q.unit).toBe(mapping!.dbUnit);
|
||||
// Quantity rows are the non-event-class mappings.
|
||||
expect(
|
||||
Boolean(
|
||||
mapping!.eventClassificationMap || mapping!.fallbackClassification,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("apiContractVersion is a non-empty string", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(typeof body.data.apiContractVersion).toBe("string");
|
||||
expect(body.data.apiContractVersion.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* GET /api/meta/capabilities
|
||||
*
|
||||
* Live capability / discovery surface for the native client. Returns the
|
||||
* server's REAL id vocabularies — derived-metric ids, vitals-baseline
|
||||
* types, insights layout tile-ids, metric-status ids, the HealthKit ingest
|
||||
* mapping, and the FHIR coding constants — plus the running API contract
|
||||
* version. The iOS app reads this once on launch (or on a version bump) and
|
||||
* gates its UI / decoder against what the server actually ships, retiring
|
||||
* the recurring "doc says N, server ships M" enum-drift class.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every
|
||||
* authenticated client may discover the contract.
|
||||
*
|
||||
* DESIGN RULE: every list is SOURCED from the existing canonical server
|
||||
* constant / Zod enum / registry — never hand-duplicated here. Adding a
|
||||
* derived metric, a tile, a writable source, or a HealthKit mapping
|
||||
* auto-updates this endpoint, so it cannot drift from the real registries.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
VITALS_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores";
|
||||
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
|
||||
import {
|
||||
APPLE_HEALTH_TYPE_MAP,
|
||||
type AppleHealthMapping,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* An EVENT-class HealthKit identifier carries a device-produced verdict, not
|
||||
* a continuous reading — it is marked by an `eventClassificationMap` or a
|
||||
* `fallbackClassification` in the ingest mapping. Everything else is a
|
||||
* quantity sample. Splitting the single mapping by this predicate keeps the
|
||||
* two ingest lists derived from one registry.
|
||||
*/
|
||||
function isEventMapping(m: AppleHealthMapping): boolean {
|
||||
return Boolean(m.eventClassificationMap || m.fallbackClassification);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "meta.capabilities.read" } });
|
||||
|
||||
// Mirror the /api/version source so the contract version cannot drift from
|
||||
// the running build's reported version (build-arg env wins over the
|
||||
// package.json fallback for local dev).
|
||||
const apiContractVersion =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
|
||||
|
||||
const mappings = Object.values(APPLE_HEALTH_TYPE_MAP);
|
||||
|
||||
const quantityTypes = mappings
|
||||
.filter((m) => !isEventMapping(m))
|
||||
.map((m) => ({
|
||||
type: m.measurementType,
|
||||
hk: m.hkIdentifier,
|
||||
unit: m.dbUnit,
|
||||
}));
|
||||
|
||||
// Distinct event-class MeasurementTypes (several HK event identifiers can
|
||||
// share one MeasurementType, e.g. the audio-exposure pair).
|
||||
const eventTypes = Array.from(
|
||||
new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
apiContractVersion,
|
||||
derivedMetricIds: DERIVED_METRIC_IDS,
|
||||
vitalsBaselineTypes: VITALS_BASELINE_TYPES,
|
||||
layoutTileIds: INSIGHTS_TILE_IDS,
|
||||
metricStatusIds: METRIC_STATUS_IDS,
|
||||
ingest: {
|
||||
quantityTypes,
|
||||
eventTypes,
|
||||
computedScores: Object.values(WELLNESS_SCORE_TYPES),
|
||||
writeAllowlist: WRITABLE_MEASUREMENT_SOURCES,
|
||||
},
|
||||
fhir: {
|
||||
atcSystem: ATC_SYSTEM,
|
||||
snomedRoute: SNOMED_SYSTEM,
|
||||
germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,12 @@ export const GET = apiHandler(
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
samples: {
|
||||
select: {
|
||||
samples: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,6 +114,16 @@ export const GET = apiHandler(
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.10.0 — route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise.
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
return apiSuccess({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
@@ -126,6 +142,7 @@ export const GET = apiHandler(
|
||||
externalId: row.externalId,
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({
|
||||
workoutRoute: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
workoutSamples: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -123,6 +126,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 });
|
||||
// v1.4.43 W9 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
// that exercise a custom ladder override this in-line.
|
||||
@@ -386,6 +390,122 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-indoor-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-indoor",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-indoor", {
|
||||
sportType: "strength",
|
||||
samples: [
|
||||
{ t: "2026-05-14T06:30:00.000Z", hr: 110 },
|
||||
{ t: "2026-05-14T06:30:05.000Z", hr: 118 },
|
||||
{ t: "2026-05-14T06:30:10.000Z", hr: 124, power: 0, cadence: 0 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The route table is untouched — an indoor workout has no GPS route.
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
// The HR series lands in the dedicated child table by FK.
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0];
|
||||
const rows = (
|
||||
call as {
|
||||
data: Array<{
|
||||
workoutId: string;
|
||||
sampleCount: number;
|
||||
samples: unknown;
|
||||
}>;
|
||||
}
|
||||
).data;
|
||||
expect(rows[0]?.workoutId).toBe("wkt-indoor-id");
|
||||
// `sampleCount` is the denormalised series length.
|
||||
expect(rows[0]?.sampleCount).toBe(3);
|
||||
expect((rows[0]?.samples as unknown[]).length).toBe(3);
|
||||
});
|
||||
|
||||
it("attaches both a route and an HR series on one workout", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-both-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-both",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-both", {
|
||||
route: {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.077, 49.452],
|
||||
[11.078, 49.453],
|
||||
],
|
||||
},
|
||||
},
|
||||
samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
const routeRows = (
|
||||
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
const sampleRows = (
|
||||
vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
// Both children attach to the SAME freshly-inserted workout id.
|
||||
expect(routeRows[0]?.workoutId).toBe("wkt-both-id");
|
||||
expect(sampleRows[0]?.workoutId).toBe("wkt-both-id");
|
||||
});
|
||||
|
||||
it("does not touch the samples table when no workout ships a series", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [validWorkout("uuid-no-series")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => {
|
||||
it("looks up the user's source-priority blob exactly once per batch", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
|
||||
@@ -87,13 +87,13 @@ const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// 5 MB request-body ceiling. The route geometry is the largest tail in
|
||||
// a workout payload; with the 20 000-point cap on a single LineString
|
||||
// AND the 100-workout cap per batch, the worst-case body is bounded
|
||||
// well below this ceiling for typical lon/lat encodings. A request
|
||||
// above this ceiling almost certainly indicates a misbehaving client
|
||||
// or an attempted DoS — return 413 so the iOS client falls back to
|
||||
// one-workout-per-call.
|
||||
// 5 MB request-body ceiling (pre-existing). The route geometry is the
|
||||
// largest tail in a workout payload; a typical batch sits comfortably
|
||||
// under this, but the 20 000-point LineString cap combined with the
|
||||
// 100-workout batch cap allows a theoretical tens-of-MB body for an
|
||||
// extreme GPS-heavy backlog. Such a body returns 413 so the iOS client
|
||||
// falls back to smaller / one-workout-per-call batches — which is also
|
||||
// the correct response to a misbehaving client or an attempted DoS.
|
||||
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
@@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
// The Content-Length pre-flight above bounds the common case, but a
|
||||
// chunked upload carries no Content-Length. The `safeJson` body cap
|
||||
// backstops that gap: the raw text is measured before `JSON.parse`,
|
||||
// so an over-limit chunked body returns a clean 413 before the parse
|
||||
// cost. Reuses the same 5 MB ceiling — a typical iOS batch sits under
|
||||
// it; an extreme GPS-heavy backlog that exceeds it falls back to
|
||||
// smaller batches client-side.
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the
|
||||
@@ -218,6 +227,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
geometry: unknown;
|
||||
sampleTimestamps: unknown;
|
||||
} | null;
|
||||
/** Route-independent per-workout HR series. NULL when the entry
|
||||
* ships no `samples` array. Persisted to the `WorkoutSamples`
|
||||
* child by FK after the workout row lands. */
|
||||
samples: {
|
||||
samples: unknown;
|
||||
sampleCount: number;
|
||||
} | null;
|
||||
/** Composite-key probe for the pre-flight findMany. NULL when
|
||||
* the entry has no externalId — those rows always pass through
|
||||
* as "inserted" because the NULL-distinct unique index can never
|
||||
@@ -237,6 +253,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
|
||||
}
|
||||
: null;
|
||||
const samples = w.samples
|
||||
? {
|
||||
samples: w.samples as unknown,
|
||||
sampleCount: w.samples.length,
|
||||
}
|
||||
: null;
|
||||
const row: Prisma.WorkoutCreateManyInput = {
|
||||
userId: user.id,
|
||||
sportType: w.sportType,
|
||||
@@ -262,6 +284,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
index,
|
||||
row,
|
||||
route,
|
||||
samples,
|
||||
dedupKey: w.externalId
|
||||
? { source: w.source, externalId: w.externalId }
|
||||
: null,
|
||||
@@ -372,19 +395,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
// Look up the workouts we just wrote (or that the racing batch
|
||||
// wrote) so we can attach routes by FK. We probe by the
|
||||
// wrote) so we can attach their child rows (route geometry +
|
||||
// the route-independent HR series) by FK. We probe by the
|
||||
// composite unique key for entries that have one, and by the
|
||||
// `(userId, source, startedAt, sportType)` tuple as a fallback
|
||||
// for manual entries (where externalId is null). The fallback
|
||||
// is best-effort — manual entries from the iOS client always
|
||||
// carry an HK uuid, so the null-externalId case is exercised
|
||||
// only by future surface (manual workout entry).
|
||||
const routeAttachable = toInsert.filter((p) => p.route !== null);
|
||||
if (routeAttachable.length > 0) {
|
||||
const withExternal = routeAttachable.filter(
|
||||
//
|
||||
// v1.10.0 — `samples` (the per-workout HR series) rides the
|
||||
// SAME id lookup as `route`: an indoor workout carries samples
|
||||
// with no route, so the lookup runs whenever EITHER child is
|
||||
// present on a to-insert entry.
|
||||
const childAttachable = toInsert.filter(
|
||||
(p) => p.route !== null || p.samples !== null,
|
||||
);
|
||||
if (childAttachable.length > 0) {
|
||||
const withExternal = childAttachable.filter(
|
||||
(p) => p.dedupKey !== null,
|
||||
);
|
||||
const withoutExternal = routeAttachable.filter(
|
||||
const withoutExternal = childAttachable.filter(
|
||||
(p) => p.dedupKey === null,
|
||||
);
|
||||
|
||||
@@ -463,13 +494,30 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return match ? { id: match.id } : null;
|
||||
});
|
||||
|
||||
// Resolve the freshly-written workout id for a prepared
|
||||
// entry — by composite key for externalId-bearing rows, by
|
||||
// the deterministic (source, startedAt, sportType) tuple for
|
||||
// manual rows. Shared by both child tables (route + samples)
|
||||
// so the HR series and the route attach to the same row.
|
||||
const resolveId = (
|
||||
p: Prepared,
|
||||
manualIndex: number,
|
||||
): string | undefined => {
|
||||
if (p.dedupKey !== null) {
|
||||
return idBySourceExt.get(
|
||||
`${p.dedupKey.source}::${p.dedupKey.externalId}`,
|
||||
);
|
||||
}
|
||||
return manualMatches[manualIndex]?.id ?? undefined;
|
||||
};
|
||||
|
||||
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
|
||||
const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = [];
|
||||
for (let i = 0; i < withExternal.length; i++) {
|
||||
const p = withExternal[i];
|
||||
const id = idBySourceExt.get(
|
||||
`${p.dedupKey!.source}::${p.dedupKey!.externalId}`,
|
||||
);
|
||||
if (id && p.route) {
|
||||
const id = resolveId(p, -1);
|
||||
if (!id) continue;
|
||||
if (p.route) {
|
||||
routesToInsert.push({
|
||||
workoutId: id,
|
||||
geometry: p.route.geometry as Prisma.InputJsonValue,
|
||||
@@ -479,13 +527,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
});
|
||||
}
|
||||
if (p.samples) {
|
||||
samplesToInsert.push({
|
||||
workoutId: id,
|
||||
samples: p.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: p.samples.sampleCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < withoutExternal.length; i++) {
|
||||
const p = withoutExternal[i];
|
||||
const match = manualMatches[i];
|
||||
if (match?.id && p.route) {
|
||||
const id = resolveId(p, i);
|
||||
if (!id) continue;
|
||||
if (p.route) {
|
||||
routesToInsert.push({
|
||||
workoutId: match.id,
|
||||
workoutId: id,
|
||||
geometry: p.route.geometry as Prisma.InputJsonValue,
|
||||
sampleTimestamps:
|
||||
p.route.sampleTimestamps === null
|
||||
@@ -493,6 +549,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
});
|
||||
}
|
||||
if (p.samples) {
|
||||
samplesToInsert.push({
|
||||
workoutId: id,
|
||||
samples: p.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: p.samples.sampleCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (routesToInsert.length > 0) {
|
||||
@@ -508,6 +571,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (samplesToInsert.length > 0) {
|
||||
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutSamples.createMany({
|
||||
data: samplesToInsert.slice(i, i + CHUNK),
|
||||
// 1:1 FK on `workoutId` (UNIQUE), same idempotency as
|
||||
// the route child: a re-submitted batch that wins the
|
||||
// workout-row race but loses the sample-row race is a
|
||||
// no-op, not a hard error.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,18 @@
|
||||
--color-status-pill-in: var(--status-pill-in);
|
||||
--color-status-pill-near: var(--status-pill-near);
|
||||
--color-status-pill-out: var(--status-pill-out);
|
||||
|
||||
/*
|
||||
* Dose-accent token. The medication cards stamp the upcoming schedule
|
||||
* dose in purple. They previously hard-coded Tailwind stock
|
||||
* `text-purple-400` (`oklch(0.714 0.203 305.504)`), which reads ~3.1:1 on
|
||||
* the white Alucard card and fails WCAG AA (4.5:1) for body text. This
|
||||
* theme-aware token keeps the exact dark-mode purple and swaps to a darker
|
||||
* AA-safe purple on the light card — mirroring how the semantic feedback
|
||||
* tokens (`--success` / `--warning` / `--info`) land each tone above AA in
|
||||
* both themes.
|
||||
*/
|
||||
--color-dose-accent: var(--dose-accent);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -189,6 +201,13 @@
|
||||
--status-pill-near: #ffb86c;
|
||||
--status-pill-out: #ff8a8a;
|
||||
|
||||
/*
|
||||
* Dose accent in dark mode — the exact Tailwind `text-purple-400`
|
||||
* (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card
|
||||
* stays pixel-identical. AA on the dark card at ~6.8:1.
|
||||
*/
|
||||
--dose-accent: oklch(0.714 0.203 305.504);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -245,6 +264,14 @@
|
||||
--status-pill-near: #a34d14;
|
||||
--status-pill-out: #b32d1e;
|
||||
|
||||
/*
|
||||
* Dose accent in light mode (Alucard). The bright `text-purple-400` reads
|
||||
* ~3.1:1 on the white card and fails AA; this darker purple — the same
|
||||
* family as the light-mode `--primary` (#644ac9) — clears 4.5:1 on both
|
||||
* the white card and the muted panel while keeping the purple identity.
|
||||
*/
|
||||
--dose-accent: #6f42c1;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -101,7 +103,22 @@ export default function InsightsBlutdruckPage() {
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
explainerMetric="bloodPressure"
|
||||
coachLaunch
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="BLOOD_PRESSURE_SYS"
|
||||
metricLabel={t("insights.bloodPressureSectionTitle")}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
showAllValuesType="BLOOD_PRESSURE_SYS"
|
||||
/* No `statStrip`: `<MetricStatStrip>` is single-series, but blood
|
||||
pressure is two series (systolic + diastolic). A single min / max /
|
||||
median / mean strip would silently drop the diastolic half, so the
|
||||
page omits it rather than mislead. */
|
||||
>
|
||||
{/* The period-over-period delta keys on the systolic series — the
|
||||
canonical BP figure the targets + status surfaces lead with. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -112,6 +113,15 @@ export default function InsightsBmiPage() {
|
||||
explainerMetric="bmi"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `statStrip` / `diversityNudge` / `showAllValuesType`: BMI is a
|
||||
derived metric (computed from the WEIGHT series + the user's height),
|
||||
so it has no first-class summary or raw-reading rows of its own. A
|
||||
stat strip would have nothing to read, and a diversity nudge / "show
|
||||
all values" entry would point at WEIGHT and duplicate the weight
|
||||
page's controls.
|
||||
BMI is derived from WEIGHT, so the period-over-period delta keys on
|
||||
the underlying weight series the chart reuses. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/breathing-disturbances`.
|
||||
*
|
||||
* BREATHING_DISTURBANCES sub-page. Apple's per-night sleep-breathing
|
||||
* index (iOS 18+), classified as NotElevated / Elevated; fewer is better.
|
||||
* The series arrives from Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsBreathingDisturbancesPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BREATHING_DISTURBANCES"
|
||||
statusMetric="BREATHING_DISTURBANCES"
|
||||
insightMetric="BREATHING_DISTURBANCES"
|
||||
chartKey="breathingDisturbances"
|
||||
i18nPrefix="insights.breathingDisturbances"
|
||||
explainerMetric="breathingDisturbances"
|
||||
color="#8be9fd"
|
||||
unit="count"
|
||||
yAxisUnit="count"
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/cardio-fitness`.
|
||||
*
|
||||
* VO2_MAX sub-page. VO₂ max previously rode as a bare chart-row on the
|
||||
* pulse page with no plain-language assessment; this dedicated page gives
|
||||
* it the generic metric-status assessment treatment alongside the chart.
|
||||
* The series arrives from Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsCardioFitnessPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="VO2_MAX"
|
||||
statusMetric="VO2_MAX"
|
||||
insightMetric="VO2_MAX"
|
||||
chartKey="vo2Max"
|
||||
i18nPrefix="insights.cardioFitness"
|
||||
explainerMetric="cardioFitness"
|
||||
color="#bd93f9"
|
||||
unit="mL/(kg·min)"
|
||||
yAxisUnit="mL/(kg·min)"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { HeartPulse } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/cardio-recovery`.
|
||||
*
|
||||
* CARDIO_RECOVERY sub-page. The heart-rate drop one minute after peak
|
||||
* exercise; a larger drop signals fitter autonomic recovery. The series
|
||||
* arrives from Apple Health sync after qualifying workouts — no
|
||||
* manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsCardioRecoveryPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="CARDIO_RECOVERY"
|
||||
statusMetric="CARDIO_RECOVERY"
|
||||
insightMetric="CARDIO_RECOVERY"
|
||||
chartKey="cardioRecovery"
|
||||
i18nPrefix="insights.cardioRecovery"
|
||||
explainerMetric="cardioRecovery"
|
||||
color="#ff79c6"
|
||||
unit="bpm"
|
||||
yAxisUnit="bpm"
|
||||
emptyStateIcon={<HeartPulse className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { PersonStanding } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/falls`.
|
||||
*
|
||||
* FALL_COUNT sub-page. Hard-fall detections, summed per day; fewer is
|
||||
* better. The series arrives from Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsFallsPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="FALL_COUNT"
|
||||
statusMetric="FALL_COUNT"
|
||||
insightMetric="FALL_COUNT"
|
||||
chartKey="fallCount"
|
||||
i18nPrefix="insights.falls"
|
||||
explainerMetric="falls"
|
||||
color="#ff5555"
|
||||
unit="falls"
|
||||
yAxisUnit="falls"
|
||||
emptyStateIcon={<PersonStanding className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -193,6 +193,9 @@ export default function InsightsMedikamentePage() {
|
||||
explainerMetric="medications"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: medication compliance is
|
||||
event-driven, not a MeasurementType series, so the period-over-period
|
||||
range read has nothing to aggregate. */}
|
||||
<div
|
||||
className={
|
||||
medications.length >= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4"
|
||||
|
||||
@@ -100,6 +100,10 @@ export default function InsightsStimmungPage() {
|
||||
explainerMetric="mood"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
|
||||
MeasurementType series, so the period-over-period range read
|
||||
(`/api/analytics/range`, keyed on a MeasurementType enum) has
|
||||
nothing to aggregate. */}
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
compareBaseline={compareBaseline}
|
||||
|
||||
+38
-11
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -70,6 +69,34 @@ const TrendsRow = dynamic(
|
||||
),
|
||||
},
|
||||
);
|
||||
// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of
|
||||
// personal-typical-range + passthrough-reframe tiles). Deferred behind
|
||||
// `next/dynamic` like the other below-the-fold blocks; each tile owns its
|
||||
// own derived-metric query and un-mounts when its vital is absent.
|
||||
const VitalsDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/derived").then((mod) => ({
|
||||
default: mod.VitalsDashboard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
// v1.10.0 — categorical events (WX-B). The device-flagged event awareness
|
||||
// timeline. Deferred like the other below-the-fold blocks; the card
|
||||
// un-mounts itself when the user has no such events (no skeleton-then-empty
|
||||
// flash — `ssr: false` + the card's own data gate), so it carries no
|
||||
// loading placeholder of its own.
|
||||
const RhythmEventsCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/rhythm-events-card").then((mod) => ({
|
||||
default: mod.RhythmEventsCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
@@ -152,16 +179,12 @@ export default function InsightsPage() {
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
// v1.8.7.1 — on-demand full assessment warm. The auto-trigger fires once
|
||||
// per session as soon as the overview confirms the user has data, so the
|
||||
// status cards (and the iOS client, which reads the same routes) land on
|
||||
// warm caches without the user waiting. The manual button below re-warms
|
||||
// everything on demand.
|
||||
const { warm, isWarming, autoWarmOnce } = useInsightsWarm();
|
||||
const hasData = !!data && data.totalMeasurements > 0;
|
||||
useEffect(() => {
|
||||
autoWarmOnce(isAuthenticated && hasData);
|
||||
}, [autoWarmOnce, isAuthenticated, hasData]);
|
||||
// The "prepare assessments" button below re-warms every assessment on
|
||||
// demand. There is no warm-on-mount: the nightly cron (04:30) keeps the
|
||||
// caches warm and the per-metric status GETs revalidate gently on their
|
||||
// own (stale-while-revalidate), so opening the overview only reads cached
|
||||
// text — it never fans out a full provider warm on a page visit.
|
||||
const { warm, isWarming } = useInsightsWarm();
|
||||
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
@@ -235,6 +258,10 @@ export default function InsightsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<VitalsDashboard enabled={isAuthenticated} />
|
||||
|
||||
<RhythmEventsCard enabled={isAuthenticated} />
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
@@ -131,6 +132,7 @@ export default function InsightsPulsPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="PULSE"
|
||||
>
|
||||
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
type AnatomyMetricId,
|
||||
} from "@/components/insights/derived/composite-score-anatomy";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/scores/<metric>` — the score-anatomy detail surface.
|
||||
*
|
||||
* Reached on tap from a score's dashboard / insights entry. Renders the
|
||||
* Oura-style anatomy view (big ring + ranked contributor rows where the
|
||||
* score decomposes + cited standard + method + honesty caveat) for one
|
||||
* derived score — the two composites (sleep, readiness) AND the three
|
||||
* persisted nightly scores (recovery, stress, strain), which previously had
|
||||
* no detail page. The `[metric]` slug is validated against the known set; an
|
||||
* unknown slug falls back to the readiness view's insufficient state rather
|
||||
* than 404-ing, so a stale link never dead-ends.
|
||||
*/
|
||||
|
||||
const SLUG_TO_METRIC: Record<string, AnatomyMetricId> = {
|
||||
sleep: "SLEEP_SCORE",
|
||||
readiness: "READINESS",
|
||||
recovery: "RECOVERY_SCORE",
|
||||
stress: "STRESS_SCORE",
|
||||
strain: "STRAIN_SCORE",
|
||||
};
|
||||
|
||||
/** Localised page header per metric (title + one-line description). */
|
||||
const HEADER_KEYS: Record<
|
||||
AnatomyMetricId,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
SLEEP_SCORE: {
|
||||
title: "insights.derived.composite.SLEEP_SCORE.title",
|
||||
description: "insights.derived.composite.SLEEP_SCORE.description",
|
||||
},
|
||||
READINESS: {
|
||||
title: "insights.derived.composite.READINESS.title",
|
||||
description: "insights.derived.composite.READINESS.description",
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
title: "insights.derived.scores.recovery",
|
||||
description: "insights.derived.composite.RECOVERY_SCORE.description",
|
||||
},
|
||||
STRESS_SCORE: {
|
||||
title: "insights.derived.scores.stress",
|
||||
description: "insights.derived.composite.STRESS_SCORE.description",
|
||||
},
|
||||
STRAIN_SCORE: {
|
||||
title: "insights.derived.scores.strain",
|
||||
description: "insights.derived.composite.STRAIN_SCORE.description",
|
||||
},
|
||||
};
|
||||
|
||||
export default function CompositeScorePage() {
|
||||
const params = useParams<{ metric: string }>();
|
||||
const { t } = useTranslations();
|
||||
const slug = typeof params.metric === "string" ? params.metric : "";
|
||||
const metric: AnatomyMetricId = SLUG_TO_METRIC[slug] ?? "READINESS";
|
||||
const header = HEADER_KEYS[metric];
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t(header.title)}
|
||||
description={t(header.description)}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="composite-score-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.scoresBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachLaunch
|
||||
>
|
||||
<CompositeScoreAnatomy metric={metric} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Footprints } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/six-minute-walk`.
|
||||
*
|
||||
* SIX_MINUTE_WALK_DISTANCE sub-page. Apple's estimated six-minute-walk-test
|
||||
* distance; a mobility + cardiopulmonary endurance signal, higher-better.
|
||||
* The series arrives from Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsSixMinuteWalkPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="SIX_MINUTE_WALK_DISTANCE"
|
||||
statusMetric="SIX_MINUTE_WALK_DISTANCE"
|
||||
insightMetric="SIX_MINUTE_WALK_DISTANCE"
|
||||
chartKey="sixMinuteWalkDistance"
|
||||
i18nPrefix="insights.sixMinuteWalk"
|
||||
explainerMetric="sixMinuteWalk"
|
||||
color="#50fa7b"
|
||||
unit="m"
|
||||
yAxisUnit="m"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -30,6 +33,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
*/
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
@@ -63,7 +67,21 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.sleep.description")}
|
||||
explainerMetric="sleep"
|
||||
coachLaunch
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="SLEEP_DURATION"
|
||||
metricLabel={t("insights.sleep.title")}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
showAllValuesType="SLEEP_DURATION"
|
||||
/* No `statStrip`: `<SleepOverview>` already leads with the average
|
||||
nightly total + per-stage breakdown, so a duration-in-minutes
|
||||
min / max / median / mean strip would duplicate it and read in an
|
||||
awkward unit. */
|
||||
>
|
||||
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
|
||||
|
||||
<SleepOverview />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/stair-ascent-speed`.
|
||||
*
|
||||
* STAIR_ASCENT_SPEED sub-page. Gait speed measured while climbing stairs;
|
||||
* a faster pace is the fitter signal. The series arrives from Apple Health
|
||||
* sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsStairAscentSpeedPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="STAIR_ASCENT_SPEED"
|
||||
statusMetric="STAIR_ASCENT_SPEED"
|
||||
insightMetric="STAIR_ASCENT_SPEED"
|
||||
chartKey="stairAscentSpeed"
|
||||
i18nPrefix="insights.stairAscentSpeed"
|
||||
explainerMetric="stairAscentSpeed"
|
||||
color="#50fa7b"
|
||||
unit="m/s"
|
||||
yAxisUnit="m/s"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/stair-descent-speed`.
|
||||
*
|
||||
* STAIR_DESCENT_SPEED sub-page. Gait speed measured while descending
|
||||
* stairs; the companion to ascent speed. The series arrives from Apple
|
||||
* Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsStairDescentSpeedPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="STAIR_DESCENT_SPEED"
|
||||
statusMetric="STAIR_DESCENT_SPEED"
|
||||
insightMetric="STAIR_DESCENT_SPEED"
|
||||
chartKey="stairDescentSpeed"
|
||||
i18nPrefix="insights.stairDescentSpeed"
|
||||
explainerMetric="stairDescentSpeed"
|
||||
color="#50fa7b"
|
||||
unit="m/s"
|
||||
yAxisUnit="m/s"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW,
|
||||
resolveValuesBackHref,
|
||||
} from "@/lib/insights/values-back-link";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,6 +37,7 @@ export default function InsightsMetricValuesPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { type } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Reject any segment that is not a real MeasurementType so a typo'd
|
||||
// deep-link 404s instead of mounting a blank list.
|
||||
@@ -43,6 +48,16 @@ export default function InsightsMetricValuesPage({
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const metricLabel = labelKey ? t(labelKey) : type;
|
||||
|
||||
// v1.10.2 — return to the originating metric page when the link carried a
|
||||
// `from` param (set by `<SubPageShell>` on the "show all readings" link),
|
||||
// so a `weight → show all values` drill-in lands back on `weight` rather
|
||||
// than the Insights overview. `resolveValuesBackHref` sanitises the value to
|
||||
// an internal `/insights/<slug>` path so a crafted `?from=` can never become
|
||||
// an off-site or protocol-relative target; anything else falls back to the
|
||||
// overview.
|
||||
const backHref = resolveValuesBackHref(searchParams.get("from"));
|
||||
const backToOrigin = backHref !== INSIGHTS_OVERVIEW;
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
|
||||
@@ -55,9 +70,11 @@ export default function InsightsMetricValuesPage({
|
||||
data-slot="metric-values-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.valuesBack")}
|
||||
{backToOrigin
|
||||
? t("insights.subPage.valuesBackToMetric", { metric: metricLabel })
|
||||
: t("insights.subPage.valuesBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
@@ -88,6 +89,7 @@ export default function InsightsGewichtPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="WEIGHT"
|
||||
>
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
|
||||
@@ -37,6 +37,9 @@ export default function InsightsWorkoutsPage() {
|
||||
explainerMetric="workouts"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
{isLoading ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Thermometer } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/wrist-temperature`.
|
||||
*
|
||||
* WRIST_TEMPERATURE sub-page. Overnight wrist temperature; Apple frames
|
||||
* it as a deviation from a personal baseline, so the assessment leans on
|
||||
* the user's own trend rather than a fixed band. The series arrives from
|
||||
* Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsWristTemperaturePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="WRIST_TEMPERATURE"
|
||||
statusMetric="WRIST_TEMPERATURE"
|
||||
insightMetric="WRIST_TEMPERATURE"
|
||||
chartKey="wristTemperature"
|
||||
i18nPrefix="insights.wristTemperature"
|
||||
explainerMetric="wristTemperature"
|
||||
color="#ffb86c"
|
||||
unit="°C"
|
||||
yAxisUnit="°C"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ const inter = Inter({
|
||||
export const metadata: Metadata = {
|
||||
title: "HealthLog",
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
"Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
|
||||
@@ -84,14 +84,9 @@ export default function IntakeHistoryPage({
|
||||
<div className="space-y-6">
|
||||
{/* Back to the medication detail page — history is a drill-down
|
||||
OF the medication, not a sibling of the list. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href={`/medications/${id}`}>
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.detail.history.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -165,14 +165,9 @@ export default function MedicationDetailPage({
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -198,14 +193,9 @@ export default function MedicationDetailPage({
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -236,9 +236,9 @@ export function RemindersSection() {
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
|
||||
<CheckCircle2 className="text-success dark:text-green-400 h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
<XCircle className="text-destructive dark:text-red-400 h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium">{r.channel}</span>
|
||||
{r.error && (
|
||||
@@ -281,11 +281,11 @@ export function RemindersSection() {
|
||||
{med.schedules.map((sched, j) => {
|
||||
const statusColor =
|
||||
sched.status === "open"
|
||||
? "text-green-400"
|
||||
? "text-success dark:text-green-400"
|
||||
: sched.status === "threshold"
|
||||
? "text-yellow-400"
|
||||
? "text-warning dark:text-yellow-400"
|
||||
: sched.status === "missed"
|
||||
? "text-red-400"
|
||||
? "text-destructive dark:text-red-400"
|
||||
: sched.status === "skipped"
|
||||
? "text-muted-foreground"
|
||||
: "";
|
||||
@@ -299,7 +299,7 @@ export function RemindersSection() {
|
||||
</span>
|
||||
<span className={statusColor}>{sched.label}</span>
|
||||
{sched.notificationSent && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
|
||||
<span className="text-success dark:text-green-400 flex shrink-0 items-center gap-0.5">
|
||||
<Bell className="h-3 w-3" />
|
||||
{t("admin.reminderCheckNotifSent")}
|
||||
</span>
|
||||
|
||||
@@ -112,8 +112,8 @@ describe("<TrendCard> 7-day trend label", () => {
|
||||
expect(html).toContain("±0");
|
||||
// Tiny absolute deltas never paint sentiment colour — they read
|
||||
// as "no movement" regardless of metric type.
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("text-warning");
|
||||
});
|
||||
|
||||
it("colors a +delta orange on an up-bad metric (weight rising = bad)", () => {
|
||||
@@ -129,7 +129,7 @@ describe("<TrendCard> 7-day trend label", () => {
|
||||
// HTML, so we assert orange is present in a delta-bearing render.
|
||||
// (Tile arrow is muted for stable slope or omitted icon — but the
|
||||
// delta paints regardless, so orange must appear.)
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("colors a +delta green on an up-good metric (mood rising = good)", () => {
|
||||
@@ -146,7 +146,7 @@ describe("<TrendCard> 7-day trend label", () => {
|
||||
trend7Delta={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
expect(html).toContain("text-success");
|
||||
});
|
||||
|
||||
it("paints neutral metrics muted regardless of delta direction", () => {
|
||||
@@ -159,7 +159,7 @@ describe("<TrendCard> 7-day trend label", () => {
|
||||
);
|
||||
expect(html).toContain('data-slot="trend7-delta"');
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("text-warning");
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe("<TrendCard compareBaseline=...>", () => {
|
||||
</I18nProvider>,
|
||||
);
|
||||
// Down-trend on an up-bad metric is "improvement" → green.
|
||||
expect(html).toMatch(/text-dracula-green/);
|
||||
expect(html).toMatch(/text-success/);
|
||||
});
|
||||
|
||||
it("paints an up-good metric worsening (negative delta) with the orange sentiment class", () => {
|
||||
@@ -99,7 +99,7 @@ describe("<TrendCard compareBaseline=...>", () => {
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/text-dracula-orange/);
|
||||
expect(html).toMatch(/text-warning/);
|
||||
});
|
||||
|
||||
it("renders muted color when delta is exactly zero (stable)", () => {
|
||||
@@ -122,8 +122,8 @@ describe("<TrendCard compareBaseline=...>", () => {
|
||||
// Stable + neutral both push to muted-foreground.
|
||||
expect(html).toMatch(/text-muted-foreground/);
|
||||
// Sentiment colour classes must not appear for a stable delta.
|
||||
expect(html).not.toMatch(/text-dracula-green.*tile-compare-delta/);
|
||||
expect(html).not.toMatch(/text-dracula-orange.*tile-compare-delta/);
|
||||
expect(html).not.toMatch(/text-success.*tile-compare-delta/);
|
||||
expect(html).not.toMatch(/text-warning.*tile-compare-delta/);
|
||||
});
|
||||
|
||||
it("suppresses the callout when compareDelta is null (insufficient prior-period data)", () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
const html = render(
|
||||
<TrendCard {...baseProps} slope30={RISING} directionSentiment="up-bad" />,
|
||||
);
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("paints up-bad metric ↓ as green (improvement)", () => {
|
||||
@@ -53,7 +53,7 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
expect(html).toContain("text-success");
|
||||
});
|
||||
|
||||
it("paints up-good metric ↑ as green", () => {
|
||||
@@ -64,7 +64,7 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
expect(html).toContain("text-success");
|
||||
});
|
||||
|
||||
it("paints up-good metric ↓ as orange", () => {
|
||||
@@ -75,7 +75,7 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("keeps neutral metric arrows muted regardless of direction", () => {
|
||||
@@ -94,11 +94,11 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
/>,
|
||||
);
|
||||
expect(up).toContain("text-muted-foreground");
|
||||
expect(up).not.toContain("text-dracula-green");
|
||||
expect(up).not.toContain("text-dracula-orange");
|
||||
expect(up).not.toContain("text-success");
|
||||
expect(up).not.toContain("text-warning");
|
||||
expect(down).toContain("text-muted-foreground");
|
||||
expect(down).not.toContain("text-dracula-green");
|
||||
expect(down).not.toContain("text-dracula-orange");
|
||||
expect(down).not.toContain("text-success");
|
||||
expect(down).not.toContain("text-warning");
|
||||
});
|
||||
|
||||
it("keeps stable-trend arrows muted even when sentiment is set", () => {
|
||||
@@ -111,15 +111,15 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
);
|
||||
// Stable slope = no value judgement; the arrow points sideways.
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("text-warning");
|
||||
});
|
||||
|
||||
it("defaults to neutral when sentiment is omitted (back-compat)", () => {
|
||||
const html = render(<TrendCard {...baseProps} slope30={RISING} />);
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("text-warning");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.2 — `<MetricRangeControls>` is the shared range-controls block lifted
|
||||
* out of `<HealthKitMetricPage>` so the bespoke metric sub-pages render the
|
||||
* identical time-range pills + period-over-period delta. The component owns
|
||||
* the range pref + range read + direction-sentiment lookup; the test mocks
|
||||
* those hooks so the render stays deterministic and never touches `fetch`.
|
||||
*/
|
||||
|
||||
const rangeMock = vi.fn();
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-analytics-range", () => ({
|
||||
useAnalyticsRange: (...args: unknown[]) => rangeMock(...args),
|
||||
}));
|
||||
|
||||
import { MetricRangeControls } from "../metric-range-controls";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rangeMock.mockReset();
|
||||
rangeMock.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
describe("<MetricRangeControls>", () => {
|
||||
it("renders the time-range pills and the delta slot", () => {
|
||||
const html = render(<MetricRangeControls measurementType="WEIGHT" />);
|
||||
expect(html).toContain('data-slot="metric-range-controls"');
|
||||
expect(html).toContain('data-slot="time-range-pills"');
|
||||
});
|
||||
|
||||
it("passes the measurement type and enabled flag through to the range read", () => {
|
||||
render(<MetricRangeControls measurementType="PULSE" enabled={false} />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false);
|
||||
});
|
||||
|
||||
it("defaults the range read to enabled", () => {
|
||||
render(<MetricRangeControls measurementType="SLEEP_DURATION" />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.0 — categorical events (WX-B). `<RhythmEventsCard>` unit tests.
|
||||
*
|
||||
* The card is the device-flagged event awareness surface. The load-bearing
|
||||
* behaviour under test:
|
||||
* - data-availability gating: the card un-mounts entirely (renders
|
||||
* nothing) when the user has no events or while the payload is in
|
||||
* flight — never an empty / alarming card.
|
||||
* - the device-verdict framing ("Your device flagged …") and the
|
||||
* permanent regulatory disclaimer that states HealthLog does not make a
|
||||
* medical assessment.
|
||||
*
|
||||
* The component depends on `useAuth` (gate) + TanStack Query (`useQuery`)
|
||||
* for the payload, so both are mocked and the assertions run through SSR.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { RhythmEventsCard } = await import("../rhythm-events-card");
|
||||
|
||||
interface RhythmEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
classification: string | null;
|
||||
occurredAt: string;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
function renderWith(
|
||||
data: { events: RhythmEvent[]; hasEvents: boolean } | undefined,
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RhythmEventsCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const IRREGULAR_EVENT: RhythmEvent = {
|
||||
id: "evt_1",
|
||||
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
classification: "IRREGULAR",
|
||||
occurredAt: "2026-06-01T09:15:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
};
|
||||
|
||||
const STEADINESS_EVENT: RhythmEvent = {
|
||||
id: "evt_2",
|
||||
type: "WALKING_STEADINESS_EVENT",
|
||||
classification: "VERY_LOW",
|
||||
occurredAt: "2026-05-20T17:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "phone",
|
||||
};
|
||||
|
||||
describe("<RhythmEventsCard>", () => {
|
||||
it("renders nothing before the payload resolves", () => {
|
||||
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RhythmEventsCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when the user has no events (data-availability gate)", () => {
|
||||
const html = renderWith({ events: [], hasEvents: false });
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the timeline + each event row when events exist", () => {
|
||||
const html = renderWith({
|
||||
events: [IRREGULAR_EVENT, STEADINESS_EVENT],
|
||||
hasEvents: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="rhythm-events-card"');
|
||||
expect(html).toContain('data-slot="rhythm-events-timeline"');
|
||||
const rows = (html.match(/data-slot="rhythm-event-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(html).toContain('data-event-type="IRREGULAR_RHYTHM_NOTIFICATION"');
|
||||
expect(html).toContain('data-event-type="WALKING_STEADINESS_EVENT"');
|
||||
});
|
||||
|
||||
it("frames the verdict as the DEVICE's decision, never HealthLog's", () => {
|
||||
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
|
||||
expect(html).toContain('data-slot="rhythm-event-verdict"');
|
||||
// Verbatim device-decision framing — load-bearing regulatory copy.
|
||||
expect(html).toContain("Your device flagged a possible irregular rhythm.");
|
||||
});
|
||||
|
||||
it("renders the permanent regulatory disclaimer", () => {
|
||||
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
|
||||
expect(html).toContain('data-slot="rhythm-events-disclaimer"');
|
||||
// The disclaimer must state HealthLog does not make a medical
|
||||
// assessment and does not diagnose.
|
||||
expect(html).toContain("not a medical assessment by HealthLog");
|
||||
expect(html).toContain("does not diagnose");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,11 @@ const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null
|
||||
vo2_max: "/insights/pulse",
|
||||
body_temp: null,
|
||||
glp1_plateau: "/insights/medications",
|
||||
// ── v1.10.0 derived-wellness additive ──
|
||||
// Readiness has a score-anatomy detail page; recovery is a read-only
|
||||
// ring with no dedicated sub-page yet (renders as a static row).
|
||||
readiness: "/insights/scores/readiness",
|
||||
recovery: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,6 +124,9 @@ const METRIC_ICON: Record<
|
||||
// adherence-context observation about the user's GLP-1 therapy.
|
||||
// Non-GLP-1 accounts never see this finding type.
|
||||
glp1_plateau: Pill,
|
||||
// ── v1.10.0 derived-wellness additive ──
|
||||
readiness: Sparkles,
|
||||
recovery: HeartPulse,
|
||||
};
|
||||
|
||||
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CoverageMeter } from "../coverage-meter";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
DerivedConfidence,
|
||||
DerivedCoverage,
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FULL: DerivedCoverage = {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 5,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
};
|
||||
const PARTIAL: DerivedCoverage = {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 3,
|
||||
historyDays: 12,
|
||||
missing: ["HRV_SDNN", "RESTING_HEART_RATE"],
|
||||
};
|
||||
const EMPTY: DerivedCoverage = {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["WEIGHT", "HRV_SDNN", "RESTING_HEART_RATE", "VO2_MAX", "SLEEP"],
|
||||
};
|
||||
const HIGH: DerivedConfidence = { score: 90, band: "high" };
|
||||
const LOW: DerivedConfidence = { score: 35, band: "low" };
|
||||
|
||||
describe("<CoverageMeter>", () => {
|
||||
it("renders the root data-slot and ratio attributes (populated)", () => {
|
||||
const html = render(<CoverageMeter coverage={FULL} confidence={HIGH} />);
|
||||
expect(html).toContain('data-slot="coverage-meter"');
|
||||
expect(html).toContain('data-present="5"');
|
||||
expect(html).toContain('data-required="5"');
|
||||
expect(html).toContain('data-band="high"');
|
||||
});
|
||||
|
||||
it("lights all dots at full confidence", () => {
|
||||
const html = render(<CoverageMeter coverage={FULL} confidence={HIGH} />);
|
||||
const lit = (html.match(/data-dot-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(5);
|
||||
// Semantic --success token — AA-safe on the white card in light mode.
|
||||
expect(html).toContain("bg-success");
|
||||
});
|
||||
|
||||
it("lights a partial number of dots and tints by band (provisional)", () => {
|
||||
const html = render(<CoverageMeter coverage={PARTIAL} confidence={LOW} />);
|
||||
const lit = (html.match(/data-dot-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBeGreaterThan(0);
|
||||
expect(lit).toBeLessThan(5);
|
||||
// The four confidence bands collapse onto three semantic severities;
|
||||
// low rides the caution --warning (medium↔low told apart by lit-dot
|
||||
// count + percent, never colour alone).
|
||||
expect(html).toContain("bg-warning");
|
||||
expect(html).toContain('data-percent="35"');
|
||||
});
|
||||
|
||||
it("falls back to the present/required ratio when confidence is absent", () => {
|
||||
const html = render(<CoverageMeter coverage={PARTIAL} />);
|
||||
// 3/5 = 60%
|
||||
expect(html).toContain('data-percent="60"');
|
||||
});
|
||||
|
||||
it("renders zero lit dots for the empty/insufficient state", () => {
|
||||
const html = render(<CoverageMeter coverage={EMPTY} />);
|
||||
const lit = (html.match(/data-dot-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(0);
|
||||
expect(html).toContain('data-percent="0"');
|
||||
});
|
||||
|
||||
it("carries an accessible summary label", () => {
|
||||
const html = render(<CoverageMeter coverage={PARTIAL} confidence={LOW} />);
|
||||
expect(html).toContain("aria-label");
|
||||
expect(html).toContain("3");
|
||||
expect(html).toContain("5");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ProvenanceExplainer } from "../provenance-explainer";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const PROVENANCE: DerivedProvenance = {
|
||||
inputs: ["WEIGHT", "HEIGHT"],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T08:00:00+02:00",
|
||||
};
|
||||
|
||||
const EMPTY_PROVENANCE: DerivedProvenance = {
|
||||
inputs: [],
|
||||
source: "none",
|
||||
windowDays: 0,
|
||||
computedAt: "not-a-date",
|
||||
};
|
||||
|
||||
describe("<ProvenanceExplainer>", () => {
|
||||
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="BMI is weight divided by height squared."
|
||||
bodyId="prov-1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="prov-1"');
|
||||
expect(html).toContain("aria-label");
|
||||
});
|
||||
|
||||
it("meets the 44px hit-target floor on the trigger", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={EMPTY_PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("accepts a cited standard prop without erroring", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="method copy"
|
||||
standard={{
|
||||
name: "WHO BMI classification",
|
||||
url: "https://www.who.int/health-topics/obesity",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const COVERAGE = {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 4,
|
||||
historyDays: 21,
|
||||
missing: ["respiratory"],
|
||||
};
|
||||
const CONFIDENCE = { score: 72, band: "medium" as const };
|
||||
const PROVENANCE = {
|
||||
inputs: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"],
|
||||
source: "DAY" as const,
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
};
|
||||
const CONTRIBUTORS: AnatomyContributor[] = [
|
||||
{ key: "rhr", label: "Resting heart rate", value: 80, weight: 0.3 },
|
||||
{ key: "hrv", label: "HRV (SDNN)", value: 60, weight: 0.3 },
|
||||
{ key: "respiratory", label: "Respiratory rate", value: null, weight: 0 },
|
||||
];
|
||||
|
||||
describe("<ScoreAnatomyView>", () => {
|
||||
it("renders the ok state: ring + contributor rows + provenance", () => {
|
||||
const html = render(
|
||||
<ScoreAnatomyView
|
||||
title="Readiness"
|
||||
score={74}
|
||||
contributors={CONTRIBUTORS}
|
||||
coverage={COVERAGE}
|
||||
confidence={CONFIDENCE}
|
||||
provenance={PROVENANCE}
|
||||
method="A blend of deviation components."
|
||||
standard={{ name: "Plews 2013", url: "https://example.org" }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="score-anatomy-view"');
|
||||
expect(html).toContain('data-status="ok"');
|
||||
expect(html).toContain('data-slot="score-ring"');
|
||||
// Each contributor renders a row with its stable key attr.
|
||||
expect(html).toContain('data-contributor="rhr"');
|
||||
expect(html).toContain('data-contributor="hrv"');
|
||||
// The missing contributor renders present=false (dropped, not blank).
|
||||
expect(html).toContain('data-contributor="respiratory"');
|
||||
expect(html).toContain('data-present="false"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="score-anatomy-disclaimer"');
|
||||
});
|
||||
|
||||
it("renders the insufficient state with the provisional ring + nudge", () => {
|
||||
const html = render(
|
||||
<ScoreAnatomyView
|
||||
title="Sleep score"
|
||||
score={null}
|
||||
contributors={[]}
|
||||
coverage={{
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
}}
|
||||
confidence={null}
|
||||
provenance={{
|
||||
inputs: [],
|
||||
source: "none",
|
||||
windowDays: 0,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
}}
|
||||
method="A transparent blend."
|
||||
insufficient
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-status="insufficient"');
|
||||
expect(html).toContain('data-provisional="true"'); // the ring's empty state
|
||||
expect(html).toContain('data-slot="score-anatomy-insufficient"');
|
||||
// No contributor rows in the insufficient state.
|
||||
expect(html).not.toContain('data-slot="anatomy-contributor-row"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ScoreRing } from "../score-ring";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ScoreRing>", () => {
|
||||
it("renders the root data-slot with a band derived from the score (populated, good)", () => {
|
||||
const html = render(<ScoreRing score={82} label="/100" />);
|
||||
expect(html).toContain('data-slot="score-ring"');
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).not.toContain('data-provisional="true"');
|
||||
});
|
||||
|
||||
it("derives a yellow band for a mid score", () => {
|
||||
const html = render(<ScoreRing score={55} />);
|
||||
expect(html).toContain('data-band="yellow"');
|
||||
});
|
||||
|
||||
it("derives a red band for a low score", () => {
|
||||
const html = render(<ScoreRing score={20} />);
|
||||
expect(html).toContain('data-band="red"');
|
||||
});
|
||||
|
||||
it("honours an explicit band override", () => {
|
||||
const html = render(<ScoreRing score={20} band="green" />);
|
||||
expect(html).toContain('data-band="green"');
|
||||
});
|
||||
|
||||
it("renders the provisional/empty state for a null score", () => {
|
||||
const html = render(<ScoreRing score={null} />);
|
||||
expect(html).toContain('data-provisional="true"');
|
||||
expect(html).toContain('data-band="none"');
|
||||
expect(html).toContain('data-slot="score-ring-provisional"');
|
||||
});
|
||||
|
||||
it("carries an aria-label restating the number (not colour-only)", () => {
|
||||
const html = render(<ScoreRing score={74} />);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
expect(html).toContain("74");
|
||||
});
|
||||
|
||||
it("carries a provisional aria-label when no score is available", () => {
|
||||
const html = render(<ScoreRing score={null} />);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
import { SparklineDeltaTile } from "../sparkline-delta-tile";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
label: "Resting HR",
|
||||
unit: "bpm",
|
||||
icon: Heart,
|
||||
};
|
||||
|
||||
describe("<SparklineDeltaTile>", () => {
|
||||
it("renders the value, unit and a framing line (populated)", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
series={[60, 59, 58, 57, 58]}
|
||||
delta={-3}
|
||||
directionSentiment="up-bad"
|
||||
framing="typical for age 31"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile"');
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-value"');
|
||||
expect(html).toContain("58");
|
||||
expect(html).toContain("bpm");
|
||||
expect(html).toContain("typical for age 31");
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-framing"');
|
||||
});
|
||||
|
||||
it("paints a falling delta on an up-bad metric as green (improvement)", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
delta={-3}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-delta"');
|
||||
// Semantic --success token (AA-safe in light mode, identical in dark).
|
||||
expect(html).toContain("text-success");
|
||||
});
|
||||
|
||||
it("renders the empty state for a null value with no sparkline slot", () => {
|
||||
const html = render(<SparklineDeltaTile {...base} value={null} />);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-value"');
|
||||
expect(html).toContain("—");
|
||||
// No series → the sparkline row collapses entirely (no reserved empty
|
||||
// dashed placeholder, which read as visual dead space across the grid).
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("omits the sparkline row when series < 2 points", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} series={[58]} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
});
|
||||
|
||||
it("renders the sparkline chart when a series of >= 2 points is passed", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} series={[60, 59, 58]} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
});
|
||||
|
||||
it("renders the provenance affordance in the label row when passed", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
provenance={<button data-slot="test-prov">i</button>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-provenance"');
|
||||
expect(html).toContain('data-slot="test-prov"');
|
||||
});
|
||||
|
||||
it("surfaces the stale caption when staleDays > 7", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} staleDays={12} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-stale"');
|
||||
expect(html).toContain('data-stale-days="12"');
|
||||
});
|
||||
|
||||
it("omits the stale caption for fresh data", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} staleDays={3} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-stale"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
DerivedMetricResponse,
|
||||
DerivedBatchToken,
|
||||
} from "../use-derived-metric";
|
||||
|
||||
// The dashboard now reads the whole grid through ONE batched query. Drive it
|
||||
// by stubbing `useDerivedBatch` and returning a `read(token)` selector that
|
||||
// resolves each (metric, type) pair from a scenario map.
|
||||
const useDerivedBatch = vi.fn();
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a),
|
||||
}));
|
||||
// The auth hook gates the (mocked-away) query — stub it authenticated.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
import { VitalsDashboard } from "../vitals-dashboard";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
type Resp = DerivedMetricResponse<Record<string, unknown>>;
|
||||
|
||||
function ok(value: Record<string, unknown>): Resp {
|
||||
return {
|
||||
metric: "X",
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 90, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
function insufficient(reason: string, historyDays = 0): Resp {
|
||||
return {
|
||||
metric: "X",
|
||||
status: "insufficient",
|
||||
value: null,
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: reason === "no_readings_in_window" ? 0 : 1,
|
||||
historyDays,
|
||||
missing: [],
|
||||
},
|
||||
confidence: null,
|
||||
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a batch mock that resolves each requested token from `resolve`,
|
||||
* defaulting unmatched tokens to the absent state so a scenario only has to
|
||||
* name the tiles it cares about.
|
||||
*/
|
||||
function mockBatch(
|
||||
resolve: (token: DerivedBatchToken) => Resp,
|
||||
extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {},
|
||||
) {
|
||||
useDerivedBatch.mockReturnValue({
|
||||
isLoading: extra.isLoading ?? false,
|
||||
isError: extra.isError ?? false,
|
||||
refetch: extra.refetch ?? (() => {}),
|
||||
read: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("<VitalsDashboard>", () => {
|
||||
it("renders an available baseline vital as an ok tile", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") {
|
||||
return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-slot="vitals-dashboard"');
|
||||
expect(html).toContain('data-metric="RESTING_HEART_RATE"');
|
||||
expect(html).toContain('data-state="ok"');
|
||||
// Typical-range framing rendered.
|
||||
expect(html).toContain("Typical for you");
|
||||
});
|
||||
|
||||
it("hides the whole section (heading + grid) when no vital has content", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"));
|
||||
const html = render(<VitalsDashboard />);
|
||||
// No content under the heading → the heading must not strand over blank
|
||||
// space. The whole section un-mounts.
|
||||
expect(html).not.toContain('data-slot="vitals-dashboard"');
|
||||
expect(html).not.toContain('data-slot="vitals-dashboard-grid"');
|
||||
expect(html).not.toContain("Your vitals");
|
||||
expect(html).not.toContain('data-state="ok"');
|
||||
});
|
||||
|
||||
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
|
||||
const html = render(<VitalsDashboard />);
|
||||
// Heading + grid present so the section reserves its final height, with a
|
||||
// busy/live region and skeleton placeholders — but no real tiles yet.
|
||||
expect(html).toContain('data-slot="vitals-dashboard"');
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain('data-slot="vitals-tile-skeleton"');
|
||||
expect(html).not.toContain('data-slot="vitals-tile"');
|
||||
});
|
||||
|
||||
it("shows an inline error + retry when the batch fails", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
|
||||
const html = render(<VitalsDashboard />);
|
||||
// The surface must not silently vanish — a compact error + a Retry button.
|
||||
expect(html).toContain('data-slot="vitals-dashboard-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('data-slot="vitals-dashboard-retry"');
|
||||
expect(html).toContain("Could not load your vitals");
|
||||
expect(html).toContain("Retry");
|
||||
});
|
||||
|
||||
it("shows a provisional baseline tile with the coverage meter, not a headline", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") {
|
||||
return insufficient("insufficient_history_for_band", 4);
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="WEIGHT"');
|
||||
expect(html).toContain('data-state="provisional"');
|
||||
expect(html).toContain('data-slot="coverage-meter"');
|
||||
expect(html).toContain("Building your typical range");
|
||||
});
|
||||
|
||||
it("renders the fitness-age tile with its age framing", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "FITNESS_AGE") {
|
||||
return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="FITNESS_AGE"');
|
||||
expect(html).toContain("yr younger");
|
||||
});
|
||||
|
||||
it("renders the BMI tile with its WHO category", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "BMI") {
|
||||
return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="BMI"');
|
||||
expect(html).toContain("Normal range (WHO)");
|
||||
});
|
||||
|
||||
it("hides BMI when there is no height/weight", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "BMI") return insufficient("no_height_on_profile");
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="BMI"');
|
||||
});
|
||||
|
||||
it("surfaces the coincident-deviation flag when it fired", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({
|
||||
fired: true,
|
||||
day: "2026-06-02",
|
||||
vitals: [],
|
||||
contributing: [
|
||||
{ type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" },
|
||||
{ type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
expect(html).toContain('data-state="fired"');
|
||||
// The provenance affordance reaches the flag.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("hides the coincident-deviation flag when it did not fire", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.10.0 — shared score-band token vocabulary for the derived-metrics
|
||||
* design system. Extracted so the new primitives (score-ring,
|
||||
* coverage-meter, sparkline-delta-tile, provenance-explainer) paint the
|
||||
* exact same band colour grammar the shipped `health-score-card` already
|
||||
* uses, without each component redeclaring the map (concept cohesion).
|
||||
*
|
||||
* These point at the *semantic* feedback tokens (`--success` / `--warning`
|
||||
* / `--destructive`), NOT the raw `--dracula-*` primitives. That matters
|
||||
* for contrast: `:root.light` (Alucard) overrides the semantic tokens to
|
||||
* AA-safe darker tones (`--success: #14720a`, `--warning: #a34d14`,
|
||||
* `--destructive: #cb3a2a`) precisely because straight Dracula
|
||||
* green/orange on a white `--card` clears only ~1.1–1.5:1 — under AA.
|
||||
* Painting the band off the semantic token means every score surface
|
||||
* tracks that light-mode correction automatically, in both themes; the
|
||||
* raw `--dracula-*` primitives do NOT carry a light override. A
|
||||
* green/yellow/red band is the one semantic the whole v1.10 score
|
||||
* language extends.
|
||||
*/
|
||||
|
||||
export type ScoreBand = "green" | "yellow" | "red";
|
||||
|
||||
/** Tailwind text-colour class per band (the headline number colour). */
|
||||
export const BAND_NUMBER_CLASS: Record<ScoreBand, string> = {
|
||||
green: "text-success",
|
||||
yellow: "text-warning",
|
||||
red: "text-destructive",
|
||||
};
|
||||
|
||||
/** Tailwind background class per band (progress/fill bars). */
|
||||
export const BAND_PROGRESS_CLASS: Record<ScoreBand, string> = {
|
||||
green: "bg-success",
|
||||
yellow: "bg-warning",
|
||||
red: "bg-destructive",
|
||||
};
|
||||
|
||||
/** Tailwind border-colour class per band (card / chip outlines). */
|
||||
export const BAND_BORDER_CLASS: Record<ScoreBand, string> = {
|
||||
green: "border-success/40",
|
||||
yellow: "border-warning/40",
|
||||
red: "border-destructive/40",
|
||||
};
|
||||
|
||||
/**
|
||||
* The raw CSS variable per band — used where a Recharts `fill` needs an
|
||||
* actual colour string rather than a Tailwind class (the score ring's
|
||||
* `<RadialBar fill>`). Reads the semantic tokens so dark and Alucard light
|
||||
* modes track the AA-safe light overrides automatically (the
|
||||
* `--dracula-*` primitives carry no light override and would fail AA on
|
||||
* the white card).
|
||||
*/
|
||||
export const BAND_VAR: Record<ScoreBand, string> = {
|
||||
green: "var(--success)",
|
||||
yellow: "var(--warning)",
|
||||
red: "var(--destructive)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a 0..100 score to its band. Mirrors the thresholds the existing
|
||||
* health-score blend uses (≥ 70 green, ≥ 40 yellow, else red) so a
|
||||
* derived score and the composite health score never disagree on colour
|
||||
* for the same number.
|
||||
*/
|
||||
export function bandForScore(score: number): ScoreBand {
|
||||
if (score >= 70) return "green";
|
||||
if (score >= 40) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
/** Clamp a score into the 0..100 rendering range, defending bad input. */
|
||||
export function clampScore(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDerivedMetric } from "./use-derived-metric";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
ReadinessValue,
|
||||
SleepScoreValue,
|
||||
WellnessScoreValue,
|
||||
} from "@/lib/insights/derived";
|
||||
import {
|
||||
ScoreAnatomyView,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived
|
||||
* score and renders the reusable `ScoreAnatomyView`. It owns the per-metric
|
||||
* presentation map (title, contributor labels, the cited standard, the
|
||||
* plain-language method + honesty caveat) so the anatomy view itself stays
|
||||
* metric-agnostic.
|
||||
*
|
||||
* Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` —
|
||||
* each maps its sub-scores onto ranked `AnatomyContributor` rows) AND the
|
||||
* three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`,
|
||||
* `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they
|
||||
* render the ring + coverage + the provenance surface (method + cited
|
||||
* standard + caveat) with no contributor rows — the acceptance-criterion fix
|
||||
* that gives every wellness ring a provenance surface instead of a read-only
|
||||
* dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic
|
||||
* measurement" caveat via the standards map.
|
||||
*
|
||||
* The standard + method/caveat keys come from the single `METRIC_PROVENANCE`
|
||||
* source map so the citation a metric exposes never drifts across surfaces.
|
||||
*
|
||||
* Client-only: `import type` for the value shapes (no server graph leaks);
|
||||
* the fetch rides the same `/api/insights/derived` route + 8 s client
|
||||
* ceiling as the rest of the surface.
|
||||
*/
|
||||
|
||||
export type AnatomyMetricId =
|
||||
| "SLEEP_SCORE"
|
||||
| "READINESS"
|
||||
| "RECOVERY_SCORE"
|
||||
| "STRESS_SCORE"
|
||||
| "STRAIN_SCORE";
|
||||
|
||||
export interface CompositeScoreAnatomyProps {
|
||||
metric: AnatomyMetricId;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompositeScoreAnatomy({
|
||||
metric,
|
||||
className,
|
||||
}: CompositeScoreAnatomyProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const query = useDerivedMetric<
|
||||
SleepScoreValue | ReadinessValue | WellnessScoreValue
|
||||
>(metric);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div data-slot="composite-anatomy-loading" className={className}>
|
||||
<Skeleton className="h-[28rem] w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
const meta = METRIC_PROVENANCE[metric];
|
||||
const standard = meta.standard;
|
||||
const title = titleFor(metric, t);
|
||||
// Method copy carries an optional honesty caveat above it (STRESS proxy,
|
||||
// descriptive-not-clinical, …) so the caveat reaches the user, not just
|
||||
// the engine header.
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
// Network/abort fallback — render the insufficient state honestly.
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
score={null}
|
||||
contributors={[]}
|
||||
coverage={{
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
}}
|
||||
confidence={null}
|
||||
provenance={{
|
||||
inputs: [],
|
||||
source: "none",
|
||||
windowDays: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
}}
|
||||
method={method}
|
||||
standard={standard}
|
||||
insufficient
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const insufficient = data.status !== "ok";
|
||||
let score: number | null = null;
|
||||
let contributors: AnatomyContributor[] = [];
|
||||
let caption: string | undefined;
|
||||
|
||||
if (data.status === "ok" && data.value) {
|
||||
if (metric === "SLEEP_SCORE") {
|
||||
const v = data.value as SleepScoreValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
contributors = v.subScores
|
||||
.map((s) => ({
|
||||
key: s.key,
|
||||
label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`),
|
||||
value: s.value,
|
||||
weight: s.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
} else if (metric === "READINESS") {
|
||||
const v = data.value as ReadinessValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
contributors = v.components
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
label: t(`insights.derived.composite.READINESS.component.${c.key}`),
|
||||
value: c.value,
|
||||
weight: c.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
} else {
|
||||
// Persisted nightly score — no sub-decomposition; ring + provenance.
|
||||
const v = data.value as WellnessScoreValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
score={score}
|
||||
caption={caption}
|
||||
contributors={contributors}
|
||||
coverage={data.coverage}
|
||||
confidence={data.confidence}
|
||||
provenance={data.provenance}
|
||||
method={method}
|
||||
standard={standard}
|
||||
insufficient={insufficient}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Localised title per metric — composites use their existing copy keys;
|
||||
* the persisted scores reuse the wellness-strip labels. */
|
||||
function titleFor(
|
||||
metric: AnatomyMetricId,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "SLEEP_SCORE":
|
||||
case "READINESS":
|
||||
return t(`insights.derived.composite.${metric}.title`);
|
||||
case "RECOVERY_SCORE":
|
||||
return t("insights.derived.scores.recovery");
|
||||
case "STRESS_SCORE":
|
||||
return t("insights.derived.scores.stress");
|
||||
case "STRAIN_SCORE":
|
||||
return t("insights.derived.scores.strain");
|
||||
}
|
||||
}
|
||||
|
||||
/** Rank contributors by impact: present-and-higher-weight first. */
|
||||
function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number {
|
||||
const aPresent = a.value != null ? 1 : 0;
|
||||
const bPresent = b.value != null ? 1 : 0;
|
||||
if (aPresent !== bPresent) return bPresent - aPresent;
|
||||
return b.weight - a.weight;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DerivedConfidence,
|
||||
DerivedConfidenceBand,
|
||||
DerivedCoverage,
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the single reusable data-confidence indicator.
|
||||
*
|
||||
* `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs`
|
||||
* ratio off `Derived<T>.coverage`, tinted by the `confidence.band`, with an
|
||||
* accessible breakdown tooltip (present vs required inputs, history days,
|
||||
* and the named missing inputs). This is the one place the whole app speaks
|
||||
* data-confidence — every score tile, anatomy view, correlation card,
|
||||
* briefing and report header renders it identically.
|
||||
*
|
||||
* It is the load-bearing graceful-degradation primitive: a partial score is
|
||||
* shown WITH its confidence, never silently downgraded and never blank. The
|
||||
* dots read as intentional ("4 of 5 inputs · 80%"), not apologetic.
|
||||
*
|
||||
* CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime.
|
||||
*/
|
||||
|
||||
export interface CoverageMeterProps {
|
||||
coverage: DerivedCoverage;
|
||||
/**
|
||||
* Optional confidence facet. When present, the dots take the band tint
|
||||
* and the percent label reads the confidence score; when omitted the
|
||||
* meter falls back to the present/required ratio for both.
|
||||
*/
|
||||
confidence?: DerivedConfidence;
|
||||
/** Total number of dots rendered. Defaults to 5 (the `●●●●○` grammar). */
|
||||
dots?: number;
|
||||
/** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */
|
||||
size?: "sm" | "md";
|
||||
/** Optional className applied to the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dot tint per confidence band. Points at the *semantic* feedback tokens
|
||||
* (`--success` / `--warning` / `--destructive`), not the raw `--dracula-*`
|
||||
* primitives, so the dots track the AA-safe `:root.light` overrides on the
|
||||
* white card — straight `bg-dracula-yellow` cleared barely 1.1:1 on white
|
||||
* and read near-invisible. The four bands collapse onto the three semantic
|
||||
* severities (medium + low both ride the caution `--warning`); the lit-dot
|
||||
* count + the percent label carry the medium↔low distinction, never colour
|
||||
* alone.
|
||||
*/
|
||||
const BAND_DOT_CLASS: Record<DerivedConfidenceBand, string> = {
|
||||
high: "bg-success",
|
||||
medium: "bg-warning",
|
||||
low: "bg-warning",
|
||||
draft: "bg-destructive",
|
||||
};
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
export function CoverageMeter({
|
||||
coverage,
|
||||
confidence,
|
||||
dots = 5,
|
||||
size = "sm",
|
||||
className,
|
||||
}: CoverageMeterProps) {
|
||||
const { t } = useTranslations();
|
||||
const tooltipId = useId();
|
||||
|
||||
const required = clampInt(coverage.requiredInputs, 0, 999);
|
||||
const present = clampInt(coverage.presentInputs, 0, required || 999);
|
||||
const totalDots = clampInt(dots, 1, 12);
|
||||
|
||||
// The fill ratio prefers the explicit confidence score (so the meter
|
||||
// tracks the same number the band is derived from); without it, fall
|
||||
// back to the present/required ratio.
|
||||
const ratio =
|
||||
confidence != null
|
||||
? clampInt(confidence.score, 0, 100) / 100
|
||||
: required > 0
|
||||
? present / required
|
||||
: 0;
|
||||
const litDots = clampInt(ratio * totalDots, 0, totalDots);
|
||||
const percent = Math.round(ratio * 100);
|
||||
const band: DerivedConfidenceBand = confidence?.band ?? "low";
|
||||
const dotLit = BAND_DOT_CLASS[band];
|
||||
|
||||
const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2";
|
||||
|
||||
const summaryLabel = t("insights.derived.coverage.summary", {
|
||||
present,
|
||||
required,
|
||||
percent,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coverage-meter"
|
||||
data-band={band}
|
||||
data-present={present}
|
||||
data-required={required}
|
||||
data-percent={percent}
|
||||
aria-label={summaryLabel}
|
||||
aria-describedby={tooltipId}
|
||||
className={cn(
|
||||
// 44px hit target via padding while the dots stay optically
|
||||
// small; negative margin collapses the row back so the meter
|
||||
// doesn't inflate its host row.
|
||||
"inline-flex min-h-11 items-center gap-2 rounded -my-3 px-1 py-3",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-slot="coverage-meter-dots"
|
||||
className="inline-flex items-center gap-[3px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: totalDots }).map((_, i) => {
|
||||
const isLit = i < litDots;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-dot-index={i}
|
||||
data-dot-state={isLit ? "lit" : "unlit"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
dotSize,
|
||||
isLit ? dotLit : "bg-muted/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
data-slot="coverage-meter-label"
|
||||
className={cn(
|
||||
"text-muted-foreground tabular-nums",
|
||||
size === "md" ? "text-xs" : "text-[11px]",
|
||||
)}
|
||||
>
|
||||
{t("insights.derived.coverage.ratioLabel", { present, required })}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
id={tooltipId}
|
||||
data-slot="coverage-meter-tooltip"
|
||||
className="bg-muted border-border text-foreground max-w-[15rem]"
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium">{summaryLabel}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("insights.derived.coverage.historyDays", {
|
||||
count: clampInt(coverage.historyDays, 0, 99999),
|
||||
})}
|
||||
</p>
|
||||
{coverage.missing.length > 0 && (
|
||||
<p
|
||||
data-slot="coverage-meter-missing"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("insights.derived.coverage.missing", {
|
||||
list: coverage.missing.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* v1.10.0 — the derived-metrics design-system primitives barrel.
|
||||
*
|
||||
* Four reusable, presentational components that extend the existing token +
|
||||
* sentiment vocabulary; none introduce a new colour system. Reused across
|
||||
* the dashboard, Insights pages, the Coach / briefing and the doctor/FHIR
|
||||
* report. See `.planning/v1.10-build/design-sota-and-direction.md`.
|
||||
*/
|
||||
|
||||
export { ScoreRing, type ScoreRingProps } from "./score-ring";
|
||||
export { CoverageMeter, type CoverageMeterProps } from "./coverage-meter";
|
||||
export {
|
||||
SparklineDeltaTile,
|
||||
type SparklineDeltaTileProps,
|
||||
} from "./sparkline-delta-tile";
|
||||
export {
|
||||
ProvenanceExplainer,
|
||||
type ProvenanceExplainerProps,
|
||||
type ProvenanceStandard,
|
||||
} from "./provenance-explainer";
|
||||
export {
|
||||
ScoreAnatomyView,
|
||||
type ScoreAnatomyViewProps,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
export {
|
||||
bandForScore,
|
||||
clampScore,
|
||||
BAND_NUMBER_CLASS,
|
||||
BAND_PROGRESS_CLASS,
|
||||
BAND_BORDER_CLASS,
|
||||
BAND_VAR,
|
||||
type ScoreBand,
|
||||
} from "./band-tokens";
|
||||
|
||||
// ── W2b vitals dashboard surface + query hook ────────────────────────
|
||||
export { VitalsDashboard } from "./vitals-dashboard";
|
||||
export { WellnessScores } from "./wellness-scores";
|
||||
export {
|
||||
useDerivedMetric,
|
||||
type DerivedMetricResponse,
|
||||
type UseDerivedMetricOptions,
|
||||
} from "./use-derived-metric";
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the "how this is computed / standard cited" surface.
|
||||
*
|
||||
* A collapsible `ⓘ` explainer that renders, as React text children (NO
|
||||
* markdown library — the XSS rule), the metric's inputs, a plain-language
|
||||
* method, and an optional link to the cited standard (IEEE / RFC / WHO /
|
||||
* LOINC / Wikipedia). Reads the `provenance` field off `Derived<T>` for the
|
||||
* inputs chip + the "as of" + window framing.
|
||||
*
|
||||
* Surface follows the shipped `health-score-delta-explainer` pattern:
|
||||
* Popover on `md+`, bottom-sheet on phone-class viewports via
|
||||
* `ResponsiveSheet`. Owns its own open state. aria-describedby threads the
|
||||
* trigger to the body.
|
||||
*/
|
||||
|
||||
export interface ProvenanceStandard {
|
||||
/** Display name of the cited standard (e.g. "WHO BMI classification"). */
|
||||
name: string;
|
||||
/** Absolute URL to the standard. Rendered as a plain `<a>`, never HTML. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceExplainerProps {
|
||||
/** The provenance facet off `Derived<T>` — inputs, source, window, asOf. */
|
||||
provenance: DerivedProvenance;
|
||||
/**
|
||||
* Plain-language method description. Text children only — the caller
|
||||
* passes a localised string; the component never parses markup.
|
||||
*/
|
||||
method: React.ReactNode;
|
||||
/** Optional cited standard rendered as an external link in the footer. */
|
||||
standard?: ProvenanceStandard;
|
||||
/** Accessible title of the surface (sheet header / popover first line). */
|
||||
title?: string;
|
||||
/** Optional className for the trigger button. */
|
||||
className?: string;
|
||||
/** Optional id the parent threads as aria-describedby on its own element. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function ProvenanceExplainer({
|
||||
provenance,
|
||||
method,
|
||||
standard,
|
||||
title,
|
||||
className,
|
||||
bodyId,
|
||||
}: ProvenanceExplainerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
|
||||
const resolvedTitle = title ?? t("insights.derived.provenance.title");
|
||||
const triggerLabel = t("insights.derived.provenance.trigger");
|
||||
|
||||
const asOf = (() => {
|
||||
const parsed = new Date(provenance.computedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const body = (
|
||||
<div
|
||||
id={resolvedBodyId}
|
||||
data-slot="provenance-explainer-body"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div data-slot="provenance-explainer-inputs">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.inputsLabel")}
|
||||
</p>
|
||||
{provenance.inputs.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{provenance.inputs.map((input) => (
|
||||
<li
|
||||
key={input}
|
||||
data-slot="provenance-explainer-input-chip"
|
||||
className="border-info/40 text-info inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
|
||||
>
|
||||
{input}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
{t("insights.derived.provenance.noInputs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-slot="provenance-explainer-method">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.methodLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
|
||||
{method}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="provenance-explainer-window"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.provenance.window", {
|
||||
days: provenance.windowDays,
|
||||
source: provenance.source,
|
||||
})}
|
||||
{asOf
|
||||
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{standard && (
|
||||
<a
|
||||
data-slot="provenance-explainer-standard-link"
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.derived.provenance.standardLink", {
|
||||
name: standard.name,
|
||||
})}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="provenance-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-my-3 -mx-2",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<div className="text-sm">{body}</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="provenance-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-2"
|
||||
>
|
||||
<p
|
||||
data-slot="provenance-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{resolvedTitle}
|
||||
</p>
|
||||
{body}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DerivedConfidence,
|
||||
DerivedCoverage,
|
||||
DerivedProvenance,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import {
|
||||
ProvenanceExplainer,
|
||||
type ProvenanceStandard,
|
||||
} from "./provenance-explainer";
|
||||
import {
|
||||
bandForScore,
|
||||
BAND_PROGRESS_CLASS,
|
||||
clampScore,
|
||||
type ScoreBand,
|
||||
} from "./band-tokens";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Oura-style score-anatomy detail view (the Hybrid
|
||||
* direction's "reinhauen" surface).
|
||||
*
|
||||
* A full-bleed `ScoreRing` + ranked contributor rows + a cited-standard
|
||||
* `ProvenanceExplainer`. Each contributor row is one input's push/pull on
|
||||
* the score: a label, an impact bar (band-coloured, width = the
|
||||
* contributor's 0..100 value), and the contributor's own mini coverage
|
||||
* (its effective weight share). Reused by every composite (sleep score,
|
||||
* readiness, …) — the caller normalises its `value` into the
|
||||
* `contributors` list, so this component stays metric-agnostic.
|
||||
*
|
||||
* Renders the same three states the rest of the design system speaks:
|
||||
* - ok → ring + contributors + coverage + provenance
|
||||
* - insufficient → the ring's provisional state + the coverage "track N"
|
||||
* nudge (never a blank, never a fabricated number)
|
||||
*
|
||||
* Mobile-first (the ring is large where there is room), dark-first, a11y:
|
||||
* the ring restates the number in its aria-label; contributor bars carry a
|
||||
* text value (never colour-only); the provenance explainer threads
|
||||
* aria-describedby. Plain React text children only — no markdown library.
|
||||
*/
|
||||
|
||||
/** One normalised contributor the view ranks by impact. */
|
||||
export interface AnatomyContributor {
|
||||
/** Stable key (drives the data-attr + the localised label lookup by caller). */
|
||||
key: string;
|
||||
/** Localised display label. */
|
||||
label: ReactNode;
|
||||
/** 0..100 contribution score, or null when the input was missing (dropped). */
|
||||
value: number | null;
|
||||
/** Effective weight share after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ScoreAnatomyViewProps {
|
||||
/** Localised metric title (e.g. "Readiness", "Sleep score"). */
|
||||
title: ReactNode;
|
||||
/** The composite 0..100 score, or null for the insufficient/empty state. */
|
||||
score: number | null;
|
||||
/** Band override; derived from the score when omitted. */
|
||||
band?: ScoreBand;
|
||||
/** Short caption under the ring (e.g. a band word or a one-line summary). */
|
||||
caption?: ReactNode;
|
||||
/** Ranked contributors (the caller pre-ranks; the view renders in order). */
|
||||
contributors: AnatomyContributor[];
|
||||
coverage: DerivedCoverage;
|
||||
confidence?: DerivedConfidence | null;
|
||||
provenance: DerivedProvenance;
|
||||
/** Plain-language method shown in the provenance explainer. */
|
||||
method: ReactNode;
|
||||
/** Cited standard rendered as an external link in the explainer footer. */
|
||||
standard?: ProvenanceStandard;
|
||||
/** When true the score is unavailable — render the provisional state. */
|
||||
insufficient?: boolean;
|
||||
/** Localised "not enough data" nudge for the insufficient state. */
|
||||
insufficientNote?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreAnatomyView({
|
||||
title,
|
||||
score,
|
||||
band,
|
||||
caption,
|
||||
contributors,
|
||||
coverage,
|
||||
confidence,
|
||||
provenance,
|
||||
method,
|
||||
standard,
|
||||
insufficient = false,
|
||||
insufficientNote,
|
||||
className,
|
||||
}: ScoreAnatomyViewProps) {
|
||||
const { t } = useTranslations();
|
||||
const effectiveScore = insufficient ? null : score;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="score-anatomy-view"
|
||||
data-status={insufficient ? "insufficient" : "ok"}
|
||||
className={cn(
|
||||
"border-border bg-card flex flex-col gap-5 rounded-xl border p-5",
|
||||
className,
|
||||
)}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
{/* Hero: title + ring + caption */}
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<h2
|
||||
data-slot="score-anatomy-title"
|
||||
className="text-foreground text-sm font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
{caption && !insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-caption"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
<CoverageMeter
|
||||
coverage={coverage}
|
||||
confidence={confidence ?? undefined}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-insufficient"
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
|
||||
>
|
||||
{insufficientNote ??
|
||||
t("insights.derived.anatomy.insufficient")}
|
||||
</p>
|
||||
) : (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||
{t("insights.derived.anatomy.contributorsLabel")}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{contributors.map((c) => (
|
||||
<ContributorRow key={c.key} contributor={c} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provenance + cited standard + the non-clinical disclaimer */}
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||
<p
|
||||
data-slot="score-anatomy-disclaimer"
|
||||
className="text-muted-foreground text-[10px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.anatomy.disclaimer")}
|
||||
</p>
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={standard}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ranked contributor row — label + impact bar + value + weight share. */
|
||||
function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
|
||||
const { t } = useTranslations();
|
||||
const present = contributor.value != null && Number.isFinite(contributor.value);
|
||||
const clamped = present ? clampScore(contributor.value as number) : 0;
|
||||
const rowBand: ScoreBand = bandForScore(clamped);
|
||||
const weightPct = Math.round(contributor.weight * 100);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="anatomy-contributor-row"
|
||||
data-contributor={contributor.key}
|
||||
data-present={present ? "true" : "false"}
|
||||
className="space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
{contributor.label}
|
||||
</span>
|
||||
<span
|
||||
data-slot="anatomy-contributor-value"
|
||||
className="text-muted-foreground text-[11px] tabular-nums"
|
||||
>
|
||||
{present
|
||||
? t("insights.derived.anatomy.contributorValue", {
|
||||
value: Math.round(clamped),
|
||||
weight: weightPct,
|
||||
})
|
||||
: t("insights.derived.anatomy.contributorMissing")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
data-slot="anatomy-contributor-bar"
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
present ? BAND_PROGRESS_CLASS[rowBand] : "bg-muted",
|
||||
)}
|
||||
style={{ width: present ? `${clamped}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Label,
|
||||
PolarRadiusAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCountUp } from "@/hooks/use-count-up";
|
||||
import {
|
||||
bandForScore,
|
||||
BAND_NUMBER_CLASS,
|
||||
BAND_VAR,
|
||||
clampScore,
|
||||
type ScoreBand,
|
||||
} from "./band-tokens";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the composite-score dial. A radial Recharts ring with the
|
||||
* `tabular-nums` number centred, band-coloured, sweeping to fill on first
|
||||
* paint. Rendered small in a grid tile and large on the score-anatomy
|
||||
* detail view from the same component via the `size` prop — one score
|
||||
* language, two intensities (the Hybrid direction).
|
||||
*
|
||||
* Built on raw Recharts `RadialBarChart` (the existing house pattern — 6
|
||||
* sites already use Recharts via `ResponsiveContainer`), NOT the shadcn
|
||||
* `chart` primitive: the ring needs no per-series CSS-var injection, so it
|
||||
* paints its fill straight from the `--dracula-*` band token and avoids
|
||||
* adding a second `dangerouslySetInnerHTML` to the tree. 0 KB new runtime.
|
||||
*
|
||||
* `score === null` renders the provisional/empty state: an unfilled ring
|
||||
* with an em-dash and a localised "not enough data yet" caption, so a
|
||||
* sparse-data hero never goes blank.
|
||||
*
|
||||
* a11y: `role="img"` + an aria-label restating the number and band, so the
|
||||
* ring is never colour-only. `prefers-reduced-motion` disables the sweep
|
||||
* (Recharts `isAnimationActive`) and the count-up.
|
||||
*/
|
||||
|
||||
const SIZE: Record<
|
||||
NonNullable<ScoreRingProps["size"]>,
|
||||
{ px: number; numberClass: string; labelClass: string; barSize: number }
|
||||
> = {
|
||||
sm: { px: 96, numberClass: "text-2xl", labelClass: "text-[10px]", barSize: 10 },
|
||||
md: { px: 140, numberClass: "text-4xl", labelClass: "text-xs", barSize: 14 },
|
||||
lg: { px: 200, numberClass: "text-6xl", labelClass: "text-sm", barSize: 18 },
|
||||
};
|
||||
|
||||
export interface ScoreRingProps {
|
||||
/** The 0..100 score, or `null` for the provisional/empty state. */
|
||||
score: number | null;
|
||||
/** Band override; when omitted it is derived from the score via thresholds. */
|
||||
band?: ScoreBand;
|
||||
/** Short label rendered under the number (e.g. "Readiness", "/100"). */
|
||||
label?: string;
|
||||
/** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Disable the sweep + count-up (e.g. when already animated by a parent). */
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreRing({
|
||||
score,
|
||||
band,
|
||||
label,
|
||||
size = "md",
|
||||
animate = true,
|
||||
className,
|
||||
}: ScoreRingProps) {
|
||||
const { t } = useTranslations();
|
||||
const dims = SIZE[size];
|
||||
|
||||
const hasScore = score != null && Number.isFinite(score);
|
||||
const clamped = hasScore ? clampScore(score) : 0;
|
||||
const resolvedBand: ScoreBand = band ?? bandForScore(clamped);
|
||||
const fill = hasScore ? BAND_VAR[resolvedBand] : "var(--muted-foreground)";
|
||||
|
||||
// Count-up only feeds the centred number; the ring arc reads the final
|
||||
// value (Recharts owns its own sweep via isAnimationActive).
|
||||
const displayed = useCountUp(clamped, { enabled: animate && hasScore });
|
||||
const displayedRounded = Math.round(displayed);
|
||||
|
||||
const ariaLabel = hasScore
|
||||
? t("insights.derived.scoreRing.aria", {
|
||||
score: Math.round(clamped),
|
||||
band: t(`insights.derived.scoreRing.band.${resolvedBand}`),
|
||||
})
|
||||
: t("insights.derived.scoreRing.ariaProvisional");
|
||||
|
||||
// RadialBar fills the arc proportionally to the bar's value against the
|
||||
// PolarRadiusAxis [0,100] domain; the track spans a full clockwise circle
|
||||
// from 12 o'clock, so a value of 74 fills 74% of the ring.
|
||||
const data = [{ name: "score", value: hasScore ? clamped : 0, fill }];
|
||||
const startAngle = 90;
|
||||
const endAngle = -270;
|
||||
|
||||
// Geometry — keep the inner ring thin and the centre clear for the number.
|
||||
const outerRadius = "100%";
|
||||
const innerRadius = "78%";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="score-ring"
|
||||
data-band={hasScore ? resolvedBand : "none"}
|
||||
data-provisional={hasScore ? undefined : "true"}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ width: dims.px, height: dims.px }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
data={data}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
barSize={dims.barSize}
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="value"
|
||||
background={{ fill: "var(--muted)", opacity: 0.4 }}
|
||||
cornerRadius={dims.barSize}
|
||||
isAnimationActive={animate && hasScore}
|
||||
animationDuration={600}
|
||||
aria-hidden
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
tick={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const cx = viewBox.cx ?? 0;
|
||||
const cy = viewBox.cy ?? 0;
|
||||
return (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={cx}
|
||||
y={cy}
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
dims.numberClass,
|
||||
hasScore
|
||||
? BAND_NUMBER_CLASS[resolvedBand].replace(
|
||||
"text-",
|
||||
"fill-",
|
||||
)
|
||||
: "fill-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasScore ? displayedRounded : "—"}
|
||||
</tspan>
|
||||
{label ? (
|
||||
<tspan
|
||||
x={cx}
|
||||
y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)}
|
||||
className={cn(
|
||||
"fill-muted-foreground",
|
||||
dims.labelClass,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</tspan>
|
||||
) : null}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasScore && (
|
||||
<span
|
||||
data-slot="score-ring-provisional"
|
||||
className="text-muted-foreground absolute inset-x-0 bottom-0 line-clamp-1 px-1 text-center text-[10px] leading-tight"
|
||||
>
|
||||
{t("insights.derived.scoreRing.provisionalCaption")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
getTrendSentiment,
|
||||
sentimentColorClass,
|
||||
type TrendDirectionSentiment,
|
||||
} from "@/lib/insights/trend-sentiment";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Apple-Health-Highlights grid tile.
|
||||
*
|
||||
* Extends the shipped `trend-card` grammar (label + icon row, big
|
||||
* `tabular-nums` value, sentiment-coloured trend arrow) with the single
|
||||
* biggest visible grid upgrade the design direction names: an inline
|
||||
* Recharts sparkline, a signed period delta, and an age / normal-range
|
||||
* framing slot. Reuses the already-imported Recharts (Area sparkline,
|
||||
* 40–60px fixed height) — 0 KB new runtime.
|
||||
*
|
||||
* States, defined up front (no fallback afterthought):
|
||||
* - populated: value + sparkline + delta + framing line
|
||||
* - empty (`value == null`): em-dash, no sparkline, optional framing line
|
||||
* - stale (`staleDays`): "last value Xd ago" caption, value kept visible
|
||||
* Fixed footprint so a tile never reflows its neighbours.
|
||||
*/
|
||||
|
||||
export interface SparklineDeltaTileProps {
|
||||
label: string;
|
||||
/** Latest reading; `null` renders the empty state. */
|
||||
value: number | null;
|
||||
unit: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Trailing sparkline points (oldest → newest). < 2 points suppresses the
|
||||
* sparkline (a line needs at least two). Reuses Recharts, capped to a
|
||||
* small fixed height.
|
||||
*/
|
||||
series?: number[];
|
||||
/** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */
|
||||
delta?: number | null;
|
||||
/** Whether an up move is good / bad / neutral for this metric. */
|
||||
directionSentiment?: TrendDirectionSentiment;
|
||||
/**
|
||||
* The age / normal-range framing line (e.g. "typical for age 31",
|
||||
* "~5 yrs younger", "within normal range"). Plain text children — no
|
||||
* markdown. Rendered muted under the value row.
|
||||
*/
|
||||
framing?: React.ReactNode;
|
||||
/** Days since the last reading; > 7 surfaces the stale caption. */
|
||||
staleDays?: number | null;
|
||||
/** Decimal places for the value + delta. Defaults to 1. */
|
||||
precision?: number;
|
||||
/**
|
||||
* Optional provenance affordance — the `ProvenanceExplainer` ⓘ trigger
|
||||
* rendered in the label row so the method + cited standard reach the user
|
||||
* on a tile that has no detail page (vitals baseline, HRV, BMI,
|
||||
* fitness/vascular age). Plain element children — the caller wires the
|
||||
* explainer; the tile just gives it a slot.
|
||||
*/
|
||||
provenance?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SparklineDeltaTile({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
icon: Icon,
|
||||
series,
|
||||
delta = null,
|
||||
directionSentiment = "neutral",
|
||||
framing,
|
||||
staleDays = null,
|
||||
precision = 1,
|
||||
provenance,
|
||||
className,
|
||||
}: SparklineDeltaTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment);
|
||||
const trendColor = sentimentColorClass(arrowSentiment);
|
||||
|
||||
const TrendIcon = ((): typeof ArrowUp => {
|
||||
if (delta == null) return Minus;
|
||||
if (Math.abs(delta) < 0.05) return ArrowRight;
|
||||
return delta > 0 ? ArrowUp : ArrowDown;
|
||||
})();
|
||||
|
||||
const formatDelta = (d: number): string => {
|
||||
if (Math.abs(d) < 0.05) return "±0";
|
||||
const sign = d > 0 ? "+" : "−";
|
||||
return `${sign}${fmt.number(Math.abs(d), precision)}`;
|
||||
};
|
||||
|
||||
const sparkData =
|
||||
series && series.length >= 2
|
||||
? series.map((v, i) => ({ i, v }))
|
||||
: null;
|
||||
|
||||
// The sparkline tints with the trend sentiment so it reads "same signal"
|
||||
// as the arrow; neutral metrics ride the muted-foreground line.
|
||||
// Semantic tokens, not raw --dracula-*, so the sparkline tint tracks the
|
||||
// AA-safe :root.light overrides on the white card (the band-token fix).
|
||||
const strokeVar =
|
||||
arrowSentiment === "positive"
|
||||
? "var(--success)"
|
||||
: arrowSentiment === "negative"
|
||||
? "var(--warning)"
|
||||
: "var(--muted-foreground)";
|
||||
const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sparkline-delta-tile"
|
||||
className={cn(
|
||||
"bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
|
||||
data-slot="sparkline-delta-tile-label"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<span
|
||||
data-slot="sparkline-delta-tile-provenance"
|
||||
className="shrink-0"
|
||||
>
|
||||
{provenance}
|
||||
</span>
|
||||
) : null}
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 flex items-baseline gap-x-1.5"
|
||||
data-slot="sparkline-delta-tile-value-row"
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-3xl leading-none font-semibold tracking-tight whitespace-nowrap tabular-nums"
|
||||
data-slot="sparkline-delta-tile-value"
|
||||
>
|
||||
{value !== null ? fmt.number(value, precision) : "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
|
||||
{unit}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
data-slot="sparkline-delta-tile-arrow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{delta != null ? (
|
||||
<TrendIcon className={cn("h-4 w-4", trendColor)} />
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs opacity-30">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Inline sparkline — fixed 40px height so the tile never reflows.
|
||||
When there is no trailing series the row collapses entirely rather
|
||||
than reserving an empty dashed placeholder (which read as visual
|
||||
dead space across the whole grid). */}
|
||||
{sparkData ? (
|
||||
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={sparkData}
|
||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={strokeVar}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#${fillId})`}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Framing + delta caption row. */}
|
||||
<div className="mt-2 flex min-h-[18px] items-baseline justify-between gap-2">
|
||||
{framing ? (
|
||||
<span
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-snug"
|
||||
data-slot="sparkline-delta-tile-framing"
|
||||
>
|
||||
{framing}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
{delta != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium tabular-nums",
|
||||
trendColor,
|
||||
)}
|
||||
data-slot="sparkline-delta-tile-delta"
|
||||
aria-label={`${formatDelta(delta)} ${unit}`.trim()}
|
||||
>
|
||||
{formatDelta(delta)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{staleDays != null && staleDays > 7 && (
|
||||
<span
|
||||
className="text-muted-foreground mt-1 line-clamp-1 text-xs leading-snug tabular-nums"
|
||||
data-slot="sparkline-delta-tile-stale"
|
||||
data-stale-days={staleDays}
|
||||
>
|
||||
{t("dashboard.staleHint", { count: staleDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.10.0 — the single derived-metric provenance source map.
|
||||
*
|
||||
* One place that pins, per derived metric, the cited standard (a real,
|
||||
* linkable primary source — DOI / WHO / professional-society standard) and
|
||||
* the i18n keys for the plain-language method copy + the honesty caveat the
|
||||
* `ProvenanceExplainer` / `ScoreAnatomyView` surface. Every score surface
|
||||
* (the dashboard tiles, the wellness rings, the anatomy detail pages, the
|
||||
* coincident-deviation flag) reads from here so the method + cited standard
|
||||
* a metric exposes stays one source — never a comment buried in the engine
|
||||
* module, never two surfaces disagreeing on the citation.
|
||||
*
|
||||
* Client-safe: a string-keyed metadata map + i18n key names, no compute, no
|
||||
* server imports — a `"use client"` surface value-imports it freely.
|
||||
*/
|
||||
import type { ProvenanceStandard } from "./provenance-explainer";
|
||||
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
|
||||
|
||||
export interface MetricProvenanceMeta {
|
||||
/** i18n key for the plain-language method copy (under composite.*.method). */
|
||||
methodKey: string;
|
||||
/** The cited primary standard rendered as an external link. */
|
||||
standard: ProvenanceStandard;
|
||||
/**
|
||||
* Optional i18n key for an honesty caveat surfaced above the method —
|
||||
* e.g. STRESS_SCORE must state it is an HRV-derived proxy, not an
|
||||
* EDA/galvanic measurement.
|
||||
*/
|
||||
caveatKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cited standard + method/caveat keys for every derived metric. The
|
||||
* `method`/`caveat` keys live under `insights.derived.composite.<ID>.*`
|
||||
* (extending the shape SLEEP_SCORE + READINESS already use) so the i18n
|
||||
* bundle carries one consistent namespace.
|
||||
*/
|
||||
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> = {
|
||||
VITALS_BASELINE: {
|
||||
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
|
||||
standard: {
|
||||
// Robust central-tendency / dispersion via the median ± k·MAD.
|
||||
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
|
||||
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
|
||||
},
|
||||
},
|
||||
HRV_BALANCE: {
|
||||
methodKey: "insights.derived.composite.HRV_BALANCE.method",
|
||||
standard: {
|
||||
// Task Force of the ESC / NASPE — HRV standards of measurement.
|
||||
name: "Task Force ESC/NASPE 1996, Circulation",
|
||||
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
|
||||
},
|
||||
},
|
||||
BMI: {
|
||||
methodKey: "insights.derived.composite.BMI.method",
|
||||
standard: {
|
||||
// WHO Technical Report Series 894 — BMI classification.
|
||||
name: "WHO Technical Report Series 894",
|
||||
url: "https://iris.who.int/handle/10665/42330",
|
||||
},
|
||||
},
|
||||
FITNESS_AGE: {
|
||||
methodKey: "insights.derived.composite.FITNESS_AGE.method",
|
||||
standard: {
|
||||
// FRIEND registry CRF reference standards (Kaminsky et al.).
|
||||
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
|
||||
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
|
||||
},
|
||||
},
|
||||
VASCULAR_AGE_DELTA: {
|
||||
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
|
||||
standard: {
|
||||
// Reference values for arterial stiffness (PWV) collaboration.
|
||||
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
|
||||
url: "https://doi.org/10.1093/eurheartj/ehq165",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
|
||||
},
|
||||
COINCIDENT_DEVIATION: {
|
||||
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
|
||||
standard: {
|
||||
// Hampel-identifier / median ± k·MAD outlier flagging.
|
||||
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
|
||||
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
|
||||
},
|
||||
SLEEP_SCORE: {
|
||||
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
|
||||
standard: {
|
||||
name: "Hirshkowitz et al. 2015, Sleep Health",
|
||||
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
|
||||
},
|
||||
},
|
||||
READINESS: {
|
||||
methodKey: "insights.derived.composite.READINESS.method",
|
||||
standard: {
|
||||
name: "Plews et al. 2013, Sports Medicine",
|
||||
url: "https://doi.org/10.1007/s40279-013-0071-8",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.READINESS.caveat",
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
|
||||
standard: {
|
||||
// HRV-guided recovery / training-readiness framing.
|
||||
name: "Buchheit 2014, Front. Physiol.",
|
||||
url: "https://doi.org/10.3389/fphys.2014.00073",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
|
||||
},
|
||||
STRESS_SCORE: {
|
||||
methodKey: "insights.derived.composite.STRESS_SCORE.method",
|
||||
standard: {
|
||||
// Neurovisceral-integration / HRV-as-stress-index basis.
|
||||
name: "Kim et al. 2018, Psychiatry Investig.",
|
||||
url: "https://doi.org/10.30773/pi.2017.08.17",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
|
||||
},
|
||||
STRAIN_SCORE: {
|
||||
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
|
||||
standard: {
|
||||
// Banister TRIMP training-load model (HR-reserve weighting).
|
||||
name: "Banister 1991, TRIMP model",
|
||||
url: "https://en.wikipedia.org/wiki/Training_Impulse",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DerivedCoverage,
|
||||
DerivedConfidence,
|
||||
DerivedProvenance,
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the single TanStack Query hook for the generic derived-metric
|
||||
* route, shared by every client surface (vitals dashboard tiles, composite
|
||||
* score-anatomy, the home wellness strip).
|
||||
*
|
||||
* `GET /api/insights/derived?metric=<id>[&type=<vital>]` returns the
|
||||
* compute-once flattened `Derived<T>` the surfaces read. The queryKey comes
|
||||
* from the centralised factory (`queryKeys.insightsDerived`) so the bare-array
|
||||
* ESLint rule stays satisfied and the cache never poisons across metrics; the
|
||||
* read unwraps `(await res.json()).data` per the envelope convention.
|
||||
*
|
||||
* Pure compute on the server (sub-second on a warm rollup tenant) — so the
|
||||
* hook bounds the fetch on an 8 s client ceiling (a slow response means a
|
||||
* degraded network, not a provider call), keeps a 60 s `staleTime`, gates on
|
||||
* auth, and never inline-retries. These reads NEVER warm/generate on visit —
|
||||
* they read already-computed rollup values (the v1.4.36 / v1.9.1 lesson: a
|
||||
* warm-on-mount fan-out looks like the app hanging).
|
||||
*
|
||||
* Type-only import of the `Derived<T>` facets — no server module enters the
|
||||
* client bundle (the v1.9.0 lesson). The value `T` is left generic so each
|
||||
* caller narrows to its own metric payload (`FitnessAgeValue`, `BmiValue`, …)
|
||||
* imported as `import type`.
|
||||
*/
|
||||
|
||||
const DERIVED_TIMEOUT_MS = 8_000;
|
||||
|
||||
/** The flattened wire shape the route emits in the `data` envelope slot. */
|
||||
export interface DerivedMetricResponse<T> {
|
||||
metric: string;
|
||||
status: "ok" | "insufficient";
|
||||
value: T | null;
|
||||
coverage: DerivedCoverage;
|
||||
confidence: DerivedConfidence | null;
|
||||
provenance: DerivedProvenance;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface UseDerivedMetricOptions {
|
||||
/** Optional sub-target (the chosen vital for `VITALS_BASELINE`). */
|
||||
type?: string | null;
|
||||
/** Gate the fetch (e.g. on a surface flag). Defaults to enabled. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useDerivedMetric<T>(
|
||||
metric: string,
|
||||
options: UseDerivedMetricOptions = {},
|
||||
) {
|
||||
const { type = null, enabled = true } = options;
|
||||
const { isAuthenticated } = useAuth();
|
||||
const gated = enabled && isAuthenticated && metric !== "";
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insightsDerived(metric, type),
|
||||
queryFn: async (): Promise<DerivedMetricResponse<T>> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
DERIVED_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const params = new URLSearchParams({ metric });
|
||||
if (type) params.set("type", type);
|
||||
const res = await fetch(`/api/insights/derived?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`derived ${metric} request failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data as DerivedMetricResponse<T>;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: gated,
|
||||
staleTime: 60_000,
|
||||
retry: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch request token: a metric id with an optional VITALS_BASELINE
|
||||
* sub-target. Wire form is `metric` or `metric:type` inside the CSV.
|
||||
*/
|
||||
export interface DerivedBatchToken {
|
||||
metric: string;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
/** Build the `metric` / `metric:type` wire token for one batch item. */
|
||||
function tokenString(token: DerivedBatchToken): string {
|
||||
return token.type ? `${token.metric}:${token.type}` : token.metric;
|
||||
}
|
||||
|
||||
/** The batch route's `data` shape — a map keyed by the per-request token. */
|
||||
export interface DerivedBatchResponse {
|
||||
metrics: Record<string, DerivedMetricResponse<unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The typed `read` selector `useDerivedBatch` returns — a tile narrows its
|
||||
* own metric value out of the resolved map exactly as it did per-metric.
|
||||
*/
|
||||
export type DerivedBatchRead = <T>(
|
||||
token: DerivedBatchToken,
|
||||
) => DerivedMetricResponse<T> | null;
|
||||
|
||||
/**
|
||||
* v1.10.0 — ONE batched read for a set of derived metrics, the dashboard's
|
||||
* cold-mount fan-out fix. Replaces N independent `useDerivedMetric` queries
|
||||
* (each a separate request sharing the Prisma pool — the v1.9.1 "hangs then
|
||||
* recovers" symptom) with a single `GET /api/insights/derived/batch`. The
|
||||
* server fans out under a bounded limiter and loads the profile once.
|
||||
*
|
||||
* Returns a typed `read(token)` selector so a tile pulls its own
|
||||
* `DerivedMetricResponse<T>` out of the map with the same narrowing it had
|
||||
* per-metric. Same 8 s ceiling + `retry:0` + 60 s `staleTime` as the single
|
||||
* hook; these reads never warm/generate on visit.
|
||||
*/
|
||||
export function useDerivedBatch(
|
||||
tokens: DerivedBatchToken[],
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { enabled = true } = options;
|
||||
const { isAuthenticated } = useAuth();
|
||||
const wireTokens = tokens.map(tokenString);
|
||||
const gated = enabled && isAuthenticated && wireTokens.length > 0;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.insightsDerivedBatch(wireTokens),
|
||||
queryFn: async (): Promise<DerivedBatchResponse> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
DERIVED_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const params = new URLSearchParams({ metrics: wireTokens.join(",") });
|
||||
const res = await fetch(
|
||||
`/api/insights/derived/batch?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`derived batch request failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data as DerivedBatchResponse;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: gated,
|
||||
staleTime: 60_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
/** Pull one metric's value out of the resolved map, narrowed to `T`. */
|
||||
function read<T>(token: DerivedBatchToken): DerivedMetricResponse<T> | null {
|
||||
const entry = query.data?.metrics[tokenString(token)];
|
||||
return (entry as DerivedMetricResponse<T> | undefined) ?? null;
|
||||
}
|
||||
|
||||
return { ...query, read };
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
MEASUREMENT_TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
} from "@/components/measurements/measurement-list-meta";
|
||||
import { SparklineDeltaTile } from "./sparkline-delta-tile";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import { WellnessScores } from "./wellness-scores";
|
||||
import { ProvenanceExplainer } from "./provenance-explainer";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
import {
|
||||
useDerivedBatch,
|
||||
type DerivedBatchRead,
|
||||
type DerivedBatchToken,
|
||||
} from "./use-derived-metric";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
|
||||
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
|
||||
import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age";
|
||||
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
|
||||
import type { BmiValue } from "@/lib/insights/derived/bmi";
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
|
||||
*
|
||||
* A responsive grid of `SparklineDeltaTile`s, one per available vital, each
|
||||
* framed with its personal typical range (the flagship baseline metric) plus
|
||||
* the four passthrough/derived re-frames (cardio-fitness band, vascular-age
|
||||
* delta, HRV balance, BMI). Every tile is data-availability-gated:
|
||||
* - absent (`no_readings_in_window` / `no_height` / `no_weight`) → the tile
|
||||
* does not render at all (no apologetic empty card).
|
||||
* - provisional (`insufficient_history_for_band`) → the tile renders with a
|
||||
* `CoverageMeter` that shows the honest "N of 7 days" state, never a
|
||||
* headline number from too-little history.
|
||||
* - ok → the full value + framing line + a provenance ⓘ affordance.
|
||||
*
|
||||
* The whole grid (every vital tile AND the wellness-score strip) reads from
|
||||
* ONE batched `/api/insights/derived/batch` request, fanned out server-side
|
||||
* under a bounded limiter with the profile loaded once. This replaces the
|
||||
* cold-mount fan-out of 14+ independent requests sharing the Prisma pool —
|
||||
* the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1).
|
||||
*/
|
||||
|
||||
const SECTION_VITALS: string[] = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESPIRATORY_RATE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"WEIGHT",
|
||||
];
|
||||
|
||||
/** Up-is-bad for the vitals where a rise is unfavourable. */
|
||||
const UP_BAD_VITALS = new Set([
|
||||
"RESTING_HEART_RATE",
|
||||
"BODY_TEMPERATURE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"WEIGHT",
|
||||
]);
|
||||
/** Up-is-good for the vitals where a rise is favourable. */
|
||||
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
|
||||
|
||||
/**
|
||||
* Storage-unit → display-symbol overrides. `getUnitForType` returns the
|
||||
* canonical *storage* string (e.g. the literal "celsius"); a tile must show
|
||||
* the symbol a user reads ("°C"). Only the units whose storage string is not
|
||||
* already a display symbol need an entry — kg / bpm / ms / % / mg/dL render
|
||||
* fine as-is.
|
||||
*/
|
||||
const DISPLAY_UNIT_SYMBOL: Record<string, string> = {
|
||||
celsius: "°C",
|
||||
fahrenheit: "°F",
|
||||
};
|
||||
|
||||
function displayUnit(type: string): string {
|
||||
const storage = getUnitForType(type);
|
||||
return DISPLAY_UNIT_SYMBOL[storage] ?? storage;
|
||||
}
|
||||
|
||||
function vitalSentiment(type: string): TrendDirectionSentiment {
|
||||
if (UP_BAD_VITALS.has(type)) return "up-bad";
|
||||
if (UP_GOOD_VITALS.has(type)) return "up-good";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The provenance ⓘ explainer for one derived metric, wired from the map. */
|
||||
function MetricProvenance({
|
||||
metric,
|
||||
provenance,
|
||||
}: {
|
||||
metric: keyof typeof METRIC_PROVENANCE;
|
||||
provenance: DerivedProvenance;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const meta = METRIC_PROVENANCE[metric];
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
read: DerivedBatchRead;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** A single personal-typical-range tile for one vital. */
|
||||
function BaselineTile({
|
||||
type,
|
||||
read,
|
||||
isLoading,
|
||||
}: TileProps & { type: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const data = read<VitalsBaselineValue>({ metric: "VITALS_BASELINE", type });
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
// Absent → don't render the tile at all.
|
||||
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge;
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const label = labelKey ? t(labelKey) : type;
|
||||
const unit = displayUnit(type);
|
||||
|
||||
// Provisional — building the band; show value-less coverage state.
|
||||
if (data.status === "insufficient") {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric={type}
|
||||
data-state="provisional"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
|
||||
{t("insights.derived.vitals.building", {
|
||||
count: data.coverage.historyDays,
|
||||
target: 7,
|
||||
})}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
const framing = t("insights.derived.vitals.typicalRange", {
|
||||
low: fmt.number(v.low, 1),
|
||||
high: fmt.number(v.high, 1),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric={type} data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={label}
|
||||
value={v.center}
|
||||
unit={unit}
|
||||
icon={Icon}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
provenance={
|
||||
<MetricProvenance metric="VITALS_BASELINE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cardio-fitness band tile (VO₂max passthrough re-frame). */
|
||||
function FitnessAgeTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing =
|
||||
v.fitnessAgeDeltaYears != null
|
||||
? v.fitnessAgeDeltaYears < 0
|
||||
? t("insights.derived.vitals.fitnessYounger", {
|
||||
years: Math.abs(v.fitnessAgeDeltaYears),
|
||||
})
|
||||
: v.fitnessAgeDeltaYears > 0
|
||||
? t("insights.derived.vitals.fitnessOlder", {
|
||||
years: v.fitnessAgeDeltaYears,
|
||||
})
|
||||
: t("insights.derived.vitals.fitnessTypical")
|
||||
: t("insights.derived.vitals.fitnessNoNorm");
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="FITNESS_AGE" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeVo2Max")}
|
||||
value={v.vo2Max}
|
||||
unit={getUnitForType("VO2_MAX")}
|
||||
icon={Gauge}
|
||||
delta={v.trendDelta}
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
provenance={
|
||||
<MetricProvenance metric="FITNESS_AGE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vascular-age delta tile (Withings passthrough re-frame). */
|
||||
function VascularAgeTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing =
|
||||
v.deltaYears != null
|
||||
? v.deltaYears < 0
|
||||
? t("insights.derived.vitals.vascularBelow", {
|
||||
years: Math.abs(Math.round(v.deltaYears)),
|
||||
})
|
||||
: v.deltaYears > 0
|
||||
? t("insights.derived.vitals.vascularAbove", {
|
||||
years: Math.round(v.deltaYears),
|
||||
})
|
||||
: t("insights.derived.vitals.vascularMatch")
|
||||
: t("insights.derived.vitals.vascularNoAge");
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="VASCULAR_AGE_DELTA" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeVascularAge")}
|
||||
value={v.vascularAge}
|
||||
unit={getUnitForType("VASCULAR_AGE")}
|
||||
icon={HeartPulse}
|
||||
delta={v.trendDelta}
|
||||
directionSentiment="up-bad"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
provenance={
|
||||
<MetricProvenance
|
||||
metric="VASCULAR_AGE_DELTA"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** HRV (SDNN) balance tile (reuses the baseline engine). */
|
||||
function HrvBalanceTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
|
||||
if (isLoading || !data) return null;
|
||||
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
|
||||
return null;
|
||||
}
|
||||
if (data.status === "insufficient") {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="HRV_BALANCE"
|
||||
data-state="provisional"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("measurements.typeHeartRateVariability")}
|
||||
</span>
|
||||
<HeartPulse className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.derived.vitals.building", {
|
||||
count: data.coverage.historyDays,
|
||||
target: 7,
|
||||
})}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const v = data.value!;
|
||||
const framing = t(`insights.derived.vitals.hrvBand.${v.band}`);
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="HRV_BALANCE" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeHeartRateVariability")}
|
||||
value={v.recentAvg}
|
||||
unit={getUnitForType("HEART_RATE_VARIABILITY")}
|
||||
icon={HeartPulse}
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
provenance={
|
||||
<MetricProvenance metric="HRV_BALANCE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BMI tile (weight + height fallback). */
|
||||
function BmiTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<BmiValue>({ metric: "BMI" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`);
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="BMI" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeBodyMassIndex")}
|
||||
value={v.bmi}
|
||||
unit={getUnitForType("BODY_MASS_INDEX")}
|
||||
icon={Scale}
|
||||
directionSentiment="neutral"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
provenance={
|
||||
<MetricProvenance metric="BMI" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coincident-deviation flag tile — surfaces ONLY when the flag fired today
|
||||
* (≥ N vitals outside their personal band on the same day). Lists the
|
||||
* contributing vitals as plain text and carries the provenance affordance
|
||||
* (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical
|
||||
* caveat). When the flag has not fired the tile un-mounts entirely — never
|
||||
* an alarming empty card.
|
||||
*/
|
||||
function CoincidentDeviationTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
if (!v.fired || v.contributing.length === 0) return null;
|
||||
|
||||
const names = v.contributing
|
||||
.map((d) => {
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
|
||||
return labelKey ? t(labelKey) : d.type;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="COINCIDENT_DEVIATION"
|
||||
data-state="fired"
|
||||
className="bg-card border-warning/40 flex h-full w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.label")}
|
||||
</span>
|
||||
<MetricProvenance
|
||||
metric="COINCIDENT_DEVIATION"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
<AlertTriangle className="text-warning h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm" data-slot="coincident-summary">
|
||||
{t("insights.derived.coincident.summary", {
|
||||
count: v.contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A loading placeholder occupying the resolved tile footprint so the grid
|
||||
* reserves its final height and the real tiles drop in without a layout
|
||||
* shift. Mirrors the SparklineDeltaTile geometry (card + label row + value
|
||||
* row + framing line) with `Skeleton` blocks. Decorative — `aria-hidden`.
|
||||
*/
|
||||
function VitalsTileSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resolved batch holds at least one vital tile worth painting.
|
||||
* Mirrors the per-tile gates so the section heading is only rendered once
|
||||
* there is content under it (an empty heading stranded over blank space was
|
||||
* the CLS the load-state QoL pass closes). Kept in sync with the tile gates
|
||||
* above — if a new tile gate changes, reflect it here.
|
||||
*/
|
||||
function hasRenderableVital(read: DerivedBatchRead): boolean {
|
||||
const coincident = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (
|
||||
coincident?.status === "ok" &&
|
||||
coincident.value?.fired &&
|
||||
coincident.value.contributing.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (fitness?.status === "ok" && fitness.value) return true;
|
||||
|
||||
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
if (vascular?.status === "ok" && vascular.value) return true;
|
||||
|
||||
const bmi = read<BmiValue>({ metric: "BMI" });
|
||||
if (bmi?.status === "ok" && bmi.value) return true;
|
||||
|
||||
const hrv = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
|
||||
if (
|
||||
hrv &&
|
||||
!(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
const baseline = read<VitalsBaselineValue>({
|
||||
metric: "VITALS_BASELINE",
|
||||
type,
|
||||
});
|
||||
if (
|
||||
baseline &&
|
||||
!(
|
||||
baseline.status === "insufficient" &&
|
||||
baseline.reason === "no_readings_in_window"
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of tokens the dashboard reads in one batch — the five
|
||||
* wellness scores + the four derived re-frames + the coincident-deviation
|
||||
* flag + one baseline per vital (minus HRV, which has its own balance tile).
|
||||
*/
|
||||
function dashboardTokens(): DerivedBatchToken[] {
|
||||
const tokens: DerivedBatchToken[] = [
|
||||
{ metric: "READINESS" },
|
||||
{ metric: "SLEEP_SCORE" },
|
||||
{ metric: "RECOVERY_SCORE" },
|
||||
{ metric: "STRESS_SCORE" },
|
||||
{ metric: "STRAIN_SCORE" },
|
||||
{ metric: "FITNESS_AGE" },
|
||||
{ metric: "VASCULAR_AGE_DELTA" },
|
||||
{ metric: "HRV_BALANCE" },
|
||||
{ metric: "BMI" },
|
||||
{ metric: "COINCIDENT_DEVIATION" },
|
||||
];
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
tokens.push({ metric: "VITALS_BASELINE", type });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const vitals = useMemo(() => SECTION_VITALS, []);
|
||||
const tokens = useMemo(() => dashboardTokens(), []);
|
||||
|
||||
const batch = useDerivedBatch(tokens, {
|
||||
enabled: enabled && isAuthenticated,
|
||||
});
|
||||
const read = batch.read;
|
||||
const isLoading = batch.isLoading;
|
||||
const isError = batch.isError;
|
||||
const refetch = batch.refetch;
|
||||
const tileProps: TileProps = { read, isLoading };
|
||||
|
||||
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
|
||||
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
|
||||
// vanishes. Surface a compact message + a Retry that refetches the one
|
||||
// batch query both strips share.
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-dashboard-wrap"
|
||||
className={cn("space-y-6", className)}
|
||||
>
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-error"
|
||||
role="alert"
|
||||
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("insights.derived.vitals.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="vitals-dashboard-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The heading only renders once there is content under it (or while the
|
||||
// skeleton row reserves the final height). When the batch resolves to zero
|
||||
// vitals the heading would otherwise strand over blank space, then content
|
||||
// pops in below it — the CLS this pass removes.
|
||||
const showSection = isLoading || hasRenderableVital(read);
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<WellnessScores read={read} isLoading={isLoading} />
|
||||
{showSection && (
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
aria-busy={isLoading}
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
isLoading ? t("insights.derived.vitals.loadingLabel") : undefined
|
||||
}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import type { DerivedBatchRead } from "./use-derived-metric";
|
||||
import type { ScoreBand } from "./band-tokens";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
|
||||
import type { SleepScoreValue } from "@/lib/insights/derived/sleep-score";
|
||||
import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the wellness-scores strip.
|
||||
*
|
||||
* A row of score rings for the composite + persisted wellness scores, each
|
||||
* data-availability-gated: a score the user has no data for simply does not
|
||||
* render (never an apologetic empty ring). All five composites/scores now
|
||||
* route to a `ScoreAnatomyView` detail page (`/insights/scores/<slug>`) so
|
||||
* every ring carries its inputs + method + cited standard one tap away — the
|
||||
* three persisted scores (Recovery, Stress, Strain) are no longer read-only
|
||||
* dead-ends. The band word renders under each ring so a less-technical user
|
||||
* reads "low 62", not a bare reddish 62.
|
||||
*
|
||||
* Every ring reads from the ONE batched `/api/insights/derived/batch`
|
||||
* request the parent dashboard owns (passed in via `read`) — no per-ring
|
||||
* request, no cold-mount fan-out. The reads are already-computed
|
||||
* rollup/persisted values; nothing warms on visit.
|
||||
*/
|
||||
|
||||
interface ScoreStripProps {
|
||||
/** The batched-read selector from the parent dashboard's one query. */
|
||||
read: DerivedBatchRead;
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A score ring wrapped in a link to its anatomy detail page. */
|
||||
function RingTile({
|
||||
score,
|
||||
band,
|
||||
label,
|
||||
bandWord,
|
||||
href,
|
||||
metricSlot,
|
||||
}: {
|
||||
score: number;
|
||||
band: ScoreBand;
|
||||
label: string;
|
||||
bandWord: string;
|
||||
href: string;
|
||||
metricSlot: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<ScoreRing score={score} band={band} size="sm" label={label} />
|
||||
<span
|
||||
data-slot="wellness-score-band-word"
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{bandWord}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function WellnessScores({ read, isLoading, className }: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const readiness = read<ReadinessValue>({ metric: "READINESS" });
|
||||
const sleep = read<SleepScoreValue>({ metric: "SLEEP_SCORE" });
|
||||
const recovery = read<WellnessScoreValue>({ metric: "RECOVERY_SCORE" });
|
||||
const stress = read<WellnessScoreValue>({ metric: "STRESS_SCORE" });
|
||||
const strain = read<WellnessScoreValue>({ metric: "STRAIN_SCORE" });
|
||||
|
||||
const bandWord = (band: ScoreBand) =>
|
||||
t(`insights.derived.scoreRing.band.${band}`);
|
||||
|
||||
const tiles: React.ReactNode[] = [];
|
||||
|
||||
if (!isLoading && readiness?.status === "ok" && readiness.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="READINESS"
|
||||
score={readiness.value.score}
|
||||
band={readiness.value.band}
|
||||
label={t("insights.derived.composite.READINESS.title")}
|
||||
bandWord={bandWord(readiness.value.band)}
|
||||
href="/insights/scores/readiness"
|
||||
metricSlot="READINESS"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && recovery?.status === "ok" && recovery.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="RECOVERY_SCORE"
|
||||
score={recovery.value.score}
|
||||
band={recovery.value.band}
|
||||
label={t("insights.derived.scores.recovery")}
|
||||
bandWord={bandWord(recovery.value.band)}
|
||||
href="/insights/scores/recovery"
|
||||
metricSlot="RECOVERY_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && sleep?.status === "ok" && sleep.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="SLEEP_SCORE"
|
||||
score={sleep.value.score}
|
||||
band={sleep.value.band}
|
||||
label={t("insights.derived.composite.SLEEP_SCORE.title")}
|
||||
bandWord={bandWord(sleep.value.band)}
|
||||
href="/insights/scores/sleep"
|
||||
metricSlot="SLEEP_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && stress?.status === "ok" && stress.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRESS_SCORE"
|
||||
score={stress.value.score}
|
||||
band={stress.value.band}
|
||||
label={t("insights.derived.scores.stress")}
|
||||
bandWord={bandWord(stress.value.band)}
|
||||
href="/insights/scores/stress"
|
||||
metricSlot="STRESS_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && strain?.status === "ok" && strain.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRAIN_SCORE"
|
||||
score={strain.value.score}
|
||||
band={strain.value.band}
|
||||
label={t("insights.derived.scores.strain")}
|
||||
bandWord={bandWord(strain.value.band)}
|
||||
href="/insights/scores/strain"
|
||||
metricSlot="STRAIN_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// Whole strip un-mounts when no score has data — never an empty section.
|
||||
if (tiles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="wellness-scores"
|
||||
aria-label={t("insights.derived.scores.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.scores.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="wellness-scores-grid"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
{tiles}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,11 @@ import { Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import {
|
||||
useInsightsLayoutPrefs,
|
||||
useInsightsRangePref,
|
||||
} from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InsightMetric } from "@/lib/insights/metric-availability";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
metricIdForMeasurementType,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
@@ -29,8 +19,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { TimeRangePills } from "@/components/insights/time-range-pills";
|
||||
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -151,30 +140,9 @@ export function HealthKitMetricPage({
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
const { range, setRange } = useInsightsRangePref();
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
|
||||
|
||||
// v1.9.0 — period-over-period range read. Single-metric, additive route;
|
||||
// gated off the empty-data branch so a brand-new metric never fires it.
|
||||
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
|
||||
measurementType,
|
||||
range,
|
||||
!isEmpty,
|
||||
);
|
||||
|
||||
// The delta's sentiment colour follows the metric's "good direction" from
|
||||
// the metric-status registry (higher-better → up-good, lower-better →
|
||||
// up-bad, target-band → neutral). Every HealthKit metric page is backed by
|
||||
// a registry entry; a metric absent from the registry falls back to neutral.
|
||||
const registryMeta = (() => {
|
||||
const id = metricIdForMeasurementType(measurementType as MeasurementType);
|
||||
return id ? getMetricStatusMeta(id) : null;
|
||||
})();
|
||||
const directionSentiment = registryMeta
|
||||
? sentimentFromMetricDirection(registryMeta.direction)
|
||||
: "neutral";
|
||||
|
||||
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
|
||||
// The stat strip renders display-unit values. The summary holds stored
|
||||
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
|
||||
@@ -242,19 +210,10 @@ export function HealthKitMetricPage({
|
||||
>
|
||||
{/* v1.9.0 — time-range pills + period-over-period delta, between the
|
||||
stat strip and the chart. The selected range persists across metrics
|
||||
via `useInsightsRangePref`. */}
|
||||
<div
|
||||
data-slot="metric-range-controls"
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<TimeRangePills value={range} onChange={setRange} />
|
||||
<MetricRangeDelta
|
||||
data={rangeData}
|
||||
range={range}
|
||||
directionSentiment={directionSentiment}
|
||||
isLoading={isRangeLoading}
|
||||
/>
|
||||
</div>
|
||||
via `useInsightsRangePref`.
|
||||
v1.10.2 — extracted into the shared `<MetricRangeControls>` so the
|
||||
bespoke metric sub-pages render the identical control. */}
|
||||
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey={chartKey}
|
||||
types={[measurementType]}
|
||||
|
||||
@@ -196,8 +196,28 @@ const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navWalkingSpeed",
|
||||
metric: "WALKING_SPEED",
|
||||
},
|
||||
// v1.10.0 — cardio fitness (VO2 max) + Apple-Health Mobility additions.
|
||||
"cardio-fitness": { labelKey: "insights.navCardioFitness", metric: "VO2_MAX" },
|
||||
falls: { labelKey: "insights.navFalls", metric: "FALL_COUNT" },
|
||||
"six-minute-walk": {
|
||||
labelKey: "insights.navSixMinuteWalk",
|
||||
metric: "SIX_MINUTE_WALK_DISTANCE",
|
||||
},
|
||||
"stair-ascent-speed": {
|
||||
labelKey: "insights.navStairAscentSpeed",
|
||||
metric: "STAIR_ASCENT_SPEED",
|
||||
},
|
||||
"stair-descent-speed": {
|
||||
labelKey: "insights.navStairDescentSpeed",
|
||||
metric: "STAIR_DESCENT_SPEED",
|
||||
},
|
||||
// ── sleep ──
|
||||
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
|
||||
// v1.10.0 — sleep breathing-disturbance index.
|
||||
"breathing-disturbances": {
|
||||
labelKey: "insights.navBreathingDisturbances",
|
||||
metric: "BREATHING_DISTURBANCES",
|
||||
},
|
||||
// ── cardiovascular ──
|
||||
"resting-pulse": {
|
||||
labelKey: "insights.navRestingHr",
|
||||
@@ -212,6 +232,11 @@ const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navVascularAge",
|
||||
metric: "VASCULAR_AGE",
|
||||
},
|
||||
// v1.10.0 — cardio recovery (post-exercise HR drop).
|
||||
"cardio-recovery": {
|
||||
labelKey: "insights.navCardioRecovery",
|
||||
metric: "CARDIO_RECOVERY",
|
||||
},
|
||||
// ── hearing ──
|
||||
"environmental-audio": {
|
||||
labelKey: "insights.navAudioExposureEnv",
|
||||
@@ -239,6 +264,11 @@ const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navSkinTemperature",
|
||||
metric: "SKIN_TEMPERATURE",
|
||||
},
|
||||
// v1.10.0 — overnight wrist temperature.
|
||||
"wrist-temperature": {
|
||||
labelKey: "insights.navWristTemperature",
|
||||
metric: "WRIST_TEMPERATURE",
|
||||
},
|
||||
// ── mood ──
|
||||
mood: { labelKey: "insights.navMood", metric: "MOOD" },
|
||||
// ── events ──
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useInsightsRangePref } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
metricIdForMeasurementType,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TimeRangePills } from "@/components/insights/time-range-pills";
|
||||
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
|
||||
|
||||
/**
|
||||
* v1.10.2 — shared range-controls block (time-range pills +
|
||||
* period-over-period delta) lifted out of `<HealthKitMetricPage>` so the
|
||||
* bespoke metric sub-pages (`weight`, `blood-pressure`, `pulse`, `mood`,
|
||||
* `sleep`, `bmi`, …) render the identical control.
|
||||
*
|
||||
* Before this, the 37 HealthKit pages carried the selector and the 8
|
||||
* hand-rolled pages carried nothing, so navigating from an HK metric to
|
||||
* `weight` made the control vanish. This component is the single source the
|
||||
* HK scaffold and the bespoke pages both consume, wired to the same
|
||||
* persisted range pref (`useInsightsRangePref`) so the choice sticks across
|
||||
* every metric.
|
||||
*
|
||||
* The delta's sentiment colour follows the metric's "good direction" from
|
||||
* the metric-status registry (higher-better → up-good, lower-better →
|
||||
* up-bad, target-band → neutral); a metric absent from the registry falls
|
||||
* back to neutral.
|
||||
*/
|
||||
export function MetricRangeControls({
|
||||
measurementType,
|
||||
enabled = true,
|
||||
}: {
|
||||
/** The MeasurementType the period-over-period read is keyed on. */
|
||||
measurementType: string;
|
||||
/**
|
||||
* Gate the range read. Pages pass `!isEmpty` so a brand-new metric with
|
||||
* no observations never fires the round-trip. Defaults to `true`.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { range, setRange } = useInsightsRangePref();
|
||||
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
|
||||
measurementType,
|
||||
range,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const registryMeta = (() => {
|
||||
const id = metricIdForMeasurementType(measurementType as MeasurementType);
|
||||
return id ? getMetricStatusMeta(id) : null;
|
||||
})();
|
||||
const directionSentiment = registryMeta
|
||||
? sentimentFromMetricDirection(registryMeta.direction)
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="metric-range-controls"
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<TimeRangePills value={range} onChange={setRange} />
|
||||
<MetricRangeDelta
|
||||
data={rangeData}
|
||||
range={range}
|
||||
directionSentiment={directionSentiment}
|
||||
isLoading={isRangeLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
Footprints,
|
||||
Wind,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
* WX-B).
|
||||
*
|
||||
* A timeline of the on-device notifications the user's wearable (Apple
|
||||
* Watch / Withings ScanWatch) already produced and synced —
|
||||
* irregular-rhythm / high-HR / low-HR / walking-steadiness /
|
||||
* breathing-disturbance.
|
||||
*
|
||||
* Regulatory framing (load-bearing, do not soften): this surface is
|
||||
* AWARENESS / SCREENING of the DEVICE's own decision. It reflects ONLY the
|
||||
* classification RESULT the device's FDA-cleared / CE-marked on-device
|
||||
* algorithm produced. HealthLog never re-classifies and never emits a
|
||||
* diagnosis of its own. The disclaimer below states this verbatim; it is a
|
||||
* permanent, non-dismissible part of the surface.
|
||||
*
|
||||
* Data-availability-gated: the card un-mounts entirely (`return null`) when
|
||||
* the user has no such events — never an empty / alarming card.
|
||||
*/
|
||||
|
||||
interface RhythmEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
classification: string | null;
|
||||
occurredAt: string;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
interface RhythmEventsResponse {
|
||||
events: RhythmEvent[];
|
||||
hasEvents: boolean;
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<string, LucideIcon> = {
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: Activity,
|
||||
HIGH_HEART_RATE_EVENT: HeartPulse,
|
||||
LOW_HEART_RATE_EVENT: HeartPulse,
|
||||
WALKING_STEADINESS_EVENT: Footprints,
|
||||
BREATHING_DISTURBANCE_EVENT: Wind,
|
||||
};
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "insights.rhythmEvents.event.irregularRhythm",
|
||||
HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate",
|
||||
LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate",
|
||||
WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness",
|
||||
BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance",
|
||||
};
|
||||
|
||||
/**
|
||||
* The device's verdict, surfaced verbatim. Each line is framed as the
|
||||
* device's decision ("Your device flagged …"), never HealthLog's.
|
||||
*/
|
||||
const CLASSIFICATION_LABEL_KEYS: Record<string, string> = {
|
||||
IRREGULAR: "insights.rhythmEvents.verdict.irregular",
|
||||
NOT_DETECTED: "insights.rhythmEvents.verdict.notDetected",
|
||||
INCONCLUSIVE: "insights.rhythmEvents.verdict.inconclusive",
|
||||
LOW: "insights.rhythmEvents.verdict.low",
|
||||
VERY_LOW: "insights.rhythmEvents.verdict.veryLow",
|
||||
FIRED: "insights.rhythmEvents.verdict.fired",
|
||||
};
|
||||
|
||||
interface RhythmEventsCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RhythmEventsCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: RhythmEventsCardProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsRhythmEvents(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/rhythm-events");
|
||||
if (!res.ok) throw new Error(t("insights.rhythmEvents.loadError"));
|
||||
const json = await res.json();
|
||||
return json.data as RhythmEventsResponse;
|
||||
},
|
||||
enabled: enabled && isAuthenticated,
|
||||
});
|
||||
|
||||
// Data-availability gate — never paint an empty / alarming card.
|
||||
if (isLoading || !data || !data.hasEvents) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="rhythm-events-card"
|
||||
aria-label={t("insights.rhythmEvents.sectionTitle")}
|
||||
className={cn(
|
||||
"bg-card border-border space-y-4 rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.rhythmEvents.sectionTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.rhythmEvents.sectionIntro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol data-slot="rhythm-events-timeline" className="space-y-3">
|
||||
{data.events.map((event) => {
|
||||
const Icon = EVENT_ICONS[event.type] ?? Activity;
|
||||
const labelKey = EVENT_LABEL_KEYS[event.type];
|
||||
const label = labelKey ? t(labelKey) : event.type;
|
||||
const verdictKey = event.classification
|
||||
? CLASSIFICATION_LABEL_KEYS[event.classification]
|
||||
: undefined;
|
||||
const verdict = verdictKey ? t(verdictKey) : null;
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
data-slot="rhythm-event-row"
|
||||
data-event-type={event.type}
|
||||
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
{verdict && (
|
||||
<p
|
||||
data-slot="rhythm-event-verdict"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{verdict}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
|
||||
Plain React text children (no markdown library — XSS rule). */}
|
||||
<div
|
||||
data-slot="rhythm-events-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.rhythmEvents.disclaimer")}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -129,6 +130,11 @@ export function SubPageShell({
|
||||
children,
|
||||
}: SubPageShellProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.10.2 — the "show all readings" link carries the originating metric
|
||||
// page as a `from` param so the values sub-page can offer a back-link to
|
||||
// where the user drilled in from (e.g. `weight → show all values → back to
|
||||
// weight`) rather than always returning to the Insights overview.
|
||||
const pathname = usePathname();
|
||||
// a11y: focus the heading on mount so a tab-strip navigation actually
|
||||
// moves screen-reader focus into the sub-page body. v1.4.27 MB7 /
|
||||
// CF-35 made the focus call opt-in via `focusOnMount` — the
|
||||
@@ -254,7 +260,11 @@ export function SubPageShell({
|
||||
data-slot="metric-show-all-values"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<Link href={`/insights/values/${showAllValuesType}`}>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.showAllValues")}
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.7.1 — on-demand full assessment warm.
|
||||
* On-demand full assessment warm.
|
||||
*
|
||||
* POSTs `/api/insights/pregenerate`, which enqueues a forced warm of every
|
||||
* AI assessment (comprehensive insight + the seven specialised status cards
|
||||
@@ -16,18 +16,13 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* the worker and returns immediately. The cards then fill via their existing
|
||||
* stale-while-revalidate GETs — there is nothing to invalidate here.
|
||||
*
|
||||
* Two entry points share the one mutation:
|
||||
* - `warm()` — the manual "prepare assessments" button. Shows a toast.
|
||||
* - `autoWarmOnce(enabled)` — a fire-and-forget background warm fired ONCE
|
||||
* per browser session (sessionStorage-gated) when the Insights overview
|
||||
* mounts with data, so a returning user lands on warm caches without
|
||||
* tapping anything. The auto path is silent (no toast) and tolerates a
|
||||
* blocked/failed request — the server-side anti-spam bucket and the
|
||||
* nightly cron are the catch-net.
|
||||
* This is the explicit "prepare assessments" button only. There is no
|
||||
* warm-on-mount: the nightly cron (04:30 Europe/Berlin) keeps every user's
|
||||
* caches warm, and the per-metric status GETs revalidate gently on their
|
||||
* own — so a page visit reads cached text instead of fanning out a full
|
||||
* provider warm that would contend with foreground requests.
|
||||
*/
|
||||
|
||||
const AUTO_WARM_SESSION_KEY = "healthlog:insights:auto-warm";
|
||||
|
||||
async function postWarm(): Promise<void> {
|
||||
// Same-origin relative-path fetch — exempt from the safe-fetch rule by
|
||||
// construction. A 429 (warm already in progress) is an expected,
|
||||
@@ -47,12 +42,6 @@ export interface UseInsightsWarmResult {
|
||||
warm: () => void;
|
||||
/** True while the warm POST is in flight. */
|
||||
isWarming: boolean;
|
||||
/**
|
||||
* Fire the background warm once per session. Pass `enabled` (e.g.
|
||||
* authenticated AND the user has data) — the warm only fires on the
|
||||
* first call where `enabled` is true, and never again this session.
|
||||
*/
|
||||
autoWarmOnce: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function useInsightsWarm(): UseInsightsWarmResult {
|
||||
@@ -71,31 +60,8 @@ export function useInsightsWarm(): UseInsightsWarmResult {
|
||||
});
|
||||
}, [mutate, t]);
|
||||
|
||||
// The auto-trigger must fire at most once per session even across the
|
||||
// remounts a route navigation causes. A ref guards within the mount;
|
||||
// sessionStorage guards across mounts / navigations.
|
||||
const firedThisMount = useRef(false);
|
||||
const autoWarmOnce = useCallback(
|
||||
(enabled: boolean) => {
|
||||
if (!enabled || firedThisMount.current) return;
|
||||
firedThisMount.current = true;
|
||||
try {
|
||||
if (window.sessionStorage.getItem(AUTO_WARM_SESSION_KEY)) return;
|
||||
window.sessionStorage.setItem(AUTO_WARM_SESSION_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// sessionStorage can throw under strict-privacy modes — fall
|
||||
// through and fire the warm anyway; the server bucket de-dupes.
|
||||
}
|
||||
// Silent background warm — no toast, errors swallowed by the
|
||||
// mutation's default (no onError handler surfaces it).
|
||||
mutate();
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return {
|
||||
warm,
|
||||
isWarming: mutation.isPending,
|
||||
autoWarmOnce,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Volume2,
|
||||
Headphones,
|
||||
Sun,
|
||||
PersonStanding,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -81,6 +82,24 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength",
|
||||
WALKING_SPEED: "measurements.typeWalkingSpeed",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
CARDIO_RECOVERY: "measurements.typeCardioRecovery",
|
||||
WRIST_TEMPERATURE: "measurements.typeWristTemperature",
|
||||
FALL_COUNT: "measurements.typeFallCount",
|
||||
SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance",
|
||||
STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed",
|
||||
STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed",
|
||||
BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification",
|
||||
HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent",
|
||||
LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
|
||||
WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
|
||||
BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
RECOVERY_SCORE: "measurements.typeRecoveryScore",
|
||||
STRESS_SCORE: "measurements.typeStressScore",
|
||||
STRAIN_SCORE: "measurements.typeStrainScore",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -145,6 +164,33 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
// WALKING_STEADINESS).
|
||||
WALKING_STEP_LENGTH: Footprints,
|
||||
WALKING_SPEED: Gauge,
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// HeartPulse carries the post-exercise cardiac-recovery signal;
|
||||
// Thermometer the overnight wrist reading; PersonStanding the
|
||||
// fall-detection tally; Gauge the gait-speed family (stairs + 6MWT);
|
||||
// Wind rounds out the sleep-breathing signal (SpO2 + resp rate
|
||||
// already use it).
|
||||
CARDIO_RECOVERY: HeartPulse,
|
||||
WRIST_TEMPERATURE: Thermometer,
|
||||
FALL_COUNT: PersonStanding,
|
||||
SIX_MINUTE_WALK_DISTANCE: Footprints,
|
||||
STAIR_ASCENT_SPEED: Gauge,
|
||||
STAIR_DESCENT_SPEED: Gauge,
|
||||
BREATHING_DISTURBANCES: Wind,
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// Activity carries the irregular-rhythm trace shape; HeartPulse the
|
||||
// high/low-HR cardio pair; Footprints the steadiness/mobility family;
|
||||
// Wind the breathing family (SpO2 + respiratory rate already use it).
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: Activity,
|
||||
HIGH_HEART_RATE_EVENT: HeartPulse,
|
||||
LOW_HEART_RATE_EVENT: HeartPulse,
|
||||
WALKING_STEADINESS_EVENT: Footprints,
|
||||
BREATHING_DISTURBANCE_EVENT: Wind,
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// Gauge reads as a composite "index / score" dial for all three.
|
||||
RECOVERY_SCORE: Gauge,
|
||||
STRESS_SCORE: Gauge,
|
||||
STRAIN_SCORE: Gauge,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -203,4 +249,29 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
// visual group on Insights.
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family),
|
||||
// chart-5 (sleep-breathing, shares the SpO2 family).
|
||||
CARDIO_RECOVERY: "bg-chart-3/20 text-chart-3",
|
||||
WRIST_TEMPERATURE: "bg-chart-4/20 text-chart-4",
|
||||
FALL_COUNT: "bg-chart-2/20 text-chart-2",
|
||||
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/20 text-chart-2",
|
||||
STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// chart-3 (cardio family) carries the rhythm + heart-rate events;
|
||||
// chart-2 (activity/mobility) carries the steadiness event; chart-5
|
||||
// (respiratory/pulse family) carries the breathing event.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "bg-chart-3/20 text-chart-3",
|
||||
HIGH_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
|
||||
LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
|
||||
WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2",
|
||||
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// chart-1 (Dracula purple) marks the server-derived composites as their
|
||||
// own visual group, distinct from the raw-signal families above.
|
||||
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -163,6 +163,17 @@ function render(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Pin the clock so the schedule-driven window pill never displaces the
|
||||
// upcoming-injection line under a CI run at a live wall-clock. The card
|
||||
// suppresses the next/last line while `now` sits inside a schedule
|
||||
// window, so pin to 07:00 Berlin (before the 08:00–20:00 fixture window)
|
||||
// on a non-scheduled weekday — the upcoming line then always renders.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T05:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
@@ -359,11 +370,15 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
|
||||
it("renders the injection-site rotation marker (last + recommended)", () => {
|
||||
// The rotation hint surfaces only when `lastSite` and the
|
||||
// recommender disagree. We feed a left-cluster history → the
|
||||
// recommender lands on a right-side or thigh/arm site, so the
|
||||
// hint card renders.
|
||||
it("renders the injection-site rotation nudge folded into the upcoming line", () => {
|
||||
// The rotation nudge surfaces only when `lastSite` and the recommender
|
||||
// disagree. We feed a left-cluster history → the recommender lands on a
|
||||
// right-side or thigh/arm site. The nudge is no longer a separate
|
||||
// bordered block (which made the GLP-1 card taller than the generic card
|
||||
// in a grid row); it is folded into the upcoming-injection line as a
|
||||
// "· Recommended next: …" suffix, so the card keeps the same row
|
||||
// inventory as the generic card. The last site stays visible on the
|
||||
// last-injection line.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -386,9 +401,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Last site:");
|
||||
// The recommended-next nudge renders in the upcoming line.
|
||||
expect(html).toContain("Recommended next:");
|
||||
// Last site is the most-recent intake site.
|
||||
// The standalone bordered rotation block is gone (height-neutral fold).
|
||||
expect(html).not.toContain("bg-muted/40 rounded-md border");
|
||||
// Last site stays visible on the last-injection line.
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -52,6 +52,20 @@ const pastWindow = {
|
||||
dose: null,
|
||||
};
|
||||
|
||||
// Pin the clock to a fixed mid-day instant so window-status ("take now"
|
||||
// vs "next intake") and relative day labels are deterministic regardless
|
||||
// of the CI host clock + timezone. The 01:00–02:00 windows below read as
|
||||
// inactive at noon in every timezone; an unpinned run that happened to
|
||||
// fall inside that window (e.g. 01:01 CEST) flipped the card to the
|
||||
// take-now pill and broke these assertions.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T12:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
|
||||
it("renders the server nextDueAt date for a rolling medication, not a today/tomorrow walker value", () => {
|
||||
// Rolling cadence: server anchors the next dose 35 days out. The
|
||||
@@ -152,11 +166,13 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
|
||||
it("renders the day label for a single-time daysOfWeek medication due tomorrow", () => {
|
||||
// A daily / weekly medication whose server next-due is tomorrow
|
||||
// still renders the relative "tomorrow" label — proving the ordinary
|
||||
// single-time path keeps working.
|
||||
// single-time path keeps working. `due` is a fixed UTC instant ~20h
|
||||
// after the pinned clock, so it lands on the next calendar day in both
|
||||
// UTC and the Berlin default — the relative label is deterministic.
|
||||
const now = new Date();
|
||||
const due = new Date(now);
|
||||
due.setDate(due.getDate() + 1);
|
||||
due.setHours(8, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + 1);
|
||||
due.setUTCHours(8, 0, 0, 0);
|
||||
|
||||
const med = {
|
||||
id: "med-daily-1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -127,6 +127,20 @@ const mounjaro: Glp1Medication = {
|
||||
],
|
||||
};
|
||||
|
||||
// Pin the clock to a fixed mid-day instant so window-status is
|
||||
// deterministic: the all-day window (00:00–23:59) reads active (the
|
||||
// take-now pill) and the 01:00–02:00 window reads inactive (the upcoming
|
||||
// "next intake" line that carries the purple dose accent), in every
|
||||
// timezone. An unpinned run that fell inside 01:00–02:00 (e.g. 01:01 CEST)
|
||||
// flipped the accent test's card to the take-now pill.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T12:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
it("both cards collapse the header actions into a single kebab + navigable header link", () => {
|
||||
const client = makeClient();
|
||||
@@ -314,8 +328,12 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
};
|
||||
// A future next-due so the GLP-1 card's now-gated next-injection line
|
||||
// renders (it no longer prints a "—" placeholder when there is no next).
|
||||
const nextDueAt = new Date("2026-06-05T01:00:00Z").toISOString();
|
||||
const ramiprilFuture = {
|
||||
...ramipril,
|
||||
nextDueAt,
|
||||
schedules: [
|
||||
{
|
||||
id: "s-ramipril-future",
|
||||
@@ -326,6 +344,7 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
};
|
||||
const mounjaroFuture: Glp1Medication = {
|
||||
...mounjaro,
|
||||
nextDueAt,
|
||||
schedules: [
|
||||
{
|
||||
id: "s-mounjaro-future",
|
||||
@@ -359,8 +378,185 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// Both surfaces carry the purple token on the upcoming dose.
|
||||
expect(ramiprilHtml).toContain("text-purple-400");
|
||||
expect(mounjaroHtml).toContain("text-purple-400");
|
||||
// Both surfaces carry the dose-accent token on the upcoming dose. The
|
||||
// token is theme-aware (`--dose-accent`): pixel-identical to the former
|
||||
// Tailwind `text-purple-400` in dark mode, AA-safe on the white Alucard
|
||||
// card in light mode. The hard-coded `text-purple-400` is gone.
|
||||
expect(ramiprilHtml).toContain("text-dose-accent");
|
||||
expect(mounjaroHtml).toContain("text-dose-accent");
|
||||
expect(ramiprilHtml).not.toContain("text-purple-400");
|
||||
expect(mounjaroHtml).not.toContain("text-purple-400");
|
||||
});
|
||||
|
||||
it("the next/last slot is structurally identical (same wrapper, order, colour, spacing) on both cards", () => {
|
||||
// The two cards now route the middle slot through the shared
|
||||
// <MedicationNextLastSlot>. The wrapper, line order (next then last),
|
||||
// colour token and spacing must be byte-identical across types — the
|
||||
// historical divergence (reversed order, `text-foreground/85` vs
|
||||
// `text-muted-foreground`, `space-y-1` vs `space-y-3.5`) is gone.
|
||||
const pastWindow = {
|
||||
windowStart: "01:00",
|
||||
windowEnd: "02:00",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
};
|
||||
// Render with a last-intake set + an upcoming (non-active) window so
|
||||
// BOTH the next and last lines are present, exercising the full slot.
|
||||
const yesterday = new Date("2026-06-01T09:00:00Z").toISOString();
|
||||
const ramiprilBoth = {
|
||||
...ramipril,
|
||||
lastTakenAt: yesterday,
|
||||
nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(),
|
||||
schedules: [{ id: "s-r-both", ...pastWindow, dose: "5 mg" }],
|
||||
};
|
||||
const mounjaroBoth: Glp1Medication = {
|
||||
...mounjaro,
|
||||
lastTakenAt: yesterday,
|
||||
nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(),
|
||||
schedules: [{ id: "s-m-both", ...pastWindow, dose: "7.5 mg" }],
|
||||
};
|
||||
|
||||
const client = makeClient();
|
||||
seedCompliance(client, ramiprilBoth.id);
|
||||
seedCompliance(client, mounjaroBoth.id);
|
||||
seedGlp1Details(client, mounjaroBoth.id);
|
||||
|
||||
const ramiprilHtml = render(
|
||||
<MedicationCard
|
||||
medication={ramiprilBoth}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
const mounjaroHtml = render(
|
||||
<Glp1MedicationCard
|
||||
medication={mounjaroBoth}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
// Identical reserved-height wrapper with identical spacing.
|
||||
expect(html).toContain('class="min-h-[2.75rem] space-y-3.5 text-sm"');
|
||||
// The slot's lines paint with the muted token on both cards — the
|
||||
// brighter `text-foreground/85` GLP-1 next-line is gone.
|
||||
expect(html).not.toContain("text-foreground/85");
|
||||
// No literal em-dash placeholder for an absent next-injection.
|
||||
expect(html).not.toContain(">—<");
|
||||
// Both lines present (next + last), so two muted <p> in the slot.
|
||||
const slotStart = html.indexOf("min-h-[2.75rem]");
|
||||
const slot = html.slice(slotStart, slotStart + 800);
|
||||
const lines = slot.match(/<p class="text-muted-foreground">/g) ?? [];
|
||||
expect(lines.length).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("both cards keep a constant-height card body and bottom-pin the action row across types", () => {
|
||||
// The body is a flex column (`flex h-full flex-col`) and the action row
|
||||
// carries `mt-auto`, so unequal content can't misalign the take/skip
|
||||
// buttons across a grid row. A compliance skeleton reserves the bars'
|
||||
// footprint while the query is null. Exercise the generic card across
|
||||
// schedule-less types (PRN / one-shot) plus a scheduled oral and GLP-1.
|
||||
const base = {
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
};
|
||||
const oral = {
|
||||
...base,
|
||||
id: "m-oral",
|
||||
name: "Metformin",
|
||||
dose: "500 mg",
|
||||
category: "DIABETES",
|
||||
schedules: [{ id: "so", ...allDayWindow, dose: "500 mg" }],
|
||||
};
|
||||
const prn = {
|
||||
...base,
|
||||
id: "m-prn",
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "OTHER",
|
||||
// PRN: no schedule, no next-due → shortest possible body.
|
||||
schedules: [],
|
||||
};
|
||||
const oneShot = {
|
||||
...base,
|
||||
id: "m-oneshot",
|
||||
name: "Vaccine",
|
||||
dose: "1 dose",
|
||||
category: "OTHER",
|
||||
schedules: [],
|
||||
};
|
||||
const glp1: Glp1Medication = {
|
||||
...mounjaro,
|
||||
id: "m-glp1",
|
||||
};
|
||||
|
||||
const client = makeClient();
|
||||
// Seed compliance for some but NOT others, so the skeleton path
|
||||
// (compliance null) is exercised alongside the loaded path.
|
||||
seedCompliance(client, oral.id);
|
||||
seedGlp1Details(client, glp1.id);
|
||||
// prn + oneShot intentionally left without compliance → skeleton.
|
||||
|
||||
const htmls = [
|
||||
render(
|
||||
<MedicationCard
|
||||
medication={oral}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
),
|
||||
render(
|
||||
<MedicationCard
|
||||
medication={prn}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
),
|
||||
render(
|
||||
<MedicationCard
|
||||
medication={oneShot}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
),
|
||||
render(
|
||||
<Glp1MedicationCard
|
||||
medication={glp1}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
),
|
||||
];
|
||||
|
||||
for (const html of htmls) {
|
||||
// Card body is a bottom-pinning flex column on every type.
|
||||
expect(html).toContain("flex h-full flex-col space-y-3.5");
|
||||
// Action row carries the bottom-pin.
|
||||
expect(html).toContain("mt-auto");
|
||||
// Reserved next/last slot is present on every type (constant height).
|
||||
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
|
||||
}
|
||||
|
||||
// The two cards left without compliance render the skeleton so their
|
||||
// body keeps the bars' footprint rather than collapsing ~5rem shorter.
|
||||
expect(htmls[1]).toContain("aria-hidden"); // prn skeleton
|
||||
expect(htmls[2]).toContain("aria-hidden"); // one-shot skeleton
|
||||
});
|
||||
});
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user