From 8fdb6a9fb1d6d37deb315ee99c83845572459769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Wed, 8 Apr 2026 09:28:12 +0200 Subject: [PATCH] Fix SPM bug --- .../fitpub/model/entity/Activity.java | 22 ++- .../net/javahippie/fitpub/util/FitParser.java | 38 ++++ .../net/javahippie/fitpub/util/GpxParser.java | 34 ++++ ...31__double_cadence_for_foot_activities.sql | 66 +++++++ src/main/resources/static/js/timeline.js | 22 ++- .../templates/activities/detail.html | 180 +++++++++++++++++- .../resources/templates/activities/list.html | 2 +- .../resources/templates/profile/public.html | 2 +- .../resources/templates/profile/view.html | 2 +- 9 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 src/main/resources/db/migration/V31__double_cadence_for_foot_activities.sql 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 @@