Fix follow-ups: lap bests, segments, charts, dashboard health
- Lap bests: compare against OTHER activities on the route (exclude self), so single-activity routes no longer show every lap as "best" - Segment create: POST to trailing-slash URL (was a 307 that dropped the body); surface errors in the UI - PR splits: scale GPS distance stream to the activity's official distance so over-measured GPS no longer yields bogus split PRs - Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth interpolation + a Slow/Fast gradient key under the map - Health body battery: snap activity highlight to the categorical axis; white tooltip text + % suffix - Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too - Health sleep: move 8h/avg reference labels into the right margin - Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max); body battery tile renders a condensed colour-graded intraday graph Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||
import { useMemo } from 'react'
|
||||
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
@@ -27,53 +28,45 @@ function bbLevelColor(level) {
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
function MiniBodyBattery({ bb }) {
|
||||
if (!bb?.end_level && !bb?.charged) return null
|
||||
const { charged, drained, start_level, end_level, values } = bb
|
||||
const color = bbLevelColor(end_level)
|
||||
const sparkData = Array.isArray(values)
|
||||
? values.map(([ts, level]) => ({ ts, level }))
|
||||
: []
|
||||
function MiniBodyBattery({ bb, hires }) {
|
||||
const data = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
|
||||
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
|
||||
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
|
||||
const hasGraph = data.length >= 2
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||
<Link to="/health" className="text-xs text-blue-400 hover:underline">View →</Link>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
{end_level != null && (
|
||||
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
|
||||
)}
|
||||
{charged != null && (
|
||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||
)}
|
||||
{drained != null && (
|
||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||
{peak != null && (
|
||||
<span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>
|
||||
)}
|
||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||
</div>
|
||||
{start_level != null && end_level != null && (
|
||||
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
||||
)}
|
||||
{sparkData.length >= 2 && (
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
|
||||
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
|
||||
{hasGraph ? (
|
||||
<div className="mt-3 flex-1">
|
||||
<ResponsiveContainer width="100%" height={70}>
|
||||
<BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||
<YAxis domain={[0, 100]} hide />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
||||
formatter={v => [`${Math.round(v)}%`, 'Battery']}
|
||||
/>
|
||||
</AreaChart>
|
||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||
{data.map((d, i) => <Cell key={i} fill={bbLevelColor(d.level)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -148,9 +141,32 @@ export default function DashboardPage() {
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: healthSummary } = useQuery({
|
||||
queryKey: ['health-summary'],
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
const { data: recentHealth } = useQuery({
|
||||
queryKey: ['health-metrics', 'dash'],
|
||||
queryFn: () => api.get('/health-metrics/', { params: { limit: 365 } }).then(r => r.data),
|
||||
})
|
||||
|
||||
// Latest available (non-null) value per metric — Garmin updates some fields
|
||||
// less often than daily, so "today" can be sparse.
|
||||
const health = useMemo(() => {
|
||||
const rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
const pick = f => rows.find(d => d[f] != null)?.[f] ?? null
|
||||
return {
|
||||
date: rows[0]?.date ?? null,
|
||||
resting_hr: pick('resting_hr'),
|
||||
sleep_duration_s: pick('sleep_duration_s'),
|
||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||
sleep_score: pick('sleep_score'),
|
||||
steps: pick('steps'),
|
||||
vo2max: pick('vo2max'),
|
||||
avg_stress: pick('avg_stress'),
|
||||
}
|
||||
}, [recentHealth])
|
||||
|
||||
const { data: intraday } = useQuery({
|
||||
queryKey: ['health-intraday-dash', health.date],
|
||||
queryFn: () => api.get('/health-metrics/intraday', { params: { date: health.date } }).then(r => r.data),
|
||||
enabled: !!health.date,
|
||||
})
|
||||
|
||||
const { data: records } = useQuery({
|
||||
@@ -163,7 +179,6 @@ export default function DashboardPage() {
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const latest = healthSummary?.latest
|
||||
const featured = recentActivities?.[0]
|
||||
|
||||
return (
|
||||
@@ -176,8 +191,8 @@ export default function DashboardPage() {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(health.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
@@ -187,19 +202,19 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<MiniBodyBattery bb={latest?.body_battery} />
|
||||
<MiniBodyBattery bb={intraday?.body_battery} hires={intraday?.body_battery_hires} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||
{latest ? (
|
||||
{health.date ? (
|
||||
<>
|
||||
{[
|
||||
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
|
||||
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
|
||||
['Steps', latest.steps?.toLocaleString() ?? '--'],
|
||||
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
|
||||
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
|
||||
['HRV', health.hrv_nightly_avg ? `${Math.round(health.hrv_nightly_avg)} ms` : '--'],
|
||||
['Sleep score', health.sleep_score ? Math.round(health.sleep_score) : '--'],
|
||||
['Steps', health.steps?.toLocaleString() ?? '--'],
|
||||
['VO2 Max', health.vo2max ? health.vo2max.toFixed(1) : '--'],
|
||||
['Stress', health.avg_stress ? Math.round(health.avg_stress) : '--'],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
|
||||
Reference in New Issue
Block a user