mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
3f5816112b
commit
1f01b941ae
14 files changed
+619
-3
No files matched your search
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InsightMetric } from "@/lib/insights/metric-availability";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
* v1.4.32 — shared scaffold for the wave-A HealthKit metric pages.
|
||||
*
|
||||
* Each of the five new metric sub-pages (HRV, resting HR, oxygen
|
||||
* saturation, body temperature, active energy) shares the same
|
||||
* envelope:
|
||||
*
|
||||
* 1. `<SubPageShell>` with a localised title + description.
|
||||
* 2. Empty-state CTA when the user has no observations for the
|
||||
* metric — points at `/measurements?add=<TYPE>` so the existing
|
||||
* quick-entry dialog can light up. The CTA target is optional;
|
||||
* Apple-Health-only metrics (active energy, body temperature)
|
||||
* pass `null` to render an onboarding hint without a dead link.
|
||||
* 3. `<HealthChartDynamic>` mounted on the canonical
|
||||
* `ChartOverlayKey` so the chart-cog popover persists per metric.
|
||||
* 4. `<CoachLaunchButton>` at the foot — feature-flag gating runs
|
||||
* inside the button so this scaffold stays agnostic.
|
||||
*
|
||||
* Adding a new metric is a four-line page module that hands the right
|
||||
* Zod / MeasurementType + ChartOverlayKey + i18n-key prefix to this
|
||||
* component; the template stays single-source-of-truth.
|
||||
*/
|
||||
|
||||
export interface HealthKitMetricPageProps {
|
||||
/** The MeasurementType that backs the chart. */
|
||||
measurementType: string;
|
||||
/** The InsightMetric key used by `useInsightsAnalytics()`. */
|
||||
insightMetric: InsightMetric;
|
||||
/** The chart-overlay slot id. */
|
||||
chartKey: ChartOverlayKey;
|
||||
/**
|
||||
* i18n key prefix that drives `title`, `description`, `chartTitle`,
|
||||
* `emptyState.title`, `emptyState.description`, `emptyState.cta`.
|
||||
* Pages pass e.g. `"insights.hrv"` and the scaffold resolves each
|
||||
* sub-key off the prefix.
|
||||
*/
|
||||
i18nPrefix: string;
|
||||
/** Single canonical colour for the chart line. */
|
||||
color: string;
|
||||
/** Unit suffix the chart renders next to the value. */
|
||||
unit: string;
|
||||
/** Optional y-axis label shown above the unit. */
|
||||
yAxisUnit?: string;
|
||||
/** Optional value bands (Apple-Health-style target zone shading). */
|
||||
valueBands?: ComponentProps<typeof HealthChartDynamic>["valueBands"];
|
||||
/**
|
||||
* Optional empty-state CTA target. `null` renders the empty state
|
||||
* without a primary action (Apple-Health-only metrics that have no
|
||||
* manual entry form). String values land in `/measurements?add=<x>`.
|
||||
*/
|
||||
emptyStateCtaType?: string | null;
|
||||
/** Icon node mounted in the empty-state card. */
|
||||
emptyStateIcon: ReactNode;
|
||||
/**
|
||||
* Optional Coach prefill for the empty-state launch. Falls back to a
|
||||
* generic onboarding prompt threaded through `<CoachLaunchButton>`'s
|
||||
* default.
|
||||
*/
|
||||
coachPrefill?: string;
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
measurementType,
|
||||
insightMetric,
|
||||
chartKey,
|
||||
i18nPrefix,
|
||||
color,
|
||||
unit,
|
||||
yAxisUnit,
|
||||
valueBands,
|
||||
emptyStateCtaType,
|
||||
emptyStateIcon,
|
||||
coachPrefill,
|
||||
}: HealthKitMetricPageProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics(insightMetric);
|
||||
|
||||
const title = t(`${i18nPrefix}.title`);
|
||||
const description = t(`${i18nPrefix}.description`);
|
||||
|
||||
if (isEmpty) {
|
||||
const ctaNode =
|
||||
emptyStateCtaType != null ? (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/measurements?add=${emptyStateCtaType}`}>
|
||||
{t(`${i18nPrefix}.emptyState.cta`)}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null;
|
||||
return (
|
||||
<SubPageShell title={title} description={description}>
|
||||
<MetricEmptyState
|
||||
icon={emptyStateIcon}
|
||||
title={t(`${i18nPrefix}.emptyState.title`)}
|
||||
description={t(`${i18nPrefix}.emptyState.description`)}
|
||||
cta={ctaNode}
|
||||
coachPrefill={coachPrefill ?? null}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell title={title} description={description}>
|
||||
<HealthChartDynamic
|
||||
chartKey={chartKey}
|
||||
types={[measurementType]}
|
||||
title={t(`${i18nPrefix}.chartTitle`)}
|
||||
colors={[color]}
|
||||
unit={unit}
|
||||
yAxisUnit={yAxisUnit}
|
||||
valueBands={valueBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,14 @@ export const CHART_OVERLAY_KEYS = [
|
||||
// overlay-prefs slot so the cardio sub-page's VO2 chart-cog state
|
||||
// doesn't bleed into the pulse chart sitting directly above it.
|
||||
"vo2Max",
|
||||
// v1.4.32 — wave-A HealthKit chart cards. Each one owns its own
|
||||
// overlay-prefs slot so the chart-cog popover (7-day trend / Trend
|
||||
// arrow / Target range / Comparison baseline) persists per metric.
|
||||
"hrv",
|
||||
"restingHr",
|
||||
"oxygenSaturation",
|
||||
"bodyTemperature",
|
||||
"activeEnergy",
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
["BLOOD_PRESSURE_SYS", "vitals"],
|
||||
["BLOOD_PRESSURE_DIA", "vitals"],
|
||||
["PULSE", "vitals"],
|
||||
["RESTING_HEART_RATE", "vitals"],
|
||||
["HEART_RATE_VARIABILITY", "vitals"],
|
||||
["OXYGEN_SATURATION", "vitals"],
|
||||
["BODY_TEMPERATURE", "vitals"],
|
||||
|
||||
@@ -96,7 +94,15 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// ── Environment ──
|
||||
["TIME_IN_DAYLIGHT", "environment"],
|
||||
|
||||
// ── Cardiovascular (derived risk markers) ──
|
||||
// ── Cardiovascular (derived risk markers + Apple-Health-grouped
|
||||
// heart-rate variants). v1.4.32 — RESTING_HEART_RATE +
|
||||
// HEART_RATE_VARIABILITY moved here from `vitals` to align with the
|
||||
// iOS handoff brief's category table (RESTING_HR, HRV →
|
||||
// cardiovascular). PULSE stays in `vitals` because spot-pulse
|
||||
// samples are point-in-time signals; the resting + variability
|
||||
// metrics are the derived cardiac-risk surface. ──
|
||||
["RESTING_HEART_RATE", "cardiovascular"],
|
||||
["HEART_RATE_VARIABILITY", "cardiovascular"],
|
||||
["PULSE_WAVE_VELOCITY", "cardiovascular"],
|
||||
["VASCULAR_AGE", "cardiovascular"],
|
||||
|
||||
|
||||
Reference in new issue
Block a user