Process activities without GPS better

This commit is contained in:
Tim Zöller 2026-01-05 10:42:06 +01:00
parent 4fe283f246
commit 4b166c0637
10 changed files with 623 additions and 50 deletions

View file

@ -50,6 +50,7 @@ public class ActivityDTO {
// Map rendering data // Map rendering data
private Map<String, Object> simplifiedTrack; // GeoJSON LineString private Map<String, Object> simplifiedTrack; // GeoJSON LineString
private List<Map<String, Object>> trackPoints; // Full track points from JSONB private List<Map<String, Object>> trackPoints; // Full track points from JSONB
private Boolean hasGpsTrack; // True if activity has GPS data (outdoor), false for indoor activities
// Social interaction counts (populated separately) // Social interaction counts (populated separately)
private Long likesCount; private Long likesCount;
@ -115,7 +116,10 @@ public class ActivityDTO {
} }
// Convert simplified track to GeoJSON // Convert simplified track to GeoJSON
if (activity.getSimplifiedTrack() != null) { boolean hasGps = activity.getSimplifiedTrack() != null;
builder.hasGpsTrack(hasGps);
if (hasGps) {
builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack())); builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack()));
} }

View file

@ -53,6 +53,9 @@ public class TimelineActivityDTO {
private Long commentsCount; private Long commentsCount;
private Boolean likedByCurrentUser; private Boolean likedByCurrentUser;
// GPS track availability
private Boolean hasGpsTrack; // True if activity has GPS data
// Metrics summary // Metrics summary
private ActivityMetricsSummary metrics; private ActivityMetricsSummary metrics;
@ -77,6 +80,7 @@ public class TimelineActivityDTO {
.displayName(displayName) .displayName(displayName)
.avatarUrl(avatarUrl) .avatarUrl(avatarUrl)
.isLocal(true) .isLocal(true)
.hasGpsTrack(activity.getSimplifiedTrack() != null)
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null) .metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
.build(); .build();
} }
@ -116,6 +120,7 @@ public class TimelineActivityDTO {
.isLocal(false) .isLocal(false)
.activityUri(remote.getActivityUri()) .activityUri(remote.getActivityUri())
.mapImageUrl(remote.getMapImageUrl()) .mapImageUrl(remote.getMapImageUrl())
.hasGpsTrack(remote.getMapImageUrl() != null) // Remote activity has GPS if it has a map image
.metrics(metrics) .metrics(metrics)
.build(); .build();
} }

View file

@ -265,12 +265,19 @@ public class ActivityFileService {
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints()); String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(trackPointsJson); activity.setTrackPointsJson(trackPointsJson);
// Create full LineString from all points // Create and simplify track only if GPS data is present
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints()); if (!parsedData.getTrackPoints().isEmpty()) {
// Create full LineString from all points
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
// Simplify track for map rendering // Simplify track for map rendering
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates()); LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
activity.setSimplifiedTrack(simplifiedTrack); activity.setSimplifiedTrack(simplifiedTrack);
} else {
// No GPS track for indoor activities
activity.setSimplifiedTrack(null);
log.info("Activity has no GPS track (indoor activity)");
}
// Create metrics // Create metrics
if (parsedData.getMetrics() != null) { if (parsedData.getMetrics() != null) {
@ -282,11 +289,17 @@ public class ActivityFileService {
// Save activity (single INSERT instead of 855!) // Save activity (single INSERT instead of 855!)
Activity savedActivity = activityRepository.save(activity); Activity savedActivity = activityRepository.save(activity);
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)", if (savedActivity.getSimplifiedTrack() != null) {
parsedData.getSourceFormat(), log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
savedActivity.getId(), parsedData.getSourceFormat(),
parsedData.getTrackPoints().size(), savedActivity.getId(),
simplifiedTrack.getNumPoints()); parsedData.getTrackPoints().size(),
savedActivity.getSimplifiedTrack().getNumPoints());
} else {
log.info("Successfully created {} activity {} (indoor activity without GPS track)",
parsedData.getSourceFormat(),
savedActivity.getId());
}
// Execute side effects based on processing options // Execute side effects based on processing options
// In batch import mode, these are skipped and executed later as a batch // In batch import mode, these are skipped and executed later as a batch

