Moving Time ergänzt

This commit is contained in:
Tim Zöller 2026-01-05 14:12:29 +01:00
parent 1e833d52b6
commit f34ce5723e
7 changed files with 138 additions and 7 deletions

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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<TrackPointData> 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;
}
}

View file

@ -157,10 +157,12 @@ const FitPubTimeline = {
<small class="text-muted">
${activity.hasGpsTrack
? `<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)}
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDurationSeconds ? ` • <strong>Moving:</strong> ${this.formatDuration(activity.movingTimeSeconds)}` : ''}
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}`
: `<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDurationSeconds ? ` • <strong>Moving:</strong> ${this.formatDuration(activity.movingTimeSeconds)}` : ''}
${activity.metrics?.averageHeartRate ? ` • <strong>Avg HR:</strong> ${activity.metrics.averageHeartRate} bpm` : ''}
${activity.metrics?.calories ? ` • <strong>Calories:</strong> ${activity.metrics.calories} kcal` : ''}`
}

View file

@ -83,6 +83,16 @@
</div>
</div>
</div>
<!-- Moving Time -->
<div class="col-md-3 col-6" id="metricMovingTimeContainer" style="display: none;">
<div class="d-flex align-items-center py-2">
<i class="bi bi-clock-history text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Moving Time</div>
<div class="fw-bold" id="metricMovingTime">--</div>
</div>
</div>
</div>
<!-- Elevation Gain -->
<div class="col-md-3 col-6">
<div class="d-flex align-items-center py-2">
@ -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) {

View file

@ -174,7 +174,10 @@
<div class="col-md-4">
<small class="text-muted">
<strong>Distance:</strong> ${formatDistance(activity.totalDistance)}<br>
<strong>Duration:</strong> ${formatDuration(activity.totalDuration)}<br>
<strong>Duration:</strong> ${formatDuration(activity.totalDuration)}
${activity.movingTimeSeconds && activity.movingTimeSeconds < activity.totalDuration
? `<br><strong>Moving:</strong> ${formatDuration(activity.movingTimeSeconds)}`
: ''}<br>
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
</small>
</div>