Fix follow-ups: lap bests, segments, charts, dashboard health
- Lap bests: compare against OTHER activities on the route (exclude self), so single-activity routes no longer show every lap as "best" - Segment create: POST to trailing-slash URL (was a 307 that dropped the body); surface errors in the UI - PR splits: scale GPS distance stream to the activity's official distance so over-measured GPS no longer yields bogus split PRs - Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth interpolation + a Slow/Fast gradient key under the map - Health body battery: snap activity highlight to the categorical axis; white tooltip text + % suffix - Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too - Health sleep: move 8h/avg reference labels into the right margin - Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max); body battery tile renders a condensed colour-graded intraday graph Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -213,12 +213,15 @@ async def get_lap_bests(
|
|||||||
if not act.named_route_id:
|
if not act.named_route_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# Best per lap number across OTHER activities on the same route, so the
|
||||||
|
# comparison is meaningful (excluding this activity from its own benchmark).
|
||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
|
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
|
||||||
.join(Activity, Activity.id == ActivityLap.activity_id)
|
.join(Activity, Activity.id == ActivityLap.activity_id)
|
||||||
.where(
|
.where(
|
||||||
Activity.named_route_id == act.named_route_id,
|
Activity.named_route_id == act.named_route_id,
|
||||||
Activity.user_id == current_user.id,
|
Activity.user_id == current_user.id,
|
||||||
|
Activity.id != activity_id,
|
||||||
ActivityLap.duration_s.isnot(None),
|
ActivityLap.duration_s.isnot(None),
|
||||||
)
|
)
|
||||||
.group_by(ActivityLap.lap_number)
|
.group_by(ActivityLap.lap_number)
|
||||||
|
|||||||
@@ -380,6 +380,19 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
|||||||
start_time_str = parsed.get("start_time")
|
start_time_str = parsed.get("start_time")
|
||||||
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
|
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# GPS can over/under-measure relative to the activity's official distance
|
||||||
|
# (e.g. a 5 km run whose GPS track sums to 5.8 km), which would otherwise
|
||||||
|
# produce a bogus "best 5 km" split. Scale the distance stream so its max
|
||||||
|
# matches the recorded total before computing splits.
|
||||||
|
if total_dist > 0 and data_points:
|
||||||
|
gps_max = max((p.get("distance_m") or 0) for p in data_points)
|
||||||
|
if gps_max > 0 and abs(gps_max - total_dist) / total_dist > 0.02:
|
||||||
|
factor = total_dist / gps_max
|
||||||
|
data_points = [
|
||||||
|
{**p, "distance_m": p["distance_m"] * factor} if p.get("distance_m") is not None else p
|
||||||
|
for p in data_points
|
||||||
|
]
|
||||||
|
|
||||||
best_splits = compute_best_splits(data_points, total_dist)
|
best_splits = compute_best_splits(data_points, total_dist)
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
|
|||||||
@@ -28,13 +28,27 @@ const TILE_LAYERS = {
|
|||||||
// buffer of tiles and don't defer loads until the map is idle.
|
// buffer of tiles and don't defer loads until the map is idle.
|
||||||
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
|
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
|
||||||
|
|
||||||
// Slow → fast colour ramp for speed-coloured routes.
|
// Slow → fast colour ramp for speed-coloured routes (red → purple).
|
||||||
const SPEED_STOPS = ['#3b82f6', '#22c55e', '#eab308', '#f97316', '#ef4444']
|
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
|
||||||
|
|
||||||
function speedColorIndex(speed, min, max) {
|
// CSS gradient string for the speed legend.
|
||||||
if (!(max > min)) return 1
|
export const SPEED_GRADIENT = `linear-gradient(to right, ${SPEED_STOPS.join(', ')})`
|
||||||
const t = (speed - min) / (max - min)
|
|
||||||
return Math.min(SPEED_STOPS.length - 1, Math.max(0, Math.floor(t * SPEED_STOPS.length)))
|
const SPEED_LEVELS = 24 // quantisation steps → smooth gradient while limiting layer count
|
||||||
|
|
||||||
|
function lerpColor(c1, c2, t) {
|
||||||
|
const a = parseInt(c1.slice(1), 16), b = parseInt(c2.slice(1), 16)
|
||||||
|
const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t)
|
||||||
|
const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t)
|
||||||
|
const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t)
|
||||||
|
return `#${((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rampColor(t) {
|
||||||
|
t = Math.max(0, Math.min(1, t))
|
||||||
|
const seg = t * (SPEED_STOPS.length - 1)
|
||||||
|
const i = Math.min(SPEED_STOPS.length - 2, Math.floor(seg))
|
||||||
|
return lerpColor(SPEED_STOPS[i], SPEED_STOPS[i + 1], seg - i)
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodePolyline(encoded) {
|
function decodePolyline(encoded) {
|
||||||
@@ -77,21 +91,26 @@ function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef
|
|||||||
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
|
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
|
||||||
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
|
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
|
||||||
|
|
||||||
// Group consecutive points into runs of the same colour bucket → one polyline per run.
|
const levelOf = (s) => {
|
||||||
|
const t = (hi > lo) ? (((s ?? lo) - lo) / (hi - lo)) : 0.5
|
||||||
|
return Math.round(Math.max(0, Math.min(1, t)) * SPEED_LEVELS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group consecutive points into runs of the same colour level → one polyline per run.
|
||||||
let runStart = 0
|
let runStart = 0
|
||||||
let runIdx = speedColorIndex(speedPts[0].speed_ms ?? lo, lo, hi)
|
let runLevel = levelOf(speedPts[0].speed_ms)
|
||||||
const flush = (end) => {
|
const flush = (end) => {
|
||||||
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
|
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
|
||||||
if (coords.length >= 2) {
|
if (coords.length >= 2) {
|
||||||
L.polyline(coords, { color: SPEED_STOPS[runIdx], weight: 3, opacity: 0.95 }).addTo(group)
|
L.polyline(coords, { color: rampColor(runLevel / SPEED_LEVELS), weight: 3, opacity: 0.95 }).addTo(group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let i = 1; i < speedPts.length; i++) {
|
for (let i = 1; i < speedPts.length; i++) {
|
||||||
const idx = speedColorIndex(speedPts[i].speed_ms ?? lo, lo, hi)
|
const level = levelOf(speedPts[i].speed_ms)
|
||||||
if (idx !== runIdx) {
|
if (level !== runLevel) {
|
||||||
flush(i) // include current point so runs join up
|
flush(i) // include current point so runs join up
|
||||||
runStart = i
|
runStart = i
|
||||||
runIdx = idx
|
runLevel = level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(speedPts.length - 1)
|
flush(speedPts.length - 1)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useParams } from 'react-router-dom'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap, { SPEED_GRADIENT } from '../components/activity/ActivityMap'
|
||||||
import MetricTimeline from '../components/activity/MetricTimeline'
|
import MetricTimeline from '../components/activity/MetricTimeline'
|
||||||
import HRZoneBar from '../components/activity/HRZoneBar'
|
import HRZoneBar from '../components/activity/HRZoneBar'
|
||||||
import LapTable from '../components/activity/LapTable'
|
import LapTable from '../components/activity/LapTable'
|
||||||
@@ -81,9 +81,12 @@ export default function ActivityDetailPage() {
|
|||||||
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
|
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [segError, setSegError] = useState('')
|
||||||
const createSegment = async () => {
|
const createSegment = async () => {
|
||||||
const [a, b] = segPoints
|
const [a, b] = segPoints
|
||||||
await api.post('/segments', {
|
setSegError('')
|
||||||
|
try {
|
||||||
|
await api.post('/segments/', {
|
||||||
name: segName.trim() || 'Segment',
|
name: segName.trim() || 'Segment',
|
||||||
activity_id: Number(id),
|
activity_id: Number(id),
|
||||||
start_distance_m: a.distance_m,
|
start_distance_m: a.distance_m,
|
||||||
@@ -91,6 +94,9 @@ export default function ActivityDetailPage() {
|
|||||||
})
|
})
|
||||||
setSegCreate(false); setSegPoints([]); setSegName('')
|
setSegCreate(false); setSegPoints([]); setSegName('')
|
||||||
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
|
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
|
||||||
|
} catch (e) {
|
||||||
|
setSegError(e.response?.data?.detail || 'Failed to create segment')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMetric = (key) => {
|
const toggleMetric = (key) => {
|
||||||
@@ -230,6 +236,7 @@ export default function ActivityDetailPage() {
|
|||||||
{segPoints.length > 0 && (
|
{segPoints.length > 0 && (
|
||||||
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
|
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
|
||||||
)}
|
)}
|
||||||
|
{segError && <span className="text-red-400">{segError}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ height: mapHeight }}>
|
<div style={{ height: mapHeight }}>
|
||||||
@@ -243,6 +250,13 @@ export default function ActivityDetailPage() {
|
|||||||
onMapClick={segCreate ? handleMapClick : undefined}
|
onMapClick={segCreate ? handleMapClick : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{colorMode === 'speed' && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-t border-gray-800">
|
||||||
|
<span className="text-xs text-gray-500">Slow</span>
|
||||||
|
<div className="h-2 flex-1 max-w-xs rounded-full" style={{ background: SPEED_GRADIENT }} />
|
||||||
|
<span className="text-xs text-gray-500">Fast</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metric timeline */}
|
{/* Metric timeline */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
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 { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
@@ -27,53 +28,45 @@ function bbLevelColor(level) {
|
|||||||
return '#ef4444'
|
return '#ef4444'
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniBodyBattery({ bb }) {
|
function MiniBodyBattery({ bb, hires }) {
|
||||||
if (!bb?.end_level && !bb?.charged) return null
|
const data = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
|
||||||
const { charged, drained, start_level, end_level, values } = bb
|
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
|
||||||
const color = bbLevelColor(end_level)
|
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
|
||||||
const sparkData = Array.isArray(values)
|
const hasGraph = data.length >= 2
|
||||||
? values.map(([ts, level]) => ({ ts, level }))
|
|
||||||
: []
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||||
<Link to="/health" className="text-xs text-blue-400 hover:underline">View →</Link>
|
<Link to="/health" className="text-xs text-blue-400 hover:underline">View →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
{end_level != null && (
|
{peak != null && (
|
||||||
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
|
<span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>
|
||||||
)}
|
|
||||||
{charged != null && (
|
|
||||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
|
||||||
)}
|
|
||||||
{drained != null && (
|
|
||||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
|
||||||
)}
|
)}
|
||||||
|
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||||
|
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||||
|
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||||
</div>
|
</div>
|
||||||
{start_level != null && end_level != null && (
|
{hasGraph ? (
|
||||||
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
<div className="mt-3 flex-1">
|
||||||
)}
|
<ResponsiveContainer width="100%" height={70}>
|
||||||
{sparkData.length >= 2 && (
|
<BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||||
<div className="mt-3">
|
<YAxis domain={[0, 100]} hide />
|
||||||
<ResponsiveContainer width="100%" height={60}>
|
|
||||||
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
|
|
||||||
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
|
||||||
|
itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
formatter={v => [`${Math.round(v)}%`, 'Battery']}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||||
|
{data.map((d, i) => <Cell key={i} fill={bbLevelColor(d.level)} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -148,9 +141,32 @@ export default function DashboardPage() {
|
|||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: healthSummary } = useQuery({
|
const { data: recentHealth } = useQuery({
|
||||||
queryKey: ['health-summary'],
|
queryKey: ['health-metrics', 'dash'],
|
||||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
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 ?? null,
|
||||||
|
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({
|
const { data: records } = useQuery({
|
||||||
@@ -163,7 +179,6 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const latest = healthSummary?.latest
|
|
||||||
const featured = recentActivities?.[0]
|
const featured = recentActivities?.[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,8 +191,8 @@ export default function DashboardPage() {
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||||
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
<StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
|
||||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
<StatCard label="Sleep" value={formatSleep(health.sleep_duration_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
@@ -187,19 +202,19 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<MiniBodyBattery bb={latest?.body_battery} />
|
<MiniBodyBattery bb={intraday?.body_battery} hires={intraday?.body_battery_hires} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||||
{latest ? (
|
{health.date ? (
|
||||||
<>
|
<>
|
||||||
{[
|
{[
|
||||||
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
|
['HRV', health.hrv_nightly_avg ? `${Math.round(health.hrv_nightly_avg)} ms` : '--'],
|
||||||
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
|
['Sleep score', health.sleep_score ? Math.round(health.sleep_score) : '--'],
|
||||||
['Steps', latest.steps?.toLocaleString() ?? '--'],
|
['Steps', health.steps?.toLocaleString() ?? '--'],
|
||||||
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
|
['VO2 Max', health.vo2max ? health.vo2max.toFixed(1) : '--'],
|
||||||
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
|
['Stress', health.avg_stress ? Math.round(health.avg_stress) : '--'],
|
||||||
].map(([label, val]) => (
|
].map(([label, val]) => (
|
||||||
<div key={label} className="flex justify-between text-sm">
|
<div key={label} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-gray-500">{label}</span>
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
|||||||
|
|
||||||
function ActivityRefLabel({ viewBox, icon }) {
|
function ActivityRefLabel({ viewBox, icon }) {
|
||||||
if (!viewBox) return null
|
if (!viewBox) return null
|
||||||
const { x, y } = viewBox
|
const { x, y, width = 0 } = viewBox
|
||||||
return (
|
return (
|
||||||
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
<text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||||
{icon}
|
{icon}
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
@@ -245,6 +245,14 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
|||||||
const levelColor = bbLevelColor(end_level)
|
const levelColor = bbLevelColor(end_level)
|
||||||
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
||||||
|
|
||||||
|
// The X axis is categorical (band scale), so overlays must use values that
|
||||||
|
// exist in the data — snap activity start/end to the nearest sample.
|
||||||
|
const nearestT = (ms) => {
|
||||||
|
let best = null, bd = Infinity
|
||||||
|
for (const d of chartData) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } }
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
||||||
@@ -272,9 +280,9 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
|||||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
formatter={v => [`${Math.round(v)}%`, 'Battery']} />
|
||||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||||
{chartData.map((d, i) => (
|
{chartData.map((d, i) => (
|
||||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||||
@@ -283,26 +291,20 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
|||||||
{(activities || []).map(a => {
|
{(activities || []).map(a => {
|
||||||
const start = new Date(a.start_time).getTime()
|
const start = new Date(a.start_time).getTime()
|
||||||
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
||||||
|
const x1 = nearestT(start), x2 = nearestT(end)
|
||||||
|
if (x1 == null || x2 == null) return null
|
||||||
return (
|
return (
|
||||||
<ReferenceArea
|
<ReferenceArea
|
||||||
key={`area-${a.id}`}
|
key={`area-${a.id}`}
|
||||||
x1={start}
|
x1={x1}
|
||||||
x2={end}
|
x2={x2}
|
||||||
fill="rgba(255,255,255,0.12)"
|
fill="rgba(255,255,255,0.16)"
|
||||||
stroke="rgba(255,255,255,0.25)"
|
stroke="rgba(255,255,255,0.3)"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
|
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{(activities || []).map(a => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={a.id}
|
|
||||||
x={new Date(a.start_time).getTime()}
|
|
||||||
stroke="rgba(255,255,255,0.3)"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -741,7 +743,7 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
|||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
|
||||||
barSize={6}
|
barSize={6}
|
||||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
@@ -759,10 +761,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
|||||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
)}
|
)}
|
||||||
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
|
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
|
||||||
label={{ value: '8h', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 }} />
|
label={{ value: '8h', position: 'right', fill: '#22c55e', fontSize: 9 }} />
|
||||||
{avgSleep != null && (
|
{avgSleep != null && (
|
||||||
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
|
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
|
||||||
label={{ value: `avg ${avgSleep}h`, position: 'insideBottomRight', fill: '#a855f7', fontSize: 9 }} />
|
label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} />
|
||||||
)}
|
)}
|
||||||
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||||
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||||
@@ -819,11 +821,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
||||||
const minW = Math.min(...series.map(s => s.w))
|
const minKg = Math.min(...withWeight.map(d => d.weight_kg))
|
||||||
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
|
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
|
||||||
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
|
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
|
||||||
const yMin = Math.max(0, Math.floor(minW - (imperial ? 6 : 3)))
|
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent
|
||||||
const fmtVal = (v) => (imperial ? fmtStLb(v) : `${v.toFixed(1)} kg`)
|
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -855,7 +857,7 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
)}
|
)}
|
||||||
{goalU != null && (
|
{goalU != null && (
|
||||||
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
|
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
|
||||||
label={{ value: `Goal ${fmtVal(goalU)}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
||||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user