diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py
index 69504e1..8fbcca6 100644
--- a/backend/app/api/profile.py
+++ b/backend/app/api/profile.py
@@ -35,11 +35,16 @@ class ProfileOut(BaseModel):
goal_weight_kg: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
+ dashboard_layout: Optional[list] = None
class Config:
from_attributes = True
+class DashboardLayoutIn(BaseModel):
+ layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
+
+
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
@@ -53,6 +58,18 @@ async def get_profile(current_user: User = Depends(get_current_user)):
"estimated_max_hr": _estimated_max_hr(current_user)}
+@router.put("/dashboard-layout")
+async def save_dashboard_layout(
+ body: DashboardLayoutIn,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Persist the user's customised dashboard widget layout."""
+ current_user.dashboard_layout = body.layout
+ await db.commit()
+ return {"status": "ok"}
+
+
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
diff --git a/backend/app/main.py b/backend/app/main.py
index 681c272..5133fcc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -100,6 +100,15 @@ async def init_db():
except Exception as e:
print(f"users.goal_weight_kg column migration skipped: {e}")
+ # dashboard_layout column on users added after initial creation
+ try:
+ async with engine.begin() as conn:
+ await conn.execute(text(
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS dashboard_layout JSON"
+ ))
+ except Exception as e:
+ print(f"users.dashboard_layout column migration skipped: {e}")
+
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index ec15a25..16fec31 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -37,6 +37,9 @@ class User(Base):
# Only PocketID users in this group may sign in. Null/blank = allow all.
pocketid_allowed_group = Column(String(128), nullable=True)
+ # Saved dashboard widget layout (react-grid-layout array). Null = use default.
+ dashboard_layout = Column(JSON, nullable=True)
+
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3bea707..d9aa96d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,6 +16,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
+ "react-grid-layout": "^1.5.3",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
@@ -2820,6 +2821,20 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-draggable": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
+ "integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
"node_modules/react-dropzone": {
"version": "14.4.1",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz",
@@ -2837,6 +2852,30 @@
"react": ">= 16.8 || 18.0.0"
}
},
+ "node_modules/react-grid-layout": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz",
+ "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "fast-equals": "^4.0.3",
+ "prop-types": "^15.8.1",
+ "react-draggable": "^4.4.6",
+ "react-resizable": "^3.0.5",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-grid-layout/node_modules/fast-equals": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+ "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
+ "license": "MIT"
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -2867,6 +2906,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-resizable": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
+ "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "15.x",
+ "react-draggable": "^4.5.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3",
+ "react-dom": ">= 16.3"
+ }
+ },
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
@@ -2992,6 +3045,12 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 025339e..932858d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,24 +9,25 @@
"preview": "vite preview"
},
"dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.23.1",
- "leaflet": "^1.9.4",
- "react-leaflet": "^4.2.1",
- "recharts": "^2.12.7",
- "date-fns": "^3.6.0",
- "clsx": "^2.1.1",
- "zustand": "^4.5.2",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
- "react-dropzone": "^14.2.3"
+ "clsx": "^2.1.1",
+ "date-fns": "^3.6.0",
+ "leaflet": "^1.9.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^14.2.3",
+ "react-grid-layout": "^1.5.3",
+ "react-leaflet": "^4.2.1",
+ "react-router-dom": "^6.23.1",
+ "recharts": "^2.12.7",
+ "zustand": "^4.5.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
- "vite": "^5.2.13",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
- "tailwindcss": "^3.4.4"
+ "tailwindcss": "^3.4.4",
+ "vite": "^5.2.13"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index accaa33..a39db7f 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,19 +1,73 @@
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 { 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, formatPace, formatHeartRate, formatElevation,
+ 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 (
@@ -23,11 +77,22 @@ function Stat({ 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
- // Same classification the Health page uses, so colours match across views.
const data = raw.map((d, i) => ({
...d,
type: inferBBType(d.ts, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
@@ -38,11 +103,7 @@ function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) {
const presentTypes = [...new Set(data.map(d => d.type))]
return (
-
-
-
Body Battery
- View →
-
+
{peak != null && (
{Math.round(peak)}
@@ -53,8 +114,8 @@ function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) {
{hasGraph ? (
<>
-
-
+
+
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
+ 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 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
- })
+ .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,
+ 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) => {
- if (entry?.activePayload?.[0]?.payload) {
- const { weekStartISO, weekEndISO } = entry.activePayload[0].payload
- navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`)
- }
+ const p = entry?.activePayload?.[0]?.payload
+ if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`)
}
-
return (
-
+
@@ -132,14 +279,126 @@ function WeeklyChart({ activities }) {
`${v.toFixed(0)}`} />
[`${v.toFixed(1)} km`, 'Distance']}
- cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
+ formatter={(v) => [`${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'],
@@ -149,9 +408,7 @@ export default function DashboardPage() {
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),
+ api.get('/activities/', { params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() } }).then(r => r.data),
})
const { data: recentHealth } = useQuery({
@@ -159,24 +416,32 @@ export default function DashboardPage() {
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 { 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, // intraday endpoint wants YYYY-MM-DD
+ date: rows[0]?.date ? rows[0].date.slice(0, 10) : null,
resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'),
- // Sleep window must come from the SAME day as `date` (the day whose intraday
- // body battery we chart), not the latest non-null — otherwise the sleep
- // shading is aligned to a different night. Null here just means "no shading".
- sleep_start: rows[0]?.sleep_start ?? null,
- sleep_end: rows[0]?.sleep_end ?? null,
- hrv_nightly_avg: pick('hrv_nightly_avg'),
+ 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])
@@ -198,170 +463,106 @@ export default function DashboardPage() {
})
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
- + 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.
+
+ {editMode && (
+
)}
+
+ + Import data
- {/* Featured most-recent activity */}
- {featured && (
-
-
-
-
{sportIcon(featured.sport_type)}
-
-
- {featured.name}
-
-
{formatDate(featured.start_time)}
-
-
-
Open →
-
-
-
- {featured.polyline
- ?
- :
No GPS track
}
-
-
-
-
-
-
-
-
-
- {featuredSegments?.length > 0 && (
-
-
-
Segments
- Details →
-
-
- {featuredSegments.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)}
- : --}
-
-
- )
- })}
-
-
- )}
-
+ {editMode && (
+
Drag widgets to move them, or drag a corner to resize. Changes save automatically.
)}
- {/* 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 →
+
+ {WIDGETS.map(w => (
+
+
+ {WIDGET_CONTENT[w.id]}
+
-
- {records.slice(0, 5).map(rec => (
-
-
{rec.distance_label}
-
{formatDuration(rec.duration_s)}
-
- ))}
-
-
- )}
+ ))}
+
)
}
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx
index 503586b..cc037ac 100644
--- a/frontend/src/pages/HealthPage.jsx
+++ b/frontend/src/pages/HealthPage.jsx
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
- AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
+ AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
@@ -651,7 +651,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
// Highlight problem days on a trend line by colouring the dot from a status field
// (e.g. HRV status): orange = unbalanced, red = low/poor. Other days get no dot.
-const STATUS_DOT_COLORS = { unbalanced: '#f97316', low: '#ef4444', poor: '#ef4444' }
+const STATUS_DOT_COLORS = { balanced: '#22c55e', unbalanced: '#f97316', low: '#ef4444', poor: '#ef4444' }
const statusDot = (statusKey) => (props) => {
const { cx, cy, payload } = props
const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()]
@@ -666,7 +666,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
)
return (
- (
))}
+ {/* Dashed line bridging gaps (no data). Drawn first; the solid area below
+ covers it wherever real data exists, leaving only gaps shown dashed. */}
+
-
+ connectNulls={false} isAnimationActive={false} />
+
)
}
@@ -1030,6 +1034,7 @@ export default function HealthPage() {
HRV (nightly avg)
+ Balanced
Unbalanced
Low