diff --git a/pom.xml b/pom.xml index b3204dc..6e76fe5 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,13 @@ 21.141.0 + + + net.iakovlev + timeshape + 2025b.26 + + com.fasterxml.jackson.core 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 e2d6d22..f4837c5 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -37,6 +37,7 @@ public class ActivityDTO { private String description; private LocalDateTime startedAt; private LocalDateTime endedAt; + private String timezone; // IANA timezone ID (e.g., "Europe/Berlin") private String visibility; private BigDecimal totalDistance; private Long totalDurationSeconds; @@ -97,6 +98,7 @@ public class ActivityDTO { .description(activity.getDescription()) .startedAt(activity.getStartedAt()) .endedAt(activity.getEndedAt()) + .timezone(activity.getTimezone()) .visibility(activity.getVisibility().name()) .totalDistance(activity.getTotalDistance()) .elevationGain(activity.getElevationGain()) diff --git a/src/main/java/org/operaton/fitpub/model/entity/Activity.java b/src/main/java/org/operaton/fitpub/model/entity/Activity.java index 2f1d5af..c4a4b1f 100644 --- a/src/main/java/org/operaton/fitpub/model/entity/Activity.java +++ b/src/main/java/org/operaton/fitpub/model/entity/Activity.java @@ -53,6 +53,14 @@ public class Activity { @Column(name = "ended_at", nullable = false) private LocalDateTime endedAt; + /** + * Timezone ID where the activity was recorded (e.g., "Europe/Berlin", "America/New_York"). + * Stored to display activity times in the athlete's local timezone. + * Defaults to UTC if timezone cannot be determined from FIT file. + */ + @Column(name = "timezone", length = 50) + private String timezone; + @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private Visibility visibility; diff --git a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java index e7c359d..0064951 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java @@ -153,6 +153,11 @@ public class ActivityImageService { // Get letterbox transformation from OSM renderer OsmTileRenderer.LetterboxTransform letterbox = osmTileRenderer.getLastLetterboxTransform(); + if (letterbox == null) { + log.warn("No letterbox transform available, track overlay may be misaligned"); + return; + } + // Convert bounds to Web Mercator normalized coordinates (0-1) // This matches the projection used by OSM tiles double minX = longitudeToWebMercatorX(bounds.minLon); @@ -160,16 +165,16 @@ public class ActivityImageService { double minY = latitudeToWebMercatorY(bounds.maxLat); // Note: maxLat -> minY (inverted) double maxY = latitudeToWebMercatorY(bounds.minLat); // Note: minLat -> maxY (inverted) - // Calculate scale to map Web Mercator coordinates to pixels - // Apply letterbox scaling if available - double baseScaleX = trackWidth / (maxX - minX); - double baseScaleY = trackHeight / (maxY - minY); + // The letterbox transform gives us the actual rendered area within trackWidth x trackHeight + // We need to map our mercator coordinates to fit within that rendered area - double scaleX = letterbox != null ? baseScaleX * letterbox.scaleFactorX : baseScaleX; - double scaleY = letterbox != null ? baseScaleY * letterbox.scaleFactorY : baseScaleY; + // Calculate the mercator range that corresponds to the letterboxed (cropped/scaled) map + double mercatorWidth = maxX - minX; + double mercatorHeight = maxY - minY; - int offsetX = letterbox != null ? letterbox.offsetX : 0; - int offsetY = letterbox != null ? letterbox.offsetY : 0; + // The scale factors tell us how the mercator coordinates map to the letterboxed area + double pixelsPerMercatorX = letterbox.scaledWidth / mercatorWidth; + double pixelsPerMercatorY = letterbox.scaledHeight / mercatorHeight; // Draw track segments with privacy fade g2d.setStroke(new BasicStroke(4.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); @@ -193,12 +198,11 @@ public class ActivityImageService { double mercatorX2 = longitudeToWebMercatorX(lon2); double mercatorY2 = latitudeToWebMercatorY(lat2); - // Map Web Mercator coordinates to pixel coordinates - // Apply letterbox offset - double x1 = (mercatorX1 - minX) * scaleX + offsetX; - double y1 = (mercatorY1 - minY) * scaleY + offsetY; - double x2 = (mercatorX2 - minX) * scaleX + offsetX; - double y2 = (mercatorY2 - minY) * scaleY + offsetY; + // Map Web Mercator coordinates to pixel coordinates within the letterbox + double x1 = (mercatorX1 - minX) * pixelsPerMercatorX + letterbox.offsetX; + double y1 = (mercatorY1 - minY) * pixelsPerMercatorY + letterbox.offsetY; + double x2 = (mercatorX2 - minX) * pixelsPerMercatorX + letterbox.offsetX; + double y2 = (mercatorY2 - minY) * pixelsPerMercatorY + letterbox.offsetY; // Calculate opacity based on distance from start/end double distanceFromStart = cumulativeDistances[i]; diff --git a/src/main/java/org/operaton/fitpub/service/FitFileService.java b/src/main/java/org/operaton/fitpub/service/FitFileService.java index 5c3bb4e..eec562c 100644 --- a/src/main/java/org/operaton/fitpub/service/FitFileService.java +++ b/src/main/java/org/operaton/fitpub/service/FitFileService.java @@ -171,6 +171,7 @@ public class FitFileService { .description(description) .startedAt(parsedData.getStartTime()) .endedAt(parsedData.getEndTime()) + .timezone(parsedData.getTimezone()) .visibility(activityVisibility) .totalDistance(parsedData.getTotalDistance()) .totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null) diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index 8fbc625..062cd85 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -2,6 +2,7 @@ package org.operaton.fitpub.util; import com.garmin.fit.*; import lombok.extern.slf4j.Slf4j; +import net.iakovlev.timeshape.TimeZoneEngine; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; @@ -21,6 +22,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** * Parser for Garmin FIT files. @@ -37,6 +39,9 @@ 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; + // Lazy-loaded timezone engine (expensive to initialize) + private static TimeZoneEngine timezoneEngine = null; + /** * Parses a FIT file and returns the extracted data. * @@ -96,8 +101,11 @@ public class FitParser { throw new FitFileProcessingException("No GPS track points found in FIT file"); } - log.info("Successfully parsed FIT file: {} track points, activity type: {}", - parsedData.getTrackPoints().size(), parsedData.getActivityType()); + // Determine timezone from first GPS coordinate + determineTimezone(parsedData); + + log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}", + parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone()); return parsedData; } catch (FitRuntimeException e) { @@ -280,6 +288,43 @@ public class FitParser { } } + /** + * Determines the timezone based on the first GPS coordinate. + * Uses TimeZoneEngine library for accurate timezone lookup from coordinates. + */ + private void determineTimezone(ParsedFitData parsedData) { + if (parsedData.getTrackPoints().isEmpty()) { + parsedData.setTimezone("UTC"); + return; + } + + TrackPointData firstPoint = parsedData.getTrackPoints().get(0); + double latitude = firstPoint.getLatitude(); + double longitude = firstPoint.getLongitude(); + + try { + // Lazy-load timezone engine (expensive initialization ~200ms first time) + if (timezoneEngine == null) { + log.info("Initializing TimeZoneEngine for timezone lookup..."); + timezoneEngine = TimeZoneEngine.initialize(); + } + + Optional zoneId = timezoneEngine.query(latitude, longitude); + if (zoneId.isPresent()) { + parsedData.setTimezone(zoneId.get().getId()); + log.debug("Determined timezone: {} from coordinates ({}, {})", + zoneId.get().getId(), latitude, longitude); + } else { + log.warn("Could not determine timezone for coordinates ({}, {}), defaulting to UTC", + latitude, longitude); + parsedData.setTimezone("UTC"); + } + } catch (Exception e) { + log.error("Error determining timezone, defaulting to UTC", e); + parsedData.setTimezone("UTC"); + } + } + /** * Converts FIT DateTime to LocalDateTime. * FIT timestamps use a special epoch: December 31, 1989, 00:00:00 UTC. @@ -397,6 +442,7 @@ public class FitParser { private LocalDateTime startTime; private LocalDateTime endTime; private LocalDateTime activityTimestamp; + private String timezone; // IANA timezone ID (e.g., "Europe/Berlin") private BigDecimal totalDistance; private Duration totalDuration; private BigDecimal elevationGain; diff --git a/src/main/resources/db/migration/V12__add_timezone_to_activities.sql b/src/main/resources/db/migration/V12__add_timezone_to_activities.sql new file mode 100644 index 0000000..df352a6 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_timezone_to_activities.sql @@ -0,0 +1,15 @@ +-- V12: Add timezone column to activities table +-- This allows storing the timezone where the activity was recorded, +-- enabling proper display of activity times in the athlete's local timezone + +-- Add timezone column (nullable for backward compatibility with existing activities) +ALTER TABLE activities +ADD COLUMN timezone VARCHAR(50); + +-- Set default timezone to UTC for existing activities +UPDATE activities +SET timezone = 'UTC' +WHERE timezone IS NULL; + +-- Add comment explaining the column +COMMENT ON COLUMN activities.timezone IS 'IANA timezone ID where the activity was recorded (e.g., Europe/Berlin, America/New_York). Used to display activity times in athlete''s local timezone.'; diff --git a/src/main/resources/static/js/fitpub.js b/src/main/resources/static/js/fitpub.js index ae878c4..261db8e 100644 --- a/src/main/resources/static/js/fitpub.js +++ b/src/main/resources/static/js/fitpub.js @@ -418,6 +418,58 @@ function formatPace(speed) { return `${minutes}:${seconds.toString().padStart(2, '0')} /km`; } +/** + * Format a timestamp with timezone awareness + * + * @param {string} timestamp - ISO timestamp or LocalDateTime string + * @param {string} timezone - IANA timezone ID (e.g., "Europe/Berlin") + * @param {object} options - Intl.DateTimeFormat options + * @returns {string} Formatted date/time string + */ +function formatDateTimeWithTimezone(timestamp, timezone, options = {}) { + if (!timestamp) return ''; + + // Parse the timestamp - backend sends LocalDateTime without 'Z' + // We need to interpret it in the specified timezone + const date = new Date(timestamp); + + // Default options for date/time display + const defaultOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: timezone || 'UTC', + ...options + }; + + try { + return new Intl.DateTimeFormat('en-US', defaultOptions).format(date); + } catch (e) { + console.error('Error formatting date with timezone:', e); + // Fallback to simple formatting + return date.toLocaleString(); + } +} + +/** + * Format a date with timezone awareness (date only, no time) + * + * @param {string} timestamp - ISO timestamp or LocalDateTime string + * @param {string} timezone - IANA timezone ID + * @returns {string} Formatted date string + */ +function formatDateWithTimezone(timestamp, timezone) { + return formatDateTimeWithTimezone(timestamp, timezone, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: undefined, + minute: undefined + }); +} + // Make functions available globally for inline scripts window.FitPub = { createActivityMap, @@ -425,5 +477,7 @@ window.FitPub = { showAlert, formatDuration, formatDistance, - formatPace + formatPace, + formatDateTimeWithTimezone, + formatDateWithTimezone }; diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 4053695..ea7c497 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -391,7 +391,11 @@ document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity'; document.getElementById('activityType').textContent = activity.activityType; document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`; - document.getElementById('activityDate').textContent = new Date(activity.startedAt).toLocaleString(); + // Format date with timezone awareness + document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone( + activity.startedAt, + activity.timezone || 'UTC' + ); document.getElementById('activityVisibility').textContent = activity.visibility; // Visibility icon diff --git a/src/main/resources/templates/activities/list.html b/src/main/resources/templates/activities/list.html index d517c50..a2d75f1 100644 --- a/src/main/resources/templates/activities/list.html +++ b/src/main/resources/templates/activities/list.html @@ -162,7 +162,7 @@ - ${new Date(activity.startedAt).toLocaleDateString()} + ${FitPub.formatDateWithTimezone(activity.startedAt, activity.timezone || 'UTC')} diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html index c811504..d35f920 100644 --- a/src/main/resources/templates/activities/upload.html +++ b/src/main/resources/templates/activities/upload.html @@ -303,16 +303,23 @@ metadataSection.classList.remove('d-none'); // Populate summary + const formattedDateTime = FitPub.formatDateTimeWithTimezone( + response.startedAt, + response.timezone || 'UTC' + ); + const formattedDate = FitPub.formatDateWithTimezone( + response.startedAt, + response.timezone || 'UTC' + ); document.getElementById('summaryContent').innerHTML = `

Type: ${response.activityType || 'Unknown'}

Distance: ${formatDistance(response.totalDistance)}

Duration: ${formatDuration(response.totalDurationSeconds)}

-

Date: ${new Date(response.startedAt).toLocaleString()}

+

Date: ${formattedDateTime}

`; // Pre-fill title with activity type and date - const activityDate = new Date(response.startedAt); - document.getElementById('title').value = `${response.activityType || 'Activity'} - ${activityDate.toLocaleDateString()}`; + document.getElementById('title').value = `${response.activityType || 'Activity'} - ${formattedDate}`; } // Reset form state diff --git a/src/test/java/org/operaton/fitpub/service/ActivityImageServiceTest.java b/src/test/java/org/operaton/fitpub/service/ActivityImageServiceTest.java new file mode 100644 index 0000000..7b5e5bd --- /dev/null +++ b/src/test/java/org/operaton/fitpub/service/ActivityImageServiceTest.java @@ -0,0 +1,188 @@ +package org.operaton.fitpub.service; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.ActivityMetrics; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.ActivityRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.util.FitParser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Manual test for ActivityImageService. + * These tests are disabled by default and should only be run manually. + */ +@SpringBootTest(properties = { + "fitpub.image.osm-tiles.enabled=true" +}) +@ActiveProfiles("test") +class ActivityImageServiceTest { + + @Autowired + private ActivityImageService activityImageService; + + @Autowired + private FitParser fitParser; + + @Autowired + private ActivityRepository activityRepository; + + @Autowired + private UserRepository userRepository; + + /** + * Manual test to generate an activity image from the test FIT file. + * The image will be written to target/test-activity-image.png. + * + * To run this test manually: + * mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual + */ + @Test + @Disabled("Manual test - run explicitly when needed") + @DisplayName("Generate activity image from test FIT file") + void testGenerateActivityImage_Manual() throws Exception { + // Load test FIT file + Path fitFilePath = Paths.get("src/test/resources/69287079d5e0a4532ba818ee.fit"); + assertTrue(Files.exists(fitFilePath), "Test FIT file should exist"); + + byte[] fitFileData = Files.readAllBytes(fitFilePath); + assertNotNull(fitFileData); + assertTrue(fitFileData.length > 0, "FIT file should not be empty"); + + // Parse FIT file + FitParser.ParsedFitData parsedData = fitParser.parse(fitFileData); + assertNotNull(parsedData); + assertNotNull(parsedData.getStartTime()); + assertNotNull(parsedData.getEndTime()); + assertFalse(parsedData.getTrackPoints().isEmpty(), "Should have track points"); + + System.out.println("Parsed FIT file:"); + System.out.println(" Start time: " + parsedData.getStartTime()); + System.out.println(" End time: " + parsedData.getEndTime()); + System.out.println(" Timezone: " + parsedData.getTimezone()); + System.out.println(" Track points: " + parsedData.getTrackPoints().size()); + System.out.println(" Activity type: " + parsedData.getActivityType()); + System.out.println(" Total distance: " + parsedData.getTotalDistance() + " m"); + System.out.println(" Total duration: " + parsedData.getTotalDuration()); + + // Create a test user with required fields + User testUser = new User(); + testUser.setUsername("testuser_" + System.currentTimeMillis()); + testUser.setEmail("test@example.com"); + testUser.setPasswordHash("hashedpassword"); + testUser.setDisplayName("Test User"); + testUser.setEnabled(true); + // Dummy RSA keys for ActivityPub (not used in this test) + testUser.setPublicKey("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----"); + testUser.setPrivateKey("-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC\n-----END PRIVATE KEY-----"); + testUser = userRepository.save(testUser); + + // Create a test activity entity + Activity activity = Activity.builder() + .id(UUID.randomUUID()) + .userId(testUser.getId()) + .activityType(parsedData.getActivityType()) + .title("Test Activity - Manual Image Rendering") + .description("This is a test activity for manual image rendering") + .startedAt(parsedData.getStartTime()) + .endedAt(parsedData.getEndTime()) + .timezone(parsedData.getTimezone()) + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(parsedData.getTotalDistance()) + .totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null) + .elevationGain(parsedData.getElevationGain()) + .elevationLoss(parsedData.getElevationLoss()) + .build(); + + // Add metrics if available + if (parsedData.getMetrics() != null) { + ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity); + activity.setMetrics(metrics); + } + + // Convert track points to JSON + String trackPointsJson = convertTrackPointsToJson(parsedData); + activity.setTrackPointsJson(trackPointsJson); + + // Save activity temporarily (needed for image generation) + Activity savedActivity = activityRepository.save(activity); + + try { + System.out.println("\nGenerating activity image..."); + + // Generate the image + String imageUrl = activityImageService.generateActivityImage(savedActivity); + + System.out.println("Generated image URL: " + imageUrl); + + if (imageUrl == null) { + System.err.println("ERROR: Image generation returned null! Check logs for errors."); + fail("Image generation failed - returned null"); + } + + // Try multiple possible locations for the generated image + String[] possiblePaths = { + "fitpub-images/" + savedActivity.getId() + ".png", + "/tmp/fitpub/images/" + savedActivity.getId() + ".png", + System.getProperty("java.io.tmpdir") + "/fitpub/images/" + savedActivity.getId() + ".png" + }; + + Path foundImagePath = null; + for (String pathStr : possiblePaths) { + Path testPath = Paths.get(pathStr); + System.out.println("Checking: " + testPath.toAbsolutePath()); + if (Files.exists(testPath)) { + foundImagePath = testPath; + System.out.println("Found image at: " + testPath.toAbsolutePath()); + break; + } + } + + if (foundImagePath != null) { + // Copy the generated image to target directory for easy inspection + Path targetPath = Paths.get("target", "test-activity-image.png"); + Files.copy(foundImagePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + System.out.println("\n✓ SUCCESS! Image copied to: " + targetPath.toAbsolutePath()); + System.out.println("Open this file to inspect the generated image with track overlay."); + + // Verify file size + long fileSize = Files.size(targetPath); + System.out.println("Image file size: " + fileSize + " bytes"); + assertTrue(fileSize > 1000, "Image file should be larger than 1KB"); + } else { + System.err.println("ERROR: Generated image not found in any expected location!"); + fail("Generated image file not found"); + } + + } finally { + // Clean up test activity and user + activityRepository.delete(savedActivity); + userRepository.delete(testUser); + System.out.println("\nTest activity and user cleaned up."); + } + } + + /** + * Helper method to convert parsed track points to JSON format. + */ + private String convertTrackPointsToJson(FitParser.ParsedFitData parsedData) throws IOException { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + + return mapper.writeValueAsString(parsedData.getTrackPoints()); + } +}