import { Link, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' 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' import ActivityMap from '../components/activity/ActivityMap' import { formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation, formatDate, sportIcon, formatSleep, } from '../utils/format' function Stat({ label, value }) { return (

{label}

{value}

) } 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 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 (

Body Battery

View →
{peak != null && ( {Math.round(peak)} )} {charged != null && +{charged}} {drained != null && -{drained}} {end_level != null && now {Math.round(end_level)}}
{hasGraph ? (
format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} /> {data.map((d, i) => )}
) : (

No body battery data today

)}
) } function WeeklyChart({ activities }) { const navigate = useNavigate() if (!activities?.length) return (
No activities yet
) // Build last 8 weeks in chronological order const now = new Date() const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now), }) const data = weeks.map(weekStart => { const weekKey = format(weekStart, 'MMM d') const weekEnd = addDays(weekStart, 7) const km = activities .filter(a => { const t = new Date(a.start_time) return t >= weekStart && t < weekEnd }) .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0) return { week: weekKey, km: parseFloat(km.toFixed(2)), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd'), } }) const handleBarClick = (entry) => { if (entry?.activePayload?.[0]?.payload) { const { weekStartISO, weekEndISO } = entry.activePayload[0].payload navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`) } } return ( `${v.toFixed(0)}`} /> [`${v.toFixed(1)} km`, 'Distance']} cursor={{ fill: 'rgba(59,130,246,0.1)' }} /> ) } export default function DashboardPage() { const { data: recentActivities } = useQuery({ queryKey: ['activities-recent'], queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data), }) const { data: allActivities } = useQuery({ queryKey: ['activities-all-chart'], queryFn: () => api.get('/activities/', { params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() }, }).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 ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD 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({ queryKey: ['records-running'], queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data), }) const { data: ytdStats } = useQuery({ queryKey: ['ytd-stats'], queryFn: () => api.get('/activities/stats/ytd').then(r => r.data), }) const featured = recentActivities?.[0] return (

Dashboard

+ Import data

Weekly distance (km)

Health today

{health.date ? ( <> {[ ['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]) => (
{label} {val}
))} View full health dashboard → ) : (

No health data. Import a Garmin export.

)}
{/* Featured most-recent activity */} {featured && (
{sportIcon(featured.sport_type)}
{featured.name}

{formatDate(featured.start_time)}

Open →
{featured.polyline ? :
No GPS track
}
)} {/* Recent activities */}

Recent activities

View all →
{recentActivities?.slice(0, 5).map(activity => ( {sportIcon(activity.sport_type)}

{activity.name}

{formatDate(activity.start_time)}

{formatDistance(activity.distance_m)}

dist

{formatDuration(activity.duration_s)}

time

{formatHeartRate(activity.avg_heart_rate)}

HR

))} {!recentActivities?.length && (

No activities yet — import some data

)}
{records?.length > 0 && (

Running PRs

View all →
{records.slice(0, 5).map(rec => (

{rec.distance_label}

{formatDuration(rec.duration_s)}

))}
)}
) }