feat: record cadence from step detector during GPS workouts

Use Android TYPE_STEP_DETECTOR sensor in LocationForegroundService to
count steps in a 15s rolling window. Cadence (spm) is computed at each
GPS point and stored alongside lat/lng/altitude/speed. Session detail
page shows cadence chart when data is available.

No additional permissions required — step detector is not a restricted
sensor. Gracefully skipped on devices without the sensor.
This commit is contained in:
2026-04-11 15:26:29 +02:00
parent 4ae3793c06
commit 1434580c3f
9 changed files with 123 additions and 5 deletions
@@ -473,6 +473,47 @@
};
}
/**
* Compute cadence samples over distance from GPS track.
* Returns array of { dist (km), cadence (spm) } — only for points with cadence data.
* @param {any[]} track
*/
function computeCadenceSamples(track) {
/** @type {Array<{dist: number, cadence: number}>} */
const samples = [];
let cumDist = 0;
for (let i = 0; i < track.length; i++) {
if (track[i].cadence == null) continue;
if (i > 0) cumDist += haversine(track[i - 1], track[i]);
samples.push({ dist: cumDist, cadence: Math.round(track[i].cadence) });
}
return samples;
}
/**
* Build Chart.js data for cadence over distance
* @param {Array<{dist: number, cadence: number}>} samples
*/
function buildCadenceChartData(samples) {
const step = Math.max(1, Math.floor(samples.length / 50));
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
const color = dark ? '#B48EAD' : '#5E81AC';
const fill = dark ? 'rgba(180, 142, 173, 0.12)' : 'rgba(94, 129, 172, 0.12)';
return {
labels: filtered.map(s => s.dist.toFixed(2)),
datasets: [{
label: t('cadence', lang),
data: filtered.map(s => s.cadence),
borderColor: color,
backgroundColor: fill,
borderWidth: 1.5,
pointRadius: 0,
tension: 0.3,
fill: true
}]
};
}
/** @param {number} exIdx */
async function uploadGpx(exIdx) {
const input = document.createElement('input');
@@ -754,6 +795,19 @@
</div>
{/if}
{@const cadenceSamples = computeCadenceSamples(ex.gpsTrack)}
{#if cadenceSamples.length > 1}
{@const avgCadence = Math.round(cadenceSamples.reduce((a, s) => a + s.cadence, 0) / cadenceSamples.length)}
<div class="chart-section">
<FitnessChart
data={buildCadenceChartData(cadenceSamples)}
title="{t('cadence', lang)} ({t('cadence_unit', lang)}) · {t('avg', lang)} {avgCadence}"
height="160px"
yUnit=" spm"
/>
</div>
{/if}
{#if splits.length > 1}
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
<div class="splits-section">