From f34ce5723ece7ab3460462846e77a4cf62e8d97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Mon, 5 Jan 2026 14:12:29 +0100 Subject: [PATCH] =?UTF-8?q?Moving=20Time=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fitpub/model/dto/ActivityDTO.java | 8 ++ .../fitpub/model/dto/TimelineActivityDTO.java | 8 ++ .../fitpub/service/ActivityImageService.java | 17 ++-- .../org/operaton/fitpub/util/FitParser.java | 87 +++++++++++++++++++ src/main/resources/static/js/timeline.js | 4 +- .../templates/activities/detail.html | 16 ++++ .../resources/templates/activities/list.html | 5 +- 7 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java index 1cc52c2..53b99b8 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -82,6 +82,14 @@ public class ActivityDTO { return metrics != null ? metrics.getCalories() : null; } + public Long getMovingTimeSeconds() { + return metrics != null ? metrics.getMovingTimeSeconds() : null; + } + + public Long getStoppedTimeSeconds() { + return metrics != null ? metrics.getStoppedTimeSeconds() : null; + } + // Alias for frontend compatibility public Long getTotalDuration() { return totalDurationSeconds; diff --git a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java index 4dcf31d..8612423 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java @@ -31,6 +31,8 @@ public class TimelineActivityDTO { private LocalDateTime endedAt; private Double totalDistance; private Long totalDurationSeconds; + private Long movingTimeSeconds; + private Long stoppedTimeSeconds; private Double elevationGain; private Double elevationLoss; private String visibility; @@ -72,6 +74,8 @@ public class TimelineActivityDTO { .endedAt(activity.getEndedAt()) .totalDistance(activity.getTotalDistance() != null ? activity.getTotalDistance().doubleValue() : null) .totalDurationSeconds(activity.getTotalDurationSeconds()) + .movingTimeSeconds(activity.getMetrics() != null ? activity.getMetrics().getMovingTimeSeconds() : null) + .stoppedTimeSeconds(activity.getMetrics() != null ? activity.getMetrics().getStoppedTimeSeconds() : null) .elevationGain(activity.getElevationGain() != null ? activity.getElevationGain().doubleValue() : null) .elevationLoss(activity.getElevationLoss() != null ? activity.getElevationLoss().doubleValue() : null) .visibility(activity.getVisibility().name()) @@ -140,6 +144,8 @@ public class TimelineActivityDTO { private Long averagePaceSeconds; private Integer averagePower; private Integer calories; + private Long movingTimeSeconds; + private Long stoppedTimeSeconds; public static ActivityMetricsSummary fromMetrics(org.operaton.fitpub.model.entity.ActivityMetrics metrics) { return ActivityMetricsSummary.builder() @@ -150,6 +156,8 @@ public class TimelineActivityDTO { .averagePaceSeconds(metrics.getAveragePaceSeconds()) .averagePower(metrics.getAveragePower()) .calories(metrics.getCalories()) + .movingTimeSeconds(metrics.getMovingTimeSeconds()) + .stoppedTimeSeconds(metrics.getStoppedTimeSeconds()) .build(); } } diff --git a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java index 99196a0..eb6a5b5 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java @@ -447,11 +447,17 @@ public class ActivityImageService { y += lineHeight + 35; } - // Duration with neon cyan value + // Duration/Moving Time with neon cyan value if (activity.getTotalDurationSeconds() != null) { - long hours = activity.getTotalDurationSeconds() / 3600; - long minutes = (activity.getTotalDurationSeconds() % 3600) / 60; - long seconds = activity.getTotalDurationSeconds() % 60; + // Check if we have moving time that's different from total duration + Long movingTime = activity.getMetrics() != null ? activity.getMetrics().getMovingTimeSeconds() : null; + Long totalDuration = activity.getTotalDurationSeconds(); + boolean showMovingTime = movingTime != null && movingTime < totalDuration; + + long timeToDisplay = showMovingTime ? movingTime : totalDuration; + long hours = timeToDisplay / 3600; + long minutes = (timeToDisplay % 3600) / 60; + long seconds = timeToDisplay % 60; g2d.setFont(new Font("Arial Black", Font.BOLD, 40)); g2d.setColor(neonCyan); @@ -464,7 +470,8 @@ public class ActivityImageService { g2d.drawString(duration, metadataX, y); g2d.setFont(new Font("Arial Black", Font.PLAIN, 16)); g2d.setColor(new Color(180, 180, 180)); - g2d.drawString("DURATION", metadataX, y + 22); + String label = showMovingTime ? "MOVING TIME" : "DURATION"; + g2d.drawString(label, metadataX, y + 22); y += lineHeight + 35; } diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index c5a5563..ca2be5a 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -40,6 +40,8 @@ public class FitParser { private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31); private static final double MPS_TO_KPH = 3.6; + private static final double STOPPED_SPEED_THRESHOLD = 0.5; // km/h - below this is considered stopped + private static final long STOPPED_TIME_THRESHOLD = 30; // seconds - must be stopped this long to count // Lazy-loaded timezone engine (expensive to initialize) private static TimeZoneEngine timezoneEngine = null; @@ -275,6 +277,13 @@ public class FitParser { if (session.getTotalMovingTime() != null) { metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue())); + } else { + // Fallback: Calculate moving time from track points if native value is not available + Duration calculatedMovingTime = calculateMovingTimeFromTrackPoints(parsedData); + if (calculatedMovingTime != null) { + metrics.setMovingTime(calculatedMovingTime); + log.debug("Calculated moving time from track points: {}", calculatedMovingTime); + } } if (session.getTotalStrides() != null) { @@ -401,4 +410,82 @@ public class FitParser { return Activity.ActivityType.OTHER; } } + + /** + * Calculates moving time from track points when native moving time is not available. + * Uses same logic as GPX parser: speed < 0.5 km/h for > 30 seconds = stopped. + */ + private Duration calculateMovingTimeFromTrackPoints(ParsedActivityData parsedData) { + List trackPoints = parsedData.getTrackPoints(); + + // For indoor activities or activities without track points, use total duration + if (trackPoints == null || trackPoints.isEmpty()) { + Duration totalDuration = parsedData.getTotalDuration(); + if (totalDuration != null) { + log.debug("No track points available, using total duration as moving time: {}", totalDuration); + return totalDuration; + } + return null; + } + + // Need at least 2 points to calculate moving time + if (trackPoints.size() < 2) { + Duration totalDuration = parsedData.getTotalDuration(); + log.debug("Only 1 track point, using total duration as moving time: {}", totalDuration); + return totalDuration; + } + + Duration movingTime = Duration.ZERO; + Duration stoppedTime = Duration.ZERO; + LocalDateTime lastStoppedTime = null; + + for (int i = 1; i < trackPoints.size(); i++) { + TrackPointData prev = trackPoints.get(i - 1); + TrackPointData curr = trackPoints.get(i); + + if (prev.getTimestamp() == null || curr.getTimestamp() == null) { + continue; + } + + Duration timeDelta = Duration.between(prev.getTimestamp(), curr.getTimestamp()); + + // Skip unrealistic time deltas (> 1 hour between points) + if (timeDelta.getSeconds() > 3600) { + continue; + } + + // Check if we have speed data + BigDecimal speed = curr.getSpeed(); + if (speed != null) { + double speedKmh = speed.doubleValue(); // Already in km/h from FIT parser + + // Track moving vs stopped time + if (speedKmh < STOPPED_SPEED_THRESHOLD) { + if (lastStoppedTime == null) { + lastStoppedTime = prev.getTimestamp(); + } + Duration currentStopDuration = Duration.between(lastStoppedTime, curr.getTimestamp()); + if (currentStopDuration.getSeconds() > STOPPED_TIME_THRESHOLD) { + stoppedTime = stoppedTime.plus(timeDelta); + } + } else { + lastStoppedTime = null; + movingTime = movingTime.plus(timeDelta); + } + } else { + // No speed data, assume moving + movingTime = movingTime.plus(timeDelta); + } + } + + // If we didn't calculate any moving time, use total duration + if (movingTime.isZero() && stoppedTime.isZero()) { + Duration totalDuration = parsedData.getTotalDuration(); + log.debug("No speed data in track points, using total duration as moving time: {}", totalDuration); + return totalDuration; + } + + log.debug("Calculated moving time from track points: moving={}, stopped={}", movingTime, stoppedTime); + return movingTime; + } } diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index a8bf8d5..9e02389 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -157,10 +157,12 @@ const FitPubTimeline = { ${activity.hasGpsTrack ? `Distance: ${this.formatDistance(activity.totalDistance)} • - Duration: ${this.formatDuration(activity.totalDurationSeconds)} • + Duration: ${this.formatDuration(activity.totalDurationSeconds)} + ${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDurationSeconds ? ` • Moving: ${this.formatDuration(activity.movingTimeSeconds)}` : ''} • Pace: ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} • Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}` : `Duration: ${this.formatDuration(activity.totalDurationSeconds)} + ${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDurationSeconds ? ` • Moving: ${this.formatDuration(activity.movingTimeSeconds)}` : ''} ${activity.metrics?.averageHeartRate ? ` • Avg HR: ${activity.metrics.averageHeartRate} bpm` : ''} ${activity.metrics?.calories ? ` • Calories: ${activity.metrics.calories} kcal` : ''}` } diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 248c1c6..48a6518 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -83,6 +83,16 @@ + +
@@ -512,6 +522,12 @@ // Duration is always shown document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration); + // Moving Time - only show if available and different from total duration + if (activity.movingTimeSeconds != null && activity.movingTimeSeconds < activity.totalDuration) { + document.getElementById('metricMovingTimeContainer').style.display = 'block'; + document.getElementById('metricMovingTime').textContent = formatDuration(activity.movingTimeSeconds); + } + // Additional Metrics (conditional) // Note: averageSpeed is already in km/h from backend (converted in FitParser) if (activity.averageSpeed && hasGpsTrack) { diff --git a/src/main/resources/templates/activities/list.html b/src/main/resources/templates/activities/list.html index a2d75f1..229f8e0 100644 --- a/src/main/resources/templates/activities/list.html +++ b/src/main/resources/templates/activities/list.html @@ -174,7 +174,10 @@
Distance: ${formatDistance(activity.totalDistance)}
- Duration: ${formatDuration(activity.totalDuration)}
+ Duration: ${formatDuration(activity.totalDuration)} + ${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDuration + ? `
Moving: ${formatDuration(activity.movingTimeSeconds)}` + : ''}
Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}