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]) => (
-
- ))}
-
{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} />