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