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
- 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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user