mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #265 from MBombeck/release/v1.12.8
release/v1.12.8 — fuller WHOOP data, charts that follow your selection
This commit is contained in:
38 files changed
+537
-315
No files matched your search
+16
-1
@@ -2,7 +2,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.12.7] — 2026-06-05 — Insights that react, read clean, and stay legible
|
||||
## [1.12.8] — 2026-06-05 — fuller WHOOP data, charts that follow your selection
|
||||
|
||||
### Added
|
||||
|
||||
- **WHOOP now records more of what it measures.** Daily average heart rate, daily max heart rate, and per-night sleep disturbances are imported alongside the recovery, sleep, cycle and workout data already synced.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The stats above a chart follow the range you pick.** Choosing 7 / 30 / 90 days or a year now recomputes the min / max / median / average for exactly that span — the numbers always match what the chart shows. The separate draggable range slider is removed.
|
||||
- **Every chart leads with the same titled header** as the rest of the Insights tiles, for one consistent layout.
|
||||
- **The wellness dials are refreshed** — a white ring on a gradient tile, matching the greeting card.
|
||||
- **On the pulse page, the assessment sits above the cardio-fitness link**, and the assessment card's heading sits closer to its text.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AI insights use the current default models consistently** across every path, so the shared-key experience matches the rest.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
+16
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.12.7
|
||||
version: 1.12.8
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -738,6 +738,9 @@ paths:
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -894,6 +897,9 @@ paths:
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -2722,6 +2728,9 @@ paths:
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
required: true
|
||||
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
|
||||
@@ -4796,6 +4805,9 @@ components:
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -5112,6 +5124,9 @@ components:
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Schlafeffizienz",
|
||||
"typeSleepConsistency": "Schlafkonsistenz",
|
||||
"typeSleepNeed": "Schlafbedarf",
|
||||
"typeEnergyExpenditureKj": "Energieverbrauch"
|
||||
"typeEnergyExpenditureKj": "Energieverbrauch",
|
||||
"typeAverageHeartRate": "Durchschnittlicher Puls",
|
||||
"typeMaxHeartRate": "Maximaler Puls",
|
||||
"typeSleepDisturbanceCount": "Schlafstörungen"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Samstag",
|
||||
"sun": "Sonntag"
|
||||
},
|
||||
"rangeBand": "Bereich",
|
||||
"selectRange": "Zeitraum auswählen"
|
||||
"rangeBand": "Bereich"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Median",
|
||||
"mean": "Mittelwert",
|
||||
"selectedRange": "Ausgewählter Bereich"
|
||||
"mean": "Mittelwert"
|
||||
},
|
||||
"showAllValues": "Alle Werte anzeigen",
|
||||
"valuesPageTitle": "{metric} — alle Werte",
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Sleep efficiency",
|
||||
"typeSleepConsistency": "Sleep consistency",
|
||||
"typeSleepNeed": "Sleep need",
|
||||
"typeEnergyExpenditureKj": "Energy expenditure"
|
||||
"typeEnergyExpenditureKj": "Energy expenditure",
|
||||
"typeAverageHeartRate": "Average heart rate",
|
||||
"typeMaxHeartRate": "Maximum heart rate",
|
||||
"typeSleepDisturbanceCount": "Sleep disturbances"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"rangeBand": "Range",
|
||||
"selectRange": "Select a range"
|
||||
"rangeBand": "Range"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Median",
|
||||
"mean": "Mean",
|
||||
"selectedRange": "Selected range"
|
||||
"mean": "Mean"
|
||||
},
|
||||
"showAllValues": "Show all readings",
|
||||
"valuesPageTitle": "{metric} — all readings",
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Eficiencia del sueño",
|
||||
"typeSleepConsistency": "Consistencia del sueño",
|
||||
"typeSleepNeed": "Necesidad de sueño",
|
||||
"typeEnergyExpenditureKj": "Gasto energético"
|
||||
"typeEnergyExpenditureKj": "Gasto energético",
|
||||
"typeAverageHeartRate": "Frecuencia cardíaca media",
|
||||
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
|
||||
"typeSleepDisturbanceCount": "Interrupciones del sueño"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Sábado",
|
||||
"sun": "Domingo"
|
||||
},
|
||||
"rangeBand": "Rango",
|
||||
"selectRange": "Seleccionar un intervalo"
|
||||
"rangeBand": "Rango"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"median": "Mediana",
|
||||
"mean": "Media",
|
||||
"selectedRange": "Intervalo seleccionado"
|
||||
"mean": "Media"
|
||||
},
|
||||
"showAllValues": "Mostrar todas las mediciones",
|
||||
"valuesPageTitle": "{metric} — todas las mediciones",
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Efficacité du sommeil",
|
||||
"typeSleepConsistency": "Régularité du sommeil",
|
||||
"typeSleepNeed": "Besoin de sommeil",
|
||||
"typeEnergyExpenditureKj": "Dépense énergétique"
|
||||
"typeEnergyExpenditureKj": "Dépense énergétique",
|
||||
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
|
||||
"typeMaxHeartRate": "Fréquence cardiaque maximale",
|
||||
"typeSleepDisturbanceCount": "Perturbations du sommeil"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Samedi",
|
||||
"sun": "Dimanche"
|
||||
},
|
||||
"rangeBand": "Plage",
|
||||
"selectRange": "Sélectionner une plage"
|
||||
"rangeBand": "Plage"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Médiane",
|
||||
"mean": "Moyenne",
|
||||
"selectedRange": "Plage sélectionnée"
|
||||
"mean": "Moyenne"
|
||||
},
|
||||
"showAllValues": "Afficher toutes les mesures",
|
||||
"valuesPageTitle": "{metric} — toutes les mesures",
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Efficienza del sonno",
|
||||
"typeSleepConsistency": "Costanza del sonno",
|
||||
"typeSleepNeed": "Fabbisogno di sonno",
|
||||
"typeEnergyExpenditureKj": "Dispendio energetico"
|
||||
"typeEnergyExpenditureKj": "Dispendio energetico",
|
||||
"typeAverageHeartRate": "Frequenza cardiaca media",
|
||||
"typeMaxHeartRate": "Frequenza cardiaca massima",
|
||||
"typeSleepDisturbanceCount": "Disturbi del sonno"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Sabato",
|
||||
"sun": "Domenica"
|
||||
},
|
||||
"rangeBand": "Intervallo",
|
||||
"selectRange": "Seleziona un intervallo"
|
||||
"rangeBand": "Intervallo"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Mediana",
|
||||
"mean": "Media",
|
||||
"selectedRange": "Intervallo selezionato"
|
||||
"mean": "Media"
|
||||
},
|
||||
"showAllValues": "Mostra tutte le misurazioni",
|
||||
"valuesPageTitle": "{metric} — tutte le misurazioni",
|
||||
|
||||
+6
-5
@@ -473,7 +473,10 @@
|
||||
"typeSleepEfficiency": "Efektywność snu",
|
||||
"typeSleepConsistency": "Regularność snu",
|
||||
"typeSleepNeed": "Zapotrzebowanie na sen",
|
||||
"typeEnergyExpenditureKj": "Wydatek energetyczny"
|
||||
"typeEnergyExpenditureKj": "Wydatek energetyczny",
|
||||
"typeAverageHeartRate": "Średnie tętno",
|
||||
"typeMaxHeartRate": "Maksymalne tętno",
|
||||
"typeSleepDisturbanceCount": "Zakłócenia snu"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -1494,8 +1497,7 @@
|
||||
"sat": "Sobota",
|
||||
"sun": "Niedziela"
|
||||
},
|
||||
"rangeBand": "Zakres",
|
||||
"selectRange": "Wybierz zakres"
|
||||
"rangeBand": "Zakres"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -2109,8 +2111,7 @@
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"median": "Mediana",
|
||||
"mean": "Średnia",
|
||||
"selectedRange": "Wybrany zakres"
|
||||
"mean": "Średnia"
|
||||
},
|
||||
"showAllValues": "Pokaż wszystkie pomiary",
|
||||
"valuesPageTitle": "{metric} — wszystkie pomiary",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.12.7",
|
||||
"version": "1.12.8",
|
||||
"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,22 @@
|
||||
-- v1.12.8 — WHOOP cycle + sleep coverage completion (additive).
|
||||
--
|
||||
-- Purely-additive: three new `measurement_type` enum members for WHOOP fields
|
||||
-- a coverage audit found were read-and-dropped (cycle average / max heart
|
||||
-- rate) or never fetched (per-night sleep disturbance count). No backfill, no
|
||||
-- existing row touched.
|
||||
--
|
||||
-- AVERAGE_HEART_RATE / MAX_HEART_RATE land the WHOOP `cycle.score`
|
||||
-- average_heart_rate / max_heart_rate (previously typed but never emitted by
|
||||
-- `mapCycle`). They stay distinct from spot PULSE, RESTING_HEART_RATE, and
|
||||
-- WALKING_HEART_RATE_AVERAGE — these are the day's whole-cycle aggregates.
|
||||
--
|
||||
-- SLEEP_DISTURBANCE_COUNT lands the WHOOP `sleep.score.stage_summary`
|
||||
-- disturbance_count (per-night tally).
|
||||
--
|
||||
-- Postgres enum-add is non-transactional; the `IF NOT EXISTS` guards make a
|
||||
-- rerun safe. Forward-only — Postgres cannot remove an enum value, so the
|
||||
-- three new members stay; with no rows carrying them they are inert.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AVERAGE_HEART_RATE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MAX_HEART_RATE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_DISTURBANCE_COUNT';
|
||||
@@ -650,6 +650,16 @@ enum MeasurementType {
|
||||
SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night.
|
||||
ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips).
|
||||
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
|
||||
// Three previously-uncaptured WHOOP fields. AVERAGE_HEART_RATE / MAX_HEART_RATE
|
||||
// ride the cycle score (already fetched, previously dropped); kept distinct
|
||||
// from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE because
|
||||
// they are the day's whole-cycle aggregates. SLEEP_DISTURBANCE_COUNT is the
|
||||
// per-night `stage_summary.disturbance_count`.
|
||||
AVERAGE_HEART_RATE // bpm — WHOOP `cycle.score.average_heart_rate`. The day's whole-cycle average heart rate.
|
||||
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
|
||||
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
const hasKey = Boolean(settings?.adminAiKeyEncrypted);
|
||||
const model = settings?.adminAiModel ?? "gpt-4o-mini";
|
||||
const model = settings?.adminAiModel ?? "gpt-4o";
|
||||
const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1";
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -381,6 +381,30 @@
|
||||
0 18px 48px -28px color-mix(in srgb, var(--dracula-purple) 50%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* `.score-tile-gradient` — the saturated purple→pink wellness-tile
|
||||
* background that echoes the hero band while staying dark enough for a
|
||||
* WHITE score ring + white centre number to clear WCAG AA in BOTH themes.
|
||||
*
|
||||
* Unlike `.hero-gradient` (which mixes over the theme-dependent
|
||||
* `var(--card)` — near-white in Alucard light, so a white ring would
|
||||
* vanish), this variant mixes purple/pink over the *fixed* dark
|
||||
* `var(--dracula-bg)` (#282a36). That keeps the surface identical and
|
||||
* dark in light + dark mode, so white text/ring read ≥ 4.5:1 either way:
|
||||
* white on the purple endpoint ≈ 5.0:1, on the pink endpoint ≈ 6.1:1.
|
||||
* A subtler glow than the hero's keeps a row of five tiles from
|
||||
* overwhelming the page.
|
||||
*/
|
||||
.score-tile-gradient {
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
color-mix(in srgb, var(--dracula-purple) 55%, var(--dracula-bg)) 0%,
|
||||
color-mix(in srgb, var(--dracula-pink) 45%, var(--dracula-bg)) 100%
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--dracula-purple) 28%, transparent);
|
||||
box-shadow: 0 12px 32px -24px color-mix(in srgb, var(--dracula-purple) 45%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* `pulseDot` keyframes — the "live, refreshed N min ago" green dot in
|
||||
* the hero meta row. Used as `.pulse-dot { animation: pulseDot ... }`.
|
||||
|
||||
@@ -33,17 +33,29 @@ const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
|
||||
// `useInsightStatus`-backed pages render `<SlugInsightStatusCard>`; the
|
||||
// medications page reads the richer `summary`-shaped route inline, so it
|
||||
// renders `<InsightStatusCard>` directly.
|
||||
const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [
|
||||
const BESPOKE_PAGES: ReadonlyArray<{
|
||||
slug: string;
|
||||
assessmentTag: string;
|
||||
/**
|
||||
* v1.12.8 — pulse intentionally trails its assessment with the
|
||||
* cardio-fitness CTA (the operator-requested "Einschätzung above the VO₂
|
||||
* max cross-link" order). The CTA is a navigation affordance, not a data
|
||||
* card, so it is the one sanctioned block that may render after the
|
||||
* assessment; the guard skips the last-block check for these slugs but
|
||||
* still enforces that the target card precedes the assessment.
|
||||
*/
|
||||
assessmentMayTrail?: boolean;
|
||||
}> = [
|
||||
{ slug: "weight", assessmentTag: "<SlugInsightStatusCard" },
|
||||
{ slug: "bmi", assessmentTag: "<SlugInsightStatusCard" },
|
||||
{ slug: "pulse", assessmentTag: "<SlugInsightStatusCard" },
|
||||
{ slug: "pulse", assessmentTag: "<SlugInsightStatusCard", assessmentMayTrail: true },
|
||||
{ slug: "blood-pressure", assessmentTag: "<SlugInsightStatusCard" },
|
||||
{ slug: "mood", assessmentTag: "<SlugInsightStatusCard" },
|
||||
{ slug: "medications", assessmentTag: "<InsightStatusCard" },
|
||||
];
|
||||
|
||||
describe("bespoke metric-detail spine — assessment is the last block", () => {
|
||||
for (const { slug, assessmentTag } of BESPOKE_PAGES) {
|
||||
for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) {
|
||||
it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
|
||||
const source = readFileSync(
|
||||
join(PAGES_DIR, slug, "page.tsx"),
|
||||
@@ -68,25 +80,29 @@ describe("bespoke metric-detail spine — assessment is the last block", () => {
|
||||
`${slug}: assessment card ${assessmentTag} not found inside the shell`,
|
||||
).toBeGreaterThan(-1);
|
||||
|
||||
// All six bespoke pages render the assessment as a self-closing
|
||||
// element. Find the `/>` that closes it, then assert nothing but
|
||||
// whitespace separates that close from `</SubPageShell>`. The icon
|
||||
// passed as a prop (`<Scale … />`) self-closes BEFORE the element's own
|
||||
// `/>`, so the first `/>` after the assessment open is the icon's and
|
||||
// the second is the element's — scan for the last `/>` in the body,
|
||||
// which belongs to the trailing assessment element by construction.
|
||||
const selfClose = body.lastIndexOf("/>");
|
||||
expect(
|
||||
selfClose,
|
||||
`${slug}: assessment card is not self-closing as expected`,
|
||||
).toBeGreaterThan(assessmentIndex);
|
||||
// Pages where the assessment is the last block (the default) must have
|
||||
// nothing but whitespace after the assessment element's `/>`. Pages that
|
||||
// intentionally trail the assessment with a navigation CTA
|
||||
// (`assessmentMayTrail`, today only pulse) skip the last-block check —
|
||||
// the target-before-assessment guard below still applies.
|
||||
if (!assessmentMayTrail) {
|
||||
// The assessment renders as a self-closing element. The icon passed
|
||||
// as a prop (`<Scale … />`) self-closes BEFORE the element's own `/>`,
|
||||
// so the last `/>` in the body belongs to the trailing assessment
|
||||
// element by construction.
|
||||
const selfClose = body.lastIndexOf("/>");
|
||||
expect(
|
||||
selfClose,
|
||||
`${slug}: assessment card is not self-closing as expected`,
|
||||
).toBeGreaterThan(assessmentIndex);
|
||||
|
||||
const trailing = body.slice(selfClose + 2).trim();
|
||||
expect(
|
||||
trailing,
|
||||
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
|
||||
`the assessment must be the last block on the canonical spine`,
|
||||
).toBe("");
|
||||
const trailing = body.slice(selfClose + 2).trim();
|
||||
expect(
|
||||
trailing,
|
||||
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
|
||||
`the assessment must be the last block on the canonical spine`,
|
||||
).toBe("");
|
||||
}
|
||||
|
||||
// v1.12.4 — the target card precedes the assessment when present.
|
||||
const targetIndex = body.indexOf("<MetricTargetSummary");
|
||||
|
||||
@@ -51,10 +51,10 @@ export default function InsightsBlutdruckPage() {
|
||||
const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null;
|
||||
const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null;
|
||||
|
||||
// v1.12.7 — chart-reactive metric statistics. Blood pressure brushes BOTH
|
||||
// series at once: the single chart reports per-type windowed stats and each
|
||||
// strip reads its own half.
|
||||
const { statsByType, onDomainStats } = useChartDomainStats();
|
||||
// v1.12.8 — chart-reactive metric statistics. Blood pressure tracks BOTH
|
||||
// series at once: the single chart reports per-type visible-range stats and
|
||||
// each strip column reads its own half.
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -155,14 +155,14 @@ export default function InsightsBlutdruckPage() {
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
titleIcon={HeartPulse}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="mmHg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
selectableDomain
|
||||
onDomainStats={onDomainStats}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
|
||||
{/* v1.12.4 — target card sits between the chart and the assessment on
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
@@ -49,9 +50,9 @@ export default function InsightsPulsPage() {
|
||||
const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0;
|
||||
const pulseSummary = analytics?.summaries?.PULSE ?? null;
|
||||
|
||||
// v1.12.7 — brushed-window stats shared between the pulse chart and the
|
||||
// v1.12.8 — visible-range stats shared between the pulse chart and the
|
||||
// strip (the VO2 chart-row below keeps its own full-range read).
|
||||
const { statsByType, onDomainStats } = useChartDomainStats();
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
|
||||
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
|
||||
@@ -139,41 +140,44 @@ export default function InsightsPulsPage() {
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
title={t("charts.pulse")}
|
||||
titleIcon={Heart}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
selectableDomain
|
||||
onDomainStats={onDomainStats}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="pulse" />
|
||||
|
||||
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
|
||||
the overview onto its metric page). */}
|
||||
<MetricCorrelationCard slug="pulse" />
|
||||
|
||||
{/* v1.12.8 — the Einschätzung (assessment) sits ABOVE the cardio-fitness
|
||||
CTA so the plain-language read of the pulse data leads, and the
|
||||
cross-link to the dedicated VO₂ max page trails it. */}
|
||||
<SlugInsightStatusCard slug="pulse" icon={<Heart className="h-5 w-5" />} />
|
||||
|
||||
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
|
||||
under "Heart"), so the pulse page links across to its dedicated
|
||||
`/insights/cardio-fitness` page — the single surface that carries
|
||||
the full chart plus the plain-language assessment — rather than
|
||||
duplicating a second, divergent VO₂ max view here. */}
|
||||
duplicating a second, divergent VO₂ max view here.
|
||||
|
||||
v1.12.8 — the card now leads with the canonical `<TileHeader>` so the
|
||||
`Gauge` glyph reads in the foreground colour at the same size and
|
||||
position as every other tile header, with the body + CTA beneath. */}
|
||||
{hasVo2 ? (
|
||||
<Link
|
||||
href="/insights/cardio-fitness"
|
||||
data-slot="vo2-cardio-link"
|
||||
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex items-start gap-3">
|
||||
<Gauge
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-semibold">
|
||||
{t("insights.vo2Max.cardioLinkTitle")}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs leading-snug">
|
||||
{t("insights.vo2Max.cardioLinkBody")}
|
||||
</span>
|
||||
</span>
|
||||
<TileHeader icon={Gauge} title={t("insights.vo2Max.cardioLinkTitle")} />
|
||||
<span className="text-muted-foreground block text-xs leading-snug">
|
||||
{t("insights.vo2Max.cardioLinkBody")}
|
||||
</span>
|
||||
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
|
||||
{t("insights.vo2Max.cardioLinkCta")}
|
||||
@@ -181,14 +185,6 @@ export default function InsightsPulsPage() {
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
|
||||
the overview onto its metric page). */}
|
||||
<MetricCorrelationCard slug="pulse" />
|
||||
|
||||
{/* v1.12.0 — Einschätzung is the last block on the canonical
|
||||
metric-detail spine. */}
|
||||
<SlugInsightStatusCard slug="pulse" icon={<Heart className="h-5 w-5" />} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export default function InsightsGewichtPage() {
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
|
||||
// v1.12.7 — brushed-window stats shared between the chart and the strip.
|
||||
const { statsByType, onDomainStats } = useChartDomainStats();
|
||||
// v1.12.8 — visible-range stats shared between the chart and the strip.
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -102,13 +102,13 @@ export default function InsightsGewichtPage() {
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
titleIcon={Scale}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
selectableDomain
|
||||
onDomainStats={onDomainStats}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
Brush,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useState, useMemo, useEffect, type ComponentType } from "react";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -194,26 +194,27 @@ interface HealthChartProps {
|
||||
*/
|
||||
valueScale?: number;
|
||||
/**
|
||||
* v1.12.7 — chart-reactive metric statistics (opt-in).
|
||||
* v1.12.8 — chart-reactive metric statistics. When supplied, the chart
|
||||
* reports the per-type Min / Max / Median / Mean of the data currently
|
||||
* visible under the active range tab (the 7 / 30 / 90 / All selector),
|
||||
* keyed by `MeasurementType`. It fires on mount and whenever the range tab
|
||||
* (or the underlying series) changes, so the `<MetricStatStrip>` a sub-page
|
||||
* lifts the callback into always reflects the same window the chart paints.
|
||||
*
|
||||
* When `true`, the chart mounts a Recharts `<Brush>` beneath the plot so
|
||||
* the user can drag a window over the visible range. The dashboard and the
|
||||
* mini / rationale mounts do NOT pass it, so they render byte-identical to
|
||||
* the pre-v1.12.7 chart — the feature is fully isolated behind this flag.
|
||||
*
|
||||
* Mini mode ignores the flag (the rationale card pins its own window and
|
||||
* has no room for a brush).
|
||||
* Mini mode never reports — the rationale card pins its own window and has
|
||||
* no stat strip. The dashboard / ad-hoc mounts simply omit the callback.
|
||||
*/
|
||||
selectableDomain?: boolean;
|
||||
onVisibleStats?: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
/**
|
||||
* v1.12.7 — companion to `selectableDomain`. Receives the per-type
|
||||
* Min / Max / Median / Mean computed over the brushed window, keyed by
|
||||
* `MeasurementType`, or `null` when the selection spans the whole range
|
||||
* (or collapses to under one point). A metric sub-page lifts this into a
|
||||
* shared state the `<MetricStatStrip>` reads, so the strip recomputes for
|
||||
* the visible domain while the default stays the cheap full-range summary.
|
||||
* v1.12.8 — optional leading glyph for the chart card's `<TileHeader>`.
|
||||
* When supplied, the chart header renders the canonical icon + title row
|
||||
* (same size + spacing as every other Insights tile) instead of the bare
|
||||
* `<h3>`. Omitted on the dashboard / mini mounts, which keep the compact
|
||||
* heading. Accepts any component that takes a `className` (the contract
|
||||
* `<TileHeader>` and the stat strip's `icon` already use), so a page can
|
||||
* thread the same glyph it hands the stat strip.
|
||||
*/
|
||||
onDomainStats?: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
titleIcon?: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -515,8 +516,8 @@ export function HealthChart({
|
||||
verticalMarkers,
|
||||
userTimezone = "Europe/Berlin",
|
||||
valueScale = 1,
|
||||
selectableDomain = false,
|
||||
onDomainStats,
|
||||
onVisibleStats,
|
||||
titleIcon,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -544,18 +545,6 @@ export function HealthChart({
|
||||
: 30;
|
||||
const [rangePoints, setRangePoints] = useState(initialRangePoints);
|
||||
|
||||
// v1.12.7 — chart-reactive metric statistics. The brush reports a
|
||||
// `[startIndex, endIndex]` window into the rendered `chartData`; `null`
|
||||
// means the full range is selected. Gated behind `selectableDomain` and
|
||||
// suppressed in mini mode, so the dashboard + rationale charts never mount
|
||||
// the brush and stay byte-identical. The range-tab change below clears the
|
||||
// selection so the brush window can't outlive the data it indexed into.
|
||||
const brushEnabled = selectableDomain && !mini;
|
||||
const [brushWindow, setBrushWindow] = useState<{
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
// v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow
|
||||
// / showTargetRange) are persisted per chart via the new
|
||||
// `useChartOverlayPrefs` hook. When the chart isn't bound to a
|
||||
@@ -940,59 +929,36 @@ export function HealthChart({
|
||||
return enriched;
|
||||
}, [data, rangePoints, showMA, showTrend, types, tzFmt]);
|
||||
|
||||
// v1.12.7 — chart-reactive metric statistics.
|
||||
// v1.12.8 — chart-reactive metric statistics.
|
||||
//
|
||||
// When a brush window is active, compute the per-type Min / Max / Median /
|
||||
// Mean over just the selected slice of `chartData` (the array the chart
|
||||
// already holds — no re-fetch). A selection that spans the whole range, or
|
||||
// collapses to under one point, reports `null` so the strip falls back to
|
||||
// the cheap full-range summary. Only runs when `brushEnabled`; the
|
||||
// dashboard / mini mounts compute nothing.
|
||||
const windowStatsByType = useMemo<Record<string, MetricWindowStats> | null>(
|
||||
// Compute the per-type Min / Max / Median / Mean over the data currently
|
||||
// visible under the active range tab — i.e. the `rangePoints`-sliced,
|
||||
// bucketed `chartData` the chart already holds (no re-fetch). The result
|
||||
// recomputes whenever the range tab changes the visible slice, so the
|
||||
// `<MetricStatStrip>` a sub-page lifts the callback into always reflects the
|
||||
// same window the chart paints. Skipped in mini mode (no stat strip).
|
||||
const visibleStatsByType = useMemo<Record<string, MetricWindowStats> | null>(
|
||||
() => {
|
||||
if (!brushEnabled || !brushWindow || !chartData?.length) return null;
|
||||
const last = chartData.length - 1;
|
||||
const start = Math.max(0, Math.min(brushWindow.startIndex, last));
|
||||
const end = Math.max(0, Math.min(brushWindow.endIndex, last));
|
||||
const lo = Math.min(start, end);
|
||||
const hi = Math.max(start, end);
|
||||
// A full-range selection is the default; reporting `null` keeps the
|
||||
// strip on its precomputed summary rather than a redundant recompute.
|
||||
if (lo <= 0 && hi >= last) return null;
|
||||
const slice = chartData.slice(lo, hi + 1);
|
||||
// Fewer than one data point in the window is not a meaningful summary;
|
||||
// fall back to the full range.
|
||||
if (slice.length < 1) return null;
|
||||
if (mini || !chartData?.length) return null;
|
||||
const out: Record<string, MetricWindowStats> = {};
|
||||
for (const type of types) {
|
||||
const stats = computeWindowStats(
|
||||
slice.map((point) => point[type] as number | undefined),
|
||||
chartData.map((point) => point[type] as number | undefined),
|
||||
);
|
||||
if (stats.count > 0) out[type] = stats;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
},
|
||||
[brushEnabled, brushWindow, chartData, types],
|
||||
[mini, chartData, types],
|
||||
);
|
||||
|
||||
// Report the windowed stats up to the sub-page so the shared
|
||||
// Report the visible-range stats up to the sub-page so the shared
|
||||
// `<MetricStatStrip>` can read them. Effect (not render-time call) so the
|
||||
// parent state update never fires during this component's render.
|
||||
useEffect(() => {
|
||||
if (!brushEnabled) return;
|
||||
onDomainStats?.(windowStatsByType);
|
||||
}, [brushEnabled, windowStatsByType, onDomainStats]);
|
||||
|
||||
// Clamp a stale brush window in render (never via setState-in-effect) so the
|
||||
// controlled `<Brush>` never points past the end of `chartData` after a
|
||||
// refetch shrinks the series. A window whose end falls past the new last
|
||||
// index is treated as "no selection" — the stats memo above already ignores
|
||||
// it, and the brush re-seeds to the full range.
|
||||
const chartLength = chartData?.length ?? 0;
|
||||
const effectiveBrushWindow =
|
||||
brushWindow && brushWindow.endIndex <= Math.max(0, chartLength - 1)
|
||||
? brushWindow
|
||||
: null;
|
||||
if (mini) return;
|
||||
onVisibleStats?.(visibleStatsByType);
|
||||
}, [mini, visibleStatsByType, onVisibleStats]);
|
||||
|
||||
// v1.4.16 phase B8 — comparison overlay.
|
||||
//
|
||||
@@ -1388,7 +1354,17 @@ export function HealthChart({
|
||||
≥sm: original side-by-side layout, all chips visible. */
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{/* v1.12.8 — when a `titleIcon` is supplied (every Insights
|
||||
metric subpage), the chart card leads with the canonical
|
||||
`<TileHeader>` (icon + title at `text-base`, foreground
|
||||
colour, h-5 w-5) so it matches the assessment / target /
|
||||
stat tiles. The dashboard / ad-hoc mounts omit the icon and
|
||||
keep the compact `<h3>`. */}
|
||||
{titleIcon ? (
|
||||
<TileHeader icon={titleIcon} title={title} />
|
||||
) : (
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
)}
|
||||
{activeBucket !== "day" && (
|
||||
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
|
||||
{t(
|
||||
@@ -1441,11 +1417,9 @@ export function HealthChart({
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => {
|
||||
// v1.12.7 — a range-tab change re-slices `chartData`, so
|
||||
// any active brush window would now index into a different
|
||||
// array. Clear it so the stat strip falls back to the new
|
||||
// full range rather than a stale selection.
|
||||
setBrushWindow(null);
|
||||
// v1.12.8 — a range-tab change re-slices `chartData`; the
|
||||
// visible-range stats memo recomputes off the new slice and
|
||||
// the stat strip follows automatically.
|
||||
setRangePoints(r.points);
|
||||
}}
|
||||
title={t(r.titleKey)}
|
||||
@@ -1989,55 +1963,6 @@ export function HealthChart({
|
||||
data-slot={`chart-compare-line-${type}`}
|
||||
/>
|
||||
))}
|
||||
{/* v1.12.7 — chart-reactive metric statistics. The brush
|
||||
lets the user drag a window over the visible range; the
|
||||
`onChange` handler stores the `[startIndex, endIndex]`
|
||||
pair, and the windowed-stats memo above recomputes the
|
||||
stat strip's Min / Max / Median / Mean from the brushed
|
||||
slice. Only mounts when `brushEnabled` (sub-pages opt in
|
||||
via `selectableDomain`); the dashboard / mini mounts skip
|
||||
it entirely so their render stays unchanged. `gap={1}`
|
||||
reports every index so a one-day drag still resolves; the
|
||||
travellers carry an `ariaLabel` so the brush announces
|
||||
itself to assistive tech. */}
|
||||
{brushEnabled ? (
|
||||
<Brush
|
||||
dataKey="pointIndex"
|
||||
height={24}
|
||||
travellerWidth={8}
|
||||
gap={1}
|
||||
stroke="var(--dracula-purple)"
|
||||
fill="var(--muted)"
|
||||
fillOpacity={0.3}
|
||||
ariaLabel={t("charts.selectRange")}
|
||||
startIndex={effectiveBrushWindow?.startIndex}
|
||||
endIndex={effectiveBrushWindow?.endIndex}
|
||||
onChange={(next) => {
|
||||
const range = next as {
|
||||
startIndex?: number;
|
||||
endIndex?: number;
|
||||
};
|
||||
if (
|
||||
typeof range.startIndex !== "number" ||
|
||||
typeof range.endIndex !== "number"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBrushWindow({
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.endIndex,
|
||||
});
|
||||
}}
|
||||
tickFormatter={(value) =>
|
||||
tzFmt.date(
|
||||
new Date(
|
||||
chartData?.[Math.round(Number(value))]?.timestamp ??
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("<MetricStatStrip>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("pins the 'selected range' pill on a brushed series", () => {
|
||||
it("reads the chart's visible-range stats over the full-range summary, with no pill", () => {
|
||||
const html = render(
|
||||
<MetricStatStrip
|
||||
groupLabel="Blood pressure"
|
||||
@@ -158,10 +158,12 @@ describe("<MetricStatStrip>", () => {
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// The brushed sys block flags windowed + carries the pill; dia does not.
|
||||
// The sys block flags windowed (visible-range stats present); dia does not.
|
||||
expect(html).toContain('data-series="sys" data-windowed="true"');
|
||||
expect(html).toContain('data-slot="metric-stat-window-badge"');
|
||||
// Windowed numbers win over the full-range summary for sys.
|
||||
// v1.12.8 — no "selected range" pill: the chart's range tab is the
|
||||
// single, visible selector for both chart and strip.
|
||||
expect(html).not.toContain('data-slot="metric-stat-window-badge"');
|
||||
// Visible-range numbers win over the full-range summary for sys.
|
||||
expect(html).toContain("120");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,4 +52,15 @@ describe("<ScoreRing>", () => {
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
});
|
||||
|
||||
it("keeps the band semantic on the white-arc onGradient variant", () => {
|
||||
// The white arc drops the band colour, so the band must still ride the
|
||||
// data attribute + aria-label for the colour-blind/non-visual read.
|
||||
const html = render(
|
||||
<ScoreRing score={82} variant="onGradient" label="/100" />,
|
||||
);
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe("<WellnessScores>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders a ring tile with a canonical TileHeader icon + heading", () => {
|
||||
it("renders a ring tile with an icon + heading and a gradient surface", () => {
|
||||
const html = render(
|
||||
<WellnessScores
|
||||
read={readFrom({ READINESS: ok({ score: 82, band: "green" }) })}
|
||||
@@ -75,14 +75,13 @@ describe("<WellnessScores>", () => {
|
||||
);
|
||||
expect(html).toContain('data-slot="wellness-scores"');
|
||||
expect(html).toContain('data-metric="READINESS"');
|
||||
// Each tile leads with the shared TileHeader (icon + heading row), with
|
||||
// the metric name in the foreground-colour CardTitle.
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
expect(html).toContain('data-slot="card-title"');
|
||||
// The tile carries the metric heading and rides the saturated
|
||||
// purple→pink gradient that echoes the hero card.
|
||||
expect(html).toContain("Readiness");
|
||||
expect(html).toContain("score-tile-gradient");
|
||||
// The ring carries its band on the arc + data-attribute (not the number).
|
||||
expect(html).toContain('data-band="green"');
|
||||
// The band word renders under the ring.
|
||||
// The band word renders under the ring (semantic kept off the white arc).
|
||||
expect(html).toContain('data-slot="wellness-score-band-word"');
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ import {
|
||||
* 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.
|
||||
*
|
||||
* `variant` picks the arc palette. `"band"` (default) paints the filled
|
||||
* arc in the score's band token (green/yellow/red) over a muted track —
|
||||
* the legible treatment on a plain `bg-card` surface (the score-anatomy
|
||||
* detail view). `"onGradient"` paints a WHITE arc over a translucent-white
|
||||
* track for use on the saturated `.score-tile-gradient` wellness cards,
|
||||
* where a band tint would muddy against the purple/pink and white reads at
|
||||
* high contrast. The band semantic is never lost to the white arc: it
|
||||
* still rides the `data-band` attribute, the aria-label, and the band-word
|
||||
* label the wellness tile renders beneath the ring.
|
||||
*/
|
||||
|
||||
const SIZE: Record<
|
||||
@@ -59,6 +69,14 @@ export interface ScoreRingProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Disable the sweep + count-up (e.g. when already animated by a parent). */
|
||||
animate?: boolean;
|
||||
/**
|
||||
* Arc palette. `"band"` (default) tints the arc by score band over a
|
||||
* muted track — legible on a plain card. `"onGradient"` paints a white
|
||||
* arc over a translucent-white track, for the saturated gradient
|
||||
* wellness tiles. See the component doc for why the band semantic is not
|
||||
* lost to the white arc.
|
||||
*/
|
||||
variant?: "band" | "onGradient";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -68,15 +86,21 @@ export function ScoreRing({
|
||||
label,
|
||||
size = "md",
|
||||
animate = true,
|
||||
variant = "band",
|
||||
className,
|
||||
}: ScoreRingProps) {
|
||||
const { t } = useTranslations();
|
||||
const dims = SIZE[size];
|
||||
const onGradient = variant === "onGradient";
|
||||
|
||||
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)";
|
||||
const fill = onGradient
|
||||
? "#ffffff"
|
||||
: 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).
|
||||
@@ -122,7 +146,11 @@ export function ScoreRing({
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="value"
|
||||
background={{ fill: "var(--muted)", opacity: 0.4 }}
|
||||
background={
|
||||
onGradient
|
||||
? { fill: "#ffffff", opacity: 0.18 }
|
||||
: { fill: "var(--muted)", opacity: 0.4 }
|
||||
}
|
||||
cornerRadius={dims.barSize}
|
||||
isAnimationActive={animate && hasScore}
|
||||
animationDuration={600}
|
||||
@@ -164,7 +192,15 @@ export function ScoreRing({
|
||||
// (`fill` = BAND_VAR) + the `data-band` attribute +
|
||||
// the aria-label, so dropping it from the number
|
||||
// costs no information while clearing AA.
|
||||
hasScore ? "fill-foreground" : "fill-muted-foreground",
|
||||
// On the saturated gradient tile the number is
|
||||
// pinned white in both themes (the dark gradient
|
||||
// clears AA either way); `fill-foreground` would go
|
||||
// near-black on the Alucard light card and vanish.
|
||||
onGradient
|
||||
? "fill-white"
|
||||
: hasScore
|
||||
? "fill-foreground"
|
||||
: "fill-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasScore ? displayedRounded : "—"}
|
||||
@@ -174,7 +210,12 @@ export function ScoreRing({
|
||||
x={cx}
|
||||
y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)}
|
||||
className={cn(
|
||||
"fill-muted-foreground",
|
||||
// On the gradient tile a translucent white reads
|
||||
// cleanly; on a plain card the muted-foreground
|
||||
// token keeps the sub-label quiet.
|
||||
onGradient
|
||||
? "fill-white/80"
|
||||
: "fill-muted-foreground",
|
||||
dims.labelClass,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -67,27 +67,41 @@ function RingTile({
|
||||
metricSlot: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
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 flex-col gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
// v1.12.8 — the wellness tile adopts the saturated purple→pink
|
||||
// `.score-tile-gradient` so the dial strip echoes the hero card. The
|
||||
// gradient is dark in BOTH themes (mixed over the fixed
|
||||
// `--dracula-bg`, not the theme `--card`), so the white ring + white
|
||||
// copy clear WCAG AA either way (white on the purple endpoint ≈ 5.0:1,
|
||||
// on the pink endpoint ≈ 6.1:1). The icon + heading + band word are
|
||||
// pinned white to match (TileHeader's `text-foreground` would go
|
||||
// near-black on the Alucard light card and disappear into the
|
||||
// gradient), so this tile renders its own white header inline rather
|
||||
// than the shared TileHeader.
|
||||
className="score-tile-gradient hover:border-white/35 focus-visible:ring-ring flex flex-col gap-3 rounded-xl p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{/* v1.12.6 — every tile leads with the canonical icon + heading
|
||||
(foreground colour, matching the Einschätzung reference) so the
|
||||
wellness strip reads as the same card language as the rest of
|
||||
Insights. The metric name no longer rides an under-ring caption. */}
|
||||
<TileHeader icon={icon} title={label} titleClassName="truncate" />
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-base leading-none font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{/* The ring keeps just the number centred; the metric name lives in
|
||||
the TileHeader above (a long localised title would overflow the
|
||||
the header above (a long localised title would overflow the
|
||||
small ring's centred SVG text and clip under recharts'
|
||||
`overflow:hidden`). */}
|
||||
<ScoreRing score={score} band={band} size="sm" />
|
||||
`overflow:hidden`). The white-arc `onGradient` variant reads
|
||||
cleanly on the gradient; the band stays conveyed by the word
|
||||
label below + the ring's `data-band` + aria-label. */}
|
||||
<ScoreRing score={score} band={band} size="sm" variant="onGradient" />
|
||||
<span
|
||||
data-slot="wellness-score-band-word"
|
||||
className="text-muted-foreground text-center text-xs"
|
||||
className="text-center text-xs text-white/80"
|
||||
>
|
||||
{bandWord}
|
||||
</span>
|
||||
|
||||
@@ -175,11 +175,10 @@ export function HealthKitMetricPage({
|
||||
refetch,
|
||||
} = useInsightsAnalytics(insightMetric);
|
||||
|
||||
// v1.12.7 — shared brushed-window state. The chart reports the per-type
|
||||
// Min / Max / Median / Mean for the selected domain; the stat strip reads
|
||||
// it back for this page's single series. No selection → null → the strip
|
||||
// keeps the full-range summary.
|
||||
const { statsByType, onDomainStats } = useChartDomainStats();
|
||||
// v1.12.8 — shared visible-range state. The chart reports the per-type
|
||||
// Min / Max / Median / Mean for the data under its active range tab; the
|
||||
// stat strip reads it back for this page's single series.
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
|
||||
// The stat strip renders display-unit values. The summary holds stored
|
||||
@@ -309,6 +308,7 @@ export function HealthKitMetricPage({
|
||||
chartKey={chartKey}
|
||||
types={[measurementType]}
|
||||
title={t(`${i18nPrefix}.chartTitle`)}
|
||||
titleIcon={statIcon}
|
||||
colors={[color]}
|
||||
unit={unit}
|
||||
yAxisUnit={yAxisUnit}
|
||||
@@ -316,8 +316,7 @@ export function HealthKitMetricPage({
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={valueScale}
|
||||
selectableDomain
|
||||
onDomainStats={onDomainStats}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
|
||||
@@ -85,9 +85,9 @@ export function InsightStatusCard({
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
data-testid="insight-status-card-preparing"
|
||||
className="gap-2 py-4 md:gap-3 md:py-5"
|
||||
className="gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -139,8 +139,8 @@ export function InsightStatusCard({
|
||||
|
||||
if (!hasProvider) {
|
||||
return (
|
||||
<Card className="gap-2 py-4 opacity-60 md:gap-3 md:py-5">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="gap-1.5 py-4 opacity-60 md:py-5">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -154,8 +154,8 @@ export function InsightStatusCard({
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<Card className="gap-2 py-4 md:gap-3 md:py-5">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="gap-1.5 py-4 md:py-5">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -190,9 +190,15 @@ export function InsightStatusCard({
|
||||
// border-l-2`). The coloured rule made the assessment card read as
|
||||
// restless against the calmer surrounding cards; the plain card
|
||||
// border carries enough separation on its own.
|
||||
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
|
||||
//
|
||||
// v1.12.8 — tighten the header-to-prose gap. The card flex gap is
|
||||
// `gap-1.5` (was `gap-2 md:gap-3`) and the `CardHeader` trims its
|
||||
// bottom padding to `pb-1` (was `pb-2`), so the `<TileHeader>` sits
|
||||
// close to the prose and matches the header-to-body rhythm of the
|
||||
// sibling tiles. The loading skeleton keeps its own spacing.
|
||||
className="animate-insight-in gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-1">
|
||||
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
|
||||
an implementation detail and devalued the assessment. The card
|
||||
still consumes the warm cache; it just no longer announces it.
|
||||
|
||||
@@ -134,12 +134,15 @@ function SeriesBlock({
|
||||
const fmt = useFormatters();
|
||||
|
||||
// Nothing to show until the summary lands or for a metric with no
|
||||
// readings. The gate rides the full-range summary even when a window is
|
||||
// active, so a brush never resurrects a series that has no data at all.
|
||||
// readings. The gate rides the full-range summary even when the chart
|
||||
// reports a narrower visible range, so a range change never resurrects a
|
||||
// series that has no data at all.
|
||||
if (!summary || summary.count <= 0) return null;
|
||||
|
||||
// A window selection with at least one reading wins over the full-range
|
||||
// summary; otherwise the block reads the precomputed numbers.
|
||||
// v1.12.8 — the chart reports stats for the data under its active range tab;
|
||||
// when present those visible-range numbers win over the full-range summary
|
||||
// so the strip always reads the same window the chart paints. No pill — the
|
||||
// chart's range tab is the single, visible selector for both.
|
||||
const windowed = windowStats != null && windowStats.count > 0;
|
||||
const source = windowed ? windowStats : summary;
|
||||
|
||||
@@ -169,20 +172,7 @@ function SeriesBlock({
|
||||
className="space-y-2"
|
||||
>
|
||||
{seriesLabel ? (
|
||||
<TileHeader
|
||||
icon={icon ?? Sigma}
|
||||
title={seriesLabel}
|
||||
right={
|
||||
windowed ? (
|
||||
<span
|
||||
data-slot="metric-stat-window-badge"
|
||||
className="bg-dracula-purple/10 text-dracula-purple rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.subPage.stats.selectedRange")}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
|
||||
{cells.map((cell) => (
|
||||
|
||||
@@ -262,11 +262,11 @@ export function SubPageShell({
|
||||
brand-new-metric paths. Numbers-first is the Apple-Health /
|
||||
Withings / Oura detail-screen convention.
|
||||
|
||||
v1.12.7 — the stats are chart-reactive: when the user brushes a
|
||||
window in the chart below, the page lifts the chart's per-type
|
||||
windowed Min / Max / Median / Mean and threads it back into the
|
||||
strip, which swaps the header to a "selected range" pill. No
|
||||
selection falls back to the cheap full-range summary. */}
|
||||
v1.12.8 — the stats are chart-reactive: the chart reports the
|
||||
per-type Min / Max / Median / Mean for the data under its active
|
||||
range tab (7 / 30 / 90 / All), and the page threads it back into the
|
||||
strip so the numbers always reflect the range the chart paints. No
|
||||
drag, no pill — the range tab is the single selector. */}
|
||||
{statStrip}
|
||||
{/* v1.12.6 — the canonical spine body: intro (header) → stat strip
|
||||
(above) → chart → target card → assessment. The page renders
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Headphones,
|
||||
Sun,
|
||||
PersonStanding,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -109,6 +110,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
|
||||
SLEEP_NEED: "measurements.typeSleepNeed",
|
||||
ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
|
||||
MAX_HEART_RATE: "measurements.typeMaxHeartRate",
|
||||
SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -213,6 +218,13 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
SLEEP_CONSISTENCY: Moon,
|
||||
SLEEP_NEED: Moon,
|
||||
ENERGY_EXPENDITURE_KJ: Flame,
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// HeartPulse + Heart carry the daily-aggregate cardiac pair (same family
|
||||
// as the other heart-rate signals); Waves reads as the per-night
|
||||
// sleep-disturbance signal.
|
||||
AVERAGE_HEART_RATE: HeartPulse,
|
||||
MAX_HEART_RATE: Heart,
|
||||
SLEEP_DISTURBANCE_COUNT: Waves,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -308,4 +320,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2",
|
||||
SLEEP_NEED: "bg-chart-2/20 text-chart-2",
|
||||
ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
|
||||
// chart-2 (sleep/activity family) for the per-night disturbance count.
|
||||
AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
MAX_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2",
|
||||
};
|
||||
@@ -5,38 +5,34 @@ import { useCallback, useRef, useState } from "react";
|
||||
import type { MetricWindowStats } from "@/lib/charts/window-stats";
|
||||
|
||||
/**
|
||||
* v1.12.7 — chart-reactive metric statistics.
|
||||
* v1.12.8 — chart-reactive metric statistics.
|
||||
*
|
||||
* A metric sub-page lifts this hook so its `<HealthChart selectableDomain>`
|
||||
* (which mounts a Recharts `<Brush>`) and its `<MetricStatStrip>` read the
|
||||
* same brushed window. The chart reports the per-type Min / Max / Median /
|
||||
* Mean for the selected domain through `onDomainStats`; the strip reads
|
||||
* `statsByType[type]` and, when present, renders the windowed numbers in
|
||||
* place of the full-range summary.
|
||||
* A metric sub-page lifts this hook so its `<HealthChart>` and its
|
||||
* `<MetricStatStrip>` read the same window. The chart reports the per-type
|
||||
* Min / Max / Median / Mean for the data currently visible under its active
|
||||
* range tab (the 7 / 30 / 90 / All selector) through `onVisibleStats`; the
|
||||
* strip reads `statsByType[type]` and renders those windowed numbers.
|
||||
*
|
||||
* `null` (no selection, or a selection that collapsed to under one point)
|
||||
* means "show the full range" — the strip falls back to the precomputed
|
||||
* summary, so the default is unchanged and costs no extra work. The chart
|
||||
* owns the maths (it already holds the bucketed series), so the hook only
|
||||
* stores what it is handed and exposes a stable callback.
|
||||
* `null` (no data, or mini mode) means "show the full range" — the strip
|
||||
* falls back to the precomputed summary. The chart owns the maths (it already
|
||||
* holds the bucketed series), so the hook only stores what it is handed and
|
||||
* exposes a stable callback.
|
||||
*
|
||||
* The callback is referentially stable across renders so it can sit in the
|
||||
* chart's effect dependency list without re-firing. A shallow guard skips
|
||||
* the state write when the chart reports the same window twice in a row,
|
||||
* keeping a drag that lands on the same indices from thrashing the strip.
|
||||
* chart's effect dependency list without re-firing. A shallow guard skips the
|
||||
* state write when the chart reports the same window twice in a row, keeping a
|
||||
* re-render that recomputes the same slice from thrashing the strip.
|
||||
*/
|
||||
export interface ChartDomainStats {
|
||||
/**
|
||||
* Per-type windowed stats, keyed by `MeasurementType`. Null means no
|
||||
* active selection (full range). A type may be absent from the map when
|
||||
* its series has no readings inside the brushed window — the strip falls
|
||||
* back to the full-range summary for that series.
|
||||
* Per-type windowed stats, keyed by `MeasurementType`. Null means the chart
|
||||
* has not reported a slice yet (or has no data). A type may be absent from
|
||||
* the map when its series has no readings in the visible range — the strip
|
||||
* falls back to the full-range summary for that series.
|
||||
*/
|
||||
statsByType: Record<string, MetricWindowStats> | null;
|
||||
/** True while a sub-range is selected (drives the "selected range" label). */
|
||||
isWindowed: boolean;
|
||||
/** Stable callback handed to `<HealthChart onDomainStats>`. */
|
||||
onDomainStats: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
/** Stable callback handed to `<HealthChart onVisibleStats>`. */
|
||||
onVisibleStats: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
}
|
||||
|
||||
export function useChartDomainStats(): ChartDomainStats {
|
||||
@@ -46,11 +42,11 @@ export function useChartDomainStats(): ChartDomainStats {
|
||||
> | null>(null);
|
||||
const lastRef = useRef<string>("null");
|
||||
|
||||
const onDomainStats = useCallback(
|
||||
const onVisibleStats = useCallback(
|
||||
(stats: Record<string, MetricWindowStats> | null) => {
|
||||
// Cheap shallow signature so an identical brush window (a drag that
|
||||
// settles on the same indices, or a re-render that recomputes the
|
||||
// same slice) doesn't re-set state and re-render the strip.
|
||||
// Cheap shallow signature so an identical window (a re-render that
|
||||
// recomputes the same slice) doesn't re-set state and re-render the
|
||||
// strip.
|
||||
const signature = stats === null ? "null" : JSON.stringify(stats);
|
||||
if (signature === lastRef.current) return;
|
||||
lastRef.current = signature;
|
||||
@@ -61,7 +57,6 @@ export function useChartDomainStats(): ChartDomainStats {
|
||||
|
||||
return {
|
||||
statsByType,
|
||||
isWindowed: statsByType !== null,
|
||||
onDomainStats,
|
||||
onVisibleStats,
|
||||
};
|
||||
}
|
||||
@@ -86,10 +86,14 @@ const EXPECTED_TYPES = [
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 60 canonical measurement types", () => {
|
||||
it("exposes the 63 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -202,6 +206,15 @@ describe("measurementTypeEnum coverage", () => {
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// v1.12.8 — WHOOP cycle + sleep coverage completion. The daily-aggregate
|
||||
// heart-rate pair and the per-night disturbance count are device-derived
|
||||
// WHOOP signals, not measured clinical vitals; they surface on the
|
||||
// measurement list + Insights chart surfaces with a "descriptive, not
|
||||
// clinical" framing and never belong in the clinical vitals PDF. See
|
||||
// doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
// the SDK default is correct.
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
|
||||
model: row.aiModel ?? "claude-3-5-sonnet-latest",
|
||||
model: row.aiModel ?? "claude-sonnet-4-6",
|
||||
});
|
||||
}
|
||||
case "LOCAL": {
|
||||
@@ -456,7 +456,7 @@ export async function resolveProviderForTest(
|
||||
// shared column.
|
||||
return new AnthropicClient({
|
||||
apiKey,
|
||||
model: model || "claude-3-5-sonnet-latest",
|
||||
model: model || "claude-sonnet-4-6",
|
||||
});
|
||||
}
|
||||
case "LOCAL": {
|
||||
|
||||
@@ -109,6 +109,12 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:SLEEP_CONSISTENCY",
|
||||
"metric:SLEEP_NEED",
|
||||
"metric:ENERGY_EXPENDITURE_KJ",
|
||||
// v1.12.8 — WHOOP cycle + sleep coverage completion. Continuous daily
|
||||
// series, so each carries a `metric:<TYPE>` token and renders through the
|
||||
// generic chart renderer.
|
||||
"metric:AVERAGE_HEART_RATE",
|
||||
"metric:MAX_HEART_RATE",
|
||||
"metric:SLEEP_DISTURBANCE_COUNT",
|
||||
] as const;
|
||||
|
||||
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
|
||||
@@ -219,6 +225,11 @@ const ORPHAN_ENUMS = [
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// v1.12.8 — WHOOP cycle + sleep coverage completion, same shape: strip the
|
||||
// bare enum name if the model drops it into prose.
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
] as const;
|
||||
|
||||
// `\b` boundaries keep ordinary English prose untouched — "weight"
|
||||
|
||||
@@ -52,6 +52,14 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// v1.12.8 — WHOOP cycle + sleep coverage completion. These ingest
|
||||
// server-side from the WHOOP API (source = WHOOP), never from HealthKit:
|
||||
// Apple ships no first-class daily-aggregate average / max heart-rate
|
||||
// quantity, and the per-night disturbance count is WHOOP-specific (Apple
|
||||
// ships sleep stages, not a disturbance tally). No HK mapping by design.
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -180,6 +180,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// Day energy expenditure is a cumulative activity metric (kJ analogue of
|
||||
// ACTIVE_ENERGY_BURNED).
|
||||
["ENERGY_EXPENDITURE_KJ", "activity"],
|
||||
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// Daily average / max heart rate join the derived cardiac surface
|
||||
// alongside RESTING_HEART_RATE + HRV (whole-cycle aggregates, not spot
|
||||
// readings). The per-night disturbance count sits in the sleep cluster.
|
||||
["AVERAGE_HEART_RATE", "cardiovascular"],
|
||||
["MAX_HEART_RATE", "cardiovascular"],
|
||||
["SLEEP_DISTURBANCE_COUNT", "sleep"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,6 +98,10 @@ export function getPRDirection(
|
||||
// events, and fewer is unambiguously the goal.
|
||||
case "FALL_COUNT":
|
||||
case "BREATHING_DISTURBANCES":
|
||||
// v1.12.8 — the per-night sleep disturbance count reads MIN: the
|
||||
// "record" is the night with the fewest disturbances, and fewer is
|
||||
// unambiguously the goal (mirrors BREATHING_DISTURBANCES).
|
||||
case "SLEEP_DISTURBANCE_COUNT":
|
||||
return PersonalRecordDirection.MIN;
|
||||
|
||||
// Explicitly no PersonalRecord — see comment block above.
|
||||
@@ -166,6 +170,12 @@ export function getPRDirection(
|
||||
case "SLEEP_CONSISTENCY":
|
||||
case "SLEEP_NEED":
|
||||
case "ENERGY_EXPENDITURE_KJ":
|
||||
// v1.12.8 — the daily average / max heart rate are whole-cycle
|
||||
// aggregates without a clean goal axis: a higher value can mean either
|
||||
// a harder-effort day or an ailing one, exactly like the
|
||||
// WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction.
|
||||
case "AVERAGE_HEART_RATE":
|
||||
case "MAX_HEART_RATE":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ export const measurementTypeEnum = z.enum([
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
|
||||
// Daily average / max heart rate ride WHOOP's cycle score (previously
|
||||
// fetched but dropped). Kept distinct from spot PULSE / RESTING_HEART_RATE /
|
||||
// WALKING_HEART_RATE_AVERAGE: these are the day's whole-cycle aggregates,
|
||||
// not a point-in-time or context-specific reading. Sleep disturbance count
|
||||
// is the per-night WHOOP `stage_summary.disturbance_count`.
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -283,6 +292,13 @@ const unitMap: Record<string, string> = {
|
||||
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
|
||||
// device value round-trips rather than being converted to kcal).
|
||||
ENERGY_EXPENDITURE_KJ: "kJ",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
|
||||
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
|
||||
AVERAGE_HEART_RATE: "bpm",
|
||||
MAX_HEART_RATE: "bpm",
|
||||
// Per-night sleep disturbance tally — a plain integer count.
|
||||
SLEEP_DISTURBANCE_COUNT: "count",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -449,6 +465,15 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous
|
||||
// ceiling over any plausible ultra-endurance day.
|
||||
ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 },
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// Daily average / max heart rate bpm — same plausibility band as the
|
||||
// spot PULSE: endurance athletes touch the 30s at rest, severe
|
||||
// tachycardia caps below 300.
|
||||
AVERAGE_HEART_RATE: { min: 20, max: 300 },
|
||||
MAX_HEART_RATE: { min: 20, max: 300 },
|
||||
// Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a
|
||||
// generous ceiling over any plausible severely-fragmented night.
|
||||
SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 },
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
|
||||
+41
-3
@@ -190,6 +190,9 @@ export interface WhoopSleep {
|
||||
total_light_sleep_time_milli: number;
|
||||
total_slow_wave_sleep_time_milli: number;
|
||||
total_rem_sleep_time_milli: number;
|
||||
// v1.12.8 — per-night sleep disturbance tally. Optional: older / still-
|
||||
// scoring records may omit it.
|
||||
disturbance_count?: number;
|
||||
};
|
||||
sleep_needed: {
|
||||
baseline_milli: number;
|
||||
@@ -581,18 +584,29 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof stages.disturbance_count === "number") {
|
||||
out.push({
|
||||
type: "SLEEP_DISTURBANCE_COUNT",
|
||||
value: stages.disturbance_count,
|
||||
unit: "count",
|
||||
measuredAt,
|
||||
fieldTag: "disturbances",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED
|
||||
* `STRAIN_SCORE`) and `ENERGY_EXPENDITURE_KJ` (kept in native kJ). Energy is
|
||||
* NOT converted to kcal here — that conversion is for the workout path only.
|
||||
* `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's
|
||||
* whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted
|
||||
* to kcal here — that conversion is for the workout path only.
|
||||
*/
|
||||
export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
|
||||
if (!c.score) return [];
|
||||
const measuredAt = new Date(c.start);
|
||||
return [
|
||||
const out: MappedMeasurement[] = [
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
value: round2(c.score.strain),
|
||||
@@ -608,6 +622,30 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
|
||||
fieldTag: "energy_kj",
|
||||
},
|
||||
];
|
||||
|
||||
// v1.12.8 — the cycle score's average / max heart rate were fetched but
|
||||
// dropped before this release. Distinct fieldTags keep them from colliding
|
||||
// with the strain / energy rows under the shared cycle `externalId`.
|
||||
if (typeof c.score.average_heart_rate === "number") {
|
||||
out.push({
|
||||
type: "AVERAGE_HEART_RATE",
|
||||
value: Math.round(c.score.average_heart_rate),
|
||||
unit: "bpm",
|
||||
measuredAt,
|
||||
fieldTag: "avg_hr",
|
||||
});
|
||||
}
|
||||
if (typeof c.score.max_heart_rate === "number") {
|
||||
out.push({
|
||||
type: "MAX_HEART_RATE",
|
||||
value: Math.round(c.score.max_heart_rate),
|
||||
unit: "bpm",
|
||||
measuredAt,
|
||||
fieldTag: "max_hr",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */
|
||||
|
||||
@@ -35,9 +35,12 @@ A recovery record with `score === null` (WHOOP still scoring) maps to nothing.
|
||||
| `sleep_efficiency_percentage` | `SLEEP_EFFICIENCY` | % | `sleep_eff` | — |
|
||||
| `sleep_consistency_percentage` | `SLEEP_CONSISTENCY` | % | `sleep_consistency` | — |
|
||||
| `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | — |
|
||||
| `stage_summary.disturbance_count` | `SLEEP_DISTURBANCE_COUNT` | count | `disturbances` | — |
|
||||
|
||||
Stage durations are ms→minutes (`÷ 60000`). One row per stage per night
|
||||
(same pattern as Apple Health). Percentage / respiratory fields are optional.
|
||||
(same pattern as Apple Health). Percentage / respiratory / disturbance-count
|
||||
fields are optional. `disturbance_count` (v1.12.8) is a per-night tally — a
|
||||
plain integer, stored as-is.
|
||||
|
||||
## Cycle (`/v2/cycle`) — record `start` is `measuredAt`
|
||||
|
||||
@@ -45,6 +48,8 @@ Stage durations are ms→minutes (`÷ 60000`). One row per stage per night
|
||||
|---|---|---|---|---|
|
||||
| `cycle.score.strain` | `DAY_STRAIN` | score | `day_strain` | 0–21 WHOOP scale. Distinct from the COMPUTED `STRAIN_SCORE` (0–100 TRIMP proxy). |
|
||||
| `cycle.score.kilojoule` | `ENERGY_EXPENDITURE_KJ` | kJ | `energy_kj` | Native kJ — NOT converted to kcal (the workout path converts). |
|
||||
| `cycle.score.average_heart_rate` | `AVERAGE_HEART_RATE` | bpm | `avg_hr` | v1.12.8 — the day's whole-cycle average HR. Fetched but dropped before v1.12.8; now mapped. Distinct from spot `PULSE` / `RESTING_HEART_RATE` / `WALKING_HEART_RATE_AVERAGE`. |
|
||||
| `cycle.score.max_heart_rate` | `MAX_HEART_RATE` | bpm | `max_hr` | v1.12.8 — the day's whole-cycle peak HR. Fetched but dropped before v1.12.8; now mapped. |
|
||||
|
||||
## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement`
|
||||
|
||||
|
||||
Reference in new issue
Block a user