frontend: health trend graphs — taller widgets, top-right keys, tighter x-axis spacing, HRV line-only (no fill) with baseline-based Y range, weight/VO2 Y axes scaled to displayed data
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

- All trend chart widgets taller (140->170) with bottom margin trimmed to 0 to cut empty space below the x-axis
- Sleep trend legend moved to top-right beside the title (matches HRV)
- HRV (overnight avg) now line+dots only (no purple gradient fill) so the grey baseline band reads clearly; Y axis spans baseline-low -15ms to baseline-high +15ms
- Weight Y axis now +/-5kg around displayed range (still keeps goal line in view)
- VO2 Max Y axis now displayed min/max +/-5 points instead of fixed 30-70

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:47:24 +01:00
co-authored by Claude Opus 4.8
parent 227dadaca0
commit c14f3d60fa
+63 -25
View File
@@ -578,6 +578,17 @@ const statusDot = (statusKey) => (props) => {
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} /> return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
} }
// 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 <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
if (value == null) return null
return <circle cx={cx} cy={cy} r={2.5} fill={baseColor} />
}
// Custom tooltip: collapses the dashed gap-bridging Line and the solid Area // Custom tooltip: collapses the dashed gap-bridging Line and the solid Area
// (same dataKey) into one row, and never shows the helper baseline band. // (same dataKey) into one row, and never shows the helper baseline band.
function ChartTooltip({ active, payload, label, formatter }) { 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) const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return ( if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div> <div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
@@ -618,7 +629,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<ComposedChart <ComposedChart
data={chartData} data={chartData}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }} style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => { onClick={evt => {
const p = evt?.activePayload?.[0]?.payload const p = evt?.activePayload?.[0]?.payload
@@ -647,14 +658,21 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
{(referenceLines || []).map((rl, i) => ( {(referenceLines || []).map((rl, i) => (
<ReferenceLine key={i} {...rl} /> <ReferenceLine key={i} {...rl} />
))} ))}
{/* 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. */} covers it wherever real data exists, leaving only gaps shown dashed. */}
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5} <Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5}
strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" /> strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2} {fillArea ? (
fill={`url(#grad-${dataKey})`} <Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)} fill={`url(#grad-${dataKey})`}
connectNulls={false} isAnimationActive={false} /> dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
connectNulls={false} isAnimationActive={false} />
) : (
// Line-only (no gradient fill) — e.g. HRV, so the grey baseline band shows through.
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
dot={statusDotKey ? statusDotWithBase(statusDotKey, color) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
connectNulls={false} isAnimationActive={false} legendType="none" />
)}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
) )
@@ -677,10 +695,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
.filter(t => t > 0) .filter(t => t > 0)
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
return ( return (
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={170}>
<BarChart <BarChart
data={chartData} data={chartData}
margin={{ top: 4, right: 44, bottom: 4, left: 0 }} margin={{ top: 4, right: 44, bottom: 0, left: 0 }}
barSize={6} barSize={6}
style={{ cursor: onDayClick ? 'pointer' : 'default' }} style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => { onClick={evt => {
@@ -760,8 +778,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
const maxKg = Math.max(...withWeight.map(d => d.weight_kg)) const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
const minKg = Math.min(...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 goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent // ±5 kg around the displayed weight range; keep the goal line in view if set.
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight 20 kg equivalent 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`) const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
return ( return (
@@ -769,8 +790,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle} <h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div> </div>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={170}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} <AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }} style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => { onClick={evt => {
const p = evt?.activePayload?.[0]?.payload const p = evt?.activePayload?.[0]?.payload
@@ -846,6 +867,21 @@ export default function HealthPage() {
}) })
const metrics = rawMetrics || [] 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 // Snapshot navigation: newest-first sorted list of all available days
const allDaysSorted = useMemo( const allDaysSorted = useMemo(
() => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)), () => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
@@ -994,6 +1030,7 @@ export default function HealthPage() {
</div> </div>
<MetricChart data={metrics} dataKey="hrv_weekly_avg" color="#8b5cf6" <MetricChart data={metrics} dataKey="hrv_weekly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`} formatter={v => `${Math.round(v)} ms`}
fillArea={false} domain={hrvDomain}
selectedDate={selDateForCharts} onDayClick={handleDayClick} selectedDate={selDateForCharts} onDayClick={handleDayClick}
statusDotKey="hrv_status" statusDotKey="hrv_status"
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af" bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
@@ -1001,17 +1038,18 @@ export default function HealthPage() {
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
<div className="flex items-center gap-3 text-xs text-gray-500">
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<span key={l} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-sm" style={{ backgroundColor: c }} />{l}
</span>
))}
</div>
</div>
<SleepChart data={metrics} <SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
<div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
</div>
))}
</div>
</div> </div>
{metrics.some(d => d.sleep_score != null) && ( {metrics.some(d => d.sleep_score != null) && (
@@ -1038,10 +1076,10 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={170}>
<BarChart <BarChart
data={metrics} data={metrics}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
barSize={6} barSize={6}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={evt => { onClick={evt => {
@@ -1095,7 +1133,7 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" <MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)} formatter={v => v.toFixed(1)}
domain={[30, 70]} domain={vo2Domain}
connectNulls showDots connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>