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