frontend: global km/mi distance-unit toggle across dashboard, activities, routes, records
This commit is contained in:
@@ -16,8 +16,9 @@ import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import {
|
||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||
formatDate, sportIcon, sportColor, formatSleep,
|
||||
formatDate, sportIcon, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
||||
} from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||
|
||||
const Grid = WidthProvider(GridLayout)
|
||||
@@ -32,8 +33,8 @@ const STAT_DEFS = {
|
||||
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
||||
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => (h.hrv_weekly_avg ?? h.hrv_nightly_avg) != null ? `${Math.round(h.hrv_weekly_avg ?? h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? <HrvBadge status={h.hrv_status} /> : undefined },
|
||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y, u) => y ? `${convertKm(y.running_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y, u) => y ? `${convertKm(y.cycling_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' },
|
||||
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||
stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' },
|
||||
stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' },
|
||||
@@ -288,6 +289,8 @@ const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
const navigate = useNavigate()
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const { data, sports } = useMemo(() => {
|
||||
if (!activities?.length) return { data: [], sports: [] }
|
||||
// Sports present, ordered by total distance (largest stacks at the bottom).
|
||||
@@ -304,14 +307,14 @@ function WeeklyChart({ activities }) {
|
||||
const t = new Date(a.start_time)
|
||||
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
|
||||
}
|
||||
for (const s of sports) row[s] = +row[s].toFixed(2)
|
||||
for (const s of sports) row[s] = +convertKm(row[s], unit).toFixed(2)
|
||||
return row
|
||||
})
|
||||
return { data, sports }
|
||||
}, [activities])
|
||||
}, [activities, unit])
|
||||
|
||||
return (
|
||||
<Card title="Weekly distance (km)">
|
||||
<Card title={`Weekly distance (${distLabel})`}>
|
||||
{data.length ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -323,7 +326,7 @@ function WeeklyChart({ activities }) {
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
||||
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
|
||||
formatter={(v, name) => [`${(+v).toFixed(1)} ${distLabel}`, sportLabel(name)]} />
|
||||
{sports.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
||||
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||
@@ -348,6 +351,7 @@ function WeeklyChart({ activities }) {
|
||||
}
|
||||
|
||||
function FeaturedActivity({ activity, segments }) {
|
||||
const unit = useUnit()
|
||||
if (!activity) return (
|
||||
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
||||
)
|
||||
@@ -370,8 +374,8 @@ function FeaturedActivity({ activity, segments }) {
|
||||
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
||||
<Stat label="Distance" value={formatDistance(activity.distance_m)} />
|
||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<Stat label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
||||
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
</div>
|
||||
@@ -406,6 +410,7 @@ function FeaturedActivity({ activity, segments }) {
|
||||
}
|
||||
|
||||
function RecentActivities({ activities }) {
|
||||
const unit = useUnit()
|
||||
return (
|
||||
<Card title="Recent activities" viewHref="/activities">
|
||||
<div className="space-y-2 overflow-auto h-full">
|
||||
@@ -421,7 +426,7 @@ function RecentActivities({ activities }) {
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
|
||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -523,6 +528,7 @@ export default function DashboardPage() {
|
||||
})
|
||||
|
||||
// ── Layout state ──────────────────────────────────────────────────────────
|
||||
const unit = useUnit()
|
||||
const isMobile = useIsMobile()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
@@ -576,7 +582,7 @@ export default function DashboardPage() {
|
||||
const renderWidget = (id) => {
|
||||
if (STAT_DEFS[id]) {
|
||||
const d = STAT_DEFS[id]
|
||||
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats)}
|
||||
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats, unit)}
|
||||
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||
}
|
||||
switch (id) {
|
||||
|
||||
Reference in New Issue
Block a user