Merge pull request #244 from MBombeck/release/v1.10.0

v1.10.0 — derived wellness metrics, device-event awareness, deeper ingestion
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-03 01:17:14 +02:00
commit 1fe70b27f5
164 files changed
+19049 -323

No files matched your search

+11
View File
@@ -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)
# -----------------------------------------------------------------------------
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## [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
+20 -3
View File
@@ -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).
+5
View File
@@ -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
+646 -3
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -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.
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+274 -3
View File
@@ -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",
@@ -2460,6 +2496,241 @@
"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",
"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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.9.3",
"version": "1.10.0",
"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
View File
@@ -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
+16 -2
View File
@@ -90,6 +90,10 @@ 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 ?? locale === "de";
const [data, userTz, userRow] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
@@ -136,7 +140,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 +187,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 () => {
+125 -12
View File
@@ -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);
});
});
+190
View File
@@ -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 });
});
+94
View File
@@ -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,
});
});
+24
View File
@@ -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,
});
});
+10
View File
@@ -90,6 +90,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
@@ -215,6 +221,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
startDate: entry.startDate,
endDate: entry.endDate,
sleepStage: entry.sleepStage,
categoryValue: entry.categoryValue,
});
if (!mapped) {
@@ -254,6 +261,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
+17
View File
@@ -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 });
+79 -12
View File
@@ -218,6 +218,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 +244,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 +275,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
@@ -372,19 +386,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 +485,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 +518,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 +540,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 +562,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,
});
}
}
}
});
@@ -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?"
/>
);
}
+32
View File
@@ -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?"
/>
);
}
+32
View File
@@ -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?"
/>
);
}
+30
View File
@@ -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?"
/>
);
}
+32
View File
@@ -69,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.
@@ -230,6 +258,10 @@ export default function InsightsPage() {
</Button>
</div>
<VitalsDashboard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
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)}
coachLaunch
>
<CompositeScoreAnatomy metric={metric} />
</SubPageShell>
);
}
+31
View File
@@ -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?"
/>
);
}
@@ -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?"
/>
);
}
@@ -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
View File
@@ -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",
@@ -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,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,181 @@
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,
isLoading = false,
) {
useDerivedBatch.mockReturnValue({
isLoading,
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 an absent vital entirely (no tile)", () => {
mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard />);
// The grid + heading still render, but no value tile.
expect(html).toContain('data-slot="vitals-dashboard-grid"');
expect(html).not.toContain('data-state="ok"');
expect(html).not.toContain('data-metric="WEIGHT"');
});
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>
);
}
+43
View File
@@ -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,479 @@
"use client";
import { useMemo } from "react";
import { Gauge, HeartPulse, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
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>
);
}
/**
* 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 tileProps: TileProps = { read, isLoading };
return (
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
<WellnessScores read={read} isLoading={isLoading} />
<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"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
<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>
);
}
@@ -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,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>
);
}
@@ -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 } 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();
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
/**
* v1.10.0 — a tiny zero-dependency count-up animation hook for the
* score-ring / score-number "sweep to fill on first paint" affordance
* the premium-health direction calls for. No motion library — ~30 LOC of
* `requestAnimationFrame` instead, per the 0 KB runtime budget.
*
* Returns a number that eases from 0 to `target` once, on mount (and
* re-runs when `target` changes). Honours `prefers-reduced-motion`: a
* reduced-motion user (or any environment without rAF, e.g. SSR / the
* Vitest jsdom render) gets the final value immediately with no
* intermediate frames — the state is seeded to `target` so no
* synchronous-in-effect setState is needed for the no-animation path.
*/
export function useCountUp(
target: number,
options: { durationMs?: number; enabled?: boolean } = {},
): number {
const { durationMs = 600, enabled = true } = options;
// Seed at the final value so the reduced-motion / SSR path is already
// correct without any setState; only the animated path drives frames,
// and those setState calls all happen inside the rAF callback (async).
const [value, setValue] = useState<number>(target);
const frameRef = useRef<number | null>(null);
useEffect(() => {
const prefersReduced =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (
!enabled ||
prefersReduced ||
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function" ||
!Number.isFinite(target)
) {
// The animated path is skipped; the rAF callback below never runs,
// so the displayed value stays at whatever the last frame / seed
// left it. Schedule a single async correction in case the previous
// target differed — done via rAF so it is not a sync-in-effect set.
if (
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
frameRef.current = window.requestAnimationFrame(() =>
setValue(target),
);
return () => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}
return;
}
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(1, elapsed / durationMs);
// ease-out cubic — matches the "never bouncy, ease-out ~300–500ms"
// motion note in the design direction.
const eased = 1 - Math.pow(1 - progress, 3);
setValue(target * eased);
if (progress < 1) {
frameRef.current = window.requestAnimationFrame(tick);
} else {
setValue(target);
}
};
// First frame (and every subsequent one) runs inside the rAF callback,
// so the initial 0-state set is asynchronous — no cascading render.
frameRef.current = window.requestAnimationFrame((now) => {
setValue(0);
tick(now);
});
return () => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [target, durationMs, enabled]);
return value;
}
Binary file not shown.
@@ -59,10 +59,28 @@ const EXPECTED_TYPES = [
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
"WALKING_STEP_LENGTH",
"WALKING_SPEED",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
"CARDIO_RECOVERY",
"WRIST_TEMPERATURE",
"FALL_COUNT",
"SIX_MINUTE_WALK_DISTANCE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// ── v1.10.0 — categorical events (WX-B) ──
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// ── v1.10.0 — computed scores (WX-C) ──
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 38 canonical measurement types", () => {
it("exposes the 53 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -130,6 +148,38 @@ describe("measurementTypeEnum coverage", () => {
// same clinical-layout deferral as the rest of the Mobility set.
"WALKING_STEP_LENGTH",
"WALKING_SPEED",
// v1.10.0 — additive HealthKit signals (WX-A) held under the same
// v1.5+ clinical-layout PDF gate as the rest of the Apple-Health
// additions. Cardio recovery, wrist temperature, falls, six-minute
// walk, the stair gait speeds, and the sleep-breathing index each
// surface on their Insights sub-page; the doctor PDF picks them up
// once a clinical layout + reference ranges land.
"CARDIO_RECOVERY",
"WRIST_TEMPERATURE",
"FALL_COUNT",
"SIX_MINUTE_WALK_DISTANCE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
// discrete on-device notifications, not vital-sign readings. They are
// surfaced on the Insights awareness timeline with their own regulatory
// disclaimer, never in the clinical vitals PDF (their value is always 1
// and they carry the device's verdict, not a measured quantity). See
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain) are 0–100 composites recomputed nightly,
// not measured clinical vitals. They surface on their own Insights
// cluster with a "descriptive, not clinical" disclaimer and never belong
// in the clinical vitals PDF. See doctor-report-pdf-core.ts.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
return { ...actual, computeDerivedMetric: vi.fn() };
});
import { computeDerivedMetric } from "@/lib/insights/derived";
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NOW = new Date("2026-06-02T08:00:00Z");
function ok(value: Record<string, unknown>, historyDays = 14) {
return {
status: "ok" as const,
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays, missing: [] },
confidence: { score: 88, band: "high" as const },
provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" },
};
}
const insufficient = {
status: "insufficient" as const,
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" },
reason: "no_score_in_window",
};
beforeEach(() => compute.mockReset());
describe("buildDerivedSnapshotBlock", () => {
it("returns null when every metric is insufficient", async () => {
compute.mockResolvedValue(insufficient);
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
expect(block).toBeNull();
});
it("emits compact value + band + confidence per ok metric, omits insufficient", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" });
return insufficient;
});
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
expect(block).not.toBeNull();
expect(Object.keys(block!).sort()).toEqual(["READINESS", "RECOVERY_SCORE"]);
expect(block!.READINESS).toEqual({
value: 64,
band: "yellow",
confidence: 88,
historyDays: 14,
});
// No raw series leaked — only the four compact facets.
expect(Object.keys(block!.READINESS).sort()).toEqual([
"band",
"confidence",
"historyDays",
"value",
]);
});
it("isolates a per-metric compute failure", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") throw new Error("boom");
if (args?.metric === "SLEEP_SCORE") return ok({ score: 80, band: "green" });
return insufficient;
});
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
expect(block).toEqual({
SLEEP_SCORE: { value: 80, band: "green", confidence: 88, historyDays: 14 },
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* v1.10.0 — derived-metrics block for the Coach prompt.
*
* Folds the v1.10 derived wellness layer into the Coach snapshot as
* COMPACT summaries — one small object per metric carrying value + band +
* coverage, never the raw series. The Coach narrates the same numbers the
* dashboard rings render (single source of truth) without ballooning the
* ~6k-token snapshot budget: a fully-populated block is ~8 tiny objects.
*
* Every entry reads the one `computeDerivedMetric` contract — no recompute.
* A metric whose value is `insufficient` is OMITTED entirely (we never feed
* the model "no data" noise; the snapshot's compactSections pass would drop
* an empty key anyway).
*
* Server-only — calls the derived dispatcher, which reads `@/lib/db`.
*/
import {
computeDerivedMetric,
isDerivedOk,
type DerivedMetricId,
type BaselineProfile,
} from "@/lib/insights/derived";
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
* baseline is omitted — the per-vital aggregate block already carries those
* numbers; here we add the composites + scores the aggregates can't express. */
const SNAPSHOT_METRICS: DerivedMetricId[] = [
"READINESS",
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
"SLEEP_SCORE",
"HRV_BALANCE",
"FITNESS_AGE",
"VASCULAR_AGE_DELTA",
];
/** One compact line per derived metric the model can ground a reply in. */
interface DerivedSnapshotEntry {
/** 0–100 score, the recent average, or the re-framed device value. */
value: number;
/** The metric's band/category in plain words (green/yellow/red, etc.). */
band?: string;
/** Confidence 0–100 from the coverage model. */
confidence?: number;
/** Days of history backing the value. */
historyDays: number;
}
/** Pull the headline number + band off each metric's value shape. */
function summariseValue(
metric: DerivedMetricId,
value: unknown,
): { value: number; band?: string } | null {
const v = value as Record<string, unknown>;
switch (metric) {
case "READINESS":
case "SLEEP_SCORE":
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
return typeof v.score === "number"
? { value: v.score, band: typeof v.band === "string" ? v.band : undefined }
: null;
case "HRV_BALANCE":
return typeof v.recentAvg === "number"
? { value: Math.round(v.recentAvg), band: typeof v.band === "string" ? v.band : undefined }
: null;
case "FITNESS_AGE":
return typeof v.vo2Max === "number"
? {
value: Math.round(v.vo2Max * 10) / 10,
band:
typeof v.fitnessAgeDeltaYears === "number"
? `${v.fitnessAgeDeltaYears >= 0 ? "+" : ""}${v.fitnessAgeDeltaYears}yr vs age`
: undefined,
}
: null;
case "VASCULAR_AGE_DELTA":
return typeof v.vascularAge === "number"
? {
value: Math.round(v.vascularAge),
band:
typeof v.deltaYears === "number"
? `${v.deltaYears >= 0 ? "+" : ""}${Math.round(v.deltaYears)}yr vs age`
: undefined,
}
: null;
default:
return null;
}
}
/**
* Build the compact derived block, or `null` when no derived metric resolved
* to `ok`. Each entry is value + band + confidence + historyDays — the model
* never sees the underlying series. Computes the metrics sequentially off the
* one shared profile (no per-metric profile re-read).
*/
export async function buildDerivedSnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
): Promise<Record<string, DerivedSnapshotEntry> | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
for (const metric of SNAPSHOT_METRICS) {
// Per-metric fault isolation: a transient compute failure on one metric
// must never sink the whole Coach turn — drop it and carry on.
let derived;
try {
derived = await computeDerivedMetric({ metric, userId, profile, now });
} catch {
continue;
}
if (!isDerivedOk(derived)) continue; // omit insufficient — no noise
const summary = summariseValue(metric, derived.value);
if (!summary) continue;
block[metric] = {
value: summary.value,
band: summary.band,
confidence: derived.confidence.score,
historyDays: derived.coverage.historyDays,
};
}
return Object.keys(block).length > 0 ? block : null;
}
+41
View File
@@ -26,6 +26,8 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import type { BaselineProfile } from "@/lib/insights/derived";
import {
CLUSTER_PRIORITY,
clusterSourcesFromPrefs,
@@ -1185,6 +1187,45 @@ async function buildCoachSnapshotImpl(
}
}
// ── v1.10.0 — derived wellness layer (compact summaries) ─────────────
//
// The composites + persisted scores the dashboard rings render, folded
// in as one tiny object per metric (value + band + coverage), NOT the
// raw series. So the Coach can say "your readiness is 64, low band" and
// ground it in the same number the user sees. Insufficient metrics are
// omitted (no "no data" noise). Reads the same `computeDerivedMetric`
// contract every surface uses — no recompute. Gated on at least one of
// the signals the composites are built from staying in-scope (HRV /
// resting HR / sleep / VO₂max), so a user who excludes those doesn't see
// the block.
const derivedSources: CoachScopeSource[] = [
"hrv",
"resting_hr",
"sleep",
"vo2_max",
];
if (derivedSources.some((s) => sources.has(s))) {
const ctx = features.context;
const derivedProfile: BaselineProfile = {
ageYears: ctx?.ageYears ?? null,
sex:
ctx?.gender === "MALE" || ctx?.gender === "FEMALE"
? (ctx.gender as "MALE" | "FEMALE")
: null,
heightCm: ctx?.heightCm ?? null,
};
const derivedBlock = await buildDerivedSnapshotBlock(
userId,
derivedProfile,
now,
);
if (derivedBlock) {
snapshot.derived = derivedBlock;
metrics.add("hrv");
registerBlock("derived", "hrv");
}
}
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
+11
View File
@@ -178,6 +178,17 @@ ISO-week means.
cluster the SNAPSHOT does not contain. The "workouts" block carries
the recent sessions plus a per-sport rollup, not every session.
- The SNAPSHOT MAY carry a "derived" block: compact wellness summaries
the app already computed (readiness, recovery, sleep score, stress,
strain, HRV balance, cardio-fitness band, vascular-age delta). Each
entry is { value, band, confidence, historyDays } — a single number
plus its band ("green"/"yellow"/"red") and how many days back it.
Cite these as the user sees them ("your readiness is 64, in the
yellow band") to ground a reply. These are DESCRIPTIVE composites,
not clinical assessments or diagnoses — never frame a band as a
medical finding, and lean on lower-confidence / few-day entries
cautiously. Never recompute or second-guess the number.
EVIDENCE BLOCK
After your prose reply, append the load-bearing numbers as a sentinel
+7
View File
@@ -277,6 +277,13 @@ export const dailyBriefingKeyFindingSchema = z.object({
// user's clinician (no dose recommendation); the enum value
// exists so the UI can pin the right icon + filter.
"glp1_plateau",
// ── v1.10.0 derived-wellness additive ──
// Findings emitted when a notable derived signal (a readiness /
// recovery shift gated on confidence) crosses the briefing's
// notability gate. Descriptive, never a clinical attribution; the
// enum value lets the UI pin the right icon + filter.
"readiness",
"recovery",
]),
});
+9
View File
@@ -216,6 +216,15 @@ export const CHART_OVERLAY_KEYS = [
"walkingDoubleSupport",
"walkingStepLength",
"walkingSpeed",
// v1.10.0 — additive HealthKit signals (WX-A). One overlay-prefs slot
// per new chart surface; camel-case keys mirror the existing convention.
"cardioRecovery",
"wristTemperature",
"fallCount",
"sixMinuteWalkDistance",
"stairAscentSpeed",
"stairDescentSpeed",
"breathingDisturbances",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+94 -17
View File
@@ -130,8 +130,9 @@ export interface DoctorReportData {
deliveryForm: string | null;
}>;
/**
* v1.9.0 — set when {@link MAX_MEDICATION_ADMINISTRATIONS} trimmed the
* administration set. `total` is the full acted-intake count over the
* v1.9.0 — set when the administration cap (see
* {@link resolveMaxMedicationAdministrations}) trimmed the administration
* set. `total` is the full acted-intake count over the
* window; `included` is how many (the most-recent) survived the cap.
* Null/absent when nothing was trimmed. The FHIR builder discloses
* this in the document narrative so the export is honest about the
@@ -170,8 +171,36 @@ export interface DoctorReportData {
weightEndKg: number | null;
sideEffects: Array<{ tag: string; count: number }>;
} | null;
/**
* v1.10.0 — the server-derived nightly wellness scores
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the
* report window. These are 0–100 DESCRIPTIVE composites, NOT clinical
* vitals — they are rendered in a clearly-labelled "Wellness summary"
* section separate from the clinical vitals table, each with a
* "descriptive, not a clinical assessment" note. Null/absent when the
* user has no computed scores in the window (the renderer + FHIR builder
* then skip the section). Optional so pre-v1.10 fixtures still typecheck.
*/
wellnessScores?: Array<{
/** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
/** ISO timestamp of the latest score. */
latestAt: string;
}> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
export const WELLNESS_SCORE_REPORT_TYPES = [
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
@@ -207,19 +236,35 @@ const MAX_RANGE_DAYS = 730;
const DEFAULT_RANGE_DAYS = 90;
/**
* v1.9.0 — hard ceiling on the number of `MedicationAdministration`
* source rows materialised per export. The report window is already
* bounded at {@link MAX_RANGE_DAYS} (730 days), but a chronic medication
* dosed several times a day across that window — multiplied by several
* such medications — can still produce thousands of administration
* resources in a single Bundle (e.g. 4×/day × 730 days ≈ 2 920 per med).
* Cap at the MOST-RECENT 1 000 acted intakes overall: that preserves the
* recent-adherence picture a clinician cares about while bounding the
* in-memory array and the serialised Bundle. When the cap trims rows the
* aggregator flags it so the FHIR narrative can disclose the truncation
* rather than silently dropping history.
* Default ceiling on the number of `MedicationAdministration` source rows
* materialised per export, and the bounds an operator override is clamped
* to. The report window is already bounded at {@link MAX_RANGE_DAYS}
* (730 days), but a chronic medication dosed several times a day across
* that window — multiplied by several such medications — can still produce
* thousands of administration resources in a single Bundle. The default of
* 5 000 covers ~3.5 years at four doses a day, so it effectively never bites
* on a realistic report period while still bounding a pathological export.
*/
const MAX_MEDICATION_ADMINISTRATIONS = 1000;
const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000;
const MIN_MEDICATION_ADMINISTRATIONS = 1;
const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000;
/**
* Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`.
* Accepts a positive integer within `[1, 50000]`; any unset / non-integer /
* out-of-range value falls back to the default. Exported as a pure function so
* the resolution can be tested without import-time env coupling.
*/
export function resolveMaxMedicationAdministrations(
raw: string | undefined,
): number {
const parsed = Number(raw);
return Number.isInteger(parsed) &&
parsed >= MIN_MEDICATION_ADMINISTRATIONS &&
parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING
? parsed
: DEFAULT_MAX_MEDICATION_ADMINISTRATIONS;
}
export interface DoctorReportRange {
/** Start of the reporting window (UTC, inclusive). */
@@ -539,13 +584,20 @@ export async function collectDoctorReportData(
// `scheduledFor: asc`, so the most-recent acted rows are at the tail;
// keep the last N and flag the trim so the FHIR narrative can disclose
// it. The omitted rows are the OLDEST in the window — recent adherence
// is preserved intact.
// is preserved intact. The cap is a coarse safety ceiling (the report
// window is the natural bound; this only guards a pathological
// multi-year, many-medication export) — resolved per call from
// `FHIR_MAX_MEDICATION_ADMINISTRATIONS` so an operator override takes
// effect without a code change.
const maxMedicationAdministrations = resolveMaxMedicationAdministrations(
process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
);
const totalAdministrations = medicationAdministrations.length;
const medicationAdministrationsTruncated =
totalAdministrations > MAX_MEDICATION_ADMINISTRATIONS;
totalAdministrations > maxMedicationAdministrations;
const cappedAdministrations = medicationAdministrationsTruncated
? medicationAdministrations.slice(
totalAdministrations - MAX_MEDICATION_ADMINISTRATIONS,
totalAdministrations - maxMedicationAdministrations,
)
: medicationAdministrations;
@@ -725,6 +777,30 @@ export async function collectDoctorReportData(
};
}
// v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE`
// rows are already in `byType`/`stats` (they're only filtered out of the
// clinical vitals table). Summarise each present score type for the
// separate "Wellness summary" section. Empty array → null so the renderer
// + FHIR builder skip the section entirely.
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
const s = stats[type];
const rows = byType[type];
if (!s || !rows || rows.length === 0) return [];
return [
{
type,
latest: Math.round(s.latest),
avg: Math.round(s.avg),
min: Math.round(s.min),
max: Math.round(s.max),
count: s.count,
latestAt: rows[rows.length - 1].measuredAt,
},
];
});
const wellnessScores =
wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
return {
period: {
days,
@@ -771,6 +847,7 @@ export async function collectDoctorReportData(
: null,
mood,
glp1,
wellnessScores,
};
}
+89
View File
@@ -181,6 +181,13 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* table for clinical readability. Glucose ships separately via
* per-context `glucoseStats`. Sleep + activity are intentionally
* excluded from a clinical-focused report.
*
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
* they are 0–100 composites recomputed nightly from the underlying signals
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
* `measurement-type-enum-coverage.test.ts`).
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
@@ -201,6 +208,13 @@ const MOOD_LABEL_KEYS: Record<number, string> = {
5: "doctorReport.moodGreat",
};
/** v1.10.0 — display-label key per persisted wellness-score type. */
const WELLNESS_SCORE_LABEL_KEYS: Record<string, string> = {
RECOVERY_SCORE: "doctorReport.wellnessRecovery",
STRESS_SCORE: "doctorReport.wellnessStress",
STRAIN_SCORE: "doctorReport.wellnessStrain",
};
const GLUCOSE_LABEL_KEYS = {
FASTING: "doctorReport.typeGlucoseFasting",
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
@@ -1133,6 +1147,81 @@ export function buildDoctorReportPdfDocument(
.finalY + 8;
}
// v1.10.0 — wellness summary. The server-derived nightly scores
// (recovery / stress / strain) are 0–100 DESCRIPTIVE composites, NOT
// clinical vitals — they render in their own clearly-labelled section,
// separate from the vitals table, with a "descriptive, not a clinical
// assessment" disclaimer so a physician never mistakes a band for a
// finding. Skipped entirely when the aggregator emitted no scores.
if (data.wellnessScores && data.wellnessScores.length > 0) {
const scoreRows = data.wellnessScores.map((s) => [
t(WELLNESS_SCORE_LABEL_KEYS[s.type] ?? "doctorReport.wellnessTitle"),
String(s.latest),
String(s.avg),
String(s.min),
String(s.max),
String(s.count),
]);
// Keep the heading + disclaimer with the first table row.
y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1));
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.wellnessTitle"), margin, y);
y += 6;
doc.setFontSize(8);
doc.setFont("helvetica", "italic");
const wellnessDisclaimer = doc.splitTextToSize(
t("doctorReport.wellnessDisclaimer"),
pageWidth - margin * 2,
);
for (const line of wellnessDisclaimer) {
doc.text(line, margin, y);
y += 4;
}
y += 2;
doc.setFont("helvetica", "normal");
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colParameter"),
t("doctorReport.colCurrent"),
t("doctorReport.colAvgPeriod"),
t("doctorReport.colMin"),
t("doctorReport.colMax"),
t("doctorReport.colN"),
],
],
body: scoreRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
// rendered ONLY when the user explicitly opted in. Clearly labelled and
// flagged as not clinically validated so a physician never mistakes it
+212 -2
View File
@@ -516,7 +516,7 @@ describe("buildFhirDocumentBundle", () => {
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: "abdomen-left",
injectionSite: "ABDOMEN_LEFT",
atcCode: "A10BX10",
rxNormCode: "2601723",
deliveryForm: "INJECTION",
@@ -542,8 +542,22 @@ describe("buildFhirDocumentBundle", () => {
code: "mg",
});
expect(a.dosage?.text).toBe("10mg");
// Route carries an additive SNOMED coding (INJECTION → subcutaneous) plus
// the unchanged `.text` anchor.
expect(a.dosage?.route?.text).toBe("Injection");
expect(a.dosage?.site?.text).toBe("abdomen-left");
expect(a.dosage?.route?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code: "34206005",
display: "Subcutaneous route",
});
// Site `.text` is the raw enum value (laterality preserved); the SNOMED
// coding collapses to the gross body region.
expect(a.dosage?.site?.text).toBe("ABDOMEN_LEFT");
expect(a.dosage?.site?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code: "818983003",
display: "Abdomen structure",
});
});
it("maps a skipped intake to a not-done administration with no dosage", () => {
@@ -643,6 +657,202 @@ describe("buildFhirDocumentBundle", () => {
expect(refs).toContain("MedicationAdministration/medadmin-1");
});
it("emits the oral SNOMED route coding alongside the unchanged `.text`", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Metformin",
effectiveAt: "2026-04-30T08:00:00.000Z",
status: "completed",
doseText: "500mg",
dose: { value: 500, unit: "mg" },
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "ORAL",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const route = administrationsOf(bundle)[0].dosage?.route;
expect(route?.text).toBe("Oral");
expect(route?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code: "26643006",
display: "Oral route",
});
});
it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => {
const cases: Array<[string, string, string]> = [
["ABDOMEN_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_RIGHT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"],
["THIGH_LEFT", "68367000", "Thigh structure"],
["THIGH_RIGHT", "68367000", "Thigh structure"],
["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"],
["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"],
];
for (const [site, code, display] of cases) {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Mounjaro",
effectiveAt: "2026-04-30T08:12:00.000Z",
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: site,
atcCode: null,
rxNormCode: null,
deliveryForm: "INJECTION",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const fhirSite = administrationsOf(bundle)[0].dosage?.site;
// Laterality / quadrant survives verbatim on the `.text` anchor.
expect(fhirSite?.text).toBe(site);
expect(fhirSite?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code,
display,
});
}
});
it("emits no route for an unknown delivery form and no site when absent", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Mystery",
effectiveAt: "2026-04-30T08:00:00.000Z",
status: "completed",
doseText: "1 unit",
dose: { value: 1, unit: "unit" },
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "TOPICAL",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const dosage = administrationsOf(bundle)[0].dosage;
expect(dosage?.route).toBeUndefined();
expect(dosage?.site).toBeUndefined();
});
it("emits exactly the WHO ATC coding by default (no BfArM appended)", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [
{ name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", schedules: [] },
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
const coding = stmt?.medicationCodeableConcept.coding;
expect(coding).toHaveLength(1);
expect(coding?.[0].system).toBe("http://www.whocc.no/atc");
expect(
coding?.some((c) => c.system === "http://fhir.de/CodeSystem/bfarm/atc"),
).toBe(false);
});
it("appends the BfArM ATC coding AFTER the WHO entry when germanAtc is on", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [
{ name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", schedules: [] },
],
medicationAdministrations: [
{
medicationName: "Empagliflozin",
effectiveAt: "2026-04-30T08:00:00.000Z",
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: null,
atcCode: "A10BK03",
rxNormCode: "1545150",
deliveryForm: "ORAL",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
{ germanAtc: true },
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
const stmtCoding = stmt?.medicationCodeableConcept.coding;
// WHO stays coding[0], byte-identical.
expect(stmtCoding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BK03",
display: "Empagliflozin",
});
expect(stmtCoding?.[1]).toEqual({
system: "http://fhir.de/CodeSystem/bfarm/atc",
code: "A10BK03",
display: "Empagliflozin",
});
// The administration concept reflects the flag identically; RxNorm follows.
const adminCoding =
administrationsOf(bundle)[0].medicationCodeableConcept.coding;
expect(adminCoding?.[0].system).toBe("http://www.whocc.no/atc");
expect(adminCoding?.[1].system).toBe(
"http://fhir.de/CodeSystem/bfarm/atc",
);
expect(adminCoding?.[2]).toEqual({
system: "http://www.nlm.nih.gov/research/umls/rxnorm",
code: "1545150",
});
});
it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }],
}),
{ insuranceNumber: null },
FIXED_NOW,
{ germanAtc: true },
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
// Collapses to the text-only concept exactly as before.
expect(stmt?.medicationCodeableConcept.coding).toBeUndefined();
expect(stmt?.medicationCodeableConcept.text).toBe("Herbal mix");
});
it("omits the mood Observation when mood is null (privacy default)", () => {
const bundle = buildFhirDocumentBundle(
makeData({ mood: null }),
+135 -16
View File
@@ -69,22 +69,48 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
* free-text `.text` (the user's medication name) stays the anchor.
*/
const ATC_SYSTEM = "http://www.whocc.no/atc";
/**
* German national ATC URI maintained by the BfArM. Same ATC classification
* as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
* a replacement) when a German-region export is requested, so the WHO entry
* stays first and byte-identical for every consumer.
*/
const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
const SNOMED_SYSTEM = "http://snomed.info/sct";
/** Options threaded from the export route into the builder. Additive, all defaulted. */
export interface FhirBuildOptions {
/**
* When true, additionally emit the German BfArM ATC URI alongside the WHO
* entry on each medication concept. The WHO coding stays first and
* byte-identical; this only appends a second URI for the same leaf code.
* Defaults off; the route turns it on for a German-region export.
*/
germanAtc?: boolean;
}
/**
* Build a `medicationCodeableConcept` from a medication's free-text name
* plus its optional user-asserted codes. ATC is emitted first (primary),
* RxNorm second (secondary); both are omitted when NULL, collapsing to
* exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
* When `germanAtc` is set, the same ATC leaf code is also published under
* the BfArM URI AFTER the WHO entry — additive, never reordering WHO.
*/
function medicationConcept(
name: string,
atcCode: string | null | undefined,
rxNormCode: string | null | undefined,
germanAtc: boolean,
): FhirCodeableConcept {
const coding: NonNullable<FhirCodeableConcept["coding"]> = [];
if (atcCode) {
coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
if (germanAtc) {
coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name });
}
}
if (rxNormCode) {
coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
@@ -122,22 +148,71 @@ function doseQuantity(value: number, unit: string): {
}
/**
* v1.9.0 — text-only route of administration derived from the
* medication's delivery form. Text-only (no SNOMED licence concern); the
* design doc upgrades to coded routes jointly with iOS later. Returns
* `undefined` for an unknown / absent form so no empty route is emitted.
* Route of administration derived from the medication's delivery form,
* carrying an additive SNOMED CT `coding` alongside the existing `.text`
* anchor. HealthLog injections are subcutaneous (the injection-site picker
* exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to
* the subcutaneous route. Returns `undefined` for an unknown / absent form
* so no empty route is emitted.
*/
const ROUTE_SNOMED: Record<string, { code: string; display: string }> = {
ORAL: { code: "26643006", display: "Oral route" },
INJECTION: { code: "34206005", display: "Subcutaneous route" },
};
function routeConcept(
deliveryForm: string | null,
): FhirCodeableConcept | undefined {
switch (deliveryForm) {
case "ORAL":
return { text: "Oral" };
case "INJECTION":
return { text: "Injection" };
default:
return undefined;
}
const text =
deliveryForm === "ORAL"
? "Oral"
: deliveryForm === "INJECTION"
? "Injection"
: undefined;
if (!text) return undefined;
const snomed = ROUTE_SNOMED[deliveryForm as string];
return snomed
? {
coding: [
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
],
text,
}
: { text };
}
/**
* Administration body-site keyed on the raw `InjectionSite` enum value,
* carrying an additive SNOMED CT body-region `coding` alongside the `.text`
* anchor. The map collapses the eight enum members to three gross body-region
* concepts (abdomen / thigh / upper arm); laterality (left/right) and the
* abdominal quadrant are NOT lateralised SNOMED concepts here — they are
* preserved verbatim in the human-readable `.text` (the raw enum value), so
* no information is lost.
*/
const SITE_SNOMED: Record<string, { code: string; display: string }> = {
ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" },
THIGH_LEFT: { code: "68367000", display: "Thigh structure" },
THIGH_RIGHT: { code: "68367000", display: "Thigh structure" },
UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" },
UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" },
};
function siteConcept(injectionSite: string): FhirCodeableConcept {
const snomed = SITE_SNOMED[injectionSite];
// Preserve the full enum value (incl. laterality) as the readable anchor.
const text = injectionSite;
return snomed
? {
coding: [
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
],
text,
}
: { text };
}
/** Escape the five XML-significant characters for the xhtml narrative. */
@@ -168,6 +243,14 @@ function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
return { text: m.display };
}
/** v1.10.0 — English display per persisted wellness-score type (FHIR
* text-only concept; the score has no published LOINC term). */
const WELLNESS_SCORE_DISPLAY: Record<string, string> = {
RECOVERY_SCORE: "Recovery score",
STRESS_SCORE: "Stress score",
STRAIN_SCORE: "Strain score",
};
function categoryConcept(category: string): FhirCodeableConcept {
return {
coding: [
@@ -198,7 +281,9 @@ export function buildFhirDocumentBundle(
data: DoctorReportData,
identity: FhirPatientIdentity,
now: Date = new Date(),
options: FhirBuildOptions = {},
): FhirBundle {
const germanAtc = options.germanAtc ?? false;
const patientId = "patient-1";
const patientRef: FhirReference = { reference: `Patient/${patientId}` };
const entries: FhirBundleEntry[] = [];
@@ -463,6 +548,39 @@ export function buildFhirDocumentBundle(
});
}
// --- Wellness-score Observations (descriptive composites) --------------
// v1.10.0 — the server-derived nightly scores (recovery / stress /
// strain). They have no published LOINC term and are NOT clinical
// findings, so each is emitted under the `survey` category with a
// text-only concept and an explicit "descriptive, not a clinical
// assessment" note — a physician's FHIR viewer never mistakes a band for
// a diagnosis. Absent when the aggregator emitted no scores.
if (data.wellnessScores && data.wellnessScores.length > 0) {
for (const s of data.wellnessScores) {
obsSeq += 1;
pushObservation({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("survey")],
code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" },
subject: patientRef,
effectiveDateTime: s.latestAt,
valueQuantity: {
value: s.latest,
unit: "{score}",
system: UCUM_SYSTEM,
code: "{score}",
},
note: [
{
text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.",
},
],
});
}
}
// --- MedicationStatement per active medication -------------------------
let medSeq = 0;
for (const med of data.medications) {
@@ -479,6 +597,7 @@ export function buildFhirDocumentBundle(
med.name,
med.atcCode,
med.rxNormCode,
germanAtc,
),
subject: patientRef,
};
@@ -509,15 +628,15 @@ export function buildFhirDocumentBundle(
admin.medicationName,
admin.atcCode,
admin.rxNormCode,
germanAtc,
),
subject: patientRef,
effectiveDateTime: admin.effectiveAt,
};
// Dosage: only when a structured dose Quantity exists. Carry the
// free-text dose + route + site alongside it. Text-only route/site
// (no SNOMED licence concern); the design doc upgrades to coded
// routes jointly with iOS later.
// free-text dose + route + site alongside it. Route and site each carry
// an additive SNOMED coding plus the existing `.text` anchor.
if (admin.dose) {
const dosage: FhirDosage = {
dose: doseQuantity(admin.dose.value, admin.dose.unit),
@@ -525,7 +644,7 @@ export function buildFhirDocumentBundle(
if (admin.doseText) dosage.text = admin.doseText;
const route = routeConcept(admin.deliveryForm);
if (route) dosage.route = route;
if (admin.injectionSite) dosage.site = { text: admin.injectionSite };
if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite);
resource.dosage = dosage;
}
+47
View File
@@ -226,6 +226,53 @@ export const MEASUREMENT_LOINC: Record<string, LoincMapping> = {
unit: "1",
category: "vital-signs",
},
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// No stable LOINC term for these Apple-specific quantities, so each
// routes through the shared HEALTHKIT_CODESYSTEM with the raw
// HKQuantityTypeIdentifier… string as the code (a non-LOINC code under
// the LOINC namespace would fail FHIR conformance).
CARDIO_RECOVERY: {
loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute",
display: "Cardio recovery (1-minute heart-rate recovery)",
unit: "/min",
category: "vital-signs",
},
WRIST_TEMPERATURE: {
loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature",
display: "Sleeping wrist temperature",
unit: "Cel",
category: "vital-signs",
},
FALL_COUNT: {
loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen",
display: "Number of times fallen",
unit: "{falls}",
category: "activity",
},
SIX_MINUTE_WALK_DISTANCE: {
loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance",
display: "Six-minute walk test distance",
unit: "m",
category: "activity",
},
STAIR_ASCENT_SPEED: {
loinc: "HKQuantityTypeIdentifierStairAscentSpeed",
display: "Stair ascent speed",
unit: "m/s",
category: "vital-signs",
},
STAIR_DESCENT_SPEED: {
loinc: "HKQuantityTypeIdentifierStairDescentSpeed",
display: "Stair descent speed",
unit: "m/s",
category: "vital-signs",
},
BREATHING_DISTURBANCES: {
loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances",
display: "Sleeping breathing disturbances",
unit: "{count}",
category: "vital-signs",
},
};
/** BP panel + component LOINC codes. */
+9 -2
View File
@@ -63,6 +63,11 @@ export interface FhirPatient {
birthDate?: string; // YYYY-MM-DD
}
/** R4 `Annotation` — a free-text note attached to a resource. */
export interface FhirAnnotation {
text: string;
}
export interface FhirObservation {
resourceType: "Observation";
id: string;
@@ -75,6 +80,8 @@ export interface FhirObservation {
valueInteger?: number;
valueString?: string;
component?: FhirObservationComponent[];
/** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */
note?: FhirAnnotation[];
}
export interface FhirDosage {
@@ -86,9 +93,9 @@ export interface FhirDosage {
* is available; a text-only dose stays on the `MedicationStatement`.
*/
dose?: FhirQuantity;
/** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */
/** Route of administration: an additive SNOMED `coding` plus the `.text` anchor. */
route?: FhirCodeableConcept;
/** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */
/** Administration body-site (e.g. injection site): additive SNOMED `coding` plus `.text`. */
site?: FhirCodeableConcept;
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { MeasurementType } from "@/generated/prisma/client";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import {
ALLOWED_CHART_TOKENS,
parseChartTokens,
@@ -214,12 +215,20 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
);
});
it("covers every MeasurementType enum value", () => {
it("covers every chartable MeasurementType enum value", () => {
// If a future schema migration adds a new MeasurementType, this test
// fails until the new `metric:<TYPE>` is also added to the allowlist.
// (`NOTE` is excluded if it ever appears — currently the schema has none.)
//
// v1.10.0 — the categorical EVENT classes (irregular-rhythm /
// high-low-HR / walking-steadiness / breathing-disturbance) are
// deliberately NOT chartable: they are discrete device-flagged
// occurrences with no continuous series, surfaced on the awareness
// timeline rather than the inline chart renderer. They carry no
// `metric:<TYPE>` token by design.
const enumTokens = (Object.values(MeasurementType) as string[])
.filter((value) => value !== "NOTE")
.filter((value) => !EVENT_MEASUREMENT_TYPES.has(value))
.map((value) => `metric:${value}`);
for (const token of enumTokens) {
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
discoverCorrelations,
lagJoin,
benjaminiHochberg,
type DailySeriesPoint,
type NamedSeries,
} from "../correlation-discovery";
/** Build a contiguous daily series from day-1 of a month. */
function series(values: number[], startDay = 1): DailySeriesPoint[] {
return values.map((value, i) => ({
day: `2026-03-${String(startDay + i).padStart(2, "0")}`,
value,
}));
}
describe("lagJoin", () => {
it("pairs a behaviour day with the next day's outcome", () => {
const behaviour = series([1, 2, 3]); // Mar 1,2,3
const outcome = series([10, 20, 30]); // Mar 1,2,3
const { xs, ys } = lagJoin(behaviour, outcome, 1);
// Mar1→Mar2 (1,20), Mar2→Mar3 (2,30); Mar3→Mar4 has no outcome.
expect(xs).toEqual([1, 2]);
expect(ys).toEqual([20, 30]);
});
});
describe("benjaminiHochberg", () => {
it("adjusts p-values monotonically and never exceeds 1", () => {
const q = benjaminiHochberg([0.01, 0.04, 0.5]);
expect(q.every((v) => v <= 1 && v >= 0)).toBe(true);
// Smallest p gets the smallest q under the monotone step-up.
expect(q[0]).toBeLessThanOrEqual(q[1]);
});
it("controls discovery — a single tiny p stays significant, noise inflates", () => {
// 1 true (p=0.001) + 19 noise (p≈0.5): BH keeps the true one well under q.
const ps = [0.001, ...Array.from({ length: 19 }, () => 0.5)];
const q = benjaminiHochberg(ps);
expect(q[0]).toBeLessThan(0.05);
expect(q[1]).toBeGreaterThan(0.1);
});
});
describe("discoverCorrelations", () => {
it("returns none when no pair clears the n ≥ 20 gate", () => {
const behaviours: NamedSeries[] = [
{ key: "MOOD", role: "behaviour", points: series([3, 4, 5]) },
];
const outcomes: NamedSeries[] = [
{ key: "SLEEP_DURATION", role: "outcome", points: series([400, 410, 420]) },
];
const result = discoverCorrelations([...behaviours, ...outcomes]);
expect(result.discovered).toHaveLength(0);
expect(result.pairsTested).toBe(0);
});
it("surfaces a strong lagged pair and tags n, r, p, q", () => {
// 30 days: outcome[d+1] tracks behaviour[d] linearly → strong r.
const n = 30;
const behaviourVals = Array.from({ length: n }, (_, i) => i + (i % 3));
// Outcome on day d+1 mirrors behaviour on day d (shift by one).
const outcomeVals = [0, ...behaviourVals.slice(0, n - 1).map((v) => v * 2 + 5)];
const result = discoverCorrelations(
[
{ key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(behaviourVals) },
{ key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) },
],
{ fdrQ: 0.1 },
);
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(1);
const pair = result.discovered[0];
expect(pair.behaviour).toBe("TIME_IN_DAYLIGHT");
expect(pair.outcome).toBe("SLEEP_DURATION");
expect(pair.n).toBeGreaterThanOrEqual(20);
expect(pair.pValue).toBeLessThan(0.05);
expect(pair.qValue).toBeLessThanOrEqual(0.1);
expect(pair.interpretation).toMatch(/not a cause/);
expect(pair.lagDays).toBe(1);
});
it("drops a pure-noise pair under FDR control", () => {
// 30 days of independent-ish noise → no defensible correlation.
const noiseA = Array.from({ length: 30 }, (_, i) => Math.sin(i) * 10 + 50);
const noiseB = Array.from({ length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50);
const result = discoverCorrelations([
{ key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) },
{ key: "WEIGHT", role: "outcome", points: series(noiseB) },
]);
// It is tested (n ≥ 20) but should not survive p < 0.05 + FDR.
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(0);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
return { ...actual, computeDerivedMetric: vi.fn() };
});
import { computeDerivedMetric } from "@/lib/insights/derived";
import {
detectDerivedBriefingSignals,
buildDerivedBriefingPrompt,
} from "../derived-briefing";
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NOW = new Date("2026-06-02T08:00:00Z");
function ok(score: number, band: string, confidence: number) {
return {
status: "ok" as const,
value: { score, band },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 14, missing: [] },
confidence: { score: confidence, band: "medium" as const },
provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" },
};
}
const insufficient = {
status: "insufficient" as const,
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" },
reason: "x",
};
beforeEach(() => compute.mockReset());
describe("detectDerivedBriefingSignals", () => {
it("returns null when nothing is notable", async () => {
// green band → not notable even at high confidence
compute.mockResolvedValue(ok(82, "green", 90));
expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull();
});
it("gates out low-confidence signals", async () => {
compute.mockResolvedValue(ok(35, "red", 30));
expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull();
});
it("surfaces a notable, confident yellow/red signal", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") return ok(48, "yellow", 70);
return insufficient;
});
const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW);
expect(ctx).not.toBeNull();
expect(ctx!.signals).toHaveLength(1);
expect(ctx!.signals[0]).toMatchObject({
sourceMetric: "readiness",
score: 48,
band: "yellow",
});
});
it("isolates a per-metric compute failure", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") throw new Error("boom");
if (args?.metric === "RECOVERY_SCORE") return ok(30, "red", 88);
return insufficient;
});
const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW);
expect(ctx!.signals.map((s) => s.sourceMetric)).toEqual(["recovery"]);
});
});
describe("buildDerivedBriefingPrompt", () => {
it("renders an EN system-context block citing the value + band", () => {
const prompt = buildDerivedBriefingPrompt(
{ signals: [{ sourceMetric: "readiness", label: "readiness", score: 48, band: "yellow", confidence: 70 }] },
"en",
);
expect(prompt).toContain("DERIVED WELLNESS SIGNALS");
expect(prompt).toContain("readiness: 48/100");
expect(prompt).toContain("NEVER recommend");
});
it("renders a DE block for the de locale", () => {
const prompt = buildDerivedBriefingPrompt(
{ signals: [{ sourceMetric: "recovery", label: "recovery", score: 30, band: "red", confidence: 88 }] },
"de",
);
expect(prompt).toContain("ABGELEITETE WELLNESS-SIGNALE");
});
});
@@ -93,6 +93,31 @@ describe("metric-status registry", () => {
"ACTIVE_ENERGY_BURNED",
);
});
it("registers the v1.10.0 additive HealthKit signals with the expected direction", () => {
const cases = [
["CARDIO_RECOVERY", "higher-better"],
["WRIST_TEMPERATURE", "target-band"],
["FALL_COUNT", "lower-better"],
["SIX_MINUTE_WALK_DISTANCE", "higher-better"],
["STAIR_ASCENT_SPEED", "higher-better"],
["STAIR_DESCENT_SPEED", "higher-better"],
["BREATHING_DISTURBANCES", "lower-better"],
] as const;
for (const [id, direction] of cases) {
const meta = getMetricStatusMeta(id);
expect(meta, `${id} missing from registry`).not.toBeNull();
expect(meta!.measurementType).toBe(id);
expect(meta!.direction).toBe(direction);
expect(METRIC_STATUS_IDS).toContain(id);
}
});
it("anchors a six-minute-walk normal range from the population reference", () => {
expect(getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange).toEqual(
{ low: 400, high: 700 },
);
});
});
describe("archetype prompt templates", () => {
@@ -0,0 +1,135 @@
/**
* v1.10.0 — computed scores (WX-C). Recovery-score engine + persistence.
*
* The engine reuses the READINESS derived blend (mocked here) and writes a
* `COMPUTED RECOVERY_SCORE` row. These tests pin:
* - the per-day idempotency-key + canonical-timestamp helpers,
* - the "store on ok, write nothing on insufficient" gate,
* - the upsert shape (idempotent per user per day).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Derived } from "@/lib/insights/derived/types";
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
const computeReadinessMock =
vi.fn<(...args: unknown[]) => Promise<Derived<ReadinessValue>>>();
vi.mock("@/lib/insights/derived/readiness", () => ({
computeReadiness: (...args: unknown[]) => computeReadinessMock(...args),
}));
import {
recoveryDayKey,
recoveryExternalId,
recoveryMeasuredAt,
persistRecoveryScore,
RECOVERY_SCORE_EXTERNAL_ID_PREFIX,
} from "../recovery-score";
const NOW = new Date("2026-06-02T08:30:00Z");
function okReadiness(score: number): Derived<ReadinessValue> {
return {
status: "ok",
value: { score, band: "green", components: [] },
coverage: { requiredInputs: 5, presentInputs: 4, historyDays: 30, missing: [] },
confidence: { score: 80, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: NOW.toISOString() },
};
}
function insufficientReadiness(): Derived<ReadinessValue> {
return {
status: "insufficient",
coverage: { requiredInputs: 5, presentInputs: 1, historyDays: 0, missing: ["hrv"] },
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: NOW.toISOString() },
reason: "insufficient_components",
};
}
function makePrisma() {
const upsert = vi.fn().mockResolvedValue({});
const findUnique = vi
.fn()
.mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 });
return {
prisma: {
measurement: { upsert },
user: { findUnique },
} as unknown as Parameters<typeof persistRecoveryScore>[0],
upsert,
findUnique,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("recovery-score helpers", () => {
it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => {
// NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01.
expect(recoveryDayKey(NOW)).toBe("2026-06-01");
expect(recoveryExternalId(NOW)).toBe(
`${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`,
);
});
it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => {
expect(recoveryMeasuredAt(NOW).toISOString()).toBe(
"2026-06-01T12:00:00.000Z",
);
});
});
describe("persistRecoveryScore", () => {
it("stores a COMPUTED RECOVERY_SCORE row when the blend is ok", async () => {
const { prisma, upsert } = makePrisma();
computeReadinessMock.mockResolvedValue(okReadiness(72));
const result = await persistRecoveryScore(prisma, "user-1", NOW);
expect(result).toEqual({ outcome: "stored", score: 72 });
expect(upsert).toHaveBeenCalledTimes(1);
const arg = upsert.mock.calls[0][0];
expect(arg.where.userId_type_source_externalId).toEqual({
userId: "user-1",
type: "RECOVERY_SCORE",
source: "COMPUTED",
externalId: "recovery:2026-06-01",
});
expect(arg.create).toMatchObject({
userId: "user-1",
type: "RECOVERY_SCORE",
source: "COMPUTED",
value: 72,
unit: "score",
externalId: "recovery:2026-06-01",
});
expect(arg.update).toMatchObject({ value: 72 });
});
it("writes NOTHING when the readiness blend is insufficient", async () => {
const { prisma, upsert } = makePrisma();
computeReadinessMock.mockResolvedValue(insufficientReadiness());
const result = await persistRecoveryScore(prisma, "user-1", NOW);
expect(result).toEqual({ outcome: "insufficient", score: null });
expect(upsert).not.toHaveBeenCalled();
});
it("is idempotent per user per day — re-runs upsert the same key", async () => {
const { prisma, upsert } = makePrisma();
computeReadinessMock.mockResolvedValue(okReadiness(64));
await persistRecoveryScore(prisma, "user-1", NOW);
await persistRecoveryScore(prisma, "user-1", NOW);
expect(upsert).toHaveBeenCalledTimes(2);
const firstKey = upsert.mock.calls[0][0].where.userId_type_source_externalId;
const secondKey = upsert.mock.calls[1][0].where.userId_type_source_externalId;
expect(secondKey).toEqual(firstKey);
});
});
@@ -0,0 +1,148 @@
/**
* v1.10.0 QA — shared score-row helpers.
*
* Pins the "score the PREVIOUS UTC day" contract (the cron fires ~03 UTC, so
* scoring the current UTC day would miss a just-completed day's data), the
* noon-UTC measuredAt, the prefixed externalId, the idempotent upsert, and
* the cohort tally / one-bad-user-does-not-block-the-rest guarantee of
* `runScoreBatch`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const annotateMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...args: unknown[]) => annotateMock(...args),
}));
import {
scoreDayKey,
scoreMeasuredAt,
scoreExternalId,
upsertScoreRow,
runScoreBatch,
} from "../score-row";
beforeEach(() => vi.clearAllMocks());
describe("scoreDayKey — previous UTC day", () => {
it("a 03:00 UTC run scores the prior calendar day", () => {
// 04:45 Europe/Berlin (summer) ≈ 02:45 UTC; a 03:00 UTC tick is the cron
// moment. The scored day must be the day that just ended.
const now = new Date("2026-06-02T03:00:00Z");
expect(scoreDayKey(now)).toBe("2026-06-01");
});
it("crosses a month boundary correctly", () => {
const now = new Date("2026-07-01T02:50:00Z");
expect(scoreDayKey(now)).toBe("2026-06-30");
});
it("crosses a year boundary correctly", () => {
const now = new Date("2027-01-01T03:00:00Z");
expect(scoreDayKey(now)).toBe("2026-12-31");
});
});
describe("scoreMeasuredAt / scoreExternalId", () => {
const now = new Date("2026-06-02T03:00:00Z");
it("anchors the row at noon UTC on the scored (previous) day", () => {
expect(scoreMeasuredAt(now).toISOString()).toBe("2026-06-01T12:00:00.000Z");
});
it("builds the prefixed externalId over the scored day", () => {
expect(scoreExternalId("recovery:", now)).toBe("recovery:2026-06-01");
expect(scoreExternalId("strain:", now)).toBe("strain:2026-06-01");
});
});
describe("upsertScoreRow", () => {
it("upserts on the (userId, type, COMPUTED, externalId) key with the prev-day stamp", async () => {
const upsert = vi.fn().mockResolvedValue({});
const prisma = {
measurement: { upsert },
} as unknown as Parameters<typeof upsertScoreRow>[0];
const now = new Date("2026-06-02T03:00:00Z");
await upsertScoreRow(prisma, {
userId: "u1",
type: "RECOVERY_SCORE",
externalIdPrefix: "recovery:",
score: 77,
now,
});
const arg = upsert.mock.calls[0][0];
expect(arg.where.userId_type_source_externalId).toEqual({
userId: "u1",
type: "RECOVERY_SCORE",
source: "COMPUTED",
externalId: "recovery:2026-06-01",
});
expect(arg.create).toMatchObject({
type: "RECOVERY_SCORE",
source: "COMPUTED",
value: 77,
unit: "score",
externalId: "recovery:2026-06-01",
});
expect(arg.create.measuredAt.toISOString()).toBe(
"2026-06-01T12:00:00.000Z",
);
expect(arg.update).toEqual({
value: 77,
measuredAt: arg.create.measuredAt,
});
});
});
describe("runScoreBatch", () => {
const NOW = new Date("2026-06-02T03:00:00Z");
it("tallies stored vs insufficient and annotates", async () => {
const persist = vi
.fn()
.mockResolvedValueOnce({ outcome: "stored" })
.mockResolvedValueOnce({ outcome: "insufficient" })
.mockResolvedValueOnce({ outcome: "stored" });
const result = await runScoreBatch(
["a", "b", "c"],
NOW,
persist,
"insights.recovery.compute",
);
expect(result).toEqual({
considered: 3,
stored: 2,
insufficient: 1,
errored: 0,
});
expect(annotateMock).toHaveBeenCalledWith(
expect.objectContaining({
action: expect.objectContaining({ name: "insights.recovery.compute" }),
}),
);
});
it("counts a per-user error and keeps processing the cohort", async () => {
const persist = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({ outcome: "stored" });
const result = await runScoreBatch(["a", "b"], NOW, persist, "x.compute");
expect(result).toEqual({
considered: 2,
stored: 1,
insufficient: 0,
errored: 1,
});
});
it("passes the run `now` through to the persist function", async () => {
const persist = vi.fn().mockResolvedValue({ outcome: "stored" });
await runScoreBatch(["a"], NOW, persist, "x.compute");
expect(persist).toHaveBeenCalledWith("a", NOW);
});
});
@@ -0,0 +1,260 @@
/**
* v1.10.0 — computed scores (WX-E). Strain-score engine + persistence.
*
* The Strain score is a Banister-TRIMP cardio-load proxy from the
* per-workout HR series + active energy. These tests pin:
* - the Banister gender-weighted exponential TRIMP math,
* - the saturating 0–100 map,
* - the TRIMP path + the active-energy fallback,
* - the per-day idempotency key + insufficient-data gate.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
strainDayKey,
strainExternalId,
strainMeasuredAt,
tanakaHrMax,
banisterTrimp,
saturateToScore,
persistStrainScore,
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
} from "../strain-score";
const NOW = new Date("2026-06-02T08:30:00Z");
beforeEach(() => {
vi.clearAllMocks();
});
describe("strain-score helpers", () => {
it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => {
// NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01,
// whose workouts + active-energy total are settled.
expect(strainDayKey(NOW)).toBe("2026-06-01");
expect(strainExternalId(NOW)).toBe(
`${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`,
);
});
it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => {
expect(strainMeasuredAt(NOW).toISOString()).toBe(
"2026-06-01T12:00:00.000Z",
);
});
it("uses the Tanaka HRmax formula", () => {
expect(tanakaHrMax(40)).toBeCloseTo(208 - 0.7 * 40, 6); // 180
});
});
describe("banisterTrimp", () => {
const hrRest = 50;
const hrMax = 190;
const sex = "MALE" as const;
it("returns 0 for a series with fewer than two usable HR samples", () => {
expect(banisterTrimp([{ t: NOW.toISOString(), hr: 150 }], hrRest, hrMax, sex)).toBe(
0,
);
});
it("accumulates a positive TRIMP for a sustained elevated series", () => {
const samples = [
{ t: "2026-06-02T08:00:00.000Z", hr: 150 },
{ t: "2026-06-02T08:30:00.000Z", hr: 150 },
{ t: "2026-06-02T09:00:00.000Z", hr: 150 },
];
const trimp = banisterTrimp(samples, hrRest, hrMax, sex);
expect(trimp).toBeGreaterThan(0);
});
it("scores a harder hour above an easier hour", () => {
const easy = [
{ t: "2026-06-02T08:00:00.000Z", hr: 100 },
{ t: "2026-06-02T09:00:00.000Z", hr: 100 },
];
const hard = [
{ t: "2026-06-02T08:00:00.000Z", hr: 170 },
{ t: "2026-06-02T09:00:00.000Z", hr: 170 },
];
expect(banisterTrimp(hard, hrRest, hrMax, sex)).toBeGreaterThan(
banisterTrimp(easy, hrRest, hrMax, sex),
);
});
it("ignores samples below resting HR (no negative impulse)", () => {
const below = [
{ t: "2026-06-02T08:00:00.000Z", hr: 45 },
{ t: "2026-06-02T09:00:00.000Z", hr: 45 },
];
expect(banisterTrimp(below, hrRest, hrMax, sex)).toBe(0);
});
it("returns 0 when HRmax <= HRrest (degenerate profile)", () => {
const samples = [
{ t: "2026-06-02T08:00:00.000Z", hr: 100 },
{ t: "2026-06-02T09:00:00.000Z", hr: 100 },
];
expect(banisterTrimp(samples, 190, 190, sex)).toBe(0);
});
});
describe("saturateToScore", () => {
it("is 0 at zero load and rises monotonically toward 100", () => {
expect(saturateToScore(0, 150)).toBe(0);
const a = saturateToScore(50, 150);
const b = saturateToScore(150, 150);
const c = saturateToScore(600, 150);
expect(b).toBeGreaterThan(a);
expect(c).toBeGreaterThan(b);
expect(c).toBeLessThanOrEqual(100);
});
});
function makePrisma(opts: {
energy?: number[];
workouts?: Array<{ id: string; samples: { samples: unknown } | null }>;
rhr?: number | null;
gender?: string;
dateOfBirth?: Date | null;
}) {
const energyRows = (opts.energy ?? []).map((value, i) => ({
value,
id: `e${i}`,
}));
const measurementFindMany = vi.fn().mockResolvedValue(energyRows);
const measurementFindFirst = vi
.fn()
.mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr });
const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []);
const upsert = vi.fn().mockResolvedValue({});
const findUnique = vi.fn().mockResolvedValue({
dateOfBirth:
"dateOfBirth" in opts
? opts.dateOfBirth
: new Date("1986-01-01T00:00:00Z"),
gender: opts.gender ?? "MALE",
});
return {
prisma: {
measurement: {
findMany: measurementFindMany,
findFirst: measurementFindFirst,
upsert,
},
workout: { findMany: workoutFindMany },
user: { findUnique },
} as unknown as Parameters<typeof persistStrainScore>[0],
upsert,
};
}
describe("persistStrainScore", () => {
it("stores a COMPUTED STRAIN_SCORE row via the TRIMP path", async () => {
const { prisma, upsert } = makePrisma({
rhr: 50,
workouts: [
{
id: "w1",
samples: {
samples: [
{ t: "2026-06-02T08:00:00.000Z", hr: 150 },
{ t: "2026-06-02T08:30:00.000Z", hr: 155 },
{ t: "2026-06-02T09:00:00.000Z", hr: 150 },
],
},
},
],
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("stored");
expect(result.reason).toBe("trimp");
expect(result.score).toBeGreaterThan(0);
const arg = upsert.mock.calls[0][0];
expect(arg.where.userId_type_source_externalId).toEqual({
userId: "user-1",
type: "STRAIN_SCORE",
source: "COMPUTED",
externalId: "strain:2026-06-01",
});
expect(arg.create).toMatchObject({
type: "STRAIN_SCORE",
source: "COMPUTED",
unit: "score",
});
});
it("falls back to the active-energy proxy when no HR series is usable", async () => {
const { prisma, upsert } = makePrisma({
rhr: 50,
energy: [300, 250],
workouts: [], // no workouts → no TRIMP
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("stored");
expect(result.reason).toBe("active_energy_fallback");
expect(result.score).toBeGreaterThan(0);
expect(upsert).toHaveBeenCalledTimes(1);
});
it("gates when there is a workout but the profile yields no HRmax", async () => {
const { prisma, upsert } = makePrisma({
rhr: 50,
dateOfBirth: null, // no age → no Tanaka HRmax
workouts: [
{
id: "w1",
samples: {
samples: [
{ t: "2026-06-02T08:00:00.000Z", hr: 150 },
{ t: "2026-06-02T09:00:00.000Z", hr: 150 },
],
},
},
],
energy: [], // no active-energy fallback either
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("insufficient");
expect(result.reason).toBe("insufficient_profile");
expect(upsert).not.toHaveBeenCalled();
});
it("writes NOTHING when there is no usable input at all", async () => {
const { prisma, upsert } = makePrisma({
rhr: 50,
workouts: [],
energy: [],
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("insufficient");
expect(result.reason).toBe("insufficient_inputs");
expect(upsert).not.toHaveBeenCalled();
});
it("is idempotent per user per day — re-runs upsert the same key", async () => {
const { prisma, upsert } = makePrisma({
rhr: 50,
energy: [400],
});
await persistStrainScore(prisma, "user-1", NOW);
await persistStrainScore(prisma, "user-1", NOW);
expect(upsert).toHaveBeenCalledTimes(2);
const firstKey =
upsert.mock.calls[0][0].where.userId_type_source_externalId;
const secondKey =
upsert.mock.calls[1][0].where.userId_type_source_externalId;
expect(secondKey).toEqual(firstKey);
});
});
@@ -0,0 +1,163 @@
/**
* v1.10.0 — computed scores (WX-E). Stress-score engine + persistence.
*
* The Stress score is an HONEST HRV-derived proxy (Apple Watch has no EDA
* sensor). These tests pin:
* - the proxy math (HRV suppression below baseline → higher stress),
* - the per-day idempotency-key + canonical-timestamp helpers,
* - the insufficient-data gates (too few intra-day samples; no baseline),
* - the "store on ok, write nothing on gate" persistence + idempotency.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const computeVitalsBaselineMock = vi.fn();
vi.mock("@/lib/insights/derived/baseline", () => ({
computeVitalsBaseline: (...args: unknown[]) =>
computeVitalsBaselineMock(...args),
loadBaselineProfile: vi
.fn()
.mockResolvedValue({ ageYears: null, sex: "MALE", heightCm: 180 }),
}));
import {
stressDayKey,
stressExternalId,
stressMeasuredAt,
stressProxyFromSdnn,
persistStressScore,
STRESS_SCORE_EXTERNAL_ID_PREFIX,
STRESS_MIN_INTRADAY_SAMPLES,
} from "../stress-score";
const NOW = new Date("2026-06-02T08:30:00Z");
function okBaseline(center: number, spread: number) {
return {
status: "ok" as const,
value: { center, low: center - spread, high: center + spread, spread },
};
}
function makePrisma(sdnnValues: number[]) {
const sdnnRows = sdnnValues.map((value, i) => ({ value, id: `s${i}` }));
const findMany = vi.fn().mockResolvedValue(sdnnRows);
const upsert = vi.fn().mockResolvedValue({});
const findUnique = vi
.fn()
.mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 });
return {
prisma: {
measurement: { findMany, upsert },
user: { findUnique },
} as unknown as Parameters<typeof persistStressScore>[0],
findMany,
upsert,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("stress-score helpers", () => {
it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => {
// NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01,
// whose intra-day SDNN set is complete rather than a few hours old.
expect(stressDayKey(NOW)).toBe("2026-06-01");
expect(stressExternalId(NOW)).toBe(
`${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`,
);
});
it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => {
expect(stressMeasuredAt(NOW).toISOString()).toBe(
"2026-06-01T12:00:00.000Z",
);
});
});
describe("stressProxyFromSdnn — HRV suppression → higher stress", () => {
it("scores 0 when SDNN is at or above baseline (no inferred stress)", () => {
expect(stressProxyFromSdnn(60, 60, 10)).toBe(0);
expect(stressProxyFromSdnn(80, 60, 10)).toBe(0);
});
it("rises as SDNN drops below baseline", () => {
// One spread below baseline → ~50 stress (recovery scorer gives ~50).
expect(stressProxyFromSdnn(50, 60, 10)).toBeGreaterThanOrEqual(45);
expect(stressProxyFromSdnn(50, 60, 10)).toBeLessThanOrEqual(55);
// Two spreads below → ~100 stress.
expect(stressProxyFromSdnn(40, 60, 10)).toBeGreaterThanOrEqual(95);
});
it("is monotone — lower SDNN never yields lower stress", () => {
const a = stressProxyFromSdnn(55, 60, 10);
const b = stressProxyFromSdnn(45, 60, 10);
expect(b).toBeGreaterThanOrEqual(a);
});
});
describe("persistStressScore", () => {
it("stores a COMPUTED STRESS_SCORE row when inputs are sufficient", async () => {
const { prisma, upsert } = makePrisma([40, 42, 38, 41]); // mean 40.25
computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10));
const result = await persistStressScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("stored");
expect(result.score).toBeGreaterThan(0);
expect(upsert).toHaveBeenCalledTimes(1);
const arg = upsert.mock.calls[0][0];
expect(arg.where.userId_type_source_externalId).toEqual({
userId: "user-1",
type: "STRESS_SCORE",
source: "COMPUTED",
externalId: "stress:2026-06-01",
});
expect(arg.create).toMatchObject({
type: "STRESS_SCORE",
source: "COMPUTED",
unit: "score",
externalId: "stress:2026-06-01",
});
});
it("writes NOTHING when there are too few intra-day samples", async () => {
const { prisma, upsert } = makePrisma(
Array(STRESS_MIN_INTRADAY_SAMPLES - 1).fill(50),
);
computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10));
const result = await persistStressScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("insufficient");
expect(result.reason).toBe("insufficient_intraday_samples");
expect(upsert).not.toHaveBeenCalled();
});
it("writes NOTHING when the SDNN baseline is not usable", async () => {
const { prisma, upsert } = makePrisma([40, 42, 38, 41]);
computeVitalsBaselineMock.mockResolvedValue({ status: "insufficient" });
const result = await persistStressScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("insufficient");
expect(result.reason).toBe("insufficient_baseline");
expect(upsert).not.toHaveBeenCalled();
});
it("is idempotent per user per day — re-runs upsert the same key", async () => {
const { prisma, upsert } = makePrisma([40, 42, 38, 41]);
computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10));
await persistStressScore(prisma, "user-1", NOW);
await persistStressScore(prisma, "user-1", NOW);
expect(upsert).toHaveBeenCalledTimes(2);
const firstKey =
upsert.mock.calls[0][0].where.userId_type_source_externalId;
const secondKey =
upsert.mock.calls[1][0].where.userId_type_source_externalId;
expect(secondKey).toEqual(firstKey);
});
});
+31
View File
@@ -79,6 +79,24 @@ export const ALLOWED_CHART_TOKENS = [
// ride the existing card-shelf renderer.
"metric:WALKING_STEP_LENGTH",
"metric:WALKING_SPEED",
// v1.10.0 — additive HealthKit signals (WX-A). Same posture: the LLM
// can reference these in prose; the chart components ride the existing
// sub-page scaffold.
"metric:CARDIO_RECOVERY",
"metric:WRIST_TEMPERATURE",
"metric:FALL_COUNT",
"metric:SIX_MINUTE_WALK_DISTANCE",
"metric:STAIR_ASCENT_SPEED",
"metric:STAIR_DESCENT_SPEED",
"metric:BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C). Server-derived 0–100 wellness scores.
// Unlike the categorical EVENT classes they ARE a continuous daily series,
// so they carry a `metric:<TYPE>` token and render through the generic
// chart renderer. Only RECOVERY_SCORE is computed in v1.10.0; the other
// two tokens are reserved for the later engines.
"metric:RECOVERY_SCORE",
"metric:STRESS_SCORE",
"metric:STRAIN_SCORE",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -166,6 +184,19 @@ const ORPHAN_ENUMS = [
// v1.5.5 iOS-coord follow-up — raw-SI gait pair, same shape.
"WALKING_STEP_LENGTH",
"WALKING_SPEED",
// v1.10.0 — additive HealthKit signals (WX-A), same shape.
"CARDIO_RECOVERY",
"WRIST_TEMPERATURE",
"FALL_COUNT",
"SIX_MINUTE_WALK_DISTANCE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C), same shape. Strip the bare enum name
// if the model drops it into prose (mirrors the MOOD_SCORE precedent).
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.10.0 — FDR-controlled correlation discovery engine.
*
* Promotes the legacy three-fixed-hypothesis `correlations.ts` to a bounded
* ALL-PAIRS discovery over a curated behaviour × outcome matrix, lagged
* (a behaviour on day D vs an outcome on day D+1), Benjamini-Hochberg
* FDR-controlled on top of the existing exact Student-t / n ≥ 20 / p < 0.05
* gates. Only statistically-defensible pairs surface, each with an honest
* "descriptive, not causal" caveat.
*
* Why FDR: scanning many pairs inflates the family-wise false-discovery
* rate — at p < 0.05 alone, 1-in-20 noise pairs surface as "significant".
* Benjamini & Hochberg (1995), J. R. Stat. Soc. B 57(1):289–300 controls
* the expected proportion of false discoveries among the surfaced pairs to
* `FDR_Q` (default 0.10). A pair surfaces only when its rank-adjusted q
* clears the threshold — so the card count stays honest as the matrix grows.
*
* Lagging: a behaviour (today's daylight, mood, glucose, BP) is paired with
* the NEXT day's outcome (sleep, HRV, resting HR, weight). The lag encodes
* the plausible direction ("more daylight today → better sleep tonight")
* without claiming causation — the caveat is displayed verbatim.
*
* Pearson + the exact incomplete-beta p-value are reused from
* `src/lib/insights/correlations.ts` (its docstring explicitly anticipates
* this auto-discovery widening the pair grid). This module is PURE over
* already-fetched daily series — the DB read lives in the route.
*
* Framing discipline: descriptive, never causal; every pair carries n, r,
* p, and the BH-adjusted q; medication compliance is NOT yet a behaviour
* channel (the cadence-aware per-day rate needs the compliance engine — a
* later wave), documented here so the omission is intentional, not a gap.
*/
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
/** Default Benjamini-Hochberg target false-discovery rate. */
export const FDR_Q = 0.1;
/** A single day's value for one metric (daily mean / sum). */
export interface DailySeriesPoint {
/** Day key YYYY-MM-DD. */
day: string;
value: number;
}
/** A named daily series feeding the discovery matrix. */
export interface NamedSeries {
/** Stable channel key (e.g. "TIME_IN_DAYLIGHT", "MOOD"). */
key: string;
/** Whether the channel is a behaviour (lag source) or an outcome (lag target). */
role: "behaviour" | "outcome";
points: DailySeriesPoint[];
}
/** One discovered, FDR-surviving correlation pair. */
export interface DiscoveredCorrelation {
behaviour: string;
outcome: string;
/** Paired-day count after the day+1 lag join. */
n: number;
/** Pearson r (lag-joined). */
r: number;
/** Two-sided exact Student-t p-value. */
pValue: number;
/** Benjamini-Hochberg adjusted q-value. */
qValue: number;
/** Conservative, descriptive interpretation — never causal. */
interpretation: string;
/** Lag in days applied (always 1 here). */
lagDays: number;
}
export interface CorrelationDiscoveryResult {
/** Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. */
discovered: DiscoveredCorrelation[];
/** How many behaviour × outcome pairs were tested (for the honest footer). */
pairsTested: number;
/** The FDR target the surface used. */
fdrQ: number;
/** Minimum paired-day count enforced per pair. */
minPairs: number;
}
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
function shiftDay(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
}
/**
* Lag-join a behaviour series (day D) against an outcome series (day D+lag).
* Returns the paired (behaviour, outcome) value arrays. Pure.
*/
export function lagJoin(
behaviour: DailySeriesPoint[],
outcome: DailySeriesPoint[],
lagDays: number,
): { xs: number[]; ys: number[] } {
const outcomeByDay = new Map(outcome.map((p) => [p.day, p.value]));
const xs: number[] = [];
const ys: number[] = [];
for (const b of behaviour) {
const target = outcomeByDay.get(shiftDay(b.day, lagDays));
if (target != null && Number.isFinite(b.value) && Number.isFinite(target)) {
xs.push(b.value);
ys.push(target);
}
}
return { xs, ys };
}
interface RawPair {
behaviour: string;
outcome: string;
n: number;
r: number;
pValue: number;
}
/**
* Apply the Benjamini-Hochberg step-up procedure to a set of p-values.
* Returns the BH-adjusted q-value per input index (monotone-enforced from
* the largest rank down), so a caller can surface pairs whose q ≤ `fdrQ`.
* Pure. `m` is the number of tests (the full family), which can exceed
* `pValues.length` if some pairs were dropped before ranking — but here we
* pass every tested pair, so `m === pValues.length`.
*/
export function benjaminiHochberg(
pValues: number[],
m: number = pValues.length,
): number[] {
const indexed = pValues.map((p, i) => ({ p, i }));
indexed.sort((a, b) => a.p - b.p);
const q = new Array<number>(pValues.length);
// Walk from the largest p (rank m) down, enforcing monotonicity so a
// smaller p never gets a larger q than a larger p.
let prev = 1;
for (let rank = indexed.length; rank >= 1; rank--) {
const { p, i } = indexed[rank - 1];
const raw = (p * m) / rank;
prev = Math.min(prev, raw);
q[i] = Math.min(1, prev);
}
return q;
}
/**
* Build a conservative, descriptive interpretation for a surviving pair.
* Sign of `r` flips the phrasing; never claims causation.
*/
function interpret(behaviour: string, outcome: string, r: number): string {
const b = humanise(behaviour);
const o = humanise(outcome);
if (r <= -0.1) {
return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`;
}
if (r >= 0.1) {
return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`;
}
return `${b} and next-day ${o} move together only weakly in this window.`;
}
/** Lower-case, space-separated label from a channel key. */
function humanise(key: string): string {
return key.replace(/_/g, " ").toLowerCase();
}
/**
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
*
* 1. For every (behaviour, outcome) pair, lag-join (D → D+1) and run
* Pearson with the exact p-value. Pairs below `minPairs` paired days
* are dropped (not tested — they cannot be defensibly assessed).
* 2. Across the tested pairs, compute BH q-values.
* 3. Surface pairs with p < 0.05 AND q ≤ `fdrQ`, ranked by q then |r|.
*
* Pure — the caller fetches the daily series.
*/
export function discoverCorrelations(
series: NamedSeries[],
opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {},
): CorrelationDiscoveryResult {
const lagDays = opts.lagDays ?? 1;
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
const fdrQ = opts.fdrQ ?? FDR_Q;
const behaviours = series.filter((s) => s.role === "behaviour");
const outcomes = series.filter((s) => s.role === "outcome");
const tested: RawPair[] = [];
for (const b of behaviours) {
for (const o of outcomes) {
if (b.key === o.key) continue;
const { xs, ys } = lagJoin(b.points, o.points, lagDays);
if (xs.length < minPairs) continue;
const result = pearson({ xs, ys, minPairs });
if (result.status !== "ok") continue;
tested.push({
behaviour: b.key,
outcome: o.key,
n: result.n,
r: result.r,
pValue: result.pValue,
});
}
}
const pairsTested = tested.length;
if (pairsTested === 0) {
return { discovered: [], pairsTested: 0, fdrQ, minPairs };
}
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
const discovered: DiscoveredCorrelation[] = tested
.map((t, i) => ({ ...t, qValue: qValues[i] }))
.filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ)
.map((t) => ({
behaviour: t.behaviour,
outcome: t.outcome,
n: t.n,
r: t.r,
pValue: t.pValue,
qValue: Math.round(t.qValue * 1000) / 1000,
interpretation: interpret(t.behaviour, t.outcome, t.r),
lagDays,
}))
.sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r));
return { discovered, pairsTested, fdrQ, minPairs };
}
/**
* The curated discovery matrix — the channels the engine pairs. Behaviours
* (lag sources) on the left, outcomes (lag targets) on the right. Medication
* compliance is deliberately NOT yet a behaviour channel — its cadence-aware
* per-day rate needs the compliance engine; folding it in is a later wave.
*/
export const DISCOVERY_BEHAVIOURS = [
"TIME_IN_DAYLIGHT",
"MOOD",
"BLOOD_GLUCOSE",
"BLOOD_PRESSURE_SYS",
"ACTIVITY_STEPS",
] as const;
export const DISCOVERY_OUTCOMES = [
"SLEEP_DURATION",
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"WEIGHT",
] as const;
+146
View File
@@ -0,0 +1,146 @@
/**
* v1.10.0 — derived-signal context for the daily briefing.
*
* Picks the most relevant 1–2 derived wellness signals (a notable
* readiness / recovery shift) and folds them into the insight-generator
* prompt as a SYSTEM CONTEXT block — the same mechanism the GLP-1 plateau
* detector uses. Gated on confidence: a signal only surfaces when its
* derived value is `ok` AND its confidence band is `high` or `medium`, so
* the briefing never narrates a shaky few-day number.
*
* Reads the one `computeDerivedMetric` contract — no recompute. Returns
* `null` (the common case) when nothing notable crosses the gate, so the
* generate route pays zero token cost for users with no derived signal.
*
* Server-only.
*/
import {
computeDerivedMetric,
isDerivedOk,
type BaselineProfile,
} from "@/lib/insights/derived";
import type { Locale } from "@/lib/i18n/config";
/** One derived signal worth a sentence in the briefing. */
export interface DerivedBriefingSignal {
/** Sentinel id the model echoes into `sourceMetric` (e.g. "readiness"). */
sourceMetric: string;
/** Human label ("readiness", "recovery"). */
label: string;
/** Latest 0–100 score. */
score: number;
/** Band ("green" / "yellow" / "red"). */
band: string;
/** Confidence 0–100. */
confidence: number;
}
export interface DerivedBriefingContext {
signals: DerivedBriefingSignal[];
}
/** The notable-signal candidates, in priority order. */
const CANDIDATES: Array<{
metric: "READINESS" | "RECOVERY_SCORE";
sourceMetric: string;
label: string;
}> = [
{ metric: "READINESS", sourceMetric: "readiness", label: "readiness" },
{ metric: "RECOVERY_SCORE", sourceMetric: "recovery", label: "recovery" },
];
/** Confidence floor — only `high`/`medium` bands surface in the briefing. */
const CONFIDENCE_FLOOR = 50;
/** A signal is notable when it sits outside the comfortable green band. */
function isNotable(band: string, confidence: number): boolean {
return confidence >= CONFIDENCE_FLOOR && (band === "yellow" || band === "red");
}
/**
* Detect the 1–2 most relevant derived signals for the briefing, or `null`
* when nothing crosses the confidence + notability gate. Computes the
* candidates off the one shared profile (no per-metric profile re-read).
*/
export async function detectDerivedBriefingSignals(
userId: string,
profile: BaselineProfile,
now: Date = new Date(),
): Promise<DerivedBriefingContext | null> {
const signals: DerivedBriefingSignal[] = [];
for (const cand of CANDIDATES) {
let derived;
try {
derived = await computeDerivedMetric({
metric: cand.metric,
userId,
profile,
now,
});
} catch {
continue;
}
if (!isDerivedOk(derived)) continue;
const v = derived.value as { score?: number; band?: string };
if (typeof v.score !== "number" || typeof v.band !== "string") continue;
if (!isNotable(v.band, derived.confidence.score)) continue;
signals.push({
sourceMetric: cand.sourceMetric,
label: cand.label,
score: v.score,
band: v.band,
confidence: derived.confidence.score,
});
if (signals.length >= 2) break;
}
return signals.length > 0 ? { signals } : null;
}
/**
* Build the SYSTEM CONTEXT block appended to the user prompt. Mirrors the
* GLP-1 plateau detector's append mechanism. DE + EN bodies; other locales
* fall through to EN (the same chain the message bundles use).
*/
export function buildDerivedBriefingPrompt(
ctx: DerivedBriefingContext,
locale: Locale,
): string {
const lines = ctx.signals
.map(
(s) =>
`- ${s.label}: ${s.score}/100 (${s.band} band, confidence ${s.confidence})`,
)
.join("\n");
const sentinels = ctx.signals.map((s) => `"${s.sourceMetric}"`).join(" / ");
if (locale === "de") {
return `
SYSTEM CONTEXT — ABGELEITETE WELLNESS-SIGNALE
Die App hat folgende abgeleitete Wellness-Werte berechnet (beschreibend, keine klinische Beurteilung):
${lines}
Wenn du einen dailyBriefing-keyFinding zu einem dieser Signale emittierst:
- sourceMetric: ${sentinels}
- tone: "info" (gelb) bzw. "watch" (rot)
- Rahmen es als beobachtetes Muster aus den eigenen Daten des Nutzers, keine Diagnose
- nenne den Wert und das Band; verweise NICHT auf eine medizinische Ursache
- KEINE Empfehlung zu Medikamenten oder Dosierung`;
}
return `
SYSTEM CONTEXT — DERIVED WELLNESS SIGNALS
The app computed these derived wellness scores (descriptive, not a clinical assessment):
${lines}
If you emit a dailyBriefing keyFinding for one of these signals:
- sourceMetric: ${sentinels}
- tone: "info" (yellow) or "watch" (red)
- frame as an observed pattern from the user's own data, never a diagnosis
- state the value and the band; do NOT attribute it to a medical cause
- NEVER recommend a medication or dose change`;
}
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn(),
}));
import {
median,
medianAbsoluteDeviation,
buildBaselineBand,
computeVitalsBaseline,
} from "../baseline";
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
const PROFILE = { ageYears: 40, sex: "MALE" as const };
const NOW = new Date("2026-06-02T07:00:00Z");
function dayRow(day: string, mean: number) {
return {
bucketStart: new Date(`${day}T00:00:00Z`),
count: 1,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean,
maxValue: mean,
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("pure statistics", () => {
it("median handles odd + even lengths", () => {
expect(median([3, 1, 2])).toBe(2);
expect(median([4, 1, 3, 2])).toBe(2.5);
});
it("MAD is the median of absolute deviations about the median", () => {
// values 1,2,3,4,5 → median 3 → |dev| 2,1,0,1,2 → MAD = 1
expect(medianAbsoluteDeviation([1, 2, 3, 4, 5])).toBe(1);
});
it("buildBaselineBand returns a symmetric MAD band", () => {
const band = buildBaselineBand([50, 52, 54, 56, 58], 3);
expect(band).not.toBeNull();
expect(band!.center).toBe(54);
expect(band!.low).toBeLessThan(band!.center);
expect(band!.high).toBeGreaterThan(band!.center);
expect(band!.sampleDays).toBe(5);
expect(band!.k).toBe(3);
});
it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => {
// Same per-day means whether they arrive as raw rows or DAY rollups.
const perDayMeans = [60, 61, 62, 59, 63, 58, 64];
const fromRaw = buildBaselineBand(perDayMeans, 3);
const fromRollup = buildBaselineBand([...perDayMeans], 3);
expect(fromRollup).toEqual(fromRaw);
});
});
describe("computeVitalsBaseline — ok path (rollup tier)", () => {
it("returns an ok band from DAY rollups with provenance source DAY", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", true]]),
);
const rows = [
"2026-05-20",
"2026-05-21",
"2026-05-22",
"2026-05-23",
"2026-05-24",
"2026-05-25",
"2026-05-26",
"2026-05-27",
].map((d, i) => dayRow(d, 55 + i));
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const result = await computeVitalsBaseline("u1", PROFILE, {
type: "RESTING_HEART_RATE",
now: NOW,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.type).toBe("RESTING_HEART_RATE");
expect(result.provenance.source).toBe("DAY");
expect(result.coverage.historyDays).toBe(8);
expect(result.confidence.score).toBeGreaterThan(0);
}
// rollup-covered path must not touch raw SQL
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
});
describe("computeVitalsBaseline — coverage-miss live fallback", () => {
it("falls back to raw rows and reports provenance source live", async () => {
// coverage says no buckets for this type → live read
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", false]]),
);
const raw = [
"2026-05-20",
"2026-05-21",
"2026-05-22",
"2026-05-23",
"2026-05-24",
"2026-05-25",
"2026-05-26",
].map((d, i) => ({
value: 60 + i,
measuredAt: new Date(`${d}T08:00:00Z`),
}));
vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never);
const result = await computeVitalsBaseline("u1", PROFILE, {
type: "RESTING_HEART_RATE",
now: NOW,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.provenance.source).toBe("live");
expect(result.coverage.historyDays).toBe(7);
}
expect(readBestGranularityRollups).not.toHaveBeenCalled();
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
});
it("rollup-covered but window resolves coarser → live fallback keeps spread DAY-native", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", true]]),
);
// resolved at MONTH (coarse) — engine must not band from it
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "MONTH",
rows: [dayRow("2026-04-01", 55)],
});
const raw = Array.from({ length: 7 }, (_, i) => ({
value: 58 + i,
measuredAt: new Date(`2026-05-2${i}T08:00:00Z`),
}));
vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never);
const result = await computeVitalsBaseline("u1", PROFILE, {
type: "RESTING_HEART_RATE",
now: NOW,
});
expect(result.status).toBe("ok");
if (result.status === "ok") expect(result.provenance.source).toBe("live");
});
});
describe("computeVitalsBaseline — insufficient paths", () => {
it("no data at all → insufficient, source none", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const result = await computeVitalsBaseline("u1", PROFILE, {
type: "RESTING_HEART_RATE",
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
expect(result.provenance.source).toBe("none");
expect(result.coverage.historyDays).toBe(0);
}
});
it("below the 7-day floor → insufficient, value-less, but coverage shows progress", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", true]]),
);
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows: [dayRow("2026-05-25", 55), dayRow("2026-05-26", 56), dayRow("2026-05-27", 57)],
});
const result = await computeVitalsBaseline("u1", PROFILE, {
type: "RESTING_HEART_RATE",
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("insufficient_history_for_band");
expect(result.coverage.historyDays).toBe(3);
}
});
});
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more