fitness: add GPX upload with map, pace chart, and km splits

Add GPX file upload for cardio exercises in workout history. Parses
GPX track points and stores them in the session. Shows route map
(Leaflet), pace-over-distance chart (Chart.js), and per-km splits
table with color-coded fast/slow pacing. Auto-fills distance and
duration on single-set exercises. Disables Chart.js animations.
This commit is contained in:
2026-03-20 13:30:47 +01:00
parent d1e5fb375d
commit d1527b2572
6 changed files with 627 additions and 2 deletions
+2
View File
@@ -29,6 +29,7 @@
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.9",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@vitest/ui": "^4.0.10",
@@ -48,6 +49,7 @@
"chart.js": "^4.5.0",
"file-type": "^19.0.0",
"ioredis": "^5.9.0",
"leaflet": "^1.9.4",
"lucide-svelte": "^0.575.0",
"mongoose": "^8.0.0",
"node-cron": "^4.2.1",
+23
View File
@@ -23,6 +23,9 @@ importers:
ioredis:
specifier: ^5.9.0
version: 5.9.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-svelte:
specifier: ^0.575.0
version: 0.575.0(svelte@5.38.6)
@@ -54,6 +57,9 @@ importers:
'@testing-library/svelte':
specifier: ^5.2.9
version: 5.2.9(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0))(vitest@4.0.10(@types/node@22.18.0)(@vitest/ui@4.0.10)(jsdom@27.2.0)(terser@5.46.0))
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@types/node':
specifier: ^22.12.0
version: 22.18.0
@@ -888,6 +894,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/node-cron@3.0.11':
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
@@ -1248,6 +1260,9 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -2358,6 +2373,12 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/node-cron@3.0.11': {}
'@types/node@22.18.0':
@@ -2740,6 +2761,8 @@ snapshots:
kleur@4.1.5: {}
leaflet@1.9.4: {}
locate-character@3.0.0: {}
lodash.defaults@4.2.0: {}
@@ -69,6 +69,7 @@
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
grid: { display: false },
+27 -1
View File
@@ -10,12 +10,22 @@ export interface ICompletedSet {
notes?: string;
}
export interface IGpsPoint {
lat: number;
lng: number;
altitude?: number;
speed?: number;
timestamp: number;
}
export interface ICompletedExercise {
exerciseId: string;
name: string;
sets: ICompletedSet[];
restTime?: number;
notes?: string;
gpsTrack?: IGpsPoint[];
totalDistance?: number; // km
}
export interface IWorkoutSession {
@@ -70,6 +80,14 @@ const CompletedSetSchema = new mongoose.Schema({
}
});
const GpsPointSchema = new mongoose.Schema({
lat: { type: Number, required: true },
lng: { type: Number, required: true },
altitude: Number,
speed: Number,
timestamp: { type: Number, required: true }
}, { _id: false });
const CompletedExerciseSchema = new mongoose.Schema({
exerciseId: {
type: String,
@@ -95,6 +113,14 @@ const CompletedExerciseSchema = new mongoose.Schema({
type: String,
trim: true,
maxlength: 500
},
gpsTrack: {
type: [GpsPointSchema],
default: undefined
},
totalDistance: {
type: Number,
min: 0
}
});
@@ -157,4 +183,4 @@ const WorkoutSessionSchema = new mongoose.Schema(
WorkoutSessionSchema.index({ createdBy: 1, startTime: -1 });
WorkoutSessionSchema.index({ templateId: 1 });
export const WorkoutSession = mongoose.model<IWorkoutSession>("WorkoutSession", WorkoutSessionSchema);
export const WorkoutSession = mongoose.models.WorkoutSession as mongoose.Model<IWorkoutSession> ?? mongoose.model<IWorkoutSession>("WorkoutSession", WorkoutSessionSchema);
@@ -0,0 +1,165 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import type { IGpsPoint } from '$models/WorkoutSession';
import mongoose from 'mongoose';
/** Haversine distance in km between two points */
function haversine(a: IGpsPoint, b: IGpsPoint): number {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h =
sinLat * sinLat +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
function trackDistance(track: IGpsPoint[]): number {
let total = 0;
for (let i = 1; i < track.length; i++) {
total += haversine(track[i - 1], track[i]);
}
return total;
}
/** Parse a GPX XML string into an array of GpsPoints */
function parseGpx(xml: string): IGpsPoint[] {
const points: IGpsPoint[] = [];
// Match <trkpt> or <rtept> elements
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
let match;
while ((match = trkptRegex.exec(xml)) !== null) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
const body = match[3];
let altitude: number | undefined;
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
if (eleMatch) altitude = parseFloat(eleMatch[1]);
let timestamp = Date.now();
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
if (!isNaN(lat) && !isNaN(lng)) {
points.push({ lat, lng, altitude, timestamp });
}
}
return points;
}
// POST /api/fitness/sessions/[id]/gpx — upload GPX file for an exercise
export const POST: RequestHandler = async ({ params, request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get('gpx');
const exerciseIdx = parseInt(formData.get('exerciseIdx') as string ?? '', 10);
if (!file || !(file instanceof File)) {
return json({ error: 'No GPX file provided' }, { status: 400 });
}
if (isNaN(exerciseIdx) || exerciseIdx < 0) {
return json({ error: 'Invalid exercise index' }, { status: 400 });
}
const gpxText = await file.text();
const track = parseGpx(gpxText);
if (track.length === 0) {
return json({ error: 'No track points found in GPX file' }, { status: 400 });
}
await dbConnect();
const workoutSession = await WorkoutSession.findOne({
_id: params.id,
createdBy: session.user.nickname
});
if (!workoutSession) {
return json({ error: 'Session not found' }, { status: 404 });
}
if (exerciseIdx >= workoutSession.exercises.length) {
return json({ error: 'Exercise index out of range' }, { status: 400 });
}
const distance = trackDistance(track);
const durationMin = Math.round((track[track.length - 1].timestamp - track[0].timestamp) / 60000);
workoutSession.exercises[exerciseIdx].gpsTrack = track;
workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000;
// Auto-fill distance and duration on a single set
const sets = workoutSession.exercises[exerciseIdx].sets;
if (sets.length === 1) {
sets[0].distance = Math.round(distance * 100) / 100;
sets[0].duration = durationMin;
}
await workoutSession.save();
return json({
points: track.length,
distance: workoutSession.exercises[exerciseIdx].totalDistance
});
} catch (error) {
console.error('Error processing GPX upload:', error);
return json({ error: 'Failed to process GPX file' }, { status: 500 });
}
};
// DELETE /api/fitness/sessions/[id]/gpx — remove GPS track from an exercise
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
try {
const { exerciseIdx } = await request.json();
await dbConnect();
const workoutSession = await WorkoutSession.findOne({
_id: params.id,
createdBy: session.user.nickname
});
if (!workoutSession) {
return json({ error: 'Session not found' }, { status: 404 });
}
if (exerciseIdx >= workoutSession.exercises.length) {
return json({ error: 'Exercise index out of range' }, { status: 400 });
}
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
await workoutSession.save();
return json({ success: true });
} catch (error) {
console.error('Error removing GPS track:', error);
return json({ error: 'Failed to remove GPS track' }, { status: 500 });
}
};
+409 -1
View File
@@ -1,10 +1,12 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus } from 'lucide-svelte';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X } from 'lucide-svelte';
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { onMount } from 'svelte';
let { data } = $props();
@@ -173,6 +175,223 @@
const metrics = getExerciseMetrics(exercise);
return metrics.includes('weight') && metrics.includes('reps');
}
/** @param {string} exerciseId */
function isCardio(exerciseId) {
const exercise = getExerciseById(exerciseId);
return exercise?.bodyPart === 'cardio';
}
/** @type {Record<number, any>} */
let maps = {};
let uploading = $state(-1);
/** Haversine distance in km */
function haversine(/** @type {any} */ a, /** @type {any} */ b) {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
/** @param {any[]} track */
function trackDistance(track) {
let total = 0;
for (let i = 1; i < track.length; i++) total += haversine(track[i - 1], track[i]);
return total;
}
/** @param {number} minPerKm */
function formatPace(minPerKm) {
const m = Math.floor(minPerKm);
const s = Math.round((minPerKm - m) * 60);
return `${m}:${s.toString().padStart(2, '0')} /km`;
}
/**
* Svelte use:action — renders a Leaflet map for a GPS track
* @param {HTMLElement} node
* @param {any} params
*/
function renderMap(node, params) {
const { track, idx } = params;
initMapForTrack(node, track, idx);
return {
destroy() {
if (maps[idx]) {
maps[idx].remove();
delete maps[idx];
}
}
};
}
/** @param {HTMLElement} node @param {any[]} track @param {number} idx */
async function initMapForTrack(node, track, idx) {
const L = await import('leaflet');
if (!node.isConnected) return;
const map = L.map(node, { attributionControl: false, zoomControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
const pts = track.map((/** @type {any} */ p) => /** @type {[number, number]} */ ([p.lat, p.lng]));
const polyline = L.polyline(pts, { color: '#88c0d0', weight: 3 }).addTo(map);
// Start/end markers
L.circleMarker(pts[0], { radius: 5, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
L.circleMarker(pts[pts.length - 1], { radius: 5, fillColor: '#bf616a', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
map.fitBounds(polyline.getBounds(), { padding: [20, 20] });
maps[idx] = map;
}
/**
* Compute km splits from a GPS track.
* Returns array of { km, pace (min/km), elapsed (min) }
* @param {any[]} track
*/
function computeSplits(track) {
if (track.length < 2) return [];
/** @type {Array<{km: number, pace: number, elapsed: number}>} */
const splits = [];
let cumDist = 0;
let splitStart = 0; // index where current km started
let splitStartTime = track[0].timestamp;
for (let i = 1; i < track.length; i++) {
cumDist += haversine(track[i - 1], track[i]);
const currentKm = Math.floor(cumDist);
const prevKm = splits.length;
if (currentKm > prevKm) {
// We crossed a km boundary
const elapsedMin = (track[i].timestamp - splitStartTime) / 60000;
// Distance covered in this segment (might be slightly over 1km)
const segDist = cumDist - prevKm;
const pace = segDist > 0 ? elapsedMin / segDist : 0;
splits.push({
km: currentKm,
pace,
elapsed: (track[i].timestamp - track[0].timestamp) / 60000
});
splitStart = i;
splitStartTime = track[i].timestamp;
}
}
// Final partial km
const lastKm = splits.length;
const partialDist = cumDist - lastKm;
if (partialDist > 0.05) { // only show if > 50m
const elapsedMin = (track[track.length - 1].timestamp - splitStartTime) / 60000;
const pace = elapsedMin / partialDist;
splits.push({
km: cumDist,
pace,
elapsed: (track[track.length - 1].timestamp - track[0].timestamp) / 60000
});
}
return splits;
}
/**
* Compute rolling pace samples for the pace graph.
* Returns array of { dist (km), pace (min/km) } sampled every ~100m.
* @param {any[]} track
*/
function computePaceSamples(track) {
if (track.length < 2) return [];
/** @type {Array<{dist: number, pace: number}>} */
const samples = [];
let cumDist = 0;
const windowDist = 0.2; // 200m rolling window
for (let i = 1; i < track.length; i++) {
cumDist += haversine(track[i - 1], track[i]);
// Find point ~windowDist km back
let backDist = 0;
let j = i;
while (j > 0 && backDist < windowDist) {
backDist += haversine(track[j - 1], track[j]);
j--;
}
if (backDist < 0.05) continue; // too little distance
const dt = (track[i].timestamp - track[j].timestamp) / 60000;
const pace = dt / backDist;
if (pace > 0 && pace < 30) { // sanity: max 30 min/km
samples.push({ dist: cumDist, pace });
}
}
return samples;
}
/**
* Build Chart.js data for pace over distance
* @param {Array<{dist: number, pace: number}>} samples
*/
function buildPaceChartData(samples) {
// Downsample to ~50 points for readability
const step = Math.max(1, Math.floor(samples.length / 50));
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
return {
labels: filtered.map(s => s.dist.toFixed(2)),
datasets: [{
label: 'Pace',
data: filtered.map(s => s.pace),
borderColor: '#88C0D0',
backgroundColor: 'rgba(136, 192, 208, 0.12)',
borderWidth: 1.5,
pointRadius: 0,
tension: 0.3,
fill: true
}]
};
}
/** @param {number} exIdx */
async function uploadGpx(exIdx) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.gpx';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
uploading = exIdx;
const fd = new FormData();
fd.append('gpx', file);
fd.append('exerciseIdx', String(exIdx));
try {
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
method: 'POST',
body: fd
});
if (res.ok) {
await invalidateAll();
}
} catch {}
uploading = -1;
};
input.click();
}
/** @param {number} exIdx */
async function removeGpx(exIdx) {
if (!confirm('Remove GPS track from this exercise?')) return;
try {
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exerciseIdx: exIdx })
});
if (res.ok) {
await invalidateAll();
}
} catch {}
}
</script>
<div class="session-detail">
@@ -316,6 +535,72 @@
{/each}
</tbody>
</table>
{#if ex.gpsTrack?.length > 0}
{@const dist = ex.totalDistance ?? trackDistance(ex.gpsTrack)}
{@const elapsed = (ex.gpsTrack[ex.gpsTrack.length - 1].timestamp - ex.gpsTrack[0].timestamp) / 60000}
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
<div class="gps-track-section">
<div class="gps-stats">
<span class="gps-stat"><Route size={14} /> {dist.toFixed(2)} km</span>
{#if pace > 0}
<span class="gps-stat">{formatPace(pace)} avg</span>
{/if}
<span class="gps-stat">{ex.gpsTrack.length} pts</span>
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
<X size={14} />
</button>
</div>
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
{#if ex.gpsTrack.length >= 2}
{@const samples = computePaceSamples(ex.gpsTrack)}
{@const splits = computeSplits(ex.gpsTrack)}
{#if samples.length > 0}
<div class="pace-chart-section">
<FitnessChart
data={buildPaceChartData(samples)}
title="Pace (min/km)"
height="180px"
/>
</div>
{/if}
{#if splits.length > 1}
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
<div class="splits-section">
<h4>Splits</h4>
<table class="splits-table">
<thead>
<tr>
<th>KM</th>
<th>PACE</th>
<th>TIME</th>
</tr>
</thead>
<tbody>
{#each splits as split, i (i)}
{@const isFull = split.km === Math.floor(split.km)}
<tr class:partial={!isFull}>
<td class="split-km">{isFull ? split.km : split.km.toFixed(2)}</td>
<td class="split-pace" class:fast={split.pace < avgPace * 0.97} class:slow={split.pace > avgPace * 1.03}>
{formatPace(split.pace)}
</td>
<td class="split-elapsed">{Math.floor(split.elapsed)}:{Math.round((split.elapsed % 1) * 60).toString().padStart(2, '0')}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
{:else if isCardio(ex.exerciseId)}
<button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}>
<Upload size={14} />
{uploading === exIdx ? 'Uploading...' : 'Upload GPX'}
</button>
{/if}
</div>
{/each}
{/if}
@@ -350,6 +635,10 @@
{/if}
</div>
<svelte:head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
{#if showPicker}
<ExercisePicker
onSelect={(id) => { addExerciseToEdit(id); showPicker = false; }}
@@ -664,4 +953,123 @@
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* GPS track section */
.gps-track-section {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gps-stats {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.gps-stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.gps-stat:first-child {
font-weight: 600;
color: var(--nord8);
}
.gpx-remove-btn {
margin-left: auto;
background: none;
border: none;
color: var(--nord11);
cursor: pointer;
padding: 0.2rem;
opacity: 0.5;
display: flex;
}
.gpx-remove-btn:hover {
opacity: 1;
}
.track-map {
height: 200px;
border-radius: 8px;
overflow: hidden;
}
.gpx-upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
}
.gpx-upload-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.gpx-upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Pace chart */
.pace-chart-section {
margin-top: 0.25rem;
}
.splits-section h4 {
margin: 0 0 0.4rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 600;
}
/* Splits table */
.splits-section {
margin-top: 0.25rem;
}
.splits-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.splits-table th {
text-align: center;
font-size: 0.7rem;
color: var(--color-text-secondary);
padding: 0.3rem 0.4rem;
letter-spacing: 0.05em;
font-weight: 600;
}
.splits-table td {
text-align: center;
padding: 0.3rem 0.4rem;
border-top: 1px solid var(--color-border);
font-variant-numeric: tabular-nums;
}
.split-km {
font-weight: 600;
color: var(--color-text-secondary);
}
.split-pace.fast {
color: var(--nord14);
}
.split-pace.slow {
color: var(--nord12);
}
tr.partial .split-km {
font-style: italic;
opacity: 0.7;
}
.split-elapsed {
color: var(--color-text-secondary);
font-size: 0.75rem;
}
</style>