feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy

Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:

  - /insights/hrv               → HEART_RATE_VARIABILITY
  - /insights/ruhepuls          → RESTING_HEART_RATE
  - /insights/sauerstoff        → OXYGEN_SATURATION
  - /insights/koerpertemperatur → BODY_TEMPERATURE
  - /insights/aktive-energie    → ACTIVE_ENERGY_BURNED

Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.

Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 15:53:18 +02:00
1 parent 3f5816112b
commit 1f01b941ae
14 files changed
+619 -3

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Flame } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/aktive-energie`.
*
* Active-energy-burned sub-page. The MeasurementType is cumulative
* (kcal per day, per R-A Option A); the v1.4.30 server-side daily-
* stats helper collapses per-sample iOS posts to a single daily row.
* The chart shows that one-row-per-day shape — long-tail back-fill
* imports collapse cleanly via the v1.4.30 drain script.
*/
export default function InsightsActiveEnergyPage() {
return (
<HealthKitMetricPage
measurementType="ACTIVE_ENERGY_BURNED"
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
color="#ffb86c"
unit="kcal"
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any active energy yet — what's a reasonable daily target, and how does it compare to total calories?"
/>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/hrv`.
*
* Heart-rate variability sub-page. Pairs with the iOS HKQuantityType
* `heartRateVariabilitySDNN` ingest path; the user typically logs one
* sample per night during sleep, so the chart is sparse but the
* weekly trend is the load-bearing signal. Stored as millisecond
* `HEART_RATE_VARIABILITY` rows by every existing ingest path.
*/
export default function InsightsHrvPage() {
return (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
color="#bd93f9"
unit="ms"
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
/>
);
}
@@ -0,0 +1,30 @@
"use client";
import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/koerpertemperatur`.
*
* Body-temperature sub-page. Canonical storage is Celsius. Distinct
* from skin temperature (Withings ScanWatch dermal reading) — surface
* temps run ~32 °C, core ~37 °C, so the schema deliberately keeps
* them on separate enum values per the v1.4.30 categorisation overlay.
*/
export default function InsightsBodyTemperaturePage() {
return (
<HealthKitMetricPage
measurementType="BODY_TEMPERATURE"
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
color="#ff79c6"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
/>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/ruhepuls`.
*
* Resting-heart-rate sub-page. Distinct from `/insights/puls` — Apple
* Health splits the steady-state daily `restingHeartRate` value from
* the spot `PULSE` samples logged manually or by Withings. The split
* here mirrors that distinction so the user has a "daily floor"
* trend that's not muddied by mid-walk readings.
*/
export default function InsightsRestingHrPage() {
return (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
/>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/sauerstoff`.
*
* Oxygen-saturation (SpO₂) sub-page. Canonical storage is percent
* (0..100); the chart axis runs 85..100 because clinical normal is
* 95..100 and the 85 floor leaves room for nightly dips without
* compressing the trend. Withings ScanWatch + Apple Watch both
* report SpO₂; the v1.4.25 source-priority picker keeps the surface
* deduped.
*/
export default function InsightsOxygenSaturationPage() {
return (
<HealthKitMetricPage
measurementType="OXYGEN_SATURATION"
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
color="#8be9fd"
unit="%"
yAxisUnit="%"
valueBands={[
// < 90 % — clinical hypoxia floor, paint the danger band.
{ min: 80, max: 90, color: "#ff5555", opacity: 0.18 },
// 90 .. 95 % — watch zone.
{ min: 90, max: 95, color: "#ffb86c", opacity: 0.18 },
// 95 .. 100 % — clinical normal.
{ min: 95, max: 100, color: "#50fa7b", opacity: 0.18 },
]}
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?"
/>
);
}