feat: show pace (min/km) for running, hiking, walking, other activities

Cycling keeps km/h; pace sports show e.g. "5:30 /km" in the feed card,
activity stat panel (avg/max), and laps table.
This commit is contained in:
Davide Scaini
2026-06-02 16:23:03 +02:00
parent 1dca00d5e3
commit 13859a34d3
3 changed files with 20 additions and 6 deletions
+4 -4
View File
@@ -3,7 +3,7 @@
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types'; import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, formatElapsed, sportIcon, sportLabel, sportColor } from '../lib/format'; import { formatDistance, formatDuration, formatElevation, formatSpeed, formatPace, isPaceSport, formatDate, formatTime, formatElapsed, sportIcon, sportLabel, sportColor } from '../lib/format';
import ActivityMap from './ActivityMap.svelte'; import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte'; import ActivityCharts from './ActivityCharts.svelte';
import ActivityPowerCurve from './ActivityPowerCurve.svelte'; import ActivityPowerCurve from './ActivityPowerCurve.svelte';
@@ -256,8 +256,8 @@
: null, : null,
], ],
[ [
stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'), stat(isPaceSport(activity.sport) ? 'Avg pace' : 'Avg speed', isPaceSport(activity.sport) ? formatPace(activity.avg_speed_kmh) : formatSpeed(activity.avg_speed_kmh), 'speed'),
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), stat(isPaceSport(activity.sport) ? 'Max pace' : 'Max speed', isPaceSport(activity.sport) ? formatPace(activity.max_speed_kmh) : formatSpeed(activity.max_speed_kmh), 'speed'),
], ],
[ [
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'),
@@ -517,7 +517,7 @@
<td class="px-4 py-2 text-zinc-400">#{lap.index + 1}</td> <td class="px-4 py-2 text-zinc-400">#{lap.index + 1}</td>
<td class="px-4 py-2 text-white">{formatDistance(lap.distance_m)}</td> <td class="px-4 py-2 text-white">{formatDistance(lap.distance_m)}</td>
<td class="px-4 py-2 text-white">{formatDuration(lap.duration_s)}</td> <td class="px-4 py-2 text-white">{formatDuration(lap.duration_s)}</td>
<td class="px-4 py-2 text-white">{formatSpeed(lap.avg_speed_kmh)}</td> <td class="px-4 py-2 text-white">{isPaceSport(activity.sport) ? formatPace(lap.avg_speed_kmh) : formatSpeed(lap.avg_speed_kmh)}</td>
<td class="px-4 py-2 text-white">{lap.avg_hr_bpm ? `${lap.avg_hr_bpm} bpm` : '—'}</td> <td class="px-4 py-2 text-white">{lap.avg_hr_bpm ? `${lap.avg_hr_bpm} bpm` : '—'}</td>
</tr> </tr>
{/each} {/each}
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format'; import { formatDistance, formatDuration, formatElevation, formatDate, formatSpeed, formatPace, isPaceSport, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader'; import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader';
import FeedMapView from './FeedMapView.svelte'; import FeedMapView from './FeedMapView.svelte';
@@ -434,7 +434,7 @@
{#if a.avg_speed_kmh || a.avg_hr_bpm} {#if a.avg_speed_kmh || a.avg_hr_bpm}
<div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400"> <div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400">
{#if a.avg_speed_kmh} {#if a.avg_speed_kmh}
<span>{a.avg_speed_kmh.toFixed(1)} km/h</span> <span>{isPaceSport(a.sport) ? formatPace(a.avg_speed_kmh) : formatSpeed(a.avg_speed_kmh)}</span>
{/if} {/if}
{#if a.avg_hr_bpm} {#if a.avg_hr_bpm}
<span>{a.avg_hr_bpm} bpm</span> <span>{a.avg_hr_bpm} bpm</span>
+14
View File
@@ -1,5 +1,19 @@
import type { Privacy, Sport } from './types'; import type { Privacy, Sport } from './types';
const PACE_SPORTS: Set<Sport> = new Set(['running', 'hiking', 'walking', 'other']);
export function isPaceSport(sport: Sport): boolean {
return PACE_SPORTS.has(sport);
}
export function formatPace(kmh: number | null): string {
if (kmh == null || kmh <= 0) return '—';
const secsPerKm = 3600 / kmh;
const m = Math.floor(secsPerKm / 60);
const s = Math.round(secsPerKm % 60);
return `${m}:${s.toString().padStart(2, '0')} /km`;
}
/** True for "unlisted" activities (and the legacy "private" alias). /** True for "unlisted" activities (and the legacy "private" alias).
* Use this everywhere instead of comparing against 'private' directly. */ * Use this everywhere instead of comparing against 'private' directly. */
export function isUnlisted(privacy: Privacy | string | null | undefined): boolean { export function isUnlisted(privacy: Privacy | string | null | undefined): boolean {