diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 2efe67e..a78bdcc 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -578,6 +578,17 @@ const statusDot = (statusKey) => (props) => { return } +// Like statusDot, but also draws a small base-coloured dot on every reading that +// has no status colour — so a line-only chart (e.g. HRV) shows every data point. +const statusDotWithBase = (statusKey, baseColor) => (props) => { + const { cx, cy, payload, value } = props + if (cx == null || cy == null) return null + const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()] + if (color) return + if (value == null) return null + return +} + // Custom tooltip: collapses the dashed gap-bridging Line and the solid Area // (same dataKey) into one row, and never shows the helper baseline band. function ChartTooltip({ active, payload, label, formatter }) { @@ -600,7 +611,7 @@ function ChartTooltip({ active, payload, label, formatter }) { ) } -function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) { +function MetricChart({ data, dataKey, color, formatter, height = 170, selectedDate, onDayClick, connectNulls = false, showDots = false, fillArea = true, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) { const vals = data.filter(d => d[dataKey] != null) if (!vals.length) return (
No data
@@ -618,7 +629,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa { const p = evt?.activePayload?.[0]?.payload @@ -647,14 +658,21 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa {(referenceLines || []).map((rl, i) => ( ))} - {/* Dashed line bridging gaps (no data). Drawn first; the solid area below + {/* Dashed line bridging gaps (no data). Drawn first; the solid series below covers it wherever real data exists, leaving only gaps shown dashed. */} - + {fillArea ? ( + + ) : ( + // Line-only (no gradient fill) — e.g. HRV, so the grey baseline band shows through. + + )} ) @@ -677,10 +695,10 @@ function SleepChart({ data, selectedDate, onDayClick }) { .filter(t => t > 0) const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null return ( - + { @@ -760,8 +778,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) { const maxKg = Math.max(...withWeight.map(d => d.weight_kg)) const minKg = Math.min(...withWeight.map(d => d.weight_kg)) const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null - const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent - const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent + // ±5 kg around the displayed weight range; keep the goal line in view if set. + const lowKg = goalKg != null ? Math.min(minKg, goalKg) : minKg + const highKg = goalKg != null ? Math.max(maxKg, goalKg) : maxKg + const yMax = Math.ceil(toU(highKg + 5)) + const yMin = Math.max(0, Math.floor(toU(Math.max(0, lowKg - 5)))) const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`) return ( @@ -769,8 +790,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {

{title}

{toggle}
- - + { const p = evt?.activePayload?.[0]?.payload @@ -846,6 +867,21 @@ export default function HealthPage() { }) const metrics = rawMetrics || [] + // HRV Y-axis: span the baseline band ±15 ms (low baseline − 15 → high baseline + 15). + const hrvDomain = useMemo(() => { + const lows = metrics.map(d => d.hrv_baseline_low).filter(v => v != null) + const ups = metrics.map(d => d.hrv_baseline_upper).filter(v => v != null) + if (!lows.length || !ups.length) return ['auto', 'auto'] + return [Math.floor(Math.min(...lows) - 15), Math.ceil(Math.max(...ups) + 15)] + }, [metrics]) + + // VO2 Max Y-axis: displayed min/max ±5 points. + const vo2Domain = useMemo(() => { + const vals = metrics.map(d => d.vo2max).filter(v => v != null) + if (!vals.length) return [30, 70] + return [Math.floor(Math.min(...vals) - 5), Math.ceil(Math.max(...vals) + 5)] + }, [metrics]) + // Snapshot navigation: newest-first sorted list of all available days const allDaysSorted = useMemo( () => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)), @@ -994,6 +1030,7 @@ export default function HealthPage() { `${Math.round(v)} ms`} + fillArea={false} domain={hrvDomain} selectedDate={selDateForCharts} onDayClick={handleDayClick} statusDotKey="hrv_status" bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af" @@ -1001,17 +1038,18 @@ export default function HealthPage() {
-

Sleep

+
+

Sleep

+
+ {[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => ( + + {l} + + ))} +
+
-
- {[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => ( -
-
- {l} -
- ))} -
{metrics.some(d => d.sleep_score != null) && ( @@ -1038,10 +1076,10 @@ export default function HealthPage() {

Daily Steps

- + { @@ -1095,7 +1133,7 @@ export default function HealthPage() {

VO2 Max

v.toFixed(1)} - domain={[30, 70]} + domain={vo2Domain} connectNulls showDots selectedDate={selDateForCharts} onDayClick={handleDayClick} />