diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java
index ac6a63e..73160dd 100644
--- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java
+++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java
@@ -235,7 +235,27 @@ public class Activity {
MOUNTAINEERING,
YOGA,
WORKOUT,
- OTHER
+ OTHER;
+
+ /**
+ * Returns true for activity types where cadence should be expressed as
+ * steps per minute (both feet) rather than the FIT spec's native
+ * revolutions per minute (one foot only).
+ *
+ *
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;
+ };
+ }
}
/**
diff --git a/src/main/java/net/javahippie/fitpub/util/FitParser.java b/src/main/java/net/javahippie/fitpub/util/FitParser.java
index b88d073..25da2f9 100644
--- a/src/main/java/net/javahippie/fitpub/util/FitParser.java
+++ b/src/main/java/net/javahippie/fitpub/util/FitParser.java
@@ -122,6 +122,12 @@ public class FitParser {
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: {}",
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);
return movingTime;
}
+
+ /**
+ * Doubles cadence values (per-track-point and session metric averages/maxes)
+ * for foot sports so the stored values represent steps per minute
+ * 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);
+ }
+ }
+ }
}
diff --git a/src/main/java/net/javahippie/fitpub/util/GpxParser.java b/src/main/java/net/javahippie/fitpub/util/GpxParser.java
index 1bcb7e8..0900af8 100644
--- a/src/main/java/net/javahippie/fitpub/util/GpxParser.java
+++ b/src/main/java/net/javahippie/fitpub/util/GpxParser.java
@@ -92,6 +92,11 @@ public class GpxParser {
// Apply speed smoothing
smoothSpeedData(parsedData);
+ // GPX cadence (Garmin/TrainingPeaks ) 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)
detectIndoorActivity(parsedData);
@@ -690,4 +695,33 @@ public class GpxParser {
return EARTH_RADIUS * c;
}
+
+ /**
+ * Doubles cadence values (per-track-point and session metric averages/maxes)
+ * for foot sports so the stored values represent steps per minute
+ * 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);
+ }
+ }
+ }
}
diff --git a/src/main/resources/db/migration/V31__double_cadence_for_foot_activities.sql b/src/main/resources/db/migration/V31__double_cadence_for_foot_activities.sql
new file mode 100644
index 0000000..df0d62c
--- /dev/null
+++ b/src/main/resources/db/migration/V31__double_cadence_for_foot_activities.sql
@@ -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;
diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js
index a2a5e37..fae5434 100644
--- a/src/main/resources/static/js/timeline.js
+++ b/src/main/resources/static/js/timeline.js
@@ -213,7 +213,7 @@ const FitPubTimeline = {
-
+
${activity.activityType}
${activity.race
@@ -817,11 +817,23 @@ const FitPubTimeline = {
},
/**
- * Render indoor activity placeholder with emoji
+ * Render indoor activity placeholder with emoji.
+ *
+ * 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 {string} activityType - Activity type
+ * @param {string} activityType - Activity type, in any common form
*/
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 = {
'RUN': '🏃',
'RIDE': '🚴',
@@ -864,8 +876,8 @@ const FitPubTimeline = {
'OTHER': 'Indoor Activity'
};
- const emoji = emojiMap[activityType] || '🏋️';
- const name = nameMap[activityType] || 'Indoor Activity';
+ const emoji = emojiMap[canonical] || '🏋️';
+ const name = nameMap[canonical] || 'Indoor Activity';
element.innerHTML = `
diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html
index d07626e..fec631c 100644
--- a/src/main/resources/templates/activities/detail.html
+++ b/src/main/resources/templates/activities/detail.html
@@ -307,6 +307,20 @@
+
+
+
@@ -330,7 +344,7 @@
Average Cadence:
- -- rpm
+ --
Average Speed:
@@ -513,7 +527,7 @@
// Header
document.getElementById('activityTitle').innerHTML = linkifyHashtags(activity.title || 'Untitled Activity');
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
document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone(
activity.startedAt,
@@ -672,8 +686,19 @@
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
- if (hasElevation || hasHeartRate || hasSpeed) {
+ if (hasElevation || hasHeartRate || hasSpeed || hasCadence) {
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 = '
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
* @param {Array} trackPoints - Array of track point objects
@@ -1435,9 +1583,13 @@
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) {
- 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';
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) {
const div = document.createElement('div');
div.textContent = text;
diff --git a/src/main/resources/templates/activities/list.html b/src/main/resources/templates/activities/list.html
index 9493ae3..3916428 100644
--- a/src/main/resources/templates/activities/list.html
+++ b/src/main/resources/templates/activities/list.html
@@ -155,7 +155,7 @@
${renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`)}
-
+
${activity.activityType}
${activity.race
diff --git a/src/main/resources/templates/profile/public.html b/src/main/resources/templates/profile/public.html
index 1d7b535..ef43a1b 100644
--- a/src/main/resources/templates/profile/public.html
+++ b/src/main/resources/templates/profile/public.html
@@ -421,7 +421,7 @@
-
+
${activity.activityType}
diff --git a/src/main/resources/templates/profile/view.html b/src/main/resources/templates/profile/view.html
index 337c4f8..aa0e431 100644
--- a/src/main/resources/templates/profile/view.html
+++ b/src/main/resources/templates/profile/view.html
@@ -288,7 +288,7 @@
-
+
${activity.activityType}