From 9521a64da4b747f87ca5331d1898998dff874d56 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 17 May 2026 09:15:11 +0200 Subject: [PATCH] Activity stats: fixed-position pairs so optional values don't shift layout --- site/src/components/ActivityDetail.svelte | 102 ++++++++++++++-------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 68f3cce..e7529d1 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -234,25 +234,47 @@ return { label: MODE_LABEL[colorMode], min: fmt(minV), max: fmt(maxV) }; })(); - const stat = (label: string, value: string, key?: string) => ({ label, value, key }); + type Stat = { label: string; value: string; key?: string }; + const stat = (label: string, value: string, key?: string): Stat => ({ label, value, key }); $: hiddenStats = new Set((detail?.custom as any)?.hide_stats ?? []); - $: stats = [ - stat('Distance', formatDistance(activity.distance_m)), - stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)), - stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'), - ...(detail?.climbing_vam_mh != null ? [ - stat('Climbing VAM', `${detail.climbing_vam_mh.toLocaleString()} m/h`, 'elevation'), - ] : []), - stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'), - stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), - stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), - stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'), - ...(activity.avg_power_w != null ? [ - stat('Avg power', `${activity.avg_power_w} W`, 'power'), - stat('NP', npPower != null ? `${npPower} W` : '—', 'power'), - ] : []), - stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'), - ].filter(s => !s.key || !hiddenStats.has(s.key)); + + // Fixed-position pairs: null = empty slot. Pairing is always preserved regardless + // of which optional values (VAM, power) are available. + $: statRows = (() => { + const h = hiddenStats; + const vis = (s: Stat | null): Stat | null => s && (!s.key || !h.has(s.key)) ? s : null; + const rows: [Stat | null, Stat | null][] = [ + [ + stat('Distance', formatDistance(activity.distance_m)), + stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)), + ], + [ + stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'), + detail?.climbing_vam_mh != null + ? stat('Climbing VAM', `${detail.climbing_vam_mh.toLocaleString()} m/h`, 'elevation') + : null, + ], + [ + stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'), + stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), + ], + [ + stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), + stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'), + ], + ...(activity.avg_power_w != null ? [[ + stat('Avg power', `${activity.avg_power_w} W`, 'power'), + stat('NP', npPower != null ? `${npPower} W` : '—', 'power'), + ] as [Stat, Stat]] : []), + [ + stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'), + null, + ], + ]; + return rows + .map(([a, b]) => [vis(a), vis(b)] as [Stat | null, Stat | null]) + .filter(([a, b]) => a !== null || b !== null); + })(); @@ -431,25 +453,31 @@
- {#each stats as s} - {@const cm = - s.key === 'speed' && hasSpeedTrack ? 'speed' : - s.key === 'heart_rate' && hasHrTrack ? 'hr' : - s.key === 'power' && hasPowerTrack ? 'power' : - s.key === 'elevation' && hasElevTrack ? 'elevation' : - s.key === 'cadence' && hasCadenceTrack ? 'cadence' : null} -
{ if (cm) colorMode = cm; }} - on:mouseleave={() => { colorMode = stickyMode ?? 'default'; }} - on:click={() => handleStatClick(cm)} - on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleStatClick(cm); } }} - > -

{s.value}

-

{s.label}

-
+ {#each statRows as [left, right]} + {#each [left, right] as s} + {@const cm = + s?.key === 'speed' && hasSpeedTrack ? 'speed' : + s?.key === 'heart_rate' && hasHrTrack ? 'hr' : + s?.key === 'power' && hasPowerTrack ? 'power' : + s?.key === 'elevation' && hasElevTrack ? 'elevation' : + s?.key === 'cadence' && hasCadenceTrack ? 'cadence' : null} + {#if s} +
{ if (cm) colorMode = cm; }} + on:mouseleave={() => { colorMode = stickyMode ?? 'default'; }} + on:click={() => handleStatClick(cm)} + on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleStatClick(cm); } }} + > +

{s.value}

+

{s.label}

+
+ {:else} +
+ {/if} + {/each} {/each} {#if detail?.gear}