Fix SPM bug
This commit is contained in:
parent
1952955f3b
commit
8fdb6a9fb1
9 changed files with 354 additions and 14 deletions
|
|
@ -235,7 +235,27 @@ public class Activity {
|
||||||
MOUNTAINEERING,
|
MOUNTAINEERING,
|
||||||
YOGA,
|
YOGA,
|
||||||
WORKOUT,
|
WORKOUT,
|
||||||
OTHER
|
OTHER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true for activity types where cadence should be expressed as
|
||||||
|
* <em>steps per minute</em> (both feet) rather than the FIT spec's native
|
||||||
|
* <em>revolutions per minute</em> (one foot only).
|
||||||
|
*
|
||||||
|
* <p>FIT files and Garmin/TrainingPeaks GPX extensions store cadence as
|
||||||
|
* one-leg RPM regardless of sport. For cycling that's correct (it's pedal
|
||||||
|
* RPM). For running / walking / hiking, every consumer (Strava, Garmin
|
||||||
|
* Connect, etc.) doubles the value to display "steps per minute" — the
|
||||||
|
* convention runners actually expect. The parsers consult this method to
|
||||||
|
* apply the ×2 at ingestion time, and the display layer consults it to
|
||||||
|
* choose the right unit label ("spm" vs "rpm").
|
||||||
|
*/
|
||||||
|
public boolean isOnFoot() {
|
||||||
|
return switch (this) {
|
||||||
|
case RUN, WALK, HIKE, MOUNTAINEERING -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,12 @@ public class FitParser {
|
||||||
smoothSpeedData(parsedData);
|
smoothSpeedData(parsedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIT cadence is one-leg RPM regardless of sport. For foot sports the
|
||||||
|
// universal display convention is "steps per minute" (both feet) — double
|
||||||
|
// the value at ingestion so the database always carries the
|
||||||
|
// sport-appropriate convention.
|
||||||
|
normaliseCadenceForOnFootActivities(parsedData);
|
||||||
|
|
||||||
log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}",
|
log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}",
|
||||||
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
|
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
|
||||||
|
|
||||||
|
|
@ -555,4 +561,36 @@ public class FitParser {
|
||||||
log.debug("Calculated moving time from track points: moving={}, stopped={}", movingTime, stoppedTime);
|
log.debug("Calculated moving time from track points: moving={}, stopped={}", movingTime, stoppedTime);
|
||||||
return movingTime;
|
return movingTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doubles cadence values (per-track-point and session metric averages/maxes)
|
||||||
|
* for foot sports so the stored values represent <em>steps per minute</em>
|
||||||
|
* instead of the FIT spec's native one-leg RPM. No-op for cycling and other
|
||||||
|
* non-foot sports. Must be called after the activity type has been set by
|
||||||
|
* {@link #extractSessionData}.
|
||||||
|
*/
|
||||||
|
private void normaliseCadenceForOnFootActivities(ParsedActivityData parsedData) {
|
||||||
|
Activity.ActivityType type = parsedData.getActivityType();
|
||||||
|
if (type == null || !type.isOnFoot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-point cadence
|
||||||
|
for (TrackPointData point : parsedData.getTrackPoints()) {
|
||||||
|
if (point.getCadence() != null) {
|
||||||
|
point.setCadence(point.getCadence() * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session metric averages / maxes
|
||||||
|
ActivityMetricsData metrics = parsedData.getMetrics();
|
||||||
|
if (metrics != null) {
|
||||||
|
if (metrics.getAverageCadence() != null) {
|
||||||
|
metrics.setAverageCadence(metrics.getAverageCadence() * 2);
|
||||||
|
}
|
||||||
|
if (metrics.getMaxCadence() != null) {
|
||||||
|
metrics.setMaxCadence(metrics.getMaxCadence() * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,11 @@ public class GpxParser {
|
||||||
// Apply speed smoothing
|
// Apply speed smoothing
|
||||||
smoothSpeedData(parsedData);
|
smoothSpeedData(parsedData);
|
||||||
|
|
||||||
|
// GPX cadence (Garmin/TrainingPeaks <gpxtpx:cad>) is one-leg RPM by
|
||||||
|
// convention, just like FIT. Foot sports get doubled to "steps per
|
||||||
|
// minute" for both per-point values and the session metric averages.
|
||||||
|
normaliseCadenceForOnFootActivities(parsedData);
|
||||||
|
|
||||||
// Detect indoor activities (GPX files use heuristic detection)
|
// Detect indoor activities (GPX files use heuristic detection)
|
||||||
detectIndoorActivity(parsedData);
|
detectIndoorActivity(parsedData);
|
||||||
|
|
||||||
|
|
@ -690,4 +695,33 @@ public class GpxParser {
|
||||||
|
|
||||||
return EARTH_RADIUS * c;
|
return EARTH_RADIUS * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doubles cadence values (per-track-point and session metric averages/maxes)
|
||||||
|
* for foot sports so the stored values represent <em>steps per minute</em>
|
||||||
|
* instead of the GPX/FIT convention's one-leg RPM. No-op for cycling and
|
||||||
|
* other non-foot sports. Mirrors the same helper in {@link FitParser}.
|
||||||
|
*/
|
||||||
|
private void normaliseCadenceForOnFootActivities(ParsedActivityData parsedData) {
|
||||||
|
Activity.ActivityType type = parsedData.getActivityType();
|
||||||
|
if (type == null || !type.isOnFoot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (TrackPointData point : parsedData.getTrackPoints()) {
|
||||||
|
if (point.getCadence() != null) {
|
||||||
|
point.setCadence(point.getCadence() * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityMetricsData metrics = parsedData.getMetrics();
|
||||||
|
if (metrics != null) {
|
||||||
|
if (metrics.getAverageCadence() != null) {
|
||||||
|
metrics.setAverageCadence(metrics.getAverageCadence() * 2);
|
||||||
|
}
|
||||||
|
if (metrics.getMaxCadence() != null) {
|
||||||
|
metrics.setMaxCadence(metrics.getMaxCadence() * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
-- Migration V31: Convert one-leg cadence to steps-per-minute for foot activities.
|
||||||
|
--
|
||||||
|
-- FIT files and Garmin/TrainingPeaks GPX extensions store cadence as one-leg
|
||||||
|
-- revolutions-per-minute regardless of sport. For cycling that's correct (it's
|
||||||
|
-- pedal RPM). For running / walking / hiking, every consumer (Strava, Garmin
|
||||||
|
-- Connect, etc.) doubles the value to display "steps per minute" — the
|
||||||
|
-- convention runners actually expect.
|
||||||
|
--
|
||||||
|
-- The application code is fixed in FitParser and GpxParser to apply the ×2 at
|
||||||
|
-- ingestion. This migration brings existing rows in line with that contract.
|
||||||
|
--
|
||||||
|
-- Three places to update:
|
||||||
|
-- 1. activity_metrics.average_cadence — bulk UPDATE
|
||||||
|
-- 2. activity_metrics.max_cadence — bulk UPDATE
|
||||||
|
-- 3. activities.track_points_json — per-row JSONB rewrite of every point's cadence
|
||||||
|
--
|
||||||
|
-- Foot activity types: RUN, WALK, HIKE, MOUNTAINEERING. Other types are untouched.
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Step 1: Double the session-level cadence aggregates.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE activity_metrics am
|
||||||
|
SET average_cadence = average_cadence * 2
|
||||||
|
FROM activities a
|
||||||
|
WHERE am.activity_id = a.id
|
||||||
|
AND a.activity_type IN ('RUN', 'WALK', 'HIKE', 'MOUNTAINEERING')
|
||||||
|
AND am.average_cadence IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE activity_metrics am
|
||||||
|
SET max_cadence = max_cadence * 2
|
||||||
|
FROM activities a
|
||||||
|
WHERE am.activity_id = a.id
|
||||||
|
AND a.activity_type IN ('RUN', 'WALK', 'HIKE', 'MOUNTAINEERING')
|
||||||
|
AND am.max_cadence IS NOT NULL;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Step 2: Double the per-track-point cadence inside track_points_json.
|
||||||
|
--
|
||||||
|
-- track_points_json is a JSONB array of objects shaped like
|
||||||
|
-- {"timestamp": "...", "latitude": ..., "cadence": 85, ...}
|
||||||
|
--
|
||||||
|
-- For each row of a foot activity, rebuild the array by walking each element
|
||||||
|
-- with jsonb_array_elements and applying jsonb_set when 'cadence' is present
|
||||||
|
-- and non-null. Untouched points (no cadence, or null cadence) pass through
|
||||||
|
-- unchanged. The whole expression is wrapped in a single UPDATE so the row
|
||||||
|
-- write is atomic.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE activities a
|
||||||
|
SET track_points_json = (
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
CASE
|
||||||
|
WHEN point ? 'cadence'
|
||||||
|
AND jsonb_typeof(point->'cadence') = 'number'
|
||||||
|
THEN jsonb_set(point, '{cadence}', to_jsonb((point->>'cadence')::int * 2))
|
||||||
|
ELSE point
|
||||||
|
END
|
||||||
|
ORDER BY ord
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements(a.track_points_json) WITH ORDINALITY arr(point, ord)
|
||||||
|
)
|
||||||
|
WHERE a.activity_type IN ('RUN', 'WALK', 'HIKE', 'MOUNTAINEERING')
|
||||||
|
AND a.track_points_json IS NOT NULL
|
||||||
|
AND jsonb_typeof(a.track_points_json) = 'array'
|
||||||
|
AND jsonb_array_length(a.track_points_json) > 0;
|
||||||
|
|
@ -213,7 +213,7 @@ const FitPubTimeline = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}">
|
<span class="activity-type-badge activity-type-${(activity.activityType || '').toLowerCase().replace(/\s+/g, '-')}${activity.race ? ' race-activity' : ''}">
|
||||||
${activity.activityType}
|
${activity.activityType}
|
||||||
</span>
|
</span>
|
||||||
${activity.race
|
${activity.race
|
||||||
|
|
@ -817,11 +817,23 @@ const FitPubTimeline = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render indoor activity placeholder with emoji
|
* Render indoor activity placeholder with emoji.
|
||||||
|
*
|
||||||
|
* <p>The activityType arrives in Title Case form ("Run", "Alpine Ski") because
|
||||||
|
* ActivityDTO runs the enum value through ActivityFormatter.formatActivityType
|
||||||
|
* before serialising. The maps below are keyed by the canonical enum names
|
||||||
|
* ("RUN", "ALPINE_SKI"), so we normalise the input before lookup. Without this
|
||||||
|
* normalisation every indoor activity falls through to the generic dumbbell
|
||||||
|
* fallback.
|
||||||
|
*
|
||||||
* @param {HTMLElement} element - Container element
|
* @param {HTMLElement} element - Container element
|
||||||
* @param {string} activityType - Activity type
|
* @param {string} activityType - Activity type, in any common form
|
||||||
*/
|
*/
|
||||||
renderIndoorPlaceholder: function(element, activityType) {
|
renderIndoorPlaceholder: function(element, activityType) {
|
||||||
|
// Normalise to canonical UPPER_SNAKE_CASE: "Alpine Ski" → "ALPINE_SKI",
|
||||||
|
// "run" → "RUN". Tolerates whatever the backend hands us.
|
||||||
|
const canonical = (activityType || '').toString().toUpperCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
const emojiMap = {
|
const emojiMap = {
|
||||||
'RUN': '🏃',
|
'RUN': '🏃',
|
||||||
'RIDE': '🚴',
|
'RIDE': '🚴',
|
||||||
|
|
@ -864,8 +876,8 @@ const FitPubTimeline = {
|
||||||
'OTHER': 'Indoor Activity'
|
'OTHER': 'Indoor Activity'
|
||||||
};
|
};
|
||||||
|
|
||||||
const emoji = emojiMap[activityType] || '🏋️';
|
const emoji = emojiMap[canonical] || '🏋️';
|
||||||
const name = nameMap[activityType] || 'Indoor Activity';
|
const name = nameMap[canonical] || 'Indoor Activity';
|
||||||
|
|
||||||
element.innerHTML = `
|
element.innerHTML = `
|
||||||
<div class="d-flex flex-column align-items-center justify-content-center h-100 indoor-activity-placeholder">
|
<div class="d-flex flex-column align-items-center justify-content-center h-100 indoor-activity-placeholder">
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cadence Chart -->
|
||||||
|
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="cadenceSection" style="display: none;">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<h6 class="mb-0" id="cadenceChartTitle">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Cadence
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="cadenceChart" height="120"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Metrics -->
|
<!-- Additional Metrics -->
|
||||||
|
|
@ -330,7 +344,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3" id="avgCadenceContainer" style="display: none;">
|
<div class="col-md-4 mb-3" id="avgCadenceContainer" style="display: none;">
|
||||||
<strong>Average Cadence:</strong>
|
<strong>Average Cadence:</strong>
|
||||||
<span id="avgCadence" class="float-end">-- rpm</span>
|
<span id="avgCadence" class="float-end">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3" id="avgSpeedContainer" style="display: none;">
|
<div class="col-md-4 mb-3" id="avgSpeedContainer" style="display: none;">
|
||||||
<strong>Average Speed:</strong>
|
<strong>Average Speed:</strong>
|
||||||
|
|
@ -513,7 +527,7 @@
|
||||||
// Header
|
// Header
|
||||||
document.getElementById('activityTitle').innerHTML = linkifyHashtags(activity.title || 'Untitled Activity');
|
document.getElementById('activityTitle').innerHTML = linkifyHashtags(activity.title || 'Untitled Activity');
|
||||||
document.getElementById('activityType').textContent = activity.activityType;
|
document.getElementById('activityType').textContent = activity.activityType;
|
||||||
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
|
document.getElementById('activityType').className = `activity-type-badge activity-type-${(activity.activityType || '').toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
// Format date with timezone awareness
|
// Format date with timezone awareness
|
||||||
document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone(
|
document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone(
|
||||||
activity.startedAt,
|
activity.startedAt,
|
||||||
|
|
@ -672,8 +686,19 @@
|
||||||
renderSpeedChart(activity.trackPoints);
|
renderSpeedChart(activity.trackPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render cadence chart if data exists. Cadence is sport-dependent:
|
||||||
|
// foot sports (run/walk/hike/mountaineering) display steps-per-minute,
|
||||||
|
// cycling and other sports display revolutions-per-minute. The stored
|
||||||
|
// values are already in the right unit per the parser fix; here we
|
||||||
|
// just pick the right axis label.
|
||||||
|
const hasCadence = activity.trackPoints.some(p => p.cadence != null && p.cadence > 0);
|
||||||
|
if (hasCadence) {
|
||||||
|
document.getElementById('cadenceSection').style.display = 'block';
|
||||||
|
renderCadenceChart(activity.trackPoints, isOnFootActivityType(activity.activityType));
|
||||||
|
}
|
||||||
|
|
||||||
// Show charts section if at least one chart is visible
|
// Show charts section if at least one chart is visible
|
||||||
if (hasElevation || hasHeartRate || hasSpeed) {
|
if (hasElevation || hasHeartRate || hasSpeed || hasCadence) {
|
||||||
document.getElementById('chartsSection').style.display = 'flex';
|
document.getElementById('chartsSection').style.display = 'flex';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1246,6 +1271,129 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render cadence chart over time. Sport-aware: foot activities are labelled
|
||||||
|
* "spm" (steps per minute), cycling and other sports are labelled "rpm".
|
||||||
|
* The stored per-point values are already in the right unit thanks to the
|
||||||
|
* parser normalisation in FitParser/GpxParser, so this function just picks
|
||||||
|
* the axis label and chart title accordingly.
|
||||||
|
*
|
||||||
|
* @param {Array} trackPoints - Array of track point objects
|
||||||
|
* @param {boolean} onFoot - True for foot sports (run/walk/hike/mountaineering)
|
||||||
|
*/
|
||||||
|
function renderCadenceChart(trackPoints, onFoot) {
|
||||||
|
const unit = onFoot ? 'spm' : 'rpm';
|
||||||
|
const axisLabel = onFoot ? 'Cadence (spm)' : 'Cadence (rpm)';
|
||||||
|
|
||||||
|
// Reflect the unit in the card header so the chart title matches the axis.
|
||||||
|
const titleEl = document.getElementById('cadenceChartTitle');
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.innerHTML = '<i class="bi bi-arrow-repeat"></i> Cadence (' + unit + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the points once, building (elapsedMinutes, cadence) pairs and
|
||||||
|
// remembering the source track point index so the map marker hover
|
||||||
|
// integration works the same way as the heart rate chart.
|
||||||
|
const cadenceData = [];
|
||||||
|
let startTime = null;
|
||||||
|
for (let i = 0; i < trackPoints.length; i++) {
|
||||||
|
const point = trackPoints[i];
|
||||||
|
if (point.cadence != null && point.cadence > 0) {
|
||||||
|
const timestamp = new Date(point.timestamp);
|
||||||
|
if (startTime === null) {
|
||||||
|
startTime = timestamp;
|
||||||
|
}
|
||||||
|
const elapsedMinutes = (timestamp - startTime) / 1000 / 60;
|
||||||
|
cadenceData.push({
|
||||||
|
time: elapsedMinutes,
|
||||||
|
cadence: point.cadence,
|
||||||
|
trackPointIndex: i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cadenceData.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = cadenceData[cadenceData.length - 1].time;
|
||||||
|
|
||||||
|
const cadenceHoverHandler = throttle((event, activeElements) => {
|
||||||
|
if (activeElements && activeElements.length > 0) {
|
||||||
|
const dataIndex = activeElements[0].index;
|
||||||
|
if (cadenceData[dataIndex]) {
|
||||||
|
updateMapMarker(cadenceData[dataIndex].trackPointIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hideMapMarker();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
const ctx = document.getElementById('cadenceChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: cadenceData.map(d => formatElapsedTime(d.time, totalMinutes)),
|
||||||
|
datasets: [{
|
||||||
|
label: axisLabel,
|
||||||
|
data: cadenceData.map(d => d.cadence),
|
||||||
|
borderColor: 'rgb(13, 110, 253)',
|
||||||
|
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
onHover: cadenceHoverHandler,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function(context) {
|
||||||
|
return 'Time: ' + context[0].label;
|
||||||
|
},
|
||||||
|
label: function(context) {
|
||||||
|
return context.parsed.y + ' ' + unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Time'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: axisLabel
|
||||||
|
},
|
||||||
|
beginAtZero: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render speed/pace chart over time
|
* Render speed/pace chart over time
|
||||||
* @param {Array} trackPoints - Array of track point objects
|
* @param {Array} trackPoints - Array of track point objects
|
||||||
|
|
@ -1435,9 +1583,13 @@
|
||||||
hasAdditionalMetrics = true;
|
hasAdditionalMetrics = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Average Cadence
|
// Average Cadence — units are sport-dependent. Foot sports (run/walk/hike/
|
||||||
|
// mountaineering) store steps-per-minute (both feet); cycling and the rest
|
||||||
|
// store revolutions-per-minute. The parsers normalise on ingestion so the
|
||||||
|
// stored value is already in the correct unit; we just pick the right label.
|
||||||
if (activity.averageCadence) {
|
if (activity.averageCadence) {
|
||||||
document.getElementById('avgCadence').textContent = Math.round(activity.averageCadence) + ' rpm';
|
const cadenceUnit = isOnFootActivityType(activity.activityType) ? 'spm' : 'rpm';
|
||||||
|
document.getElementById('avgCadence').textContent = Math.round(activity.averageCadence) + ' ' + cadenceUnit;
|
||||||
document.getElementById('avgCadenceContainer').style.display = 'block';
|
document.getElementById('avgCadenceContainer').style.display = 'block';
|
||||||
hasAdditionalMetrics = true;
|
hasAdditionalMetrics = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1790,6 +1942,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given activity type string represents an on-foot
|
||||||
|
* sport (run / walk / hike / mountaineering). The backend's ActivityDTO
|
||||||
|
* runs the enum value through ActivityFormatter.formatActivityType, which
|
||||||
|
* returns Title Case strings like "Run" or "Alpine Ski" rather than the
|
||||||
|
* raw enum names. This helper normalises back to the canonical enum form
|
||||||
|
* (UPPER_SNAKE_CASE) before comparing, so it stays correct if the
|
||||||
|
* formatter changes its output style.
|
||||||
|
*/
|
||||||
|
function isOnFootActivityType(activityType) {
|
||||||
|
if (!activityType) return false;
|
||||||
|
const canonical = activityType.toString().toUpperCase().replace(/\s+/g, '_');
|
||||||
|
return canonical === 'RUN'
|
||||||
|
|| canonical === 'WALK'
|
||||||
|
|| canonical === 'HIKE'
|
||||||
|
|| canonical === 'MOUNTAINEERING';
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@
|
||||||
${renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`)}
|
${renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`)}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}">
|
<span class="activity-type-badge activity-type-${(activity.activityType || '').toLowerCase().replace(/\s+/g, '-')}${activity.race ? ' race-activity' : ''}">
|
||||||
${activity.activityType}
|
${activity.activityType}
|
||||||
</span>
|
</span>
|
||||||
${activity.race
|
${activity.race
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,7 @@
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</h6>
|
||||||
<p class="text-muted small mb-2">
|
<p class="text-muted small mb-2">
|
||||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
|
<span class="activity-type-badge activity-type-${(activity.activityType || '').toLowerCase().replace(/\s+/g, '-')}">
|
||||||
${activity.activityType}
|
${activity.activityType}
|
||||||
</span>
|
</span>
|
||||||
<span class="ms-2">
|
<span class="ms-2">
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</h6>
|
||||||
<p class="text-muted small mb-2">
|
<p class="text-muted small mb-2">
|
||||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
|
<span class="activity-type-badge activity-type-${(activity.activityType || '').toLowerCase().replace(/\s+/g, '-')}">
|
||||||
${activity.activityType}
|
${activity.activityType}
|
||||||
</span>
|
</span>
|
||||||
<span class="ms-2">
|
<span class="ms-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue