Implemented all 9 UI fixes across health charts and activity detail pages. Changes are ready to push to git for the Docker build to pick them up.
This commit is contained in:
@@ -469,6 +469,9 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
_set(row, "active_calories", active)
|
_set(row, "active_calories", active)
|
||||||
if active and bmr:
|
if active and bmr:
|
||||||
_set(row, "total_calories", float(active) + float(bmr))
|
_set(row, "total_calories", float(active) + float(bmr))
|
||||||
|
vo2 = stats.get("vo2MaxPreciseValue") or stats.get("vo2Max")
|
||||||
|
if vo2 and float(vo2) > 0:
|
||||||
|
_set(row, "vo2max", float(vo2))
|
||||||
|
|
||||||
if sleep_data:
|
if sleep_data:
|
||||||
dto = sleep_data.get("dailySleepDTO") or sleep_data
|
dto = sleep_data.get("dailySleepDTO") or sleep_data
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
|
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
||||||
|
|
||||||
export default function LapTable({ laps, sportType }) {
|
export default function LapTable({ laps, sportType }) {
|
||||||
|
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -12,7 +15,7 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
<th className="text-right pb-2 font-medium">Pace</th>
|
<th className="text-right pb-2 font-medium">Pace</th>
|
||||||
<th className="text-right pb-2 font-medium">Avg HR</th>
|
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||||
<th className="text-right pb-2 font-medium">Power</th>
|
{showPower && <th className="text-right pb-2 font-medium">Power</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -28,9 +31,11 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||||
</td>
|
</td>
|
||||||
|
{showPower && (
|
||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -101,26 +101,28 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary stats */}
|
{/* Stats — all on one row */}
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
|
||||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary stats */}
|
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
|
||||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||||
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
|
||||||
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
|
||||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* HR Zones */}
|
||||||
|
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||||
|
<HRZoneBar zones={activity.hr_zones} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map with controls */}
|
{/* Map with controls */}
|
||||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||||
{/* Map toolbar */}
|
{/* Map toolbar */}
|
||||||
@@ -165,14 +167,6 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HR Zones */}
|
|
||||||
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
|
||||||
<HRZoneBar zones={activity.hr_zones} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metric timeline */}
|
{/* Metric timeline */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -207,22 +201,21 @@ export default function ActivityDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Laps */}
|
{/* Laps + Segments side by side */}
|
||||||
|
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{laps && laps.length > 0 && (
|
{laps && laps.length > 0 && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||||
<LapTable laps={laps} sportType={activity.sport_type} />
|
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Segments */}
|
|
||||||
{segments && segments.length > 0 && dataPoints && (
|
{segments && segments.length > 0 && dataPoints && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||||
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Column headers */}
|
|
||||||
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
|
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
|
||||||
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
|
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
|
||||||
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
|
||||||
@@ -256,5 +249,7 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { format, subDays } from 'date-fns'
|
import { format, subDays } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatSleep } from '../utils/format'
|
import { formatSleep, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
@@ -91,7 +91,17 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
|||||||
return 'stable'
|
return 'stable'
|
||||||
}
|
}
|
||||||
|
|
||||||
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
function ActivityRefLabel({ viewBox, icon }) {
|
||||||
|
if (!viewBox) return null
|
||||||
|
const { x, y } = viewBox
|
||||||
|
return (
|
||||||
|
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||||
|
{icon}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||||
if (!bb) return null
|
if (!bb) return null
|
||||||
const { charged, drained, start_level, end_level } = bb
|
const { charged, drained, start_level, end_level } = bb
|
||||||
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||||
@@ -112,14 +122,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
|||||||
|
|
||||||
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||||
const levelColor = bbLevelColor(end_level)
|
const levelColor = bbLevelColor(end_level)
|
||||||
|
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
||||||
{end_level != null && (
|
{maxLevel != null && (
|
||||||
<span className="text-3xl font-bold" style={{ color: levelColor }}>{Math.round(end_level)}</span>
|
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
|
||||||
)}
|
)}
|
||||||
{charged != null && (
|
{charged != null && (
|
||||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||||
@@ -127,18 +138,19 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
|||||||
{drained != null && (
|
{drained != null && (
|
||||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||||
)}
|
)}
|
||||||
{start_level != null && end_level != null && (
|
{end_level != null && (
|
||||||
<span className="text-xs text-gray-500">{start_level} → {end_level}</span>
|
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
<YAxis domain={[0, 100]} hide />
|
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
|
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
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']} />
|
||||||
@@ -147,6 +159,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
|||||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
{(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>
|
||||||
@@ -237,6 +258,26 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||||
|
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
||||||
|
if (!total) return null
|
||||||
|
const segments = [
|
||||||
|
{ label: 'Deep', s: deepS || 0, color: '#6366f1' },
|
||||||
|
{ label: 'REM', s: remS || 0, color: '#8b5cf6' },
|
||||||
|
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
|
||||||
|
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
|
||||||
|
].filter(seg => seg.s > 0)
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex h-5 rounded-sm overflow-hidden">
|
||||||
|
{segments.map(seg => (
|
||||||
|
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function HrvBadge({ status }) {
|
function HrvBadge({ status }) {
|
||||||
if (!status) return null
|
if (!status) return null
|
||||||
const palette = {
|
const palette = {
|
||||||
@@ -263,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, onOlder, onNewer, hasOlder, hasNewer }) {
|
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||||
if (!day) return (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<p className="text-3xl mb-2">📊</p>
|
||||||
@@ -323,10 +364,17 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
{hasSleepStages ? (
|
{hasSleepStages ? (
|
||||||
<>
|
<>
|
||||||
|
{sleepStages?.length ? (
|
||||||
<SleepHypnogram
|
<SleepHypnogram
|
||||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||||
stages={sleepStages}
|
stages={sleepStages}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SleepStageFallbackBar
|
||||||
|
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
|
||||||
|
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
|
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
|
||||||
{[
|
{[
|
||||||
['Deep', day.sleep_deep_s, '#6366f1'],
|
['Deep', day.sleep_deep_s, '#6366f1'],
|
||||||
@@ -417,7 +465,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} />
|
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -472,28 +520,13 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
{day.spo2_avg ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className="text-2xl font-bold text-sky-400">{day.spo2_avg.toFixed(1)}</span>
|
|
||||||
<span className="text-xs text-gray-500">%</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : day.vo2max ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-2xl font-bold text-blue-400">{day.vo2max.toFixed(1)}</span>
|
<span className="text-2xl font-bold text-blue-400">
|
||||||
|
{day.vo2max ? day.vo2max.toFixed(1) : '--'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
|
||||||
<span className="text-2xl font-bold text-white">--</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,7 +535,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
|
|
||||||
// ── Trend Charts ────────────────────────────────────────────────────────────
|
// ── Trend Charts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) {
|
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
|
||||||
const vals = data.filter(d => d[dataKey] != null)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
if (!vals.length) return (
|
||||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||||
@@ -528,12 +561,15 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
|||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={formatter} />
|
tickFormatter={formatter} domain={domain} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
)}
|
)}
|
||||||
|
{(referenceLines || []).map((rl, i) => (
|
||||||
|
<ReferenceLine key={i} {...rl} />
|
||||||
|
))}
|
||||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
fill={`url(#grad-${dataKey})`}
|
fill={`url(#grad-${dataKey})`}
|
||||||
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
||||||
@@ -643,6 +679,16 @@ export default function HealthPage() {
|
|||||||
enabled: !!selectedDay?.date,
|
enabled: !!selectedDay?.date,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: dayActivities } = useQuery({
|
||||||
|
queryKey: ['activities-day', selectedDay?.date],
|
||||||
|
queryFn: () => api.get('/activities/', { params: {
|
||||||
|
from_date: selectedDay.date + 'T00:00:00',
|
||||||
|
to_date: selectedDay.date + 'T23:59:59',
|
||||||
|
per_page: 20,
|
||||||
|
}}).then(r => r.data),
|
||||||
|
enabled: !!selectedDay?.date,
|
||||||
|
})
|
||||||
|
|
||||||
const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
|
const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
|
||||||
const goOlder = () => {
|
const goOlder = () => {
|
||||||
if (selectedIdx < allDaysSorted.length - 1)
|
if (selectedIdx < allDaysSorted.length - 1)
|
||||||
@@ -667,6 +713,7 @@ export default function HealthPage() {
|
|||||||
bodyBattery={intradayData?.body_battery}
|
bodyBattery={intradayData?.body_battery}
|
||||||
bbHires={intradayData?.body_battery_hires}
|
bbHires={intradayData?.body_battery_hires}
|
||||||
sleepStages={intradayData?.sleep_stages}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
|
activities={dayActivities}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
@@ -703,7 +750,8 @@ export default function HealthPage() {
|
|||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||||
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||||
formatter={v => `${Math.round(v)} bpm`}
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -711,7 +759,12 @@ export default function HealthPage() {
|
|||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
||||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
formatter={v => `${Math.round(v)} ms`}
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
|
referenceLines={[
|
||||||
|
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||||
|
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
@@ -769,13 +822,15 @@ export default function HealthPage() {
|
|||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||||
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||||
formatter={v => Math.round(v)}
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 100]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
|
||||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||||
formatter={v => `${Math.round(v)} bpm`}
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -226,8 +226,9 @@ function SegmentRecords() {
|
|||||||
enabled: !!selectedRouteId,
|
enabled: !!selectedRouteId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const theoreticalBest = bests?.length && bests.every(b => b.best_s != null)
|
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
|
||||||
? bests.reduce((sum, b) => sum + b.best_s, 0)
|
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
|
||||||
|
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!routes?.length) return (
|
if (!routes?.length) return (
|
||||||
@@ -314,7 +315,7 @@ function SegmentRecords() {
|
|||||||
</table>
|
</table>
|
||||||
{theoreticalBest != null && (
|
{theoreticalBest != null && (
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
|
||||||
<span className="text-xs text-gray-500">Theoretical best (sum of all segment bests)</span>
|
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
|
||||||
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ function SegmentsPanel({ routeId, sportType }) {
|
|||||||
const kmSplits = segments.filter(s => s.name.startsWith('km '))
|
const kmSplits = segments.filter(s => s.name.startsWith('km '))
|
||||||
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
|
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
|
||||||
|
|
||||||
const theoreticalBest = bests?.every(b => b.best_s != null)
|
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
|
||||||
? bests.reduce((sum, b) => sum + b.best_s, 0)
|
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
|
||||||
|
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const renderGroup = (group, title) => {
|
const renderGroup = (group, title) => {
|
||||||
@@ -79,7 +80,7 @@ function SegmentsPanel({ routeId, sportType }) {
|
|||||||
{renderGroup(hillsTurns, 'Hills & Turns')}
|
{renderGroup(hillsTurns, 'Hills & Turns')}
|
||||||
{theoreticalBest != null && (
|
{theoreticalBest != null && (
|
||||||
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
|
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
|
||||||
<span className="text-xs text-gray-500">Theoretical best (sum of segment bests)</span>
|
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
|
||||||
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
|
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
||||||
|
|
||||||
export default function LapTable({ laps, sportType }) {
|
export default function LapTable({ laps, sportType }) {
|
||||||
|
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -12,7 +15,7 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
<th className="text-right pb-2 font-medium">Pace</th>
|
<th className="text-right pb-2 font-medium">Pace</th>
|
||||||
<th className="text-right pb-2 font-medium">Avg HR</th>
|
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||||
<th className="text-right pb-2 font-medium">Power</th>
|
{showPower && <th className="text-right pb-2 font-medium">Power</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -28,9 +31,11 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||||
</td>
|
</td>
|
||||||
|
{showPower && (
|
||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
|
function segmentTime(points, startM, endM) {
|
||||||
|
let t0 = null
|
||||||
|
for (const p of points) {
|
||||||
|
if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime()
|
||||||
|
if (t0 !== null && p.distance_m >= endM)
|
||||||
|
return (new Date(p.timestamp).getTime() - t0) / 1000
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const METRICS = [
|
const METRICS = [
|
||||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
@@ -45,6 +55,18 @@ export default function ActivityDetailPage() {
|
|||||||
enabled: !!activity,
|
enabled: !!activity,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: segments } = useQuery({
|
||||||
|
queryKey: ['segments', activity?.named_route_id],
|
||||||
|
queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data),
|
||||||
|
enabled: !!activity?.named_route_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: segmentBests } = useQuery({
|
||||||
|
queryKey: ['segment-bests', activity?.named_route_id],
|
||||||
|
queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data),
|
||||||
|
enabled: !!activity?.named_route_id,
|
||||||
|
})
|
||||||
|
|
||||||
const toggleMetric = (key) => {
|
const toggleMetric = (key) => {
|
||||||
setActiveMetrics(prev =>
|
setActiveMetrics(prev =>
|
||||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
@@ -79,26 +101,28 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary stats */}
|
{/* Stats — all on one row */}
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
|
||||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary stats */}
|
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
|
||||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||||
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
|
||||||
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
|
||||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* HR Zones */}
|
||||||
|
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||||
|
<HRZoneBar zones={activity.hr_zones} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map with controls */}
|
{/* Map with controls */}
|
||||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||||
{/* Map toolbar */}
|
{/* Map toolbar */}
|
||||||
@@ -143,14 +167,6 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HR Zones */}
|
|
||||||
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
|
||||||
<HRZoneBar zones={activity.hr_zones} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metric timeline */}
|
{/* Metric timeline */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -185,13 +201,55 @@ export default function ActivityDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Laps */}
|
{/* Laps + Segments side by side */}
|
||||||
|
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{laps && laps.length > 0 && (
|
{laps && laps.length > 0 && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||||
<LapTable laps={laps} sportType={activity.sport_type} />
|
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{segments && segments.length > 0 && dataPoints && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||||
|
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
|
||||||
|
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{segments.map(seg => {
|
||||||
|
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
|
||||||
|
const best = segmentBests?.find(b => b.segment_id === seg.id)
|
||||||
|
const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
|
||||||
|
const delta = t != null && best?.best_s != null ? t - best.best_s : null
|
||||||
|
return (
|
||||||
|
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
|
||||||
|
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
|
||||||
|
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||||
|
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-500">
|
||||||
|
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={`font-mono text-xs w-14 text-right ${
|
||||||
|
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, AreaChart, Area, BarChart, Bar,
|
AreaChart, Area, BarChart, Bar, ReferenceLine,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { format, subDays } from 'date-fns'
|
import { format, subDays } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import { formatSleep, sportIcon } from '../utils/format'
|
||||||
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
|
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
@@ -18,16 +17,540 @@ const RANGES = [
|
|||||||
{ label: '1Y', days: 365 },
|
{ label: '1Y', days: 365 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
|
const tooltipStyle = {
|
||||||
|
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
|
||||||
|
}
|
||||||
|
|
||||||
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
// Normalise any date string to YYYY-MM-DD so XAxis values and ReferenceLine x match.
|
||||||
|
const d10 = (s) => (s || '').slice(0, 10)
|
||||||
|
|
||||||
|
// ── Daily Snapshot ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtTime(ts) {
|
||||||
|
if (!ts) return '--'
|
||||||
|
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntradayHrChart({ values }) {
|
||||||
|
if (!values?.length) return null
|
||||||
|
const data = values.map(([ts, hr]) => ({ t: ts, hr }))
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
interval={Math.max(1, Math.floor(data.length / 6))} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
|
tickFormatter={v => Math.round(v)} domain={['auto', 'auto']} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
||||||
|
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
|
||||||
|
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BB_INFERRED_COLOR = {
|
||||||
|
sleep: '#4f46e5',
|
||||||
|
rest: '#0d9488',
|
||||||
|
activity: '#f97316',
|
||||||
|
stable: '#374151',
|
||||||
|
}
|
||||||
|
const BB_INFERRED_LABEL = {
|
||||||
|
sleep: 'Sleep',
|
||||||
|
rest: 'Rest',
|
||||||
|
activity: 'Active/Stress',
|
||||||
|
stable: 'Stable',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
||||||
|
const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
|
||||||
|
if (inSleep) return 'sleep'
|
||||||
|
if (prevLevel != null) {
|
||||||
|
if (level > prevLevel + 0.3) return 'rest'
|
||||||
|
if (level < prevLevel - 0.3) return 'activity'
|
||||||
|
}
|
||||||
|
return 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityRefLabel({ viewBox, icon }) {
|
||||||
|
if (!viewBox) return null
|
||||||
|
const { x, y } = viewBox
|
||||||
|
return (
|
||||||
|
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||||
|
{icon}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||||
|
if (!bb) return null
|
||||||
|
const { charged, drained, start_level, end_level } = bb
|
||||||
|
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||||
|
|
||||||
|
const rawData = hiresValues?.length
|
||||||
|
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
||||||
|
: (bb.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||||
|
|
||||||
|
if (!rawData.length) return null
|
||||||
|
|
||||||
|
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||||
|
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||||
|
|
||||||
|
const chartData = rawData.map((d, i) => ({
|
||||||
|
...d,
|
||||||
|
type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||||
|
const levelColor = bbLevelColor(end_level)
|
||||||
|
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
||||||
|
{maxLevel != null && (
|
||||||
|
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</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 className="flex-1">
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
||||||
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
|
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
||||||
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||||
|
{chartData.map((d, i) => (
|
||||||
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
{(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>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
|
{presentTypes.map(type => (
|
||||||
|
<div key={type} className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||||
|
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis
|
||||||
|
const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
|
||||||
|
const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#8b5cf6' }
|
||||||
|
const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
|
||||||
|
const LANE_H = 15
|
||||||
|
|
||||||
|
function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
||||||
|
if (!sleepStart || !sleepEnd || !stages?.length) return null
|
||||||
|
const startMs = new Date(sleepStart).getTime()
|
||||||
|
const endMs = new Date(sleepEnd).getTime()
|
||||||
|
const windowMs = endMs - startMs
|
||||||
|
if (windowMs <= 0) return null
|
||||||
|
|
||||||
|
// Build segments per lane
|
||||||
|
const segsByLane = {}
|
||||||
|
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
|
||||||
|
stages.forEach(([tsMs, level], i) => {
|
||||||
|
if (!(level in segsByLane)) return
|
||||||
|
const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
|
||||||
|
const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
|
||||||
|
const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
|
||||||
|
const w = right - left
|
||||||
|
if (w > 0) segsByLane[level].push({ left, w })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hour ticks
|
||||||
|
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
||||||
|
const ticks = []
|
||||||
|
for (let t = sh.getTime(); t < endMs; t += 3600000) {
|
||||||
|
const pct = (t - startMs) / windowMs * 100
|
||||||
|
if (pct >= 0 && pct <= 100)
|
||||||
|
ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pl-10">
|
||||||
|
<div className="space-y-px">
|
||||||
|
{SLEEP_LANE_ORDER.map(level => (
|
||||||
|
<div key={level} className="relative flex items-center">
|
||||||
|
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
|
||||||
|
style={{ fontSize: 10 }}>
|
||||||
|
{SLEEP_STAGE_LABEL[level]}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
|
||||||
|
{segsByLane[level].map((seg, i) => (
|
||||||
|
<div key={i} className="absolute top-0 h-full"
|
||||||
|
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }} />
|
||||||
|
))}
|
||||||
|
{ticks.map((t, i) => (
|
||||||
|
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
|
||||||
|
style={{ left: `${t.pct}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-4 mt-1 ml-0">
|
||||||
|
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||||
|
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
{ticks.map((t, i) => (
|
||||||
|
<span key={i} className="absolute text-gray-600"
|
||||||
|
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||||
|
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||||
|
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
||||||
|
if (!total) return null
|
||||||
|
const segments = [
|
||||||
|
{ label: 'Deep', s: deepS || 0, color: '#6366f1' },
|
||||||
|
{ label: 'REM', s: remS || 0, color: '#8b5cf6' },
|
||||||
|
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
|
||||||
|
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
|
||||||
|
].filter(seg => seg.s > 0)
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex h-5 rounded-sm overflow-hidden">
|
||||||
|
{segments.map(seg => (
|
||||||
|
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HrvBadge({ status }) {
|
||||||
|
if (!status) return null
|
||||||
|
const palette = {
|
||||||
|
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||||
|
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||||
|
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||||
|
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||||
|
}
|
||||||
|
const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||||
|
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavArrow({ onClick, disabled, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400
|
||||||
|
hover:text-white hover:bg-gray-800 disabled:opacity-20 disabled:cursor-default
|
||||||
|
transition-colors text-base font-medium"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||||
|
if (!day) return (
|
||||||
|
<div className="text-center py-10 text-gray-600">
|
||||||
|
<p className="text-3xl mb-2">📊</p>
|
||||||
|
<p>No health data yet</p>
|
||||||
|
<p className="text-sm mt-1">Import a Garmin export to see your daily snapshot</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateLabel = day.date ? format(new Date(day.date), 'EEEE, d MMMM yyyy') : 'Latest'
|
||||||
|
const hasSleepStages = day.sleep_deep_s || day.sleep_light_s || day.sleep_rem_s
|
||||||
|
const stepsGoal = 10000
|
||||||
|
const stepsPct = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0
|
||||||
|
|
||||||
|
const stressLabel = !day.avg_stress ? null
|
||||||
|
: day.avg_stress < 25 ? 'Restful'
|
||||||
|
: day.avg_stress < 50 ? 'Low'
|
||||||
|
: day.avg_stress < 75 ? 'Medium' : 'High'
|
||||||
|
const stressColor = !day.avg_stress ? 'text-white'
|
||||||
|
: day.avg_stress < 25 ? 'text-green-400'
|
||||||
|
: day.avg_stress < 50 ? 'text-yellow-400'
|
||||||
|
: day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* Header + arrows */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<NavArrow onClick={onOlder} disabled={!hasOlder}>←</NavArrow>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Daily snapshot</p>
|
||||||
|
<h2 className="text-xl font-semibold text-white leading-tight">{dateLabel}</h2>
|
||||||
|
</div>
|
||||||
|
<NavArrow onClick={onNewer} disabled={!hasNewer}>→</NavArrow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sleep (wide) + Heart / HRV */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||||
|
{day.sleep_score != null && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full border border-indigo-400/30 bg-indigo-400/10 text-indigo-300">
|
||||||
|
Score {Math.round(day.sleep_score)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<span className="text-4xl font-bold text-white tracking-tight">
|
||||||
|
{formatSleep(day.sleep_duration_s)}
|
||||||
|
</span>
|
||||||
|
{day.sleep_start && day.sleep_end && (
|
||||||
|
<span className="text-sm text-gray-500 pb-1">
|
||||||
|
{fmtTime(day.sleep_start)} → {fmtTime(day.sleep_end)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasSleepStages ? (
|
||||||
|
<>
|
||||||
|
{sleepStages?.length ? (
|
||||||
|
<SleepHypnogram
|
||||||
|
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||||
|
stages={sleepStages}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SleepStageFallbackBar
|
||||||
|
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
|
||||||
|
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
|
||||||
|
{[
|
||||||
|
['Deep', day.sleep_deep_s, '#6366f1'],
|
||||||
|
['REM', day.sleep_rem_s, '#8b5cf6'],
|
||||||
|
['Light', day.sleep_light_s, '#a78bfa'],
|
||||||
|
['Awake', day.sleep_awake_s, '#eab308'],
|
||||||
|
].map(([label, secs, color]) => secs ? (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{label} <span className="text-white">{formatSleep(secs)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !day.sleep_duration_s ? (
|
||||||
|
<p className="text-sm text-gray-600">No sleep data</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-3xl font-bold text-rose-400">
|
||||||
|
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">bpm</span>
|
||||||
|
</div>
|
||||||
|
{avg30?.resting_hr && day.resting_hr && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
30d avg {Math.round(avg30.resting_hr)} bpm
|
||||||
|
{day.resting_hr < avg30.resting_hr
|
||||||
|
? <span className="text-green-400 ml-1">↓</span>
|
||||||
|
: day.resting_hr > avg30.resting_hr
|
||||||
|
? <span className="text-red-400 ml-1">↑</span>
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||||
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<span className="text-3xl font-bold text-violet-400">
|
||||||
|
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">ms</span>
|
||||||
|
<HrvBadge status={day.hrv_status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{day.avg_hr_day && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
|
||||||
|
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.weight_kg && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||||
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<span className="text-xl font-semibold text-emerald-400">{day.weight_kg.toFixed(1)}</span>
|
||||||
|
<span className="text-xs text-gray-500">kg</span>
|
||||||
|
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 24-hour heart rate chart + body battery (side by side) */}
|
||||||
|
{(intradayHr?.length > 0 || bodyBattery) && (
|
||||||
|
<div className={`grid gap-4 ${intradayHr?.length > 0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
|
{intradayHr?.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
||||||
|
{day.avg_hr_day && (
|
||||||
|
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<IntradayHrChart values={intradayHr} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity strip */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Steps</p>
|
||||||
|
<div className="flex items-baseline gap-1 mb-2">
|
||||||
|
<span className="text-2xl font-bold text-yellow-400">
|
||||||
|
{day.steps ? day.steps.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{day.steps ? (
|
||||||
|
<>
|
||||||
|
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-yellow-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${stepsPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{stepsPct}% of {stepsGoal.toLocaleString()}</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{day.floors_climbed
|
||||||
|
? <p className="text-xs text-gray-500 mt-1">{day.floors_climbed} floors</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Calories</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-bold text-white">
|
||||||
|
{day.total_calories
|
||||||
|
? Math.round(day.total_calories)
|
||||||
|
: day.active_calories ? Math.round(day.active_calories) : '--'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">kcal</span>
|
||||||
|
</div>
|
||||||
|
{day.active_calories && day.total_calories && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Active {Math.round(day.active_calories)} kcal</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Stress</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-2xl font-bold ${stressColor}`}>
|
||||||
|
{day.avg_stress ? Math.round(day.avg_stress) : '--'}
|
||||||
|
</span>
|
||||||
|
{day.avg_stress && <span className="text-xs text-gray-500">/100</span>}
|
||||||
|
</div>
|
||||||
|
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-bold text-blue-400">
|
||||||
|
{day.vo2max ? day.vo2max.toFixed(1) : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trend Charts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
|
||||||
const vals = data.filter(d => d[dataKey] != null)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
if (!vals.length) return (
|
||||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
|
<AreaChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||||
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
|
onClick={evt => {
|
||||||
|
const p = evt?.activePayload?.[0]?.payload
|
||||||
|
if (p?.date && onDayClick) onDayClick(p.date)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
@@ -38,122 +561,218 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
|||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={formatter} />
|
tickFormatter={formatter} domain={domain} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||||
|
{selectedDate && (
|
||||||
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
|
)}
|
||||||
|
{(referenceLines || []).map((rl, i) => (
|
||||||
|
<ReferenceLine key={i} {...rl} />
|
||||||
|
))}
|
||||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
|
fill={`url(#grad-${dataKey})`}
|
||||||
|
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
||||||
|
connectNulls={connectNulls} isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SleepChart({ data }) {
|
function SleepChart({ data, selectedDate, onDayClick }) {
|
||||||
const chartData = data.map(d => ({
|
const chartData = data.map(d => ({
|
||||||
date: d.date,
|
date: d.date, // already normalised to YYYY-MM-DD
|
||||||
deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
|
deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
|
||||||
rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
|
rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
|
||||||
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
||||||
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
||||||
}))
|
}))
|
||||||
const hasData = chartData.some(d => d.deep || d.rem || d.light)
|
const hasData = chartData.some(d => d.deep || d.rem || d.light)
|
||||||
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
if (!hasData) return (
|
||||||
|
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||||
|
barSize={6}
|
||||||
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
|
onClick={evt => {
|
||||||
|
const p = evt?.activePayload?.[0]?.payload
|
||||||
|
if (p?.date && onDayClick) onDayClick(p.date)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
||||||
tickFormatter={v => `${v}h`} />
|
tickFormatter={v => `${v}h`} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
|
{selectedDate && (
|
||||||
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
|
)}
|
||||||
<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" />
|
||||||
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
||||||
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
|
<Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HealthPage() {
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
const [rangeDays, setRangeDays] = useState(7) // default 1 week
|
|
||||||
|
|
||||||
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
|
export default function HealthPage() {
|
||||||
|
const [rangeDays, setRangeDays] = useState(7)
|
||||||
|
const [selectedDateStr, setSelectedDateStr] = useState(null) // YYYY-MM-DD or null = latest
|
||||||
|
|
||||||
|
const fromDate = useMemo(
|
||||||
|
() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'),
|
||||||
|
[rangeDays],
|
||||||
|
)
|
||||||
|
|
||||||
const { data: summary } = useQuery({
|
const { data: summary } = useQuery({
|
||||||
queryKey: ['health-summary'],
|
queryKey: ['health-summary'],
|
||||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: metrics, isLoading } = useQuery({
|
// Full history for snapshot navigation.
|
||||||
queryKey: ['health-metrics', rangeDays],
|
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
||||||
|
const { data: allDays } = useQuery({
|
||||||
|
queryKey: ['health-metrics', 'all'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/health-metrics/', {
|
api.get('/health-metrics/', { params: { limit: 365 } })
|
||||||
params: { from_date: fromDate, limit: rangeDays + 1 },
|
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
||||||
}).then(r => r.data.slice().reverse()), // oldest first for charts
|
|
||||||
keepPreviousData: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const latest = summary?.latest
|
// Trend window (changes with range selector).
|
||||||
const avg30 = summary?.avg_30d
|
// Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x.
|
||||||
|
const { data: rawMetrics, isLoading } = useQuery({
|
||||||
|
queryKey: ['health-metrics', rangeDays],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get('/health-metrics/', { params: { from_date: fromDate, limit: rangeDays + 1 } })
|
||||||
|
.then(r => r.data.slice().reverse().map(d => ({ ...d, date: d10(d.date) }))),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
const metrics = rawMetrics || []
|
||||||
|
|
||||||
|
// Snapshot navigation: newest-first sorted list of all available days
|
||||||
|
const allDaysSorted = useMemo(
|
||||||
|
() => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
|
||||||
|
[allDays],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedDay = useMemo(() => {
|
||||||
|
if (!selectedDateStr) return allDaysSorted[0] || null
|
||||||
|
return allDaysSorted.find(d => d.date === selectedDateStr) || null
|
||||||
|
}, [selectedDateStr, allDaysSorted])
|
||||||
|
|
||||||
|
const selectedIdx = useMemo(() => {
|
||||||
|
if (!selectedDay) return -1
|
||||||
|
return allDaysSorted.findIndex(d => d.date === selectedDay.date)
|
||||||
|
}, [selectedDay, allDaysSorted])
|
||||||
|
|
||||||
|
const { data: intradayData } = useQuery({
|
||||||
|
queryKey: ['health-intraday', selectedDay?.date],
|
||||||
|
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
|
||||||
|
enabled: !!selectedDay?.date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: dayActivities } = useQuery({
|
||||||
|
queryKey: ['activities-day', selectedDay?.date],
|
||||||
|
queryFn: () => api.get('/activities/', { params: {
|
||||||
|
from_date: selectedDay.date + 'T00:00:00',
|
||||||
|
to_date: selectedDay.date + 'T23:59:59',
|
||||||
|
per_page: 20,
|
||||||
|
}}).then(r => r.data),
|
||||||
|
enabled: !!selectedDay?.date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
|
||||||
|
const goOlder = () => {
|
||||||
|
if (selectedIdx < allDaysSorted.length - 1)
|
||||||
|
setSelectedDateStr(allDaysSorted[selectedIdx + 1].date)
|
||||||
|
}
|
||||||
|
const goNewer = () => {
|
||||||
|
if (selectedIdx > 0)
|
||||||
|
setSelectedDateStr(allDaysSorted[selectedIdx - 1].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The date string to highlight in charts (only shown if it falls within the current trend window)
|
||||||
|
const selDateForCharts = selectedDay?.date
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-8">
|
||||||
<h1 className="text-2xl font-bold text-white">Health</h1>
|
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||||
|
|
||||||
{/* Summary cards */}
|
<DailySnapshot
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
day={selectedDay}
|
||||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
|
avg30={summary?.avg_30d}
|
||||||
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
|
intradayHr={intradayData?.hr_values}
|
||||||
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
bodyBattery={intradayData?.body_battery}
|
||||||
sub={latest?.hrv_status || undefined} />
|
bbHires={intradayData?.body_battery_hires}
|
||||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
|
activities={dayActivities}
|
||||||
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
|
onOlder={goOlder}
|
||||||
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
|
onNewer={goNewer}
|
||||||
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
|
hasNewer={selectedIdx > 0}
|
||||||
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
/>
|
||||||
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
|
|
||||||
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
|
|
||||||
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Range selector */}
|
<div className="border-t border-gray-800" />
|
||||||
<div className="flex gap-2">
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
||||||
|
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
{RANGES.map(({ label, days }) => (
|
{RANGES.map(({ label, days }) => (
|
||||||
<button key={label} onClick={() => setRangeDays(days)}
|
<button key={label} onClick={() => setRangeDays(days)}
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
|
rangeDays === days
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
}`}>
|
}`}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-gray-500 text-sm">Loading…</div>
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
) : metrics && metrics.length > 0 ? (
|
) : metrics.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||||
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||||
formatter={v => `${Math.round(v)} bpm`} />
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 200]}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
||||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
formatter={v => `${Math.round(v)} ms`} />
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
|
referenceLines={[
|
||||||
|
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||||
|
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
|
||||||
<SleepChart data={metrics} />
|
<SleepChart data={metrics}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
<div className="flex gap-4 mt-2">
|
<div className="flex gap-4 mt-2">
|
||||||
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
|
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
|
||||||
<div key={l} className="flex items-center gap-1.5">
|
<div key={l} className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||||
<span className="text-xs text-gray-400">{l}</span>
|
<span className="text-xs text-gray-400">{l}</span>
|
||||||
@@ -164,50 +783,85 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
||||||
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
|
<MetricChart
|
||||||
formatter={v => `${v.toFixed(1)} kg`} />
|
data={metrics.filter(d => d.weight_kg != null)}
|
||||||
</div>
|
dataKey="weight_kg" color="#34d399"
|
||||||
|
formatter={v => `${v.toFixed(1)} kg`}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
connectNulls showDots />
|
||||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
<BarChart
|
||||||
|
data={metrics}
|
||||||
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||||
|
barSize={6}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={evt => {
|
||||||
|
const p = evt?.activePayload?.[0]?.payload
|
||||||
|
if (p?.date) handleDayClick(p.date)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
|
{selDateForCharts && (
|
||||||
|
<ReferenceLine x={selDateForCharts} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
|
)}
|
||||||
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||||
formatter={v => `${Math.round(v)} bpm`} />
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 100]}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
|
||||||
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||||
formatter={v => Math.round(v)} />
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 200]}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{metrics.some(d => d.body_battery?.end_level != null) && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Body Battery (end of day)</h3>
|
||||||
|
<MetricChart
|
||||||
|
data={metrics.map(d => ({ ...d, body_battery_level: d.body_battery?.end_level ?? null }))}
|
||||||
|
dataKey="body_battery_level" color="#3b82f6"
|
||||||
|
formatter={v => `${Math.round(v)}`}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.some(d => d.vo2max) && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
|
||||||
|
formatter={v => v.toFixed(1)}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-16 text-gray-600">
|
<div className="text-center py-12 text-gray-600">
|
||||||
<p className="text-4xl mb-3">📊</p>
|
<p className="text-lg">No trend data for this period</p>
|
||||||
<p className="text-lg">No health data for this period</p>
|
<p className="text-sm mt-1">Try a longer date range</p>
|
||||||
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDuration, formatDate } from '../utils/format'
|
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||||
|
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||||
|
|
||||||
const SPORTS = ['running', 'cycling', 'swimming']
|
const SPORTS = ['running', 'cycling']
|
||||||
|
|
||||||
const DISTANCE_ORDER = [
|
const DISTANCE_ORDER = [
|
||||||
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
||||||
'Half marathon', 'Marathon', '50k', '100k',
|
'Half marathon', 'Marathon', '50k', '100k',
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function RecordsPage() {
|
const TABS = ['Distance PRs', 'Route Records', 'Segment Records']
|
||||||
|
|
||||||
|
function DistancePRs() {
|
||||||
const [sport, setSport] = useState('running')
|
const [sport, setSport] = useState('running')
|
||||||
const [selectedDistance, setSelectedDistance] = useState(null)
|
const [selectedDistance, setSelectedDistance] = useState(null)
|
||||||
|
|
||||||
@@ -31,7 +34,6 @@ export default function RecordsPage() {
|
|||||||
enabled: !!selectedDistance,
|
enabled: !!selectedDistance,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by standard distance order
|
|
||||||
const sortedRecords = records?.slice().sort((a, b) => {
|
const sortedRecords = records?.slice().sort((a, b) => {
|
||||||
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
||||||
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
||||||
@@ -39,10 +41,7 @@ export default function RecordsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
|
|
||||||
|
|
||||||
{/* Sport selector */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{SPORTS.map(s => (
|
{SPORTS.map(s => (
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +66,6 @@ export default function RecordsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Records table */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -84,9 +82,7 @@ export default function RecordsPage() {
|
|||||||
key={rec.id}
|
key={rec.id}
|
||||||
onClick={() => setSelectedDistance(rec.distance_label)}
|
onClick={() => setSelectedDistance(rec.distance_label)}
|
||||||
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||||
selectedDistance === rec.distance_label
|
selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
||||||
? 'bg-blue-900/20'
|
|
||||||
: 'hover:bg-gray-800/40'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
||||||
@@ -111,52 +107,29 @@ export default function RecordsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress chart */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
{selectedDistance && history ? (
|
{selectedDistance && history ? (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-1">
|
<h3 className="text-sm font-medium text-gray-300 mb-1">{selectedDistance} progression</h3>
|
||||||
{selectedDistance} progression
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
||||||
{history.length > 1 ? (
|
{history.length > 1 ? (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={history.map(h => ({
|
data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))}
|
||||||
date: h.achieved_at,
|
|
||||||
time: h.duration_s,
|
|
||||||
}))}
|
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
dataKey="date"
|
tickFormatter={d => format(new Date(d), 'MMM yy')} />
|
||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
axisLine={false}
|
width={40} tickFormatter={formatDuration} reversed />
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={d => format(new Date(d), 'MMM yy')}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={40}
|
|
||||||
tickFormatter={formatDuration}
|
|
||||||
reversed
|
|
||||||
/>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
formatter={v => [formatDuration(v), 'Time']}
|
formatter={v => [formatDuration(v), 'Time']}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
|
||||||
type="monotone"
|
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
|
||||||
dataKey="time"
|
|
||||||
stroke="#fbbf24"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ fill: '#fbbf24', r: 4 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
@@ -175,3 +148,210 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteRecords() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: records, isLoading } = useQuery({
|
||||||
|
queryKey: ['route-records'],
|
||||||
|
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
||||||
|
|
||||||
|
if (!records?.length) return (
|
||||||
|
<div className="text-center py-16 text-gray-600">
|
||||||
|
<p className="text-4xl mb-3">🗺️</p>
|
||||||
|
<p>No route records yet — create named routes and complete activities on them</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
|
<th className="px-3 py-3" />
|
||||||
|
<th className="text-left px-3 py-3 font-medium">Route</th>
|
||||||
|
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||||
|
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||||
|
<th className="text-right px-3 py-3 font-medium">Pace</th>
|
||||||
|
<th className="text-right px-3 py-3 font-medium">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map(rec => (
|
||||||
|
<tr
|
||||||
|
key={rec.route_id}
|
||||||
|
onClick={() => navigate(`/activities/${rec.activity_id}`)}
|
||||||
|
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 font-medium text-white">
|
||||||
|
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
|
||||||
|
{rec.route_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatDistance(rec.distance_m)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
|
{formatDuration(rec.duration_s)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatDate(rec.start_time)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentRecords() {
|
||||||
|
const [selectedRouteId, setSelectedRouteId] = useState(null)
|
||||||
|
|
||||||
|
const { data: routes } = useQuery({
|
||||||
|
queryKey: ['routes'],
|
||||||
|
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: bests, isLoading } = useQuery({
|
||||||
|
queryKey: ['segment-bests', selectedRouteId],
|
||||||
|
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
|
||||||
|
enabled: !!selectedRouteId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
|
||||||
|
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
|
||||||
|
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!routes?.length) return (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
No named routes yet.{' '}
|
||||||
|
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Route tile grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{routes.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
|
||||||
|
className={`text-left rounded-xl border p-2 transition-colors ${
|
||||||
|
selectedRouteId === r.id
|
||||||
|
? 'border-blue-500 bg-blue-900/20'
|
||||||
|
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RouteMiniMap
|
||||||
|
polyline={r.reference_polyline}
|
||||||
|
sportType={r.sport_type}
|
||||||
|
width="100%"
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||||
|
{r.distance_m && (
|
||||||
|
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRouteId && (
|
||||||
|
isLoading ? (
|
||||||
|
<p className="text-gray-500 text-sm">Loading…</p>
|
||||||
|
) : !bests?.length ? (
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
No segments for this route.{' '}
|
||||||
|
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Segment</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Length</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Best time</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Runs</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bests.map(b => (
|
||||||
|
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-gray-200">
|
||||||
|
{b.name}
|
||||||
|
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-500 text-xs">
|
||||||
|
{formatDistance(b.end_distance_m - b.start_distance_m)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-semibold">
|
||||||
|
{b.best_s != null
|
||||||
|
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
|
||||||
|
: <span className="text-gray-700">--</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{b.best_activity_id && (
|
||||||
|
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{theoreticalBest != null && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
|
||||||
|
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
|
||||||
|
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordsPage() {
|
||||||
|
const [tab, setTab] = useState('Distance PRs')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Records</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'Distance PRs' && <DistancePRs />}
|
||||||
|
{tab === 'Route Records' && <RouteRecords />}
|
||||||
|
{tab === 'Segment Records' && <SegmentRecords />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,151 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
|
function formatSegDist(m) {
|
||||||
|
if (m == null) return '--'
|
||||||
|
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentsPanel({ routeId, sportType }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: segments } = useQuery({
|
||||||
|
queryKey: ['segments', routeId],
|
||||||
|
queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: bests } = useQuery({
|
||||||
|
queryKey: ['segment-bests', routeId],
|
||||||
|
queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteSeg = useMutation({
|
||||||
|
mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['segments', routeId] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['segment-bests', routeId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!segments?.length) return null
|
||||||
|
|
||||||
|
const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b]))
|
||||||
|
|
||||||
|
const kmSplits = segments.filter(s => s.name.startsWith('km '))
|
||||||
|
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
|
||||||
|
|
||||||
|
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
|
||||||
|
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
|
||||||
|
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const renderGroup = (group, title) => {
|
||||||
|
if (!group.length) return null
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
|
||||||
|
{group.map(seg => {
|
||||||
|
const best = bestMap[seg.id]
|
||||||
|
return (
|
||||||
|
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
|
||||||
|
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
|
||||||
|
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
|
||||||
|
{best?.best_s != null ? (
|
||||||
|
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700 text-xs w-14 text-right">--</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
|
||||||
|
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
|
||||||
|
title="Delete segment"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-800 pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
|
||||||
|
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
||||||
|
</div>
|
||||||
|
{renderGroup(kmSplits, '1km Splits')}
|
||||||
|
{renderGroup(hillsTurns, 'Hills & Turns')}
|
||||||
|
{theoreticalBest != null && (
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
|
||||||
|
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
|
||||||
|
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||||
|
function decodePolyline(encoded) {
|
||||||
|
if (!encoded) return []
|
||||||
|
const points = []
|
||||||
|
let idx = 0, lat = 0, lng = 0
|
||||||
|
while (idx < encoded.length) {
|
||||||
|
let shift = 0, result = 0, byte
|
||||||
|
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||||
|
lat += result & 1 ? ~(result >> 1) : result >> 1
|
||||||
|
shift = 0; result = 0
|
||||||
|
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||||
|
lng += result & 1 ? ~(result >> 1) : result >> 1
|
||||||
|
points.push([lat / 1e5, lng / 1e5])
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteMap({ polyline, className = '', sportType = '' }) {
|
||||||
|
const pts = decodePolyline(polyline)
|
||||||
|
if (pts.length < 2) return (
|
||||||
|
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
||||||
|
no track
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const t = (sportType || '').toLowerCase()
|
||||||
|
const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6'
|
||||||
|
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
|
||||||
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||||
|
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||||
|
const rangeL = maxLng - minLng || 1e-5
|
||||||
|
const rangeA = maxLat - minLat || 1e-5
|
||||||
|
const pad = 4
|
||||||
|
const w = 100, h = 60
|
||||||
|
const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2)
|
||||||
|
const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2)
|
||||||
|
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSportStyle(sportType) {
|
||||||
|
const t = (sportType || '').toLowerCase()
|
||||||
|
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
|
||||||
|
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' }
|
||||||
|
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
|
||||||
|
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' }
|
||||||
|
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' }
|
||||||
|
}
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||||
|
const [merging, setMerging] = useState(false)
|
||||||
|
const [mergeTarget, setMergeTarget] = useState('')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: routes } = useQuery({
|
const { data: routes } = useQuery({
|
||||||
@@ -14,6 +153,9 @@ export default function RoutesPage() {
|
|||||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sort by most completions first
|
||||||
|
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
||||||
|
|
||||||
const { data: routeActivities } = useQuery({
|
const { data: routeActivities } = useQuery({
|
||||||
queryKey: ['route-activities', selected?.id],
|
queryKey: ['route-activities', selected?.id],
|
||||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||||
@@ -27,8 +169,8 @@ export default function RoutesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createRoute = useMutation({
|
const createRoute = useMutation({
|
||||||
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
mutationFn: data => api.post('/routes/', data).then(r => r.data),
|
||||||
onSuccess: (route) => {
|
onSuccess: route => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setNewRoute({ name: '', activity_id: '' })
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
@@ -36,7 +178,27 @@ export default function RoutesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mergeRoute = useMutation({
|
||||||
|
mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data),
|
||||||
|
onSuccess: updated => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['route-activities', updated.id] })
|
||||||
|
setMerging(false)
|
||||||
|
setMergeTarget('')
|
||||||
|
setSelected(updated)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteRoute = useMutation({
|
||||||
|
mutationFn: id => api.delete(`/routes/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
|
setSelected(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const fastest = routeActivities?.[0]
|
const fastest = routeActivities?.[0]
|
||||||
|
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -53,7 +215,7 @@ export default function RoutesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create route */}
|
{/* Create route panel */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||||
@@ -63,21 +225,14 @@ export default function RoutesPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||||
<input value={newRoute.name}
|
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||||
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="e.g. Morning park loop" />
|
placeholder="e.g. Morning park loop" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||||
{recentActivities?.length === 0 ? (
|
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||||
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={newRoute.activity_id}
|
|
||||||
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Select an activity…</option>
|
<option value="">Select an activity…</option>
|
||||||
{recentActivities?.map(a => (
|
{recentActivities?.map(a => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
@@ -85,12 +240,10 @@ export default function RoutesPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
|
||||||
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
Create
|
Create
|
||||||
@@ -103,49 +256,105 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* Route tile grid */}
|
||||||
{/* Route list */}
|
{routes?.length === 0 && !showCreate ? (
|
||||||
<div className="space-y-2">
|
|
||||||
{routes?.length === 0 && !showCreate && (
|
|
||||||
<div className="text-center py-12 text-gray-600">
|
<div className="text-center py-12 text-gray-600">
|
||||||
<p className="text-3xl mb-2">🗺️</p>
|
<p className="text-3xl mb-2">🗺️</p>
|
||||||
<p className="text-sm">No named routes yet</p>
|
<p className="text-sm">No named routes yet</p>
|
||||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{routes?.map(route => (
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
<button key={route.id} onClick={() => setSelected(route)}
|
{sortedRoutes.map(route => {
|
||||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
const style = routeSportStyle(route.sport_type)
|
||||||
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
const isSelected = selected?.id === route.id
|
||||||
|
return (
|
||||||
|
<button key={route.id}
|
||||||
|
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }}
|
||||||
|
className={`text-left rounded-xl border p-2 transition-all ${
|
||||||
|
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-start justify-between">
|
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||||
<p className="font-medium text-white">{route.name}</p>
|
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||||
{route.auto_detected && (
|
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||||
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
|
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
||||||
)}
|
{route.activity_count > 0 && (
|
||||||
</div>
|
<span className={`text-xs font-medium ${style.accent}`}>
|
||||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
{route.activity_count}×
|
||||||
<span>{formatDistance(route.distance_m)}</span>
|
|
||||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
|
||||||
<span>{formatDate(route.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Route detail */}
|
|
||||||
{selected && (
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
|
||||||
{selected.auto_detected && (
|
|
||||||
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
|
|
||||||
Auto-detected
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{route.auto_detected && (
|
||||||
|
<span className="text-xs text-gray-600">auto</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route detail — shown below the tile grid when a route is selected */}
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
|
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
||||||
|
<span>{formatDistance(selected.distance_m)}</span>
|
||||||
|
{selected.auto_detected && (
|
||||||
|
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
|
||||||
|
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
|
||||||
|
Merge
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge panel */}
|
||||||
|
{merging && (
|
||||||
|
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
|
||||||
|
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
|
||||||
|
<option value="">Select route to merge in…</option>
|
||||||
|
{otherRoutes.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!mergeTarget || mergeRoute.isPending}
|
||||||
|
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
|
||||||
|
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
|
Merge
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setMerging(false)}
|
||||||
|
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{otherRoutes.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Course record */}
|
||||||
{fastest && (
|
{fastest && (
|
||||||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||||||
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||||||
@@ -158,13 +367,15 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||||
All runs ({routeActivities?.length ?? 0})
|
All completions ({routeActivities?.length ?? 0})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{routeActivities?.map((act, i) => (
|
{routeActivities?.map((act, i) => (
|
||||||
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
|
<Link key={act.id} to={`/activities/${act.id}`}
|
||||||
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
|
||||||
|
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
|
||||||
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||||||
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||||||
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||||
@@ -174,13 +385,15 @@ export default function RoutesPage() {
|
|||||||
{i === 0 && (
|
{i === 0 && (
|
||||||
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors">→</span>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user