Batch 1: dashboard, maps, segments rewrite, health, sync UX
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar
Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows
Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar, ReferenceLine,
|
||||
AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { format, subDays } from 'date-fns'
|
||||
@@ -280,6 +280,20 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||
))}
|
||||
</Bar>
|
||||
{(activities || []).map(a => {
|
||||
const start = new Date(a.start_time).getTime()
|
||||
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
||||
return (
|
||||
<ReferenceArea
|
||||
key={`area-${a.id}`}
|
||||
x1={start}
|
||||
x2={end}
|
||||
fill="rgba(255,255,255,0.12)"
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(activities || []).map(a => (
|
||||
<ReferenceLine
|
||||
key={a.id}
|
||||
@@ -425,7 +439,7 @@ function NavArrow({ onClick, disabled, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
if (!day) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
@@ -562,11 +576,14 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-xl font-semibold text-emerald-400">
|
||||
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
|
||||
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
|
||||
</span>
|
||||
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
|
||||
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
|
||||
{snapshotWeight?.fat && !snapshotWeight.carried && <span className="text-xs text-gray-500">{snapshotWeight.fat.toFixed(1)}% fat</span>}
|
||||
</div>
|
||||
{snapshotWeight?.carried && (
|
||||
<p className="text-xs text-gray-600 mt-0.5">as of {format(new Date(snapshotWeight.date), 'd MMM')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,6 +733,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
if (!hasData) return (
|
||||
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
||||
)
|
||||
const totals = chartData
|
||||
.map(d => (d.deep || 0) + (d.rem || 0) + (d.light || 0) + (d.awake || 0))
|
||||
.filter(t => t > 0)
|
||||
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart
|
||||
@@ -737,6 +758,12 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
{selectedDate && (
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
|
||||
label={{ value: '8h', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 }} />
|
||||
{avgSleep != null && (
|
||||
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
|
||||
label={{ value: `avg ${avgSleep}h`, position: 'insideBottomRight', fill: '#a855f7', fontSize: 9 }} />
|
||||
)}
|
||||
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
||||
@@ -746,6 +773,99 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Weight (with goal line + kg ⇄ st/lb toggle) ──────────────────────────────
|
||||
|
||||
const KG_TO_LB = 2.2046226218
|
||||
|
||||
function fmtStLb(lb) {
|
||||
let st = Math.floor(lb / 14)
|
||||
let r = Math.round(lb - st * 14)
|
||||
if (r === 14) { st += 1; r = 0 }
|
||||
return `${st} st ${r} lb`
|
||||
}
|
||||
|
||||
function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
const [unit, setUnit] = useState(() => localStorage.getItem('weightUnit') || 'kg')
|
||||
const choose = (u) => { setUnit(u); localStorage.setItem('weightUnit', u) }
|
||||
const imperial = unit === 'lb'
|
||||
const toU = (kg) => (imperial ? kg * KG_TO_LB : kg)
|
||||
|
||||
const withWeight = data.filter(d => d.weight_kg != null)
|
||||
const series = withWeight.map(d => ({ date: d.date, w: +toU(d.weight_kg).toFixed(2) }))
|
||||
|
||||
const title = imperial ? 'Weight (st & lb)' : 'Weight (kg)'
|
||||
const toggle = (
|
||||
<div className="flex gap-1">
|
||||
{[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => (
|
||||
<button key={u} onClick={() => choose(u)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 bg-gray-800 hover:text-white'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!series.length) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No weight data</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
||||
const minW = Math.min(...series.map(s => s.w))
|
||||
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(minW - (imperial ? 6 : 3)))
|
||||
const fmtVal = (v) => (imperial ? fmtStLb(v) : `${v.toFixed(1)} kg`)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
const p = evt?.activePayload?.[0]?.payload
|
||||
if (p?.date && onDayClick) onDayClick(p.date)
|
||||
}}>
|
||||
<defs>
|
||||
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||
<YAxis domain={[yMin, yMax]} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={36} tickFormatter={v => Math.round(v)} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [fmtVal(v), 'Weight']} />
|
||||
{selectedDate && (
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
{goalU != null && (
|
||||
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
|
||||
label={{ value: `Goal ${fmtVal(goalU)}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||
)}
|
||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
||||
connectNulls isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function HealthPage() {
|
||||
@@ -809,6 +929,15 @@ export default function HealthPage() {
|
||||
return found ? found.vo2max : null
|
||||
}, [allDaysSorted])
|
||||
|
||||
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
|
||||
const snapshotWeight = useMemo(() => {
|
||||
if (!selectedDay) return null
|
||||
if (selectedDay.weight_kg != null)
|
||||
return { kg: selectedDay.weight_kg, fat: selectedDay.body_fat_pct, carried: false }
|
||||
const earlier = allDaysSorted.find(d => d.weight_kg != null && d.date <= selectedDay.date)
|
||||
return earlier ? { kg: earlier.weight_kg, fat: earlier.body_fat_pct, carried: true, date: earlier.date } : null
|
||||
}, [selectedDay, allDaysSorted])
|
||||
|
||||
const { data: intradayData } = useQuery({
|
||||
queryKey: ['health-intraday', selectedDay?.date],
|
||||
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
|
||||
@@ -844,6 +973,7 @@ export default function HealthPage() {
|
||||
|
||||
<DailySnapshot
|
||||
day={selectedDay}
|
||||
snapshotWeight={snapshotWeight}
|
||||
avg30={summary?.avg_30d}
|
||||
intradayHr={intradayData?.hr_values}
|
||||
bodyBattery={intradayData?.body_battery}
|
||||
@@ -921,13 +1051,10 @@ export default function HealthPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
||||
<MetricChart
|
||||
data={metrics.filter(d => d.weight_kg != null)}
|
||||
dataKey="weight_kg" color="#34d399"
|
||||
formatter={v => `${v.toFixed(1)} kg`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
connectNulls showDots />
|
||||
<WeightChart
|
||||
data={metrics}
|
||||
goalKg={profile?.goal_weight_kg}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
@@ -989,6 +1116,7 @@ export default function HealthPage() {
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
|
||||
formatter={v => v.toFixed(1)}
|
||||
domain={[30, 70]}
|
||||
connectNulls showDots
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user