diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 1c83eff..c82f461 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -469,6 +469,9 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict: _set(row, "active_calories", active) if active and bmr: _set(row, "total_calories", float(active) + float(bmr)) + vo2 = stats.get("vo2MaxPreciseValue") or stats.get("vo2Max") + if vo2 and float(vo2) > 0: + _set(row, "vo2max", float(vo2)) if sleep_data: dto = sleep_data.get("dailySleepDTO") or sleep_data diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx index bdc0df6..7b7fb67 100644 --- a/frontend/src/components/activity/LapTable.jsx +++ b/frontend/src/components/activity/LapTable.jsx @@ -1,6 +1,9 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format' +const RUNNING_TYPES = new Set(['running', 'hiking', 'walking']) + export default function LapTable({ laps, sportType }) { + const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) return (
@@ -12,7 +15,7 @@ export default function LapTable({ laps, sportType }) { - + {showPower && } @@ -28,9 +31,11 @@ export default function LapTable({ laps, sportType }) { - + {showPower && ( + + )} ))} diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 8066511..ff547cb 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -101,26 +101,28 @@ export default function ActivityDetailPage() { - {/* Primary stats */} -
+ {/* Stats — all on one row */} +
-
- - {/* Secondary stats */} -
- -
+ {/* HR Zones */} + {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && ( +
+

Heart Rate Zones

+ +
+ )} + {/* Map with controls */}
{/* Map toolbar */} @@ -165,14 +167,6 @@ export default function ActivityDetailPage() {
- {/* HR Zones */} - {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && ( -
-

Heart Rate Zones

- -
- )} - {/* Metric timeline */}
@@ -207,52 +201,53 @@ export default function ActivityDetailPage() { )}
- {/* Laps */} - {laps && laps.length > 0 && ( -
-

Laps

- -
- )} - - {/* Segments */} - {segments && segments.length > 0 && dataPoints && ( -
-
-

Segments

- Manage → -
- {/* Column headers */} -
- 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))}`} - -
- ) - })} -
+ {/* 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/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 8fd7f50..95a1f8b 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -6,7 +6,7 @@ import { } from 'recharts' import { format, subDays } from 'date-fns' import api from '../utils/api' -import { formatSleep } from '../utils/format' +import { formatSleep, sportIcon } from '../utils/format' const RANGES = [ { label: '1W', days: 7 }, @@ -91,7 +91,17 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) { return 'stable' } -function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) { +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 @@ -112,14 +122,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) { 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

- {end_level != null && ( - {Math.round(end_level)} + {maxLevel != null && ( + {Math.round(maxLevel)} )} {charged != null && ( +{charged} @@ -127,18 +138,19 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) { {drained != null && ( -{drained} )} - {start_level != null && end_level != null && ( - {start_level} → {end_level} + {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']} /> @@ -147,6 +159,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) { ))} + {(activities || []).map(a => ( + } + /> + ))}
@@ -237,6 +258,26 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) { ) } +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 = { @@ -263,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) { ) } -function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, onOlder, onNewer, hasOlder, hasNewer }) { +function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) { if (!day) return (

📊

@@ -323,10 +364,17 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{hasSleepStages ? ( <> - + {sleepStages?.length ? ( + + ) : ( + + )}
{[ ['Deep', day.sleep_deep_s, '#6366f1'], @@ -417,7 +465,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
)} - +
)} @@ -472,28 +520,13 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
- {day.spo2_avg ? ( - <> -

SpO2

-
- {day.spo2_avg.toFixed(1)} - % -
- - ) : day.vo2max ? ( - <> -

VO2 Max

-
- {day.vo2max.toFixed(1)} -
- {day.fitness_age &&

Fitness age {day.fitness_age}

} - - ) : ( - <> -

SpO2

- -- - - )} +

VO2 Max

+
+ + {day.vo2max ? day.vo2max.toFixed(1) : '--'} + +
+ {day.fitness_age &&

Fitness age {day.fitness_age}

}
@@ -502,7 +535,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag // ── Trend Charts ──────────────────────────────────────────────────────────── -function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) { +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
@@ -528,12 +561,15 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa 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) => ( + + ))} 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) @@ -667,6 +713,7 @@ export default function HealthPage() { bodyBattery={intradayData?.body_battery} bbHires={intradayData?.body_battery_hires} sleepStages={intradayData?.sleep_stages} + activities={dayActivities} onOlder={goOlder} onNewer={goNewer} hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1} @@ -703,7 +750,8 @@ export default function HealthPage() {

Resting Heart Rate

`${Math.round(v)} bpm`} + formatter={v => Math.round(v)} + domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />
@@ -711,7 +759,12 @@ export default function HealthPage() {

HRV (nightly avg)

`${Math.round(v)} ms`} - selectedDate={selDateForCharts} onDayClick={handleDayClick} /> + 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 } }, + ]} + />
@@ -769,13 +822,15 @@ export default function HealthPage() {

Stress Level

Math.round(v)} + domain={[0, 100]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />

Heart Rate

`${Math.round(v)} bpm`} + formatter={v => Math.round(v)} + domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />
diff --git a/frontend/src/pages/RecordsPage.jsx b/frontend/src/pages/RecordsPage.jsx index 9bb81dd..9cf001b 100644 --- a/frontend/src/pages/RecordsPage.jsx +++ b/frontend/src/pages/RecordsPage.jsx @@ -226,8 +226,9 @@ function SegmentRecords() { enabled: !!selectedRouteId, }) - const theoreticalBest = bests?.length && bests.every(b => b.best_s != null) - ? bests.reduce((sum, b) => sum + b.best_s, 0) + const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) + const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) + ? kmBests.reduce((sum, b) => sum + b.best_s, 0) : null if (!routes?.length) return ( @@ -314,7 +315,7 @@ function SegmentRecords() {
Pace Avg HR CadencePowerPower
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} - {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} - + {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} +
{theoreticalBest != null && (
- Theoretical best (sum of all segment bests) + Theoretical best (1km splits only) {formatDuration(theoreticalBest)}
)} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index 0373d7c..0173643 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -37,8 +37,9 @@ function SegmentsPanel({ routeId, sportType }) { const kmSplits = segments.filter(s => s.name.startsWith('km ')) const hillsTurns = segments.filter(s => !s.name.startsWith('km ')) - const theoreticalBest = bests?.every(b => b.best_s != null) - ? bests.reduce((sum, b) => sum + b.best_s, 0) + const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) + const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) + ? kmBests.reduce((sum, b) => sum + b.best_s, 0) : null const renderGroup = (group, title) => { @@ -79,7 +80,7 @@ function SegmentsPanel({ routeId, sportType }) { {renderGroup(hillsTurns, 'Hills & Turns')} {theoreticalBest != null && (
- Theoretical best (sum of segment bests) + Theoretical best (1km splits only) {formatDuration(theoreticalBest)}
)} diff --git a/milevault_export/frontend/src/components/activity/LapTable.jsx b/milevault_export/frontend/src/components/activity/LapTable.jsx index bdc0df6..7b7fb67 100644 --- a/milevault_export/frontend/src/components/activity/LapTable.jsx +++ b/milevault_export/frontend/src/components/activity/LapTable.jsx @@ -1,6 +1,9 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format' +const RUNNING_TYPES = new Set(['running', 'hiking', 'walking']) + export default function LapTable({ laps, sportType }) { + const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) return (
@@ -12,7 +15,7 @@ export default function LapTable({ laps, sportType }) { - + {showPower && } @@ -28,9 +31,11 @@ export default function LapTable({ laps, sportType }) { - + {showPower && ( + + )} ))} diff --git a/milevault_export/frontend/src/pages/ActivityDetailPage.jsx b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx index e8c92ea..ff547cb 100644 --- a/milevault_export/frontend/src/pages/ActivityDetailPage.jsx +++ b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom' +import { useParams, Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { useState, useMemo } from 'react' import api from '../utils/api' @@ -12,6 +12,16 @@ import { formatHeartRate, formatDateTime, formatCadence, sportIcon, } from '../utils/format' +function segmentTime(points, startM, endM) { + let t0 = null + for (const p of points) { + if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime() + if (t0 !== null && p.distance_m >= endM) + return (new Date(p.timestamp).getTime() - t0) / 1000 + } + return null +} + const METRICS = [ { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, @@ -45,6 +55,18 @@ export default function ActivityDetailPage() { enabled: !!activity, }) + const { data: segments } = useQuery({ + queryKey: ['segments', activity?.named_route_id], + queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data), + enabled: !!activity?.named_route_id, + }) + + const { data: segmentBests } = useQuery({ + queryKey: ['segment-bests', activity?.named_route_id], + queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data), + enabled: !!activity?.named_route_id, + }) + const toggleMetric = (key) => { setActiveMetrics(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] @@ -79,26 +101,28 @@ export default function ActivityDetailPage() { - {/* Primary stats */} -
+ {/* Stats — all on one row */} +
-
- - {/* Secondary stats */} -
- -
+ {/* HR Zones */} + {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && ( +
+

Heart Rate Zones

+ +
+ )} + {/* Map with controls */}
{/* Map toolbar */} @@ -143,14 +167,6 @@ export default function ActivityDetailPage() {
- {/* HR Zones */} - {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && ( -
-

Heart Rate Zones

- -
- )} - {/* Metric timeline */}
@@ -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]) => ( -
-
- {l} -
- ))} + {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]) => ( +
+
+ {l} +
+ ))} +
+
-
-

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 => (
Pace Avg HR CadencePowerPower
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} - {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} - + {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} +
@@ -84,9 +82,7 @@ export default function RecordsPage() { key={rec.id} onClick={() => setSelectedDistance(rec.distance_label)} className={`border-b border-gray-800/50 cursor-pointer transition-colors ${ - selectedDistance === rec.distance_label - ? 'bg-blue-900/20' - : 'hover:bg-gray-800/40' + selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40' }`} > @@ -111,52 +107,29 @@ export default function RecordsPage() {
{rec.distance_label}
- {/* Progress chart */}
{selectedDistance && history ? ( <> -

- {selectedDistance} progression -

+

{selectedDistance} progression

Lower is faster

{history.length > 1 ? ( ({ - date: h.achieved_at, - time: h.duration_s, - }))} + data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))} margin={{ top: 4, right: 4, bottom: 4, left: 8 }} > - format(new Date(d), 'MMM yy')} - /> - + format(new Date(d), 'MMM yy')} /> + format(new Date(d), 'MMM d, yyyy')} formatter={v => [formatDuration(v), 'Time']} /> - + ) : ( @@ -175,3 +148,210 @@ export default function RecordsPage() {
) } + +function RouteRecords() { + const navigate = useNavigate() + const { data: records, isLoading } = useQuery({ + queryKey: ['route-records'], + queryFn: () => api.get('/records/routes').then(r => r.data), + }) + + if (isLoading) return

Loading…

+ + if (!records?.length) return ( +
+

🗺️

+

No route records yet — create named routes and complete activities on them

+
+ ) + + return ( +
+ + + + + + + + + + + + {records.map(rec => ( + navigate(`/activities/${rec.activity_id}`)} + className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer" + > + + + + + + + + ))} + +
+ RouteDistanceBest timePaceDate
+ + + {rec.sport_type} + {rec.route_name} + + {formatDistance(rec.distance_m)} + + {formatDuration(rec.duration_s)} + + {formatPace(rec.avg_speed_ms, rec.sport_type)} + + {formatDate(rec.start_time)} +
+
+ ) +} + +function SegmentRecords() { + const [selectedRouteId, setSelectedRouteId] = useState(null) + + const { data: routes } = useQuery({ + queryKey: ['routes'], + queryFn: () => api.get('/routes/').then(r => r.data), + }) + + const { data: bests, isLoading } = useQuery({ + queryKey: ['segment-bests', selectedRouteId], + queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data), + enabled: !!selectedRouteId, + }) + + const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) + const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) + ? kmBests.reduce((sum, b) => sum + b.best_s, 0) + : null + + if (!routes?.length) return ( +

+ No named routes yet.{' '} + Create one on the Routes page. +

+ ) + + return ( +
+ {/* Route tile grid */} +
+ {routes.map(r => ( + + ))} +
+ + {selectedRouteId && ( + isLoading ? ( +

Loading…

+ ) : !bests?.length ? ( +

+ No segments for this route.{' '} + Create some on the Segments page. +

+ ) : ( +
+ + + + + + + + + + + {bests.map(b => ( + + + + + + + + ))} + +
SegmentLengthBest timeRuns +
+ {b.name} + {b.auto_generated && (auto)} + + {formatDistance(b.end_distance_m - b.start_distance_m)} + + {b.best_s != null + ? {formatDuration(b.best_s)} + : --} + {b.count} + {b.best_activity_id && ( + + View → + + )} +
+ {theoreticalBest != null && ( +
+ Theoretical best (1km splits only) + {formatDuration(theoreticalBest)} +
+ )} +
+ ) + )} +
+ ) +} + +export default function RecordsPage() { + const [tab, setTab] = useState('Distance PRs') + + return ( +
+

Records

+ +
+ {TABS.map(t => ( + + ))} +
+ + {tab === 'Distance PRs' && } + {tab === 'Route Records' && } + {tab === 'Segment Records' && } +
+ ) +} diff --git a/milevault_export/frontend/src/pages/RoutesPage.jsx b/milevault_export/frontend/src/pages/RoutesPage.jsx index 75984e1..0173643 100644 --- a/milevault_export/frontend/src/pages/RoutesPage.jsx +++ b/milevault_export/frontend/src/pages/RoutesPage.jsx @@ -1,12 +1,151 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' +function formatSegDist(m) { + if (m == null) return '--' + return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` +} + +function SegmentsPanel({ routeId, sportType }) { + const qc = useQueryClient() + + const { data: segments } = useQuery({ + queryKey: ['segments', routeId], + queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data), + }) + + const { data: bests } = useQuery({ + queryKey: ['segment-bests', routeId], + queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data), + }) + + const deleteSeg = useMutation({ + mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['segments', routeId] }) + qc.invalidateQueries({ queryKey: ['segment-bests', routeId] }) + }, + }) + + if (!segments?.length) return null + + const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b])) + + const kmSplits = segments.filter(s => s.name.startsWith('km ')) + const hillsTurns = segments.filter(s => !s.name.startsWith('km ')) + + const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) + const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) + ? kmBests.reduce((sum, b) => sum + b.best_s, 0) + : null + + const renderGroup = (group, title) => { + if (!group.length) return null + return ( +
+

{title}

+ {group.map(seg => { + const best = bestMap[seg.id] + return ( +
+ {seg.name} + {formatSegDist(seg.end_distance_m - seg.start_distance_m)} + {best?.best_s != null ? ( + {formatDuration(best.best_s)} + ) : ( + -- + )} + +
+ ) + })} +
+ ) + } + + return ( +
+
+

Segments

+ Manage → +
+ {renderGroup(kmSplits, '1km Splits')} + {renderGroup(hillsTurns, 'Hills & Turns')} + {theoreticalBest != null && ( +
+ Theoretical best (1km splits only) + {formatDuration(theoreticalBest)} +
+ )} +
+ ) +} + +// Decode Google encoded polyline to [[lat,lng], ...] +function decodePolyline(encoded) { + if (!encoded) return [] + const points = [] + let idx = 0, lat = 0, lng = 0 + while (idx < encoded.length) { + let shift = 0, result = 0, byte + do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20) + lat += result & 1 ? ~(result >> 1) : result >> 1 + shift = 0; result = 0 + do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20) + lng += result & 1 ? ~(result >> 1) : result >> 1 + points.push([lat / 1e5, lng / 1e5]) + } + return points +} + +function RouteMap({ polyline, className = '', sportType = '' }) { + const pts = decodePolyline(polyline) + if (pts.length < 2) return ( +
+ no track +
+ ) + const t = (sportType || '').toLowerCase() + const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6' + const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1]) + const minLat = Math.min(...lats), maxLat = Math.max(...lats) + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) + const rangeL = maxLng - minLng || 1e-5 + const rangeA = maxLat - minLat || 1e-5 + const pad = 4 + const w = 100, h = 60 + const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2) + const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2) + const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ') + return ( + + + + ) +} + +function routeSportStyle(sportType) { + const t = (sportType || '').toLowerCase() + if (t.includes('cycl') || t.includes('bike') || t.includes('ride')) + return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' } + if (t.includes('run') || t.includes('jog') || t.includes('walk')) + return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' } + return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' } +} + export default function RoutesPage() { const [selected, setSelected] = useState(null) const [showCreate, setShowCreate] = useState(false) const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) + const [merging, setMerging] = useState(false) + const [mergeTarget, setMergeTarget] = useState('') const qc = useQueryClient() const { data: routes } = useQuery({ @@ -14,6 +153,9 @@ export default function RoutesPage() { queryFn: () => api.get('/routes/').then(r => r.data), }) + // Sort by most completions first + const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0)) + const { data: routeActivities } = useQuery({ queryKey: ['route-activities', selected?.id], queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), @@ -27,8 +169,8 @@ export default function RoutesPage() { }) const createRoute = useMutation({ - mutationFn: (data) => api.post('/routes/', data).then(r => r.data), - onSuccess: (route) => { + mutationFn: data => api.post('/routes/', data).then(r => r.data), + onSuccess: route => { qc.invalidateQueries({ queryKey: ['routes'] }) setShowCreate(false) setNewRoute({ name: '', activity_id: '' }) @@ -36,7 +178,27 @@ export default function RoutesPage() { }, }) + const mergeRoute = useMutation({ + mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data), + onSuccess: updated => { + qc.invalidateQueries({ queryKey: ['routes'] }) + qc.invalidateQueries({ queryKey: ['route-activities', updated.id] }) + setMerging(false) + setMergeTarget('') + setSelected(updated) + }, + }) + + const deleteRoute = useMutation({ + mutationFn: id => api.delete(`/routes/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['routes'] }) + setSelected(null) + }, + }) + const fastest = routeActivities?.[0] + const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? [] return (
@@ -53,7 +215,7 @@ export default function RoutesPage() {
- {/* Create route */} + {/* Create route panel */} {showCreate && (

Create named route

@@ -63,34 +225,25 @@ export default function RoutesPage() {
- setNewRoute(r => ({ ...r, name: e.target.value }))} + setNewRoute(r => ({ ...r, name: e.target.value }))} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g. Morning park loop" />
- {recentActivities?.length === 0 ? ( -

No recent activities found.

- ) : ( - - )} +
-
)} -
- {/* Route list */} -
- {routes?.length === 0 && !showCreate && ( -
-

🗺️

-

No named routes yet

-

Routes are created automatically when you repeat a run, or create one manually above.

-
- )} - {routes?.map(route => ( - - ))} + {/* Route tile grid */} + {routes?.length === 0 && !showCreate ? ( +
+

🗺️

+

No named routes yet

+

Routes are created automatically when you repeat a run, or create one manually above.

- - {/* Route detail */} - {selected && ( -
-
-
-

{selected.name}

- {selected.auto_detected && ( - - Auto-detected - + ) : ( +
+ {sortedRoutes.map(route => { + const style = routeSportStyle(route.sport_type) + const isSelected = selected?.id === route.id + return ( + + ) + })} +
+ )} + + {/* Route detail — shown below the tile grid when a route is selected */} + {selected && ( +
+
+
+
+ +
+

{selected.name}

+
+ {selected.sport_type && {selected.sport_type}} + {formatDistance(selected.distance_m)} + {selected.auto_detected && ( + Auto-detected + )} +
+
+
+
+ + +
+ {/* Merge panel */} + {merging && ( +
+

Merge another route into this one

+

All activities from the selected route will be moved here, then the other route will be deleted.

+
+ + + +
+ {otherRoutes.length === 0 && ( +

No other {selected.sport_type} routes to merge with.

+ )} +
+ )} + + {/* Course record */} {fastest && (

Course record 🏆

@@ -158,13 +367,15 @@ export default function RoutesPage() {
)} + {/* Activity list */}

- All runs ({routeActivities?.length ?? 0}) + All completions ({routeActivities?.length ?? 0})

-
+
{routeActivities?.map((act, i) => ( -
- {i + 1} + + {i + 1} {formatDate(act.start_time)} {formatDuration(act.duration_s)} {formatPace(act.avg_speed_ms, selected.sport_type)} @@ -174,13 +385,15 @@ export default function RoutesPage() { {i === 0 && ( CR )} -
+ + ))}
-
+ +
- )} -
+
+ )}
) }