View file

@ -59,13 +59,14 @@ public class ActivityImageService {
// Calculate bounds once for both map tiles and track rendering // Calculate bounds once for both map tiles and track rendering
TrackBounds trackBounds = null; TrackBounds trackBounds = null;
boolean isIndoorActivity = activity.getSimplifiedTrack() == null;
// Render background - either OSM tiles or gradient background // Render background - either OSM tiles or gradient background
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
trackBounds = calculateTrackBounds(activity); trackBounds = calculateTrackBounds(activity);
} }
if (osmTilesEnabled && trackBounds != null) { if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
try { try {
// Render OSM tiles for left 60% of image (track area) // Render OSM tiles for left 60% of image (track area)
int trackWidth = (int) (width * 0.6); int trackWidth = (int) (width * 0.6);
@ -96,24 +97,31 @@ public class ActivityImageService {
g2d.fillRect(0, 0, width, height); g2d.fillRect(0, 0, width, height);
} }
} else { } else {
// OSM tiles disabled or no track data - use gradient background // OSM tiles disabled or no track data (indoor activity) - use gradient background
GradientPaint gradient = new GradientPaint( GradientPaint gradient = new GradientPaint(
0, 0, new Color(26, 0, 51), 0, 0, new Color(26, 0, 51),
width, height, new Color(45, 0, 82) width, height, new Color(45, 0, 82)
); );
g2d.setPaint(gradient); g2d.setPaint(gradient);
g2d.fillRect(0, 0, width, height); g2d.fillRect(0, 0, width, height);
// For indoor activities, draw a large emoji in the center-left area
if (isIndoorActivity) {
drawIndoorActivityEmoji(g2d, activity, width, height);
}
} }
// Draw track if available // Draw track if available (not for indoor activities)
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { if (!isIndoorActivity) {
drawTrack(g2d, activity, width, height); if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
} else if (activity.getSimplifiedTrack() != null) { drawTrack(g2d, activity, width, height);
drawSimplifiedTrack(g2d, activity, width, height); } else if (activity.getSimplifiedTrack() != null) {
drawSimplifiedTrack(g2d, activity, width, height);
}
} }
// Draw metadata overlay // Draw metadata overlay
drawMetadata(g2d, activity, width, height); drawMetadata(g2d, activity, width, height, isIndoorActivity);
g2d.dispose(); g2d.dispose();
@ -377,7 +385,7 @@ public class ActivityImageService {
/** /**
* Draw metadata overlay on the right side of the image in 80s Aerobic style. * Draw metadata overlay on the right side of the image in 80s Aerobic style.
*/ */
private void drawMetadata(Graphics2D g2d, Activity activity, int width, int height) { private void drawMetadata(Graphics2D g2d, Activity activity, int width, int height, boolean isIndoorActivity) {
int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin
int y = 80; int y = 80;
int lineHeight = 50; int lineHeight = 50;
@ -387,6 +395,7 @@ public class ActivityImageService {
Color neonCyan = new Color(0, 255, 255); Color neonCyan = new Color(0, 255, 255);
Color neonOrange = new Color(255, 102, 0); Color neonOrange = new Color(255, 102, 0);
Color neonGreen = new Color(57, 255, 20); Color neonGreen = new Color(57, 255, 20);
Color neonYellow = new Color(255, 255, 0);
// Title with neon pink // Title with neon pink
g2d.setColor(neonPink); g2d.setColor(neonPink);
@ -399,6 +408,14 @@ public class ActivityImageService {
g2d.drawString(title, metadataX, y); g2d.drawString(title, metadataX, y);
y += lineHeight + 20; y += lineHeight + 20;
// Indoor activity label (if applicable)
if (isIndoorActivity) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 16));
g2d.setColor(neonYellow);
g2d.drawString("INDOOR ACTIVITY", metadataX, y);
y += 35;
}
// Activity type badge with border // Activity type badge with border
g2d.setFont(new Font("Arial Black", Font.BOLD, 20)); g2d.setFont(new Font("Arial Black", Font.BOLD, 20));
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase(); String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase();
@ -451,8 +468,8 @@ public class ActivityImageService {
y += lineHeight + 35; y += lineHeight + 35;
} }
// Elevation gain with neon green value // Elevation gain with neon green value (only for outdoor activities)
if (activity.getElevationGain() != null) { if (activity.getElevationGain() != null && !isIndoorActivity) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 40)); g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonGreen); g2d.setColor(neonGreen);
String elevation = String.format("%.0f", activity.getElevationGain()); String elevation = String.format("%.0f", activity.getElevationGain());
@ -469,6 +486,42 @@ public class ActivityImageService {
y += lineHeight + 35; y += lineHeight + 35;
} }
// Heart Rate with neon orange value (for indoor activities)
if (isIndoorActivity && activity.getMetrics() != null && activity.getMetrics().getAverageHeartRate() != null) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonOrange);
String hr = String.format("%d", activity.getMetrics().getAverageHeartRate());
g2d.drawString(hr, metadataX, y);
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
g2d.setColor(Color.WHITE);
int hrWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(hr);
g2d.drawString("BPM", metadataX + hrWidth + 10, y);
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
g2d.setColor(new Color(180, 180, 180));
g2d.drawString("AVG HEART RATE", metadataX, y + 22);
y += lineHeight + 35;
}
// Calories with neon green value (for indoor activities)
if (isIndoorActivity && activity.getMetrics() != null && activity.getMetrics().getCalories() != null) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonGreen);
String calories = String.format("%d", activity.getMetrics().getCalories());
g2d.drawString(calories, metadataX, y);
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
g2d.setColor(Color.WHITE);
int calWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(calories);
g2d.drawString("KCAL", metadataX + calWidth + 10, y);
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
g2d.setColor(new Color(180, 180, 180));
g2d.drawString("CALORIES", metadataX, y + 22);
y += lineHeight + 35;
}
// Branding with neon pink gradient effect // Branding with neon pink gradient effect
g2d.setFont(new Font("Arial Black", Font.BOLD, 28)); g2d.setFont(new Font("Arial Black", Font.BOLD, 28));
g2d.setColor(neonPink); g2d.setColor(neonPink);
@ -582,6 +635,78 @@ public class ActivityImageService {
1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0; 1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0;
} }
/**
* Draw a large emoji for indoor activities in the center-left area.
*/
private void drawIndoorActivityEmoji(Graphics2D g2d, Activity activity, int width, int height) {
// Map activity types to emojis
String emoji;
switch (activity.getActivityType()) {
case RUN:
emoji = "🏃";
break;
case RIDE:
emoji = "🚴";
break;
case SWIM:
emoji = "🏊";
break;
case WORKOUT:
emoji = "💪";
break;
case YOGA:
emoji = "🧘";
break;
case ROWING:
emoji = "🚣";
break;
case WALK:
emoji = "🚶";
break;
case HIKE:
emoji = "🥾";
break;
case ALPINE_SKI:
case NORDIC_SKI:
case BACKCOUNTRY_SKI:
emoji = "⛷️";
break;
case SNOWBOARD:
emoji = "🏂";
break;
case KAYAKING:
case CANOEING:
emoji = "🛶";
break;
case ROCK_CLIMBING:
case MOUNTAINEERING:
emoji = "🧗";
break;
case INLINE_SKATING:
emoji = "🛼";
break;
default:
emoji = "🏋️";
break;
}
// Draw emoji in the center-left area (where the map would be)
int emojiX = (int) (width * 0.3) - 100; // Center of left 60%
int emojiY = height / 2;
// Use a very large font for the emoji
g2d.setFont(new Font("Segoe UI Emoji", Font.PLAIN, 200));
g2d.setColor(Color.WHITE);
// Calculate emoji width to center it properly
FontMetrics fm = g2d.getFontMetrics();
int emojiWidth = fm.stringWidth(emoji);
int emojiHeight = fm.getHeight();
// Draw emoji centered in the left area
g2d.drawString(emoji, emojiX - emojiWidth / 2, emojiY + emojiHeight / 3);
}
/** /**
* Helper class to store track geographic bounds. * Helper class to store track geographic bounds.
*/ */

View file

@ -102,6 +102,14 @@ public class TrainingLoadService {
double distance = distanceMeters.doubleValue(); double distance = distanceMeters.doubleValue();
double elevation = elevationMeters.doubleValue(); double elevation = elevationMeters.doubleValue();
// For indoor activities without distance (treadmill, indoor cycling, etc.)
// Calculate TSS based on duration alone with moderate intensity assumption
if (distance == 0.0) {
// Assume moderate intensity (0.7) for indoor activities
double tss = durationHours * 0.7 * 100.0;
return BigDecimal.valueOf(tss).setScale(2, RoundingMode.HALF_UP);
}
// Intensity factor based on distance/time ratio and elevation // Intensity factor based on distance/time ratio and elevation
double speed = distance / durationSeconds; // m/s double speed = distance / durationSeconds; // m/s
double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline

View file

@ -105,16 +105,19 @@ public class FitParser {
throw new FitFileProcessingException("Failed to decode FIT file"); throw new FitFileProcessingException("Failed to decode FIT file");
} }
// Process GPS-related data only if track points are present
if (parsedData.getTrackPoints().isEmpty()) { if (parsedData.getTrackPoints().isEmpty()) {
throw new FitFileProcessingException("No GPS track points found in FIT file"); log.info("No GPS track points found in FIT file - likely an indoor activity");
// Default to UTC timezone for indoor activities
parsedData.setTimezone("UTC");
} else {
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
// Apply speed smoothing and recalculate max speed
smoothSpeedData(parsedData);
} }
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
// Apply speed smoothing and recalculate max speed
smoothSpeedData(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());

View file

@ -982,4 +982,36 @@ h1 {
footer.bg-light p { footer.bg-light p {
color: var(--dark-text) !important; color: var(--dark-text) !important;
} }
/* Indoor Activity Placeholder - Dark Mode */
#indoorPlaceholder .card {
background: var(--dark-surface);
border-color: var(--neon-cyan);
}
#indoorPlaceholder .card-body {
background: var(--dark-surface);
}
#indoorPlaceholder .text-muted,
#indoorPlaceholder h4 {
color: var(--dark-text) !important;
}
#indoorPlaceholder p.text-muted {
color: var(--dark-text-muted) !important;
}
/* Indoor activity placeholder for timeline - Dark Mode */
.indoor-activity-placeholder {
background-color: var(--dark-surface) !important;
}
.indoor-activity-placeholder .text-muted {
color: var(--dark-text-muted) !important;
}
.indoor-activity-placeholder .fw-bold {
color: var(--dark-text) !important;
}
} }

View file

@ -155,16 +155,21 @@ const FitPubTimeline = {
<!-- Activity Metrics --> <!-- Activity Metrics -->
<div class="mb-2"> <div class="mb-2">
<small class="text-muted"> <small class="text-muted">
<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)} ${activity.hasGpsTrack
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)} ? `<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)}
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} <strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'} <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.metrics?.averageHeartRate ? ` • <strong>Avg HR:</strong> ${activity.metrics.averageHeartRate} bpm` : ''}
${activity.metrics?.calories ? ` • <strong>Calories:</strong> ${activity.metrics.calories} kcal` : ''}`
}
</small> </small>
</div> </div>
<!-- Preview Map --> <!-- Preview Map or Indoor Placeholder -->
<div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;"> <div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;">
<!-- Map will be rendered here --> <!-- Map or placeholder will be rendered here -->
</div> </div>
<!-- Activity Actions --> <!-- Activity Actions -->
@ -295,6 +300,13 @@ const FitPubTimeline = {
return; return;
} }
// Check if activity has GPS track
if (!activity.hasGpsTrack) {
// Show indoor activity placeholder
this.renderIndoorPlaceholder(mapElement, activity.activityType);
return;
}
// Handle remote activities differently - show static map image // Handle remote activities differently - show static map image
if (!activity.isLocal) { if (!activity.isLocal) {
if (activity.mapImageUrl) { if (activity.mapImageUrl) {
@ -517,5 +529,66 @@ const FitPubTimeline = {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
},
/**
* Render indoor activity placeholder with emoji
* @param {HTMLElement} element - Container element
* @param {string} activityType - Activity type
*/
renderIndoorPlaceholder: function(element, activityType) {
const emojiMap = {
'RUN': '🏃',
'RIDE': '🚴',
'CYCLING': '🚴',
'INDOOR_CYCLING': '🚴',
'HIKE': '🥾',
'WALK': '🚶',
'SWIM': '🏊',
'WORKOUT': '💪',
'YOGA': '🧘',
'ALPINE_SKI': '⛷️',
'NORDIC_SKI': '⛷️',
'SNOWBOARD': '🏂',
'ROWING': '🚣',
'KAYAKING': '🛶',
'CANOEING': '🛶',
'ROCK_CLIMBING': '🧗',
'MOUNTAINEERING': '⛰️',
'OTHER': '🏋️'
};
const nameMap = {
'RUN': 'Indoor Running',
'RIDE': 'Indoor Cycling',
'CYCLING': 'Indoor Cycling',
'INDOOR_CYCLING': 'Indoor Cycling',
'HIKE': 'Indoor Activity',
'WALK': 'Indoor Walking',
'SWIM': 'Indoor Swimming',
'WORKOUT': 'Workout',
'YOGA': 'Yoga',
'ALPINE_SKI': 'Skiing',
'NORDIC_SKI': 'Cross-Country Skiing',
'SNOWBOARD': 'Snowboarding',
'ROWING': 'Indoor Rowing',
'KAYAKING': 'Kayaking',
'CANOEING': 'Canoeing',
'ROCK_CLIMBING': 'Climbing',
'MOUNTAINEERING': 'Mountaineering',
'OTHER': 'Indoor Activity'
};
const emoji = emojiMap[activityType] || '🏋️';
const name = nameMap[activityType] || 'Indoor Activity';
element.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center h-100 indoor-activity-placeholder">
<div style="font-size: 4rem;" class="mb-2">${emoji}</div>
<div class="text-muted fw-bold">${this.escapeHtml(name)}</div>
<div class="text-muted small">No GPS track</div>
</div>
`;
element.style.backgroundColor = '#f8f9fa';
} }
}; };

View file

@ -198,8 +198,8 @@
</div> </div>
</div> </div>
<!-- Map --> <!-- Map / Indoor Activity Placeholder -->
<div class="row mb-4"> <div class="row mb-4" id="mapSection">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@ -214,6 +214,19 @@
</div> </div>
</div> </div>
<!-- Indoor Activity Placeholder -->
<div class="row mb-4" id="indoorPlaceholder" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<div id="activityTypeEmoji" style="font-size: 5rem;" class="mb-3">🏋️</div>
<h4 id="activityTypeName" class="text-muted">Indoor Activity</h4>
<p class="text-muted">No GPS track available</p>
</div>
</div>
</div>
</div>
<!-- Charts Row --> <!-- Charts Row -->
<div class="row mb-4" id="chartsSection" style="display: none;"> <div class="row mb-4" id="chartsSection" style="display: none;">
<!-- Elevation Chart --> <!-- Elevation Chart -->
@ -475,20 +488,33 @@
checkActivityOwnership(activity); checkActivityOwnership(activity);
} }
// Metrics - Basic (always shown) // Check if activity has GPS track
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance); const hasGpsTrack = activity.hasGpsTrack === true;
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
// Calculate pace // Metrics - Conditional based on GPS availability
if (activity.totalDistance && activity.totalDuration) { if (hasGpsTrack) {
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000); // Show GPS-related metrics
document.getElementById('metricPace').textContent = formatPace(paceSeconds); document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
// Calculate pace
if (activity.totalDistance && activity.totalDuration) {
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
}
} else {
// Hide GPS-related metrics for indoor activities
document.getElementById('metricDistance').parentElement.parentElement.parentElement.style.display = 'none';
document.getElementById('metricElevationGain').parentElement.parentElement.parentElement.style.display = 'none';
document.getElementById('metricPace').parentElement.parentElement.parentElement.style.display = 'none';
} }
// Duration is always shown
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
// Additional Metrics (conditional) // Additional Metrics (conditional)
// Note: averageSpeed is already in km/h from backend (converted in FitParser) // Note: averageSpeed is already in km/h from backend (converted in FitParser)
if (activity.averageSpeed) { if (activity.averageSpeed && hasGpsTrack) {
document.getElementById('metricAvgSpeedContainer').style.display = 'block'; document.getElementById('metricAvgSpeedContainer').style.display = 'block';
document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h'; document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
} }
@ -501,13 +527,22 @@
document.getElementById('metricCalories').textContent = activity.calories + ' kcal'; document.getElementById('metricCalories').textContent = activity.calories + ' kcal';
} }
// Render map if track data exists // Render map or indoor placeholder
if (activity.simplifiedTrack) { if (hasGpsTrack && activity.simplifiedTrack) {
document.getElementById('mapSection').style.display = 'block';
document.getElementById('indoorPlaceholder').style.display = 'none';
renderMap(activity.simplifiedTrack); renderMap(activity.simplifiedTrack);
} else {
// Show indoor activity placeholder
document.getElementById('mapSection').style.display = 'none';
document.getElementById('indoorPlaceholder').style.display = 'block';
showIndoorPlaceholder(activity.activityType);
} }
// Load weather data // Load weather data (only for outdoor activities)
loadWeatherData(activity.id); if (hasGpsTrack) {
loadWeatherData(activity.id);
}
// Render elevation chart if data exists // Render elevation chart if data exists
if (activity.trackPoints && activity.trackPoints.length > 0) { if (activity.trackPoints && activity.trackPoints.length > 0) {
@ -1378,6 +1413,59 @@
default: return 'question-circle'; default: return 'question-circle';
} }
} }
/**
* Show indoor activity placeholder with appropriate emoji
*/
function showIndoorPlaceholder(activityType) {
const emojiMap = {
'RUN': '🏃',
'RIDE': '🚴',
'CYCLING': '🚴',
'INDOOR_CYCLING': '🚴',
'HIKE': '🥾',
'WALK': '🚶',
'SWIM': '🏊',
'WORKOUT': '💪',
'YOGA': '🧘',
'ALPINE_SKI': '⛷️',
'NORDIC_SKI': '⛷️',
'SNOWBOARD': '🏂',
'ROWING': '🚣',
'KAYAKING': '🛶',
'CANOEING': '🛶',
'ROCK_CLIMBING': '🧗',
'MOUNTAINEERING': '⛰️',
'OTHER': '🏋️'
};
const nameMap = {
'RUN': 'Indoor Running',
'RIDE': 'Indoor Cycling',
'CYCLING': 'Indoor Cycling',
'INDOOR_CYCLING': 'Indoor Cycling',
'HIKE': 'Indoor Activity',
'WALK': 'Indoor Walking',
'SWIM': 'Indoor Swimming',
'WORKOUT': 'Workout',
'YOGA': 'Yoga',
'ALPINE_SKI': 'Skiing',
'NORDIC_SKI': 'Cross-Country Skiing',
'SNOWBOARD': 'Snowboarding',
'ROWING': 'Indoor Rowing',
'KAYAKING': 'Kayaking',
'CANOEING': 'Canoeing',
'ROCK_CLIMBING': 'Climbing',
'MOUNTAINEERING': 'Mountaineering',
'OTHER': 'Indoor Activity'
};
const emoji = emojiMap[activityType] || '🏋️';
const name = nameMap[activityType] || 'Indoor Activity';
document.getElementById('activityTypeEmoji').textContent = emoji;
document.getElementById('activityTypeName').textContent = name;
}
}); });
</script> </script>
</th:block> </th:block>

View file

@ -0,0 +1,222 @@
package org.operaton.fitpub.util;
import com.garmin.fit.*;
import java.io.FileInputStream;
import java.io.InputStream;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* Utility to analyze FIT file structure and print all available fields.
* Helps debug what data is actually present in a FIT file.
*/
public class FitFileAnalyzer {
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
private static final long FIT_EPOCH_OFFSET = 631065600L;
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("Usage: FitFileAnalyzer <path-to-fit-file>");
System.exit(1);
}
String filePath = args[0];
analyzeFitFile(filePath);
}
public static void analyzeFitFile(String filePath) {
System.out.println("=== FIT File Analysis ===");
System.out.println("File: " + filePath);
System.out.println();
try (InputStream inputStream = new FileInputStream(filePath)) {
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
// Counter for record messages
final int[] recordCount = {0};
final boolean[] hasGpsData = {false};
// Listen for FileId messages
broadcaster.addListener((FileIdMesgListener) fileId -> {
System.out.println("=== FILE ID MESSAGE ===");
System.out.println("Type: " + fileId.getType());
System.out.println("Manufacturer: " + fileId.getManufacturer());
System.out.println("Product: " + fileId.getProduct());
if (fileId.getTimeCreated() != null) {
System.out.println("Time Created: " + convertDateTime(fileId.getTimeCreated()));
}
System.out.println();
});
// Listen for Session messages (summary)
broadcaster.addListener((SessionMesgListener) session -> {
System.out.println("=== SESSION MESSAGE ===");
System.out.println("Sport: " + session.getSport());
System.out.println("Sub Sport: " + session.getSubSport());
if (session.getStartTime() != null) {
System.out.println("Start Time: " + convertDateTime(session.getStartTime()));
}
if (session.getTotalElapsedTime() != null) {
System.out.println("Total Elapsed Time: " + session.getTotalElapsedTime() + " seconds");
}
if (session.getTotalTimerTime() != null) {
System.out.println("Total Timer Time: " + session.getTotalTimerTime() + " seconds");
}
if (session.getTotalDistance() != null) {
System.out.println("Total Distance: " + session.getTotalDistance() + " meters (" +
(session.getTotalDistance() / 1000.0) + " km)");
}
if (session.getTotalAscent() != null) {
System.out.println("Total Ascent: " + session.getTotalAscent() + " meters");
}
if (session.getTotalDescent() != null) {
System.out.println("Total Descent: " + session.getTotalDescent() + " meters");
}
if (session.getAvgSpeed() != null) {
System.out.println("Average Speed: " + session.getAvgSpeed() + " m/s (" +
(session.getAvgSpeed() * 3.6) + " km/h)");
}
if (session.getMaxSpeed() != null) {
System.out.println("Max Speed: " + session.getMaxSpeed() + " m/s (" +
(session.getMaxSpeed() * 3.6) + " km/h)");
}
if (session.getAvgHeartRate() != null) {
System.out.println("Average Heart Rate: " + session.getAvgHeartRate() + " bpm");
}
if (session.getMaxHeartRate() != null) {
System.out.println("Max Heart Rate: " + session.getMaxHeartRate() + " bpm");
}
if (session.getAvgCadence() != null) {
System.out.println("Average Cadence: " + session.getAvgCadence() + " rpm");
}
if (session.getMaxCadence() != null) {
System.out.println("Max Cadence: " + session.getMaxCadence() + " rpm");
}
if (session.getAvgPower() != null) {
System.out.println("Average Power: " + session.getAvgPower() + " watts");
}
if (session.getMaxPower() != null) {
System.out.println("Max Power: " + session.getMaxPower() + " watts");
}
if (session.getTotalCalories() != null) {
System.out.println("Total Calories: " + session.getTotalCalories() + " kcal");
}
System.out.println();
});
// Listen for Record messages (data points)
broadcaster.addListener((RecordMesgListener) record -> {
recordCount[0]++;
// Only print first 5 records to avoid spam
if (recordCount[0] <= 5) {
System.out.println("=== RECORD MESSAGE #" + recordCount[0] + " ===");
if (record.getTimestamp() != null) {
System.out.println("Timestamp: " + convertDateTime(record.getTimestamp()));
}
Integer positionLat = record.getPositionLat();
Integer positionLong = record.getPositionLong();
if (positionLat != null && positionLong != null) {
double latitude = positionLat * SEMICIRCLES_TO_DEGREES;
double longitude = positionLong * SEMICIRCLES_TO_DEGREES;
System.out.println("GPS Position: " + latitude + ", " + longitude);
hasGpsData[0] = true;
} else {
System.out.println("GPS Position: NOT AVAILABLE");
}
if (record.getDistance() != null) {
System.out.println("Distance: " + record.getDistance() + " meters");
}
if (record.getSpeed() != null) {
System.out.println("Speed: " + record.getSpeed() + " m/s (" +
(record.getSpeed() * 3.6) + " km/h)");
}
if (record.getAltitude() != null) {
System.out.println("Altitude: " + record.getAltitude() + " meters");
}
if (record.getHeartRate() != null) {
System.out.println("Heart Rate: " + record.getHeartRate() + " bpm");
}
if (record.getCadence() != null) {
System.out.println("Cadence: " + record.getCadence() + " rpm");
}
if (record.getPower() != null) {
System.out.println("Power: " + record.getPower() + " watts");
}
if (record.getTemperature() != null) {
System.out.println("Temperature: " + record.getTemperature() + " °C");
}
System.out.println();
}
});
// Listen for Lap messages
broadcaster.addListener((LapMesgListener) lap -> {
System.out.println("=== LAP MESSAGE ===");
if (lap.getTotalDistance() != null) {
System.out.println("Lap Distance: " + lap.getTotalDistance() + " meters");
}
if (lap.getTotalTimerTime() != null) {
System.out.println("Lap Time: " + lap.getTotalTimerTime() + " seconds");
}
System.out.println();
});
// Decode the file
if (!decode.read(inputStream, broadcaster)) {
System.err.println("ERROR: Failed to decode FIT file");
return;
}
System.out.println("=== SUMMARY ===");
System.out.println("Total Record Messages: " + recordCount[0]);
System.out.println("GPS Data Present: " + hasGpsData[0]);
if (!hasGpsData[0] && recordCount[0] > 0) {
System.out.println();
System.out.println("NOTE: File contains record messages with distance/speed but NO GPS coordinates.");
System.out.println("This is typical for indoor activities with virtual distance (smart trainers, apps).");
}
} catch (Exception e) {
System.err.println("ERROR analyzing FIT file: " + e.getMessage());
e.printStackTrace();
}
}
private static LocalDateTime convertDateTime(DateTime dateTime) {
long timestamp = dateTime.getTimestamp();
Instant instant = Instant.ofEpochSecond(timestamp + FIT_EPOCH_OFFSET);
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
}