import { Link, useNavigate } from 'react-router-dom' import { useQuery, useMutation } from '@tanstack/react-query' import { useMemo, useState, useEffect, useRef } from 'react' import { BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' import GridLayout, { WidthProvider } from 'react-grid-layout' import 'react-grid-layout/css/styles.css' import 'react-resizable/css/styles.css' 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, formatHeartRate, formatElevation, formatDate, sportIcon, formatSleep, } from '../utils/format' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' const Grid = WidthProvider(GridLayout) const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' } const HRV_PALETTE = { balanced: 'text-green-400 bg-green-400/10 border-green-400/30', unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30', low: 'text-red-400 bg-red-400/10 border-red-400/30', poor: 'text-red-400 bg-red-400/10 border-red-400/30', } // Widget registry + default grid positions (12-col grid). minW/minH keep widgets usable. const WIDGETS = [ { id: 'stats', default: { x: 0, y: 0, w: 12, h: 1 }, minW: 4, minH: 1 }, { id: 'weekly', default: { x: 0, y: 1, w: 6, h: 3 }, minW: 4, minH: 2 }, { id: 'bodyBattery', default: { x: 6, y: 1, w: 3, h: 3 }, minW: 3, minH: 2 }, { id: 'vo2max', default: { x: 9, y: 1, w: 3, h: 3 }, minW: 2, minH: 2 }, { id: 'sleep', default: { x: 0, y: 4, w: 5, h: 3 }, minW: 3, minH: 2 }, { id: 'hrv', default: { x: 5, y: 4, w: 3, h: 2 }, minW: 2, minH: 2 }, { id: 'featured', default: { x: 0, y: 7, w: 8, h: 5 }, minW: 4, minH: 3 }, { id: 'recent', default: { x: 8, y: 7, w: 4, h: 5 }, minW: 3, minH: 3 }, { id: 'prs', default: { x: 0, y: 12, w: 12, h: 2 }, minW: 4, minH: 2 }, ] // Merge a saved layout with the registry: keep saved positions for known widgets, // fall back to defaults for any widget the saved layout doesn't include (e.g. new ones). function buildLayout(saved) { const byId = Object.fromEntries((saved || []).map(l => [l.i, l])) return WIDGETS.map(w => { const s = byId[w.id] const pos = s ? { x: s.x, y: s.y, w: s.w, h: s.h } : w.default return { i: w.id, ...pos, minW: w.minW, minH: w.minH } }) } // ── Widgets ────────────────────────────────────────────────────────────────── function Card({ title, viewHref, children, className = '' }) { return (
{title && (

{title}

{viewHref && View →}
)}
{children}
) } function Stat({ label, value }) { return (

{label}

{value}

) } function StatsRow({ health, ytdStats }) { return (
) } function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) { const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level })) const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null const data = raw.map((d, i) => ({ ...d, type: inferBBType(d.ts, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs), })) 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 const presentTypes = [...new Set(data.map(d => d.type))] return (
{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) => )}
{presentTypes.map(type => (
{BB_INFERRED_LABEL[type]}
))}
) : (

No body battery data today

)} ) } function Vo2MaxWidget({ health, recentHealth }) { const series = useMemo( () => [...(recentHealth || [])] .filter(d => d.vo2max != null) .sort((a, b) => new Date(a.date) - new Date(b.date)) .map(d => ({ date: d.date, v: d.vo2max })), [recentHealth], ) return (
{health.vo2max != null ? health.vo2max.toFixed(1) : '--'} ml/kg/min
{health.fitness_age != null && (

Fitness age {health.fitness_age}

)} {series.length >= 2 && (
format(new Date(d), 'MMM d')} formatter={v => [v.toFixed(1), 'VOâ‚‚ max']} />
)}
) } const SLEEP_STAGES = [ { key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' }, { key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' }, { key: 'sleep_light_s', label: 'Light', color: '#60a5fa' }, { key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' }, ] function SleepMini({ health }) { const total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0) return (
{formatSleep(health.sleep_duration_s)} {health.sleep_score != null && ( score {Math.round(health.sleep_score)} )}
{total > 0 ? ( <>
{SLEEP_STAGES.map(st => { const pct = ((health[st.key] || 0) / total) * 100 if (pct < 0.5) return null return
})}
{SLEEP_STAGES.map(st => (health[st.key] ? (
{st.label} {formatSleep(health[st.key])}
) : null))}
) : (

No sleep stages for last night

)} ) } function HrvWidget({ health }) { const status = health.hrv_status const cls = status ? (HRV_PALETTE[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30') : null return (
{health.hrv_nightly_avg != null ? Math.round(health.hrv_nightly_avg) : '--'} ms
{status ? {status} : No HRV status}
) } function WeeklyChart({ activities }) { const navigate = useNavigate() if (!activities?.length) return (
No activities yet
) const now = new Date() const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now) }) const data = weeks.map(weekStart => { 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: format(weekStart, 'MMM d'), km: parseFloat(km.toFixed(2)), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd'), } }) const handleBarClick = (entry) => { const p = entry?.activePayload?.[0]?.payload if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) } return ( `${v.toFixed(0)}`} /> [`${v.toFixed(1)} km`, 'Distance']} cursor={{ fill: 'rgba(59,130,246,0.1)' }} /> ) } function FeaturedActivity({ activity, segments }) { if (!activity) return (
No activities yet
) return (
{sportIcon(activity.sport_type)}
{activity.name}

{formatDate(activity.start_time)}

Open →
{activity.polyline ? :
No GPS track
}
{segments?.length > 0 && (

Segments

Details →
{segments.map(seg => { const isPodium = seg.rank && seg.rank <= 3 const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null return (
{seg.name} {formatDuration(seg.duration_s)} {isPodium ? {MEDALS[seg.rank]} : delta != null ? +{formatDuration(delta)} : --}
) })}
)}
) } function RecentActivities({ activities }) { return (
{activities?.slice(0, 6).map(activity => ( {sportIcon(activity.sport_type)}

{activity.name}

{formatDate(activity.start_time)}

{formatDistance(activity.distance_m)}

{formatHeartRate(activity.avg_heart_rate)}

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

No activities yet — import some data

)}
) } function RunningPRs({ records }) { return ( {records?.length > 0 ? (
{records.slice(0, 5).map(rec => (

{rec.distance_label}

{formatDuration(rec.duration_s)}

))}
) : (
No running records yet
)}
) } // ── Page ─────────────────────────────────────────────────────────────────── 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), }) const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: () => api.get('/profile/').then(r => r.data), }) 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 const latest = rows[0] || {} return { date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, resting_hr: pick('resting_hr'), sleep_duration_s: pick('sleep_duration_s'), sleep_start: latest.sleep_start ?? null, sleep_end: latest.sleep_end ?? null, // Sleep stages + score for the latest night (same row as sleep_start). sleep_deep_s: latest.sleep_deep_s ?? null, sleep_rem_s: latest.sleep_rem_s ?? null, sleep_light_s: latest.sleep_light_s ?? null, sleep_awake_s: latest.sleep_awake_s ?? null, sleep_score: pick('sleep_score'), hrv_nightly_avg: pick('hrv_nightly_avg'), hrv_status: pick('hrv_status'), steps: pick('steps'), vo2max: pick('vo2max'), fitness_age: pick('fitness_age'), 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] const { data: featuredSegments } = useQuery({ queryKey: ['activity-segments', featured?.id], queryFn: () => api.get(`/segments/by-activity/${featured.id}`).then(r => r.data), enabled: !!featured?.id, }) // ── Layout state ────────────────────────────────────────────────────────── const [editMode, setEditMode] = useState(false) const [layout, setLayout] = useState(() => buildLayout(null)) const saveTimer = useRef(null) const loadedRef = useRef(false) // Apply the saved layout once the profile loads. useEffect(() => { if (profile && !loadedRef.current) { loadedRef.current = true setLayout(buildLayout(profile.dashboard_layout)) } }, [profile]) const saveLayout = useMutation({ mutationFn: (lay) => api.put('/profile/dashboard-layout', { layout: lay.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })) }), }) const handleLayoutChange = (next) => { setLayout(next) if (editMode) { clearTimeout(saveTimer.current) saveTimer.current = setTimeout(() => saveLayout.mutate(next), 700) } } const finishEditing = () => { clearTimeout(saveTimer.current) saveLayout.mutate(layout) setEditMode(false) } const resetLayout = () => { const def = buildLayout(null) setLayout(def) saveLayout.mutate(def) } const WIDGET_CONTENT = { stats: , weekly: , bodyBattery: , vo2max: , sleep: , hrv: , featured: , recent: , prs: , } return (

Dashboard

{editMode && ( )} + Import data
{editMode && (

Drag widgets to move them, or drag a corner to resize. Changes save automatically.

)} {WIDGETS.map(w => (
{WIDGET_CONTENT[w.id]}
))}
) }