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 f4837c5..1cc52c2 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -50,6 +50,7 @@ public class ActivityDTO { // Map rendering data private Map simplifiedTrack; // GeoJSON LineString private List> 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) private Long likesCount; @@ -115,7 +116,10 @@ public class ActivityDTO { } // Convert simplified track to GeoJSON - if (activity.getSimplifiedTrack() != null) { + boolean hasGps = activity.getSimplifiedTrack() != null; + builder.hasGpsTrack(hasGps); + + if (hasGps) { builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack())); } 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 b446520..4dcf31d 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java @@ -53,6 +53,9 @@ public class TimelineActivityDTO { private Long commentsCount; private Boolean likedByCurrentUser; + // GPS track availability + private Boolean hasGpsTrack; // True if activity has GPS data + // Metrics summary private ActivityMetricsSummary metrics; @@ -77,6 +80,7 @@ public class TimelineActivityDTO { .displayName(displayName) .avatarUrl(avatarUrl) .isLocal(true) + .hasGpsTrack(activity.getSimplifiedTrack() != null) .metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null) .build(); } @@ -116,6 +120,7 @@ public class TimelineActivityDTO { .isLocal(false) .activityUri(remote.getActivityUri()) .mapImageUrl(remote.getMapImageUrl()) + .hasGpsTrack(remote.getMapImageUrl() != null) // Remote activity has GPS if it has a map image .metrics(metrics) .build(); } diff --git a/src/main/java/org/operaton/fitpub/service/ActivityFileService.java b/src/main/java/org/operaton/fitpub/service/ActivityFileService.java index 8466ec5..1aff813 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityFileService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityFileService.java @@ -265,12 +265,19 @@ public class ActivityFileService { String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints()); activity.setTrackPointsJson(trackPointsJson); - // Create full LineString from all points - LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints()); + // Create and simplify track only if GPS data is present + if (!parsedData.getTrackPoints().isEmpty()) { + // Create full LineString from all points + LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints()); - // Simplify track for map rendering - LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates()); - activity.setSimplifiedTrack(simplifiedTrack); + // Simplify track for map rendering + LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates()); + activity.setSimplifiedTrack(simplifiedTrack); + } else { + // No GPS track for indoor activities + activity.setSimplifiedTrack(null); + log.info("Activity has no GPS track (indoor activity)"); + } // Create metrics if (parsedData.getMetrics() != null) { @@ -282,11 +289,17 @@ public class ActivityFileService { // Save activity (single INSERT instead of 855!) Activity savedActivity = activityRepository.save(activity); - log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)", - parsedData.getSourceFormat(), - savedActivity.getId(), - parsedData.getTrackPoints().size(), - simplifiedTrack.getNumPoints()); + if (savedActivity.getSimplifiedTrack() != null) { + log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)", + parsedData.getSourceFormat(), + savedActivity.getId(), + 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 // In batch import mode, these are skipped and executed later as a batch diff --git a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java index c63a7f1..e338227 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java @@ -59,13 +59,14 @@ public class ActivityImageService { // Calculate bounds once for both map tiles and track rendering TrackBounds trackBounds = null; + boolean isIndoorActivity = activity.getSimplifiedTrack() == null; // Render background - either OSM tiles or gradient background if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { trackBounds = calculateTrackBounds(activity); } - if (osmTilesEnabled && trackBounds != null) { + if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) { try { // Render OSM tiles for left 60% of image (track area) int trackWidth = (int) (width * 0.6); @@ -96,24 +97,31 @@ public class ActivityImageService { g2d.fillRect(0, 0, width, height); } } 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( 0, 0, new Color(26, 0, 51), width, height, new Color(45, 0, 82) ); g2d.setPaint(gradient); 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 - if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { - drawTrack(g2d, activity, width, height); - } else if (activity.getSimplifiedTrack() != null) { - drawSimplifiedTrack(g2d, activity, width, height); + // Draw track if available (not for indoor activities) + if (!isIndoorActivity) { + if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { + drawTrack(g2d, activity, width, height); + } else if (activity.getSimplifiedTrack() != null) { + drawSimplifiedTrack(g2d, activity, width, height); + } } // Draw metadata overlay - drawMetadata(g2d, activity, width, height); + drawMetadata(g2d, activity, width, height, isIndoorActivity); g2d.dispose(); @@ -377,7 +385,7 @@ public class ActivityImageService { /** * 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 y = 80; int lineHeight = 50; @@ -387,6 +395,7 @@ public class ActivityImageService { Color neonCyan = new Color(0, 255, 255); Color neonOrange = new Color(255, 102, 0); Color neonGreen = new Color(57, 255, 20); + Color neonYellow = new Color(255, 255, 0); // Title with neon pink g2d.setColor(neonPink); @@ -399,6 +408,14 @@ public class ActivityImageService { g2d.drawString(title, metadataX, y); 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 g2d.setFont(new Font("Arial Black", Font.BOLD, 20)); String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase(); @@ -451,8 +468,8 @@ public class ActivityImageService { y += lineHeight + 35; } - // Elevation gain with neon green value - if (activity.getElevationGain() != null) { + // Elevation gain with neon green value (only for outdoor activities) + if (activity.getElevationGain() != null && !isIndoorActivity) { g2d.setFont(new Font("Arial Black", Font.BOLD, 40)); g2d.setColor(neonGreen); String elevation = String.format("%.0f", activity.getElevationGain()); @@ -469,6 +486,42 @@ public class ActivityImageService { 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 g2d.setFont(new Font("Arial Black", Font.BOLD, 28)); g2d.setColor(neonPink); @@ -582,6 +635,78 @@ public class ActivityImageService { 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. */ diff --git a/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java b/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java index 33efb45..943044f 100644 --- a/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java +++ b/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java @@ -102,6 +102,14 @@ public class TrainingLoadService { double distance = distanceMeters.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 double speed = distance / durationSeconds; // m/s double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index 9b65f5c..c5a5563 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -105,16 +105,19 @@ public class FitParser { throw new FitFileProcessingException("Failed to decode FIT file"); } + // Process GPS-related data only if track points are present 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: {}", parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone()); diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css index a11d8d1..a2c2b60 100644 --- a/src/main/resources/static/css/fitpub.css +++ b/src/main/resources/static/css/fitpub.css @@ -982,4 +982,36 @@ h1 { footer.bg-light p { 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; + } } diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index 08e00b2..a8bf8d5 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -155,16 +155,21 @@ const FitPubTimeline = {
- Distance: ${this.formatDistance(activity.totalDistance)} • - Duration: ${this.formatDuration(activity.totalDurationSeconds)} • - Pace: ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} • - Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'} + ${activity.hasGpsTrack + ? `Distance: ${this.formatDistance(activity.totalDistance)} • + Duration: ${this.formatDuration(activity.totalDurationSeconds)} • + Pace: ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} • + Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}` + : `Duration: ${this.formatDuration(activity.totalDurationSeconds)} + ${activity.metrics?.averageHeartRate ? ` • Avg HR: ${activity.metrics.averageHeartRate} bpm` : ''} + ${activity.metrics?.calories ? ` • Calories: ${activity.metrics.calories} kcal` : ''}` + }
- +
- +
@@ -295,6 +300,13 @@ const FitPubTimeline = { 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 if (!activity.isLocal) { if (activity.mapImageUrl) { @@ -517,5 +529,66 @@ const FitPubTimeline = { const div = document.createElement('div'); div.textContent = text; 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 = ` +
+
${emoji}
+
${this.escapeHtml(name)}
+
No GPS track
+
+ `; + element.style.backgroundColor = '#f8f9fa'; } }; diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 7e7caa7..248c1c6 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -198,8 +198,8 @@ - -
+ +
@@ -214,6 +214,19 @@
+ + +