diff --git a/CHANGELOG.md b/CHANGELOG.md index ec697a3b3..a90e136bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b6615a691..95ec1e8af 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index b8e95477c..ef739e2c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 82405daa4..0113f3ab5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 5cff9817f..85772d59c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e855a5951..76a80ca9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 328707c98..75d2f312a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 8727a1bbf..4ce4a7ddc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 294c22337..3ff67435d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql new file mode 100644 index 000000000..58c713efe --- /dev/null +++ b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e07cb488e..0fd25f1a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/admin/ai-settings/route.ts b/src/app/api/admin/ai-settings/route.ts index 0883f5e5d..90f406b88 100644 --- a/src/app/api/admin/ai-settings/route.ts +++ b/src/app/api/admin/ai-settings/route.ts @@ -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({ diff --git a/src/app/globals.css b/src/app/globals.css index a10abb382..7a88a0c7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 ... }`. diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index acde8bac2..91347efb0 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -33,17 +33,29 @@ const PAGES_DIR = join(process.cwd(), "src", "app", "insights"); // `useInsightStatus`-backed pages render ``; the // medications page reads the richer `summary`-shaped route inline, so it // renders `` 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: " { - 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 ``. The icon - // passed as a prop (``) 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 (``) 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(" {/* v1.12.4 — target card sits between the chart and the assessment on diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 89df6a77a..23591ac99 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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} /> + {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off + the overview onto its metric page). */} + + + {/* 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. */} + } /> + {/* 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 `` 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 ? ( - -