This commit is contained in:
Tim Zöller 2025-12-04 18:39:01 +01:00
parent 1d7000d592
commit 7d07653d2a
12 changed files with 358 additions and 22 deletions

View file

@ -100,6 +100,13 @@
<version>21.141.0</version>
</dependency>
<!-- Timezone lookup from GPS coordinates -->
<dependency>
<groupId>net.iakovlev</groupId>
<artifactId>timeshape</artifactId>
<version>2025b.26</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -162,7 +162,7 @@
</span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
${new Date(activity.startedAt).toLocaleDateString()}
${FitPub.formatDateWithTimezone(activity.startedAt, activity.timezone || 'UTC')}
</span>
<span class="ms-2 visibility-${activity.visibility.toLowerCase()}">
<i class="bi bi-${getVisibilityIcon(activity.visibility)}"></i>

View file

@ -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 = `
<p class="mb-1"><strong>Type:</strong> ${response.activityType || 'Unknown'}</p>
<p class="mb-1"><strong>Distance:</strong> ${formatDistance(response.totalDistance)}</p>
<p class="mb-1"><strong>Duration:</strong> ${formatDuration(response.totalDurationSeconds)}</p>
<p class="mb-0"><strong>Date:</strong> ${new Date(response.startedAt).toLocaleString()}</p>
<p class="mb-0"><strong>Date:</strong> ${formattedDateTime}</p>
`;
// 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

View file

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