fitness: add GPX upload with map, pace chart, and km splits
All checks were successful
CI / update (push) Successful in 2m18s

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 0e5d6dceb9
commit 6835f5479e
6 changed files with 627 additions and 2 deletions

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
pnpm-lock.yaml generated
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: {}

View File

@@ -69,6 +69,7 @@
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
grid: { display: false },

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);

View File

@@ -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 });
}
};

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>