From 102d515b42b535d15c5744fd8746debbc9e1275c Mon Sep 17 00:00:00 2001 From: Niklas Date: Mon, 27 Apr 2026 22:01:08 +0200 Subject: [PATCH] Display activity date in local time (using the time zone that is stored with the activity), not in UTC (#4) * Display timestamps using the timezone that is stored at the activity (fix 'new Date()' invocation) * Display timestamps using the timezone that is stored at the activity (relative date in timeline views) * Use correct timezone for auto-generated activity title --------- Co-authored-by: Niklas Deutschmann --- .../fitpub/service/ActivityFileService.java | 3 +- .../fitpub/service/FitFileService.java | 3 +- .../fitpub/util/ActivityFormatter.java | 36 ++++++++++++++++--- src/main/resources/static/js/fitpub.js | 16 +++++++-- src/main/resources/static/js/timeline.js | 2 +- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java index 060d0f6..cfb56b4 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java @@ -386,7 +386,8 @@ public class ActivityFileService { activityTitle = parsedData.getTitle(); } else { // Generate title if not provided - activityTitle = ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType()); + activityTitle = ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getTimezone(), + parsedData.getActivityType()); } // Default to PUBLIC if visibility not specified diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java index a709797..bb7086e 100644 --- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java @@ -24,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -257,7 +256,7 @@ public class FitFileService { private String generateTitle(ParsedActivityData parsedData) { return ActivityFormatter.generateActivityTitle( parsedData.getStartTime(), - parsedData.getActivityType() + parsedData.getTimezone(), parsedData.getActivityType() ); } diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java index 324f5a1..26e4f32 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java @@ -1,13 +1,14 @@ package net.javahippie.fitpub.util; +import lombok.extern.slf4j.Slf4j; import net.javahippie.fitpub.model.entity.Activity; -import java.time.LocalDateTime; -import java.time.LocalTime; +import java.time.*; /** * Utility class for formatting activity-related data for display. */ +@Slf4j public class ActivityFormatter { /** @@ -47,21 +48,25 @@ public class ActivityFormatter { * Generates a default activity title based on the time of day and activity type. * Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride") * - * @param startedAt the activity start time + * @param startedAt the activity start time + * @param timezone the timezone ID of the activity * @param activityType the activity type * @return generated title */ - public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) { + public static String generateActivityTitle(LocalDateTime startedAt, String timezone, Activity.ActivityType activityType) { if (startedAt == null || activityType == null) { return "Activity"; } - String timeOfDay = getTimeOfDay(startedAt.toLocalTime()); + LocalDateTime startedAtLocal = getUtcDateTimeInZone(startedAt, timezone); + String timeOfDay = getTimeOfDay(startedAtLocal.toLocalTime()); String formattedType = formatActivityType(activityType); return timeOfDay + " " + formattedType; } + + /** * Determines the time of day based on the hour. * @@ -81,4 +86,25 @@ public class ActivityFormatter { return "Night"; } } + + /** + * Attempts to convert the given LocalDateTime (which is assumed to be UTC) into a LocalDateTime in the given + * timezone + * + * @param utcDateTime The original date and time (UTC) + * @param timezone A timezone ID + * @return The original date and time adjusted to the timezone, if the zone ID could be parsed. The original date + * and time otherwise + * + */ + private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) { + try { + return utcDateTime.atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZoneId.of(timezone)) + .toLocalDateTime(); + } catch (DateTimeException e) { + log.warn("Invalid time zone ID: {}", timezone); + return utcDateTime; + } + } } diff --git a/src/main/resources/static/js/fitpub.js b/src/main/resources/static/js/fitpub.js index f903d08..4e6da91 100644 --- a/src/main/resources/static/js/fitpub.js +++ b/src/main/resources/static/js/fitpub.js @@ -434,7 +434,7 @@ function formatDateTimeWithTimezone(timestamp, timezone, options = {}) { // Parse the timestamp - backend sends LocalDateTime without 'Z' // We need to interpret it in the specified timezone - const date = new Date(timestamp); + const date = new Date(ensureUTC(timestamp)); // Default options for date/time display const defaultOptions = { @@ -473,6 +473,17 @@ function formatDateWithTimezone(timestamp, timezone) { }); } +/** + * Ensures that a timestamp will be interpreted as UTC by new Date() + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date (Date time string format) + * + * @param {string} timestamp - ISO timestamp or LocalDateTime string + * @returns {string} The input string, but with a trailing 'Z' + */ +function ensureUTC(timestamp) { + return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z'; +} + // Make functions available globally for inline scripts window.FitPub = { createActivityMap, @@ -482,5 +493,6 @@ window.FitPub = { formatDistance, formatPace, formatDateTimeWithTimezone, - formatDateWithTimezone + formatDateWithTimezone, + ensureUTC }; diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index 1b48bdc..4a8fdb5 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -727,7 +727,7 @@ const FitPubTimeline = { * @returns {string} Time ago string */ formatTimeAgo: function(timestamp) { - const date = new Date(timestamp); + const date = new Date(FitPub.ensureUTC(timestamp)); const now = new Date(); const secondsAgo = Math.floor((now - date) / 1000);