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:
Marc-André Bombeck authored and GitHub committed 2026-06-05 15:05:51 +02:00
commit bbe329f52b
38 files changed
+537 -315

No files matched your search

+16 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+10
View File
@@ -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")
}
+1 -1
View File
@@ -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({
+24
View File
@@ -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");
+6 -6
View File
@@ -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
+23 -27
View File
@@ -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>
);
}
+4 -4
View File
@@ -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" />
+50 -125
View File
@@ -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"');
});
+45 -4
View File
@@ -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.
+8 -18
View File
@@ -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) => (
+5 -5
View File
@@ -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",
};
+24 -29
View File
@@ -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", () => {
+2 -2
View File
@@ -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": {
+11
View File
@@ -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", () => {
+8
View File
@@ -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"],
]);
/**
+10
View File
@@ -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;
}
}
+25
View File
@@ -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
View File
@@ -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`). */
+6 -1
View File
@@ -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`