@@ -185,11 +201,53 @@ export default function ActivityDetailPage() {
)}
- {/* Laps */}
- {laps && laps.length > 0 && (
-
-
Laps
-
+ {/* Laps + Segments side by side */}
+ {((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && (
+
+ {laps && laps.length > 0 && (
+
+
Laps
+
+
+ )}
+ {segments && segments.length > 0 && dataPoints && (
+
+
+
Segments
+ Manage →
+
+
+ Segment
+ This run
+ Best
+ Δ
+
+
+ {segments.map(seg => {
+ const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
+ const best = segmentBests?.find(b => b.segment_id === seg.id)
+ const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
+ const delta = t != null && best?.best_s != null ? t - best.best_s : null
+ return (
+
+ {seg.name}
+
+ {t != null ? formatDuration(t) : --}
+
+
+ {best?.best_s != null ? formatDuration(best.best_s) : '--'}
+
+
+ {isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
+
+
+ )
+ })}
+
+
+ )}
)}
diff --git a/milevault_export/frontend/src/pages/HealthPage.jsx b/milevault_export/frontend/src/pages/HealthPage.jsx
index 6d91413..95a1f8b 100644
--- a/milevault_export/frontend/src/pages/HealthPage.jsx
+++ b/milevault_export/frontend/src/pages/HealthPage.jsx
@@ -1,13 +1,12 @@
import { useState, useMemo } from 'react'
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
- LineChart, Line, AreaChart, Area, BarChart, Bar,
- XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
+ AreaChart, Area, BarChart, Bar, ReferenceLine,
+ XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'
import { format, subDays } from 'date-fns'
import api from '../utils/api'
-import StatCard from '../components/ui/StatCard'
-import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
+import { formatSleep, sportIcon } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
@@ -18,16 +17,540 @@ const RANGES = [
{ label: '1Y', days: 365 },
]
-const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
+const tooltipStyle = {
+ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
+}
-function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
+// Normalise any date string to YYYY-MM-DD so XAxis values and ReferenceLine x match.
+const d10 = (s) => (s || '').slice(0, 10)
+
+// ── Daily Snapshot ──────────────────────────────────────────────────────────
+
+function fmtTime(ts) {
+ if (!ts) return '--'
+ return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
+}
+
+function IntradayHrChart({ values }) {
+ if (!values?.length) return null
+ const data = values.map(([ts, hr]) => ({ t: ts, hr }))
+ return (
+
+
+
+
+
+
+
+
+ format(new Date(ts), 'HH:mm')}
+ interval={Math.max(1, Math.floor(data.length / 6))} />
+ Math.round(v)} domain={['auto', 'auto']} />
+ format(new Date(ts), 'HH:mm')}
+ formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
+
+
+
+ )
+}
+
+// ── Body Battery ─────────────────────────────────────────────────────────────
+
+const BB_INFERRED_COLOR = {
+ sleep: '#4f46e5',
+ rest: '#0d9488',
+ activity: '#f97316',
+ stable: '#374151',
+}
+const BB_INFERRED_LABEL = {
+ sleep: 'Sleep',
+ rest: 'Rest',
+ activity: 'Active/Stress',
+ stable: 'Stable',
+}
+
+function bbLevelColor(level) {
+ if (level == null) return '#6b7280'
+ if (level >= 75) return '#3b82f6'
+ if (level >= 50) return '#22c55e'
+ if (level >= 25) return '#f59e0b'
+ return '#ef4444'
+}
+
+function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
+ const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
+ if (inSleep) return 'sleep'
+ if (prevLevel != null) {
+ if (level > prevLevel + 0.3) return 'rest'
+ if (level < prevLevel - 0.3) return 'activity'
+ }
+ return 'stable'
+}
+
+function ActivityRefLabel({ viewBox, icon }) {
+ if (!viewBox) return null
+ const { x, y } = viewBox
+ return (
+
+ {icon}
+
+ )
+}
+
+function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
+ if (!bb) return null
+ const { charged, drained, start_level, end_level } = bb
+ if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
+
+ const rawData = hiresValues?.length
+ ? hiresValues.map(([ts, level]) => ({ t: ts, level }))
+ : (bb.values || []).map(([ts, level]) => ({ t: ts, level }))
+
+ if (!rawData.length) return null
+
+ const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
+ const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
+
+ const chartData = rawData.map((d, i) => ({
+ ...d,
+ type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs),
+ }))
+
+ const presentTypes = [...new Set(chartData.map(d => d.type))]
+ const levelColor = bbLevelColor(end_level)
+ const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
+
+ return (
+
+
Body Battery
+
+
+ {maxLevel != null && (
+ {Math.round(maxLevel)}
+ )}
+ {charged != null && (
+ +{charged}
+ )}
+ {drained != null && (
+ -{drained}
+ )}
+ {end_level != null && (
+ now {Math.round(end_level)}
+ )}
+
+
+
+
+
+ format(new Date(ts), 'HH:mm')}
+ interval={Math.max(1, Math.floor(chartData.length / 6))} />
+ v} ticks={[0, 25, 50, 75, 100]} />
+ format(new Date(ts), 'HH:mm')}
+ formatter={v => [`${Math.round(v)}`, 'Battery']} />
+
+ {chartData.map((d, i) => (
+ |
+ ))}
+
+ {(activities || []).map(a => (
+ }
+ />
+ ))}
+
+
+
+
+
+ {presentTypes.map(type => (
+
+
+
{BB_INFERRED_LABEL[type]}
+
+ ))}
+
+
+ )
+}
+
+// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis
+const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
+const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#8b5cf6' }
+const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
+const LANE_H = 15
+
+function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
+ if (!sleepStart || !sleepEnd || !stages?.length) return null
+ const startMs = new Date(sleepStart).getTime()
+ const endMs = new Date(sleepEnd).getTime()
+ const windowMs = endMs - startMs
+ if (windowMs <= 0) return null
+
+ // Build segments per lane
+ const segsByLane = {}
+ SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
+ stages.forEach(([tsMs, level], i) => {
+ if (!(level in segsByLane)) return
+ const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
+ const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
+ const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
+ const w = right - left
+ if (w > 0) segsByLane[level].push({ left, w })
+ })
+
+ // Hour ticks
+ const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
+ const ticks = []
+ for (let t = sh.getTime(); t < endMs; t += 3600000) {
+ const pct = (t - startMs) / windowMs * 100
+ if (pct >= 0 && pct <= 100)
+ ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
+ }
+
+ return (
+
+
+ {SLEEP_LANE_ORDER.map(level => (
+
+
+ {SLEEP_STAGE_LABEL[level]}
+
+
+ {segsByLane[level].map((seg, i) => (
+
+ ))}
+ {ticks.map((t, i) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ {new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
+
+ {ticks.map((t, i) => (
+
+ {t.label}
+
+ ))}
+
+ {new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ )
+}
+
+function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
+ const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
+ if (!total) return null
+ const segments = [
+ { label: 'Deep', s: deepS || 0, color: '#6366f1' },
+ { label: 'REM', s: remS || 0, color: '#8b5cf6' },
+ { label: 'Light', s: lightS || 0, color: '#a78bfa' },
+ { label: 'Awake', s: awakeS || 0, color: '#eab308' },
+ ].filter(seg => seg.s > 0)
+ return (
+
+
+ {segments.map(seg => (
+
+ ))}
+
+
+ )
+}
+
+function HrvBadge({ status }) {
+ if (!status) return null
+ const palette = {
+ balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
+ unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
+ low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
+ poor: 'text-red-400 bg-red-400/10 border-red-400/30',
+ }
+ const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
+ return
{status}
+}
+
+function NavArrow({ onClick, disabled, children }) {
+ return (
+
+ )
+}
+
+function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) {
+ if (!day) return (
+
+
📊
+
No health data yet
+
Import a Garmin export to see your daily snapshot
+
+ )
+
+ const dateLabel = day.date ? format(new Date(day.date), 'EEEE, d MMMM yyyy') : 'Latest'
+ const hasSleepStages = day.sleep_deep_s || day.sleep_light_s || day.sleep_rem_s
+ const stepsGoal = 10000
+ const stepsPct = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0
+
+ const stressLabel = !day.avg_stress ? null
+ : day.avg_stress < 25 ? 'Restful'
+ : day.avg_stress < 50 ? 'Low'
+ : day.avg_stress < 75 ? 'Medium' : 'High'
+ const stressColor = !day.avg_stress ? 'text-white'
+ : day.avg_stress < 25 ? 'text-green-400'
+ : day.avg_stress < 50 ? 'text-yellow-400'
+ : day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400'
+
+ return (
+
+
+ {/* Header + arrows */}
+
+
←
+
+
Daily snapshot
+
{dateLabel}
+
+
→
+
+
+ {/* Sleep (wide) + Heart / HRV */}
+
+
+
+
+
Sleep
+ {day.sleep_score != null && (
+
+ Score {Math.round(day.sleep_score)}
+
+ )}
+
+
+
+ {formatSleep(day.sleep_duration_s)}
+
+ {day.sleep_start && day.sleep_end && (
+
+ {fmtTime(day.sleep_start)} → {fmtTime(day.sleep_end)}
+
+ )}
+
+ {hasSleepStages ? (
+ <>
+ {sleepStages?.length ? (
+
+ ) : (
+
+ )}
+
+ {[
+ ['Deep', day.sleep_deep_s, '#6366f1'],
+ ['REM', day.sleep_rem_s, '#8b5cf6'],
+ ['Light', day.sleep_light_s, '#a78bfa'],
+ ['Awake', day.sleep_awake_s, '#eab308'],
+ ].map(([label, secs, color]) => secs ? (
+
+
+
+ {label} {formatSleep(secs)}
+
+
+ ) : null)}
+
+ >
+ ) : !day.sleep_duration_s ? (
+
No sleep data
+ ) : null}
+
+
+
+
Heart & HRV
+
+
Resting HR
+
+
+ {day.resting_hr ? Math.round(day.resting_hr) : '--'}
+
+ bpm
+
+ {avg30?.resting_hr && day.resting_hr && (
+
+ 30d avg {Math.round(avg30.resting_hr)} bpm
+ {day.resting_hr < avg30.resting_hr
+ ? ↓
+ : day.resting_hr > avg30.resting_hr
+ ? ↑
+ : null}
+
+ )}
+
+
+
HRV
+
+
+ {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
+
+ ms
+
+
+
+ {day.avg_hr_day && (
+
+
Avg HR (day)
+
+ {Math.round(day.avg_hr_day)}
+ {day.max_hr_day && / {Math.round(day.max_hr_day)} max bpm}
+
+
+ )}
+ {day.weight_kg && (
+
+
Weight
+
+ {day.weight_kg.toFixed(1)}
+ kg
+ {day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
+
+
+ )}
+
+
+
+ {/* 24-hour heart rate chart + body battery (side by side) */}
+ {(intradayHr?.length > 0 || bodyBattery) && (
+
0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
+ {intradayHr?.length > 0 && (
+
+
+
24-hour Heart Rate
+ {day.avg_hr_day && (
+ avg {Math.round(day.avg_hr_day)} bpm
+ )}
+
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* Activity strip */}
+
+
+
+
Steps
+
+
+ {day.steps ? day.steps.toLocaleString() : '--'}
+
+
+ {day.steps ? (
+ <>
+
+
{stepsPct}% of {stepsGoal.toLocaleString()}
+ >
+ ) : null}
+ {day.floors_climbed
+ ?
{day.floors_climbed} floors
+ : null}
+
+
+
+
Calories
+
+
+ {day.total_calories
+ ? Math.round(day.total_calories)
+ : day.active_calories ? Math.round(day.active_calories) : '--'}
+
+ kcal
+
+ {day.active_calories && day.total_calories && (
+
Active {Math.round(day.active_calories)} kcal
+ )}
+
+
+
+
Stress
+
+
+ {day.avg_stress ? Math.round(day.avg_stress) : '--'}
+
+ {day.avg_stress && /100}
+
+ {stressLabel &&
{stressLabel}
}
+
+
+
+
VO2 Max
+
+
+ {day.vo2max ? day.vo2max.toFixed(1) : '--'}
+
+
+ {day.fitness_age &&
Fitness age {day.fitness_age}
}
+
+
+
+ )
+}
+
+// ── Trend Charts ────────────────────────────────────────────────────────────
+
+function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
No data
)
return (
-
+ {
+ const p = evt?.activePayload?.[0]?.payload
+ if (p?.date && onDayClick) onDayClick(p.date)
+ }}
+ >
@@ -38,176 +561,307 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+ tickFormatter={formatter} domain={domain} />
format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
+ {selectedDate && (
+
+ )}
+ {(referenceLines || []).map((rl, i) => (
+
+ ))}
+ fill={`url(#grad-${dataKey})`}
+ dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
+ connectNulls={connectNulls} isAnimationActive={false} />
)
}
-function SleepChart({ data }) {
+function SleepChart({ data, selectedDate, onDayClick }) {
const chartData = data.map(d => ({
- date: d.date,
- deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
- rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
+ date: d.date, // already normalised to YYYY-MM-DD
+ deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
+ rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
}))
const hasData = chartData.some(d => d.deep || d.rem || d.light)
- if (!hasData) return
No sleep data
+ if (!hasData) return (
+
No sleep data
+ )
return (
-
+ {
+ const p = evt?.activePayload?.[0]?.payload
+ if (p?.date && onDayClick) onDayClick(p.date)
+ }}
+ >
format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
`${v}h`} />
format(new Date(d), 'MMM d, yyyy')} />
-
-
+ {selectedDate && (
+
+ )}
+
+
-
+
)
}
-export default function HealthPage() {
- const [rangeDays, setRangeDays] = useState(7) // default 1 week
+// ── Page ─────────────────────────────────────────────────────────────────────
- const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
+export default function HealthPage() {
+ const [rangeDays, setRangeDays] = useState(7)
+ const [selectedDateStr, setSelectedDateStr] = useState(null) // YYYY-MM-DD or null = latest
+
+ const fromDate = useMemo(
+ () => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'),
+ [rangeDays],
+ )
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
- const { data: metrics, isLoading } = useQuery({
- queryKey: ['health-metrics', rangeDays],
+ // Full history for snapshot navigation.
+ // Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
+ const { data: allDays } = useQuery({
+ queryKey: ['health-metrics', 'all'],
queryFn: () =>
- api.get('/health-metrics/', {
- params: { from_date: fromDate, limit: rangeDays + 1 },
- }).then(r => r.data.slice().reverse()), // oldest first for charts
- keepPreviousData: true,
+ api.get('/health-metrics/', { params: { limit: 365 } })
+ .then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
})
- const latest = summary?.latest
- const avg30 = summary?.avg_30d
+ // Trend window (changes with range selector).
+ // Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x.
+ const { data: rawMetrics, isLoading } = useQuery({
+ queryKey: ['health-metrics', rangeDays],
+ queryFn: () =>
+ api.get('/health-metrics/', { params: { from_date: fromDate, limit: rangeDays + 1 } })
+ .then(r => r.data.slice().reverse().map(d => ({ ...d, date: d10(d.date) }))),
+ placeholderData: keepPreviousData,
+ })
+ const metrics = rawMetrics || []
+
+ // Snapshot navigation: newest-first sorted list of all available days
+ const allDaysSorted = useMemo(
+ () => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
+ [allDays],
+ )
+
+ const selectedDay = useMemo(() => {
+ if (!selectedDateStr) return allDaysSorted[0] || null
+ return allDaysSorted.find(d => d.date === selectedDateStr) || null
+ }, [selectedDateStr, allDaysSorted])
+
+ const selectedIdx = useMemo(() => {
+ if (!selectedDay) return -1
+ return allDaysSorted.findIndex(d => d.date === selectedDay.date)
+ }, [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),
+ enabled: !!selectedDay?.date,
+ })
+
+ const { data: dayActivities } = useQuery({
+ queryKey: ['activities-day', selectedDay?.date],
+ queryFn: () => api.get('/activities/', { params: {
+ from_date: selectedDay.date + 'T00:00:00',
+ to_date: selectedDay.date + 'T23:59:59',
+ per_page: 20,
+ }}).then(r => r.data),
+ enabled: !!selectedDay?.date,
+ })
+
+ const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
+ const goOlder = () => {
+ if (selectedIdx < allDaysSorted.length - 1)
+ setSelectedDateStr(allDaysSorted[selectedIdx + 1].date)
+ }
+ const goNewer = () => {
+ if (selectedIdx > 0)
+ setSelectedDateStr(allDaysSorted[selectedIdx - 1].date)
+ }
+
+ // The date string to highlight in charts (only shown if it falls within the current trend window)
+ const selDateForCharts = selectedDay?.date
return (
-
+
Health
- {/* Summary cards */}
-
-
-
-
-
-
-
-
-
-
+
= 0 && selectedIdx < allDaysSorted.length - 1}
+ hasNewer={selectedIdx > 0}
+ />
- {/* Range selector */}
-
- {RANGES.map(({ label, days }) => (
-
- ))}
-
+
- {isLoading ? (
- Loading…
- ) : metrics && metrics.length > 0 ? (
-
-
-
-
Resting Heart Rate
-
`${Math.round(v)} bpm`} />
+
+
+
+
Trends
+
Click any point to load that day above
-
-
-
HRV (nightly avg)
-
`${Math.round(v)} ms`} />
+
+ {RANGES.map(({ label, days }) => (
+
+ ))}
+
-
-
Sleep Stages
-
-
- {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
-
- ))}
+ {isLoading ? (
+
Loading…
+ ) : metrics.length > 0 ? (
+
+
+
+
Resting Heart Rate
+ Math.round(v)}
+ domain={[0, 200]}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick} />
-
-
-
Weight
- `${v.toFixed(1)} kg`} />
-
+
+
HRV (nightly avg)
+ `${Math.round(v)} ms`}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick}
+ referenceLines={[
+ { y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
+ { y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
+ ]}
+ />
+
-
-
VO2 Max
- v.toFixed(1)} />
-
+
+
Sleep
+
+
+ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
+
+ ))}
+
+
-
-
Daily Steps
-
-
-
- format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
- v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
- format(new Date(d), 'MMM d, yyyy')} />
-
-
-
-
+
+
Weight
+ d.weight_kg != null)}
+ dataKey="weight_kg" color="#34d399"
+ formatter={v => `${v.toFixed(1)} kg`}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick}
+ connectNulls showDots />
+
-
-
Avg Heart Rate (day)
- `${Math.round(v)} bpm`} />
-
+
+
Daily Steps
+
+ {
+ const p = evt?.activePayload?.[0]?.payload
+ if (p?.date) handleDayClick(p.date)
+ }}
+ >
+
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+ v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
+ format(new Date(d), 'MMM d, yyyy')} />
+ {selDateForCharts && (
+
+ )}
+
+
+
+
-
-
Stress Level
- Math.round(v)} />
-
+
+
Stress Level
+ Math.round(v)}
+ domain={[0, 100]}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick} />
+
-
- ) : (
-
-
📊
-
No health data for this period
-
Import a Garmin export or try a longer date range
-
- )}
+
+
Heart Rate
+ Math.round(v)}
+ domain={[0, 200]}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick} />
+
+
+ {metrics.some(d => d.body_battery?.end_level != null) && (
+
+
Body Battery (end of day)
+ ({ ...d, body_battery_level: d.body_battery?.end_level ?? null }))}
+ dataKey="body_battery_level" color="#3b82f6"
+ formatter={v => `${Math.round(v)}`}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick} />
+
+ )}
+
+ {metrics.some(d => d.vo2max) && (
+
+
VO2 Max
+ v.toFixed(1)}
+ selectedDate={selDateForCharts} onDayClick={handleDayClick} />
+
+ )}
+
+
+ ) : (
+
+
No trend data for this period
+
Try a longer date range
+
+ )}
+
)
}
diff --git a/milevault_export/frontend/src/pages/RecordsPage.jsx b/milevault_export/frontend/src/pages/RecordsPage.jsx
index 98e4c2e..9cf001b 100644
--- a/milevault_export/frontend/src/pages/RecordsPage.jsx
+++ b/milevault_export/frontend/src/pages/RecordsPage.jsx
@@ -1,19 +1,22 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { Link } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format } from 'date-fns'
import api from '../utils/api'
-import { formatDuration, formatDate } from '../utils/format'
+import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
+import RouteMiniMap from '../components/ui/RouteMiniMap'
-const SPORTS = ['running', 'cycling', 'swimming']
+const SPORTS = ['running', 'cycling']
const DISTANCE_ORDER = [
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
'Half marathon', 'Marathon', '50k', '100k',
]
-export default function RecordsPage() {
+const TABS = ['Distance PRs', 'Route Records', 'Segment Records']
+
+function DistancePRs() {
const [sport, setSport] = useState('running')
const [selectedDistance, setSelectedDistance] = useState(null)
@@ -31,7 +34,6 @@ export default function RecordsPage() {
enabled: !!selectedDistance,
})
- // Sort by standard distance order
const sortedRecords = records?.slice().sort((a, b) => {
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
@@ -39,10 +41,7 @@ export default function RecordsPage() {
})
return (
-
-
Personal Records
-
- {/* Sport selector */}
+
{SPORTS.map(s => (