diff --git a/Dockerfile b/Dockerfile index 21a8b1fc..b6556bc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:24-jre-alpine +FROM eclipse-temurin:24.0.2_12-jre-alpine LABEL maintainer="dedicatedcode" LABEL org.opencontainers.image.source="https://github.com/dedicatedcode/reitti" diff --git a/pom.xml b/pom.xml index a03c18d5..777f3098 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,11 @@ org.thymeleaf.extras thymeleaf-extras-springsecurity6 + + net.iakovlev + timeshape + 2025b.26 + org.mockito mockito-core diff --git a/src/main/java/com/dedicatedcode/reitti/controller/CustomErrorController.java b/src/main/java/com/dedicatedcode/reitti/controller/CustomErrorController.java index f693cd7e..3c8cfc67 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/CustomErrorController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/CustomErrorController.java @@ -10,19 +10,22 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; +import java.util.List; + @Controller public class CustomErrorController implements ErrorController { private static final Logger log = LoggerFactory.getLogger(CustomErrorController.class); + private static final List IGNORED_PATHS = List.of("/favicon.ico", "/js/", "/img/"); @RequestMapping("/error") public String handleError(HttpServletRequest request, Model model) { Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); Object errorMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE); Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION); - Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); + String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); - if (requestUri.toString().equals("/favicon.ico")) { + if (IGNORED_PATHS.stream().anyMatch(requestUri::startsWith)) { return null; } if (status != null) { diff --git a/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java index 751220d2..455f184f 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java @@ -34,6 +34,7 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; @@ -322,7 +323,7 @@ public class SettingsController { significantPlace.getLatitudeCentroid(), significantPlace.getLongitudeCentroid() ); - rabbitTemplate.convertAndSend(RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, event); + rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event); model.addAttribute("successMessage", getMessage("places.geocode.success")); } catch (Exception e) { @@ -819,7 +820,7 @@ public class SettingsController { place.getLatitudeCentroid(), place.getLongitudeCentroid() ); - rabbitTemplate.convertAndSend(RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, event); + rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event); } model.addAttribute("successMessage", getMessage("geocoding.run.success", nonGeocodedPlaces.size())); @@ -859,7 +860,7 @@ public class SettingsController { place.getLatitudeCentroid(), place.getLongitudeCentroid() ); - rabbitTemplate.convertAndSend(RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, event); + rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event); } model.addAttribute("successMessage", getMessage("geocoding.clear.success", allPlaces.size())); @@ -903,8 +904,7 @@ public class SettingsController { model.addAttribute("externallyManaged", user.getExternalId() != null && oidcEnabled); model.addAttribute("externalProfile", user.getProfileUrl()); model.addAttribute("localLoginDisabled", this.localLoginDisabled); - UserSettings userSettings = userSettingsJdbcService.findByUserId(user.getId()) - .orElse(UserSettings.defaultSettings(user.getId())); + UserSettings userSettings = userSettingsJdbcService.findByUserId(user.getId()).orElse(UserSettings.defaultSettings(user.getId())); model.addAttribute("selectedLanguage", userSettings.getSelectedLanguage()); model.addAttribute("selectedUnitSystem", userSettings.getUnitSystem().name()); model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap()); @@ -912,6 +912,10 @@ public class SettingsController { model.addAttribute("homeLongitude", userSettings.getHomeLongitude()); model.addAttribute("unitSystems", UnitSystem.values()); model.addAttribute("isAdmin", false); + model.addAttribute("timeZoneOverride", userSettings.getTimeZoneOverride()); + model.addAttribute("timeDisplayMode", userSettings.getTimeDisplayMode().name()); + model.addAttribute("availableTimezones", ZoneId.getAvailableZoneIds().stream().sorted()); + model.addAttribute("availableTimeDisplayModes", TimeDisplayMode.values()); // Check if user has avatar boolean hasAvatar = this.avatarService.getInfo(user.getId()).isPresent(); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java index 2cd7d836..387ccdb2 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java @@ -7,6 +7,7 @@ import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; +import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; import com.dedicatedcode.reitti.service.AvatarService; import com.dedicatedcode.reitti.service.TimelineService; import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService; @@ -37,18 +38,21 @@ public class TimelineController { private final AvatarService avatarService; private final ReittiIntegrationService reittiIntegrationService; private final TimelineService timelineService; + private final UserSettingsJdbcService userSettingsJdbcService; @Autowired public TimelineController(SignificantPlaceJdbcService placeService, UserJdbcService userJdbcService, AvatarService avatarService, ReittiIntegrationService reittiIntegrationService, - TimelineService timelineService) { + TimelineService timelineService, + UserSettingsJdbcService userSettingsJdbcService) { this.placeService = placeService; this.userJdbcService = userJdbcService; this.avatarService = avatarService; this.reittiIntegrationService = reittiIntegrationService; this.timelineService = timelineService; + this.userSettingsJdbcService = userSettingsJdbcService; } @GetMapping("/content") @@ -159,6 +163,7 @@ public class TimelineController { model.addAttribute("selectedPlaceId", selectedPlaceId); model.addAttribute("data", selectedDate); model.addAttribute("timezone", timezone); + model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode()); return "fragments/timeline :: timeline-content"; } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsController.java index a90f5b6a..763060b0 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsController.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.controller; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.TimeDisplayMode; import com.dedicatedcode.reitti.model.UnitSystem; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; @@ -24,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.LocaleResolver; import java.io.IOException; +import java.time.ZoneId; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -105,20 +107,14 @@ public class UserSettingsController { model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap()); model.addAttribute("homeLatitude", userSettings.getHomeLatitude()); model.addAttribute("homeLongitude", userSettings.getHomeLongitude()); - - // Add available unit systems to model model.addAttribute("unitSystems", UnitSystem.values()); - - // Check if user has avatar - boolean hasAvatar = this.avatarService.getInfo(currentUser.getId()).isPresent(); - model.addAttribute("hasAvatar", hasAvatar); - - // Add default avatars to model + model.addAttribute("hasAvatar", this.avatarService.getInfo(currentUser.getId()).isPresent()); model.addAttribute("defaultAvatars", DEFAULT_AVATARS); - - // Add admin status to model model.addAttribute("isAdmin", false); - + model.addAttribute("timeZoneOverride", userSettings.getTimeZoneOverride()); + model.addAttribute("timeDisplayMode", userSettings.getTimeDisplayMode().name()); + model.addAttribute("availableTimezones", ZoneId.getAvailableZoneIds()); + model.addAttribute("availableTimeDisplayModes", TimeDisplayMode.values()); return "fragments/user-management :: user-form-page"; } @@ -173,6 +169,8 @@ public class UserSettingsController { @RequestParam(defaultValue = "false") boolean preferColoredMap, @RequestParam(required = false) Double homeLatitude, @RequestParam(required = false) Double homeLongitude, + @RequestParam(name = "timezone_override", required = false) String timezoneOverride, + @RequestParam(name = "time_display_mode", defaultValue = "DEFAULT") TimeDisplayMode timeDisplayMode, @RequestParam(required = false) MultipartFile avatar, @RequestParam(required = false) String defaultAvatar, Authentication authentication, @@ -194,7 +192,16 @@ public class UserSettingsController { .withRole(role)); UnitSystem unitSystem = UnitSystem.valueOf(unit_system); - UserSettings userSettings = new UserSettings(createdUser.getId(), preferColoredMap, preferred_language, unitSystem, homeLatitude, homeLongitude, null); + UserSettings userSettings = new UserSettings(createdUser.getId(), + preferColoredMap, + preferred_language, + unitSystem, + homeLatitude, + homeLongitude, + StringUtils.hasText(timezoneOverride) ? ZoneId.of(timezoneOverride) : null, + timeDisplayMode, + null, + null); userSettingsJdbcService.save(userSettings); // Handle avatar - prioritize custom upload over default @@ -233,6 +240,8 @@ public class UserSettingsController { @RequestParam(required = false) Double homeLatitude, @RequestParam(required = false) Double homeLongitude, @RequestParam(required = false) MultipartFile avatar, + @RequestParam(name = "timezone_override", required = false) String timezoneOverride, + @RequestParam(name = "time_display_mode", defaultValue = "DEFAULT") TimeDisplayMode timeDisplayMode, @RequestParam(required = false) String defaultAvatar, @RequestParam(required = false) String removeAvatar, Authentication authentication, @@ -275,7 +284,7 @@ public class UserSettingsController { .orElse(UserSettings.defaultSettings(userId)); UnitSystem unitSystem = UnitSystem.valueOf(unit_system); - UserSettings updatedSettings = new UserSettings(userId, preferColoredMap, preferred_language, unitSystem, homeLatitude, homeLongitude, existingSettings.getLatestData(), existingSettings.getVersion()); + UserSettings updatedSettings = new UserSettings(userId, preferColoredMap, preferred_language, unitSystem, homeLatitude, homeLongitude, StringUtils.hasText(timezoneOverride) ? ZoneId.of(timezoneOverride) : null, timeDisplayMode, existingSettings.getLatestData(), existingSettings.getVersion()); userSettingsJdbcService.save(updatedSettings); // Handle avatar operations @@ -355,6 +364,8 @@ public class UserSettingsController { model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap()); model.addAttribute("homeLatitude", userSettings.getHomeLatitude()); model.addAttribute("homeLongitude", userSettings.getHomeLongitude()); + model.addAttribute("timeZoneOverride", userSettings.getTimeZoneOverride()); + model.addAttribute("timeDisplayMode", userSettings.getTimeDisplayMode().name()); } else { // Default values for new users model.addAttribute("selectedLanguage", "en"); @@ -367,10 +378,10 @@ public class UserSettingsController { model.addAttribute("externalProfile", null); model.addAttribute("localLoginDisabled", localLoginDisabled); } - - // Add available unit systems to model + model.addAttribute("unitSystems", UnitSystem.values()); - + model.addAttribute("availableTimezones", ZoneId.getAvailableZoneIds().stream().sorted()); + model.addAttribute("availableTimeDisplayModes", TimeDisplayMode.values()); // Check if user has avatar if (userId != null) { boolean hasAvatar = this.avatarService.getInfo(userId).isPresent(); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java index 1c8347f5..81156106 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.controller; import com.dedicatedcode.reitti.dto.UserSettingsDTO; +import com.dedicatedcode.reitti.model.TimeDisplayMode; import com.dedicatedcode.reitti.model.UnitSystem; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; @@ -38,10 +39,9 @@ public class UserSettingsControllerAdvice { public UserSettingsDTO getCurrentUserSettings() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated() || - "anonymousUser".equals(authentication.getPrincipal())) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { // Return default settings for anonymous users - return new UserSettingsDTO(false, "en", Instant.now(), UnitSystem.METRIC, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE, tilesCustomizationProvider.getTilesConfiguration(), UserSettingsDTO.UIMode.FULL); + return new UserSettingsDTO(false, "en", Instant.now(), UnitSystem.METRIC, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE, tilesCustomizationProvider.getTilesConfiguration(), UserSettingsDTO.UIMode.FULL, TimeDisplayMode.DEFAULT, null); } String username = authentication.getName(); @@ -57,11 +57,12 @@ public class UserSettingsControllerAdvice { dbSettings.getHomeLatitude(), dbSettings.getHomeLongitude(), tilesCustomizationProvider.getTilesConfiguration(), - uiMode); + uiMode, + dbSettings.getTimeDisplayMode(), + dbSettings.getTimeZoneOverride()); } - // Fallback for authenticated users not found in database - return new UserSettingsDTO(false, "en", Instant.now(), UnitSystem.METRIC, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE, tilesCustomizationProvider.getTilesConfiguration(), uiMode); + return new UserSettingsDTO(false, "en", Instant.now(), UnitSystem.METRIC, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE, tilesCustomizationProvider.getTilesConfiguration(), uiMode, TimeDisplayMode.DEFAULT, null); } private UserSettingsDTO.UIMode mapUserToUiMode(Authentication authentication) { diff --git a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java index cbe460e5..bcf2fce2 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java @@ -3,6 +3,7 @@ package com.dedicatedcode.reitti.dto; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import java.time.Instant; +import java.time.ZoneId; /** * Inner class to represent timeline entries for the template @@ -15,8 +16,11 @@ public class TimelineEntry { private SignificantPlace place; private String path; private Instant startTime; + private ZoneId startTimezone; private Instant endTime; + private ZoneId endTimezone; private String formattedTimeRange; + private String formattedLocalTimeRange; private String formattedDuration; private Double distanceMeters; private String formattedDistance; @@ -63,6 +67,22 @@ public class TimelineEntry { this.endTime = endTime; } + public ZoneId getStartTimezone() { + return startTimezone; + } + + public void setStartTimezone(ZoneId startTimezone) { + this.startTimezone = startTimezone; + } + + public ZoneId getEndTimezone() { + return endTimezone; + } + + public void setEndTimezone(ZoneId endTimezone) { + this.endTimezone = endTimezone; + } + public String getFormattedTimeRange() { return formattedTimeRange; } @@ -71,6 +91,14 @@ public class TimelineEntry { this.formattedTimeRange = formattedTimeRange; } + public String getFormattedLocalTimeRange() { + return formattedLocalTimeRange; + } + + public void setFormattedLocalTimeRange(String formattedLocalTimeRange) { + this.formattedLocalTimeRange = formattedLocalTimeRange; + } + public String getFormattedDuration() { return formattedDuration; } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java index c963d5cc..7894dcfa 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java @@ -1,8 +1,10 @@ package com.dedicatedcode.reitti.dto; +import com.dedicatedcode.reitti.model.TimeDisplayMode; import com.dedicatedcode.reitti.model.UnitSystem; import java.time.Instant; +import java.time.ZoneId; public record UserSettingsDTO( boolean preferColoredMap, @@ -12,7 +14,9 @@ public record UserSettingsDTO( Double homeLatitude, Double homeLongitude, TilesCustomizationDTO tiles, - UIMode uiMode + UIMode uiMode, + TimeDisplayMode displayMode, + ZoneId timezoneOverride ) { public enum UIMode { diff --git a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java index 589450d5..3c617af7 100644 --- a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java +++ b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java @@ -2,26 +2,5 @@ package com.dedicatedcode.reitti.event; import java.io.Serializable; -public class SignificantPlaceCreatedEvent implements Serializable { - private final Long placeId; - private final Double latitude; - private final Double longitude; - - public SignificantPlaceCreatedEvent(Long placeId, Double latitude, Double longitude) { - this.placeId = placeId; - this.latitude = latitude; - this.longitude = longitude; - } - - public Long getPlaceId() { - return placeId; - } - - public Double getLatitude() { - return latitude; - } - - public Double getLongitude() { - return longitude; - } +public record SignificantPlaceCreatedEvent(Long placeId, Double latitude, Double longitude) implements Serializable { } diff --git a/src/main/java/com/dedicatedcode/reitti/model/TimeDisplayMode.java b/src/main/java/com/dedicatedcode/reitti/model/TimeDisplayMode.java new file mode 100644 index 00000000..7735d7f1 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/TimeDisplayMode.java @@ -0,0 +1,6 @@ +package com.dedicatedcode.reitti.model; + +public enum TimeDisplayMode { + DEFAULT, + GEO_LOCAL +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java b/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java index fa407d1e..2b50f916 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.model.geo; import java.io.Serializable; +import java.time.ZoneId; import java.util.Objects; public class SignificantPlace implements Serializable { @@ -12,25 +13,12 @@ public class SignificantPlace implements Serializable { private final Double latitudeCentroid; private final Double longitudeCentroid; private final PlaceType type; + private final ZoneId timezone; private final boolean geocoded; private final Long version; - public static SignificantPlace create(Double latitude, Double longitude) { - return new SignificantPlace(null, null, latitude, longitude, PlaceType.OTHER, null); - } - - public SignificantPlace() { - this(null, null, null, null, null, null, null, false, null); - } - - private SignificantPlace(String name, - String address, - Double latitudeCentroid, - Double longitudeCentroid, - PlaceType type, - String countryCode) { - this(null, name, address, countryCode, latitudeCentroid, longitudeCentroid, type, false, 1L); + return new SignificantPlace(null, null, null, null, latitude, longitude, PlaceType.OTHER, null, false, 1L); } public SignificantPlace(Long id, @@ -39,7 +27,7 @@ public class SignificantPlace implements Serializable { String countryCode, Double latitudeCentroid, Double longitudeCentroid, - PlaceType type, + PlaceType type, ZoneId timezone, boolean geocoded, Long version) { this.id = id; @@ -49,6 +37,7 @@ public class SignificantPlace implements Serializable { this.latitudeCentroid = latitudeCentroid; this.longitudeCentroid = longitudeCentroid; this.type = type; + this.timezone = timezone; this.geocoded = geocoded; this.version = version; } @@ -82,6 +71,10 @@ public class SignificantPlace implements Serializable { return type; } + public ZoneId getTimezone() { + return timezone; + } + public boolean isGeocoded() { return geocoded; } @@ -92,27 +85,31 @@ public class SignificantPlace implements Serializable { // Wither methods public SignificantPlace withGeocoded(boolean geocoded) { - return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version); } public SignificantPlace withName(String name) { - return new SignificantPlace(this.id, name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, this.geocoded, this.version); + return new SignificantPlace(this.id, name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withAddress(String address) { - return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withCountryCode(String countryCode) { - return new SignificantPlace(this.id, this.name, this.address, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withType(PlaceType type) { - return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version); } public SignificantPlace withId(Long id) { - return new SignificantPlace(id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, this.geocoded, this.version); + return new SignificantPlace(id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + } + + public SignificantPlace withTimezone(ZoneId timezone) { + return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); } @Override @@ -135,6 +132,7 @@ public class SignificantPlace implements Serializable { '}'; } + public enum PlaceType { RESTAURANT("lni-restaurant", "place.type.restaurant"), PARK("lni-trees", "place.type.park"), diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java index 600f8ca6..ec153099 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java +++ b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java @@ -1,8 +1,10 @@ package com.dedicatedcode.reitti.model.security; +import com.dedicatedcode.reitti.model.TimeDisplayMode; import com.dedicatedcode.reitti.model.UnitSystem; import java.time.Instant; +import java.time.ZoneId; import java.util.Objects; public class UserSettings { @@ -13,25 +15,26 @@ public class UserSettings { private final UnitSystem unitSystem; private final Double homeLatitude; private final Double homeLongitude; + private final ZoneId timeZoneOverride; + private final TimeDisplayMode timeDisplayMode; private final Instant latestData; private final Long version; - public UserSettings(Long userId, boolean preferColoredMap, String selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, Instant latestData, Long version) { + public UserSettings(Long userId, boolean preferColoredMap, String selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, ZoneId timeZoneOverride, TimeDisplayMode timeDisplayMode, Instant latestData, Long version) { this.userId = userId; this.preferColoredMap = preferColoredMap; this.selectedLanguage = selectedLanguage; this.unitSystem = unitSystem; this.homeLatitude = homeLatitude; this.homeLongitude = homeLongitude; + this.timeZoneOverride = timeZoneOverride; + this.timeDisplayMode = timeDisplayMode; this.latestData = latestData; this.version = version; } - public UserSettings(Long userId, boolean preferColoredMap, String selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, Instant latestData) { - this(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, latestData, null); - } public static UserSettings defaultSettings(Long userId) { - return new UserSettings(userId, false, "en", UnitSystem.METRIC, null, null, null, null); + return new UserSettings(userId, false, "en", UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, null, null); } public Long getUserId() { return userId; @@ -65,6 +68,14 @@ public class UserSettings { return latestData; } + public TimeDisplayMode getTimeDisplayMode() { + return timeDisplayMode; + } + + public ZoneId getTimeZoneOverride() { + return timeZoneOverride; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java index 8f7a2eaa..a195bc5e 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java @@ -12,6 +12,7 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -25,25 +26,25 @@ public class SignificantPlaceJdbcService { public SignificantPlaceJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) { this.jdbcTemplate = jdbcTemplate; this.pointReaderWriter = pointReaderWriter; - this.significantPlaceRowMapper = (rs, _) -> new SignificantPlace( - rs.getLong("id"), - rs.getString("name"), - rs.getString("address"), - rs.getString("country_code"), - rs.getDouble("latitude_centroid"), - rs.getDouble("longitude_centroid"), - SignificantPlace.PlaceType.valueOf(rs.getString("type")), - rs.getBoolean("geocoded"), - rs.getLong("version")); } - private final RowMapper significantPlaceRowMapper; + private final RowMapper significantPlaceRowMapper = (rs, _) -> new SignificantPlace( + rs.getLong("id"), + rs.getString("name"), + rs.getString("address"), + rs.getString("country_code"), + rs.getDouble("latitude_centroid"), + rs.getDouble("longitude_centroid"), + SignificantPlace.PlaceType.valueOf(rs.getString("type")), + rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null, + rs.getBoolean("geocoded"), + rs.getLong("version")); public Page findByUser(User user, PageRequest pageable) { String countSql = "SELECT COUNT(*) FROM significant_places WHERE user_id = ?"; Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, user.getId()); - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.geocoded, sp.version" + + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" + " FROM significant_places sp " + "WHERE sp.user_id = ? ORDER BY sp.id " + "LIMIT ? OFFSET ? "; @@ -54,7 +55,7 @@ public class SignificantPlaceJdbcService { } public List findNearbyPlaces(Long userId, Point point, double distanceInMeters) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.geocoded, sp.version " + + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + "FROM significant_places sp " + "WHERE sp.user_id = ? " + "AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)"; @@ -72,12 +73,12 @@ public class SignificantPlaceJdbcService { place.getLongitudeCentroid(), this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()) ); - return place.withId(id); + return findById(id).orElseThrow(); } @CacheEvict(cacheNames = "significant-places", key = "#place.id") public SignificantPlace update(SignificantPlace place) { - String sql = "UPDATE significant_places SET name = ?, address = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), geocoded = ? WHERE id = ?"; + String sql = "UPDATE significant_places SET name = ?, address = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?"; jdbcTemplate.update(sql, place.getName(), place.getAddress(), @@ -86,15 +87,16 @@ public class SignificantPlaceJdbcService { place.getLatitudeCentroid(), place.getLongitudeCentroid(), this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), + place.getTimezone() != null ? place.getTimezone().getId() : null, place.isGeocoded(), place.getId() ); - return place; + return findById(place.getId()).orElseThrow(); } @Cacheable("significant-places") public Optional findById(Long id) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.geocoded, sp.version " + + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + "FROM significant_places sp " + "WHERE sp.id = ?"; List results = jdbcTemplate.query(sql, significantPlaceRowMapper, id); @@ -106,7 +108,7 @@ public class SignificantPlaceJdbcService { } public List findNonGeocodedByUser(User user) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.geocoded, sp.version " + + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + "FROM significant_places sp " + "WHERE sp.user_id = ? AND sp.geocoded = false " + "ORDER BY sp.id"; @@ -114,10 +116,19 @@ public class SignificantPlaceJdbcService { } public List findAllByUser(User user) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.geocoded, sp.version " + + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + "FROM significant_places sp " + "WHERE sp.user_id = ? " + "ORDER BY sp.id"; return jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId()); } + + public List findWithMissingTimezone() { + String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + + "FROM significant_places sp " + + "WHERE sp.timezone IS NULL " + + "ORDER BY sp.id"; + return jdbcTemplate.query(sql, significantPlaceRowMapper); + + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java index c5ac3088..463b0677 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.repository; import com.dedicatedcode.reitti.dto.LocationDataRequest; +import com.dedicatedcode.reitti.model.TimeDisplayMode; import com.dedicatedcode.reitti.model.UnitSystem; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; import java.time.Instant; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Comparator; import java.util.List; @@ -35,9 +37,10 @@ public class UserSettingsJdbcService { UnitSystem.valueOf(rs.getString("unit_system")), rs.getDouble("home_lat"), rs.getDouble("home_lng"), + rs.getString("time_zone_override") != null ? ZoneId.of(rs.getString("time_zone_override")) : null, + TimeDisplayMode.valueOf(rs.getString("time_display_mode")), newestData != null ? newestData.toInstant() : null, - rs.getLong("version") - ); + rs.getLong("version")); }; public Optional findByUserId(Long userId) { @@ -56,13 +59,15 @@ public class UserSettingsJdbcService { public UserSettings save(UserSettings userSettings) { if (userSettings.getVersion() == null) { // Insert new settings - this.jdbcTemplate.update("INSERT INTO user_settings (user_id, prefer_colored_map, selected_language, unit_system, home_lat, home_lng, latest_data, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + this.jdbcTemplate.update("INSERT INTO user_settings (user_id, prefer_colored_map, selected_language, unit_system, home_lat, home_lng, time_zone_override, time_display_mode, latest_data, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", userSettings.getUserId(), userSettings.isPreferColoredMap(), userSettings.getSelectedLanguage(), userSettings.getUnitSystem().name(), userSettings.getHomeLatitude(), userSettings.getHomeLongitude(), + userSettings.getTimeZoneOverride() != null ? userSettings.getTimeZoneOverride().getId() : null, + userSettings.getTimeDisplayMode().name(), userSettings.getLatestData() != null ? Timestamp.from(userSettings.getLatestData()) : null); return new UserSettings(userSettings.getUserId(), @@ -71,17 +76,20 @@ public class UserSettingsJdbcService { userSettings.getUnitSystem(), userSettings.getHomeLatitude(), userSettings.getHomeLongitude(), - userSettings.getLatestData(), - 1L); + userSettings.getTimeZoneOverride(), + userSettings.getTimeDisplayMode(), + userSettings.getLatestData(), 1L); } else { // Update existing settings jdbcTemplate.update( - "UPDATE user_settings SET prefer_colored_map = ?, selected_language = ?, unit_system = ?, home_lat = ?, home_lng = ?, latest_data = GREATEST(latest_data, ?), version = version + 1 WHERE user_id = ?", + "UPDATE user_settings SET prefer_colored_map = ?, selected_language = ?, unit_system = ?, home_lat = ?, home_lng = ?, time_zone_override = ?, time_display_mode = ?, latest_data = GREATEST(latest_data, ?), version = version + 1 WHERE user_id = ?", userSettings.isPreferColoredMap(), userSettings.getSelectedLanguage(), userSettings.getUnitSystem().name(), userSettings.getHomeLatitude(), userSettings.getHomeLongitude(), + userSettings.getTimeZoneOverride() != null ? userSettings.getTimeZoneOverride().getId() : null, + userSettings.getTimeDisplayMode().name(), userSettings.getLatestData() != null ? Timestamp.from(userSettings.getLatestData()) : null, userSettings.getUserId() ); diff --git a/src/main/java/com/dedicatedcode/reitti/service/GeoLocationTimezoneService.java b/src/main/java/com/dedicatedcode/reitti/service/GeoLocationTimezoneService.java new file mode 100644 index 00000000..b171702f --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/GeoLocationTimezoneService.java @@ -0,0 +1,42 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; +import jakarta.annotation.PostConstruct; +import net.iakovlev.timeshape.TimeZoneEngine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +@Service +public class GeoLocationTimezoneService { + private static final Logger log = LoggerFactory.getLogger(GeoLocationTimezoneService.class); + private final TimeZoneEngine engine; + private final SignificantPlaceJdbcService significantPlaceJdbcService; + + public GeoLocationTimezoneService(SignificantPlaceJdbcService significantPlaceJdbcService) { + this.significantPlaceJdbcService = significantPlaceJdbcService; + this.engine = TimeZoneEngine.initialize(); + } + + @PostConstruct + public void init() { + List places = significantPlaceJdbcService.findWithMissingTimezone(); + log.info("Searching for SignificantPlaces without Timezone data. Found [{}]", places.size()); + places.forEach(place -> { + Optional zoneId = engine.query(place.getLatitudeCentroid(), place.getLongitudeCentroid()); + zoneId.ifPresent(id -> { + log.debug("Zone ID [{}] found in for [{}]", id, place); + this.significantPlaceJdbcService.update(place.withTimezone(id)); + }); + }); + } + + public Optional getTimezone(SignificantPlace place) { + return this.engine.query(place.getLatitudeCentroid(), place.getLongitudeCentroid()); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java b/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java index 31bf97b0..5f8bf326 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java @@ -70,7 +70,7 @@ public class MessageDispatcherService { @RabbitListener(queues = RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, concurrency = "${reitti.events.concurrency}") public void handleSignificantPlaceCreated(SignificantPlaceCreatedEvent event) { - logger.debug("Dispatching SignificantPlaceCreatedEvent for place: {}", event.getPlaceId()); + logger.debug("Dispatching SignificantPlaceCreatedEvent for place: {}", event.placeId()); reverseGeocodingListener.handleSignificantPlaceCreated(event); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java b/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java index 5f59878e..ce4ffc74 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java @@ -52,16 +52,14 @@ public class TimelineService { public List buildTimelineEntries(User user, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay) { // Get processed visits and trips for the user and date range - List processedVisits = processedVisitJdbcService.findByUserAndTimeOverlap( - user, startOfDay, endOfDay); - List trips = tripJdbcService.findByUserAndTimeOverlap( - user, startOfDay, endOfDay); + List processedVisits = processedVisitJdbcService.findByUserAndTimeOverlap(user, startOfDay, endOfDay); + List trips = tripJdbcService.findByUserAndTimeOverlap(user, startOfDay, endOfDay); // Get user settings for unit system and connected accounts UserSettings userSettings = userSettingsJdbcService.findByUserId(user.getId()) .orElse(UserSettings.defaultSettings(user.getId())); try { - return buildTimelineEntries(user, processedVisits, trips, userTimeZone, selectedDate, userSettings.getUnitSystem()); + return buildTimelineEntries(user, processedVisits, trips, userTimeZone, selectedDate, userSettings); } catch (JsonProcessingException e) { log.error("Unable to build timeline entries.", e); return Collections.emptyList(); @@ -71,7 +69,7 @@ public class TimelineService { /** * Build timeline entries from processed visits and trips */ - private List buildTimelineEntries(User user, List processedVisits, List trips, ZoneId timezone, LocalDate selectedDate, UnitSystem unitSystem) throws JsonProcessingException { + private List buildTimelineEntries(User user, List processedVisits, List trips, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings) throws JsonProcessingException { List entries = new ArrayList<>(); // Add processed visits to timeline @@ -83,8 +81,11 @@ public class TimelineService { entry.setType(TimelineEntry.Type.VISIT); entry.setPlace(place); entry.setStartTime(visit.getStartTime()); + entry.setStartTimezone(visit.getPlace().getTimezone()); entry.setEndTime(visit.getEndTime()); + entry.setEndTimezone(visit.getPlace().getTimezone()); entry.setFormattedTimeRange(formatTimeRange(visit.getStartTime(), visit.getEndTime(), timezone, selectedDate)); + entry.setFormattedLocalTimeRange(formatTimeRange(visit.getStartTime(), visit.getEndTime(), visit.getPlace().getTimezone(), selectedDate)); entry.setFormattedDuration(formatDuration(visit.getStartTime(), visit.getEndTime())); entries.add(entry); } @@ -96,9 +97,12 @@ public class TimelineService { entry.setId("trip-" + trip.getId()); entry.setType(TimelineEntry.Type.TRIP); entry.setStartTime(trip.getStartTime()); + entry.setStartTimezone(trip.getStartVisit().getPlace().getTimezone()); entry.setEndTime(trip.getEndTime()); + entry.setEndTimezone(trip.getEndVisit().getPlace().getTimezone()); entry.setFormattedTimeRange(formatTimeRange(trip.getStartTime(), trip.getEndTime(), timezone, selectedDate)); entry.setFormattedDuration(formatDuration(trip.getStartTime(), trip.getEndTime())); + entry.setFormattedLocalTimeRange(formatTimeRange(trip.getStartTime(), trip.getEndTime(), trip.getStartVisit().getPlace().getTimezone(), trip.getEndVisit().getPlace().getTimezone(), selectedDate)); List path = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, trip.getStartTime(), trip.getEndTime()); List pathPoints = new ArrayList<>(); @@ -109,10 +113,10 @@ public class TimelineService { entry.setPath(objectMapper.writeValueAsString(pathPoints)); if (trip.getTravelledDistanceMeters() != null) { entry.setDistanceMeters(trip.getTravelledDistanceMeters()); - entry.setFormattedDistance(formatDistance(trip.getTravelledDistanceMeters(), unitSystem)); + entry.setFormattedDistance(formatDistance(trip.getTravelledDistanceMeters(), userSettings.getUnitSystem())); } else if (trip.getEstimatedDistanceMeters() != null) { entry.setDistanceMeters(trip.getEstimatedDistanceMeters()); - entry.setFormattedDistance(formatDistance(trip.getEstimatedDistanceMeters(), unitSystem)); + entry.setFormattedDistance(formatDistance(trip.getEstimatedDistanceMeters(), userSettings.getUnitSystem())); } if (trip.getTransportModeInferred() != null) { @@ -128,36 +132,37 @@ public class TimelineService { return entries; } - - /** - * Format time range for display - */ - private String formatTimeRange(Instant startTime, Instant endTime, ZoneId timezone, LocalDate selectedDate) { + private String formatTimeRange(Instant startTime, Instant endTime, ZoneId startTimezone, ZoneId endTimezone, LocalDate selectedDate) { DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("MMM d HH:mm"); - LocalDate startDate = startTime.atZone(timezone).toLocalDate(); - LocalDate endDate = endTime.atZone(timezone).toLocalDate(); - + LocalDate startDate = startTime.atZone(startTimezone).toLocalDate(); + LocalDate endDate = endTime.atZone(endTimezone).toLocalDate(); + LocalDate selectedDateInStartTimezone = selectedDate.atTime(10,0).atZone(startTimezone).toLocalDate(); + LocalDate selectedDateInEndTimezone = selectedDate.atTime(10,0).atZone(endTimezone).toLocalDate(); String start, end; // If start time is not on the selected date, show date + time - if (!startDate.equals(selectedDate)) { - start = startTime.atZone(timezone).format(dateTimeFormatter); + if (!startDate.equals(selectedDateInStartTimezone)) { + start = startTime.atZone(startTimezone).format(dateTimeFormatter); } else { - start = startTime.atZone(timezone).format(timeFormatter); + start = startTime.atZone(startTimezone).format(timeFormatter); } // If end time is not on the selected date, show date + time - if (!endDate.equals(selectedDate)) { - end = endTime.atZone(timezone).format(dateTimeFormatter); + if (!endDate.equals(selectedDateInEndTimezone)) { + end = endTime.atZone(endTimezone).format(dateTimeFormatter); } else { - end = endTime.atZone(timezone).format(timeFormatter); + end = endTime.atZone(endTimezone).format(timeFormatter); } return start + " - " + end; } + private String formatTimeRange(Instant startTime, Instant endTime, ZoneId timezone, LocalDate selectedDate) { + return formatTimeRange(startTime, endTime, timezone, timezone, selectedDate); + } + /** * Format duration for display (this is a simple implementation, you might want to use HumanizeDuration) */ diff --git a/src/main/java/com/dedicatedcode/reitti/service/geocoding/ReverseGeocodingListener.java b/src/main/java/com/dedicatedcode/reitti/service/geocoding/ReverseGeocodingListener.java index 7ac58d50..1c315444 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/geocoding/ReverseGeocodingListener.java +++ b/src/main/java/com/dedicatedcode/reitti/service/geocoding/ReverseGeocodingListener.java @@ -25,11 +25,11 @@ public class ReverseGeocodingListener { } public void handleSignificantPlaceCreated(SignificantPlaceCreatedEvent event) { - logger.info("Received SignificantPlaceCreatedEvent for place ID: {}", event.getPlaceId()); + logger.info("Received SignificantPlaceCreatedEvent for place ID: {}", event.placeId()); - Optional placeOptional = significantPlaceJdbcService.findById(event.getPlaceId()); + Optional placeOptional = significantPlaceJdbcService.findById(event.placeId()); if (placeOptional.isEmpty()) { - logger.error("Could not find SignificantPlace with ID: {}", event.getPlaceId()); + logger.error("Could not find SignificantPlace with ID: {}", event.placeId()); return; } @@ -48,12 +48,11 @@ public class ReverseGeocodingListener { SignificantPlace.PlaceType placeType = result.placeType(); String countryCode = result.countryCode(); + String address = String.format("%s %s, %s %s", street, houseNumber, postcode, city); if (!label.isEmpty()) { - place = place.withName(label) - .withAddress(String.format("%s %s, %s %s", street, houseNumber, postcode, city)); + place = place.withName(label).withAddress(address); } else { - place = place.withName(street) - .withAddress(String.format("%s %s, %s %s", street, houseNumber, postcode, city)); + place = place.withName(street).withAddress(address); } place = place.withType(placeType).withCountryCode(countryCode); diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java index 08e2f269..791b343c 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java @@ -7,6 +7,7 @@ import com.dedicatedcode.reitti.event.VisitUpdatedEvent; import com.dedicatedcode.reitti.model.geo.*; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.*; +import com.dedicatedcode.reitti.service.GeoLocationTimezoneService; import com.dedicatedcode.reitti.service.UserNotificationService; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; @@ -20,6 +21,7 @@ import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; @@ -39,6 +41,7 @@ public class VisitMergingService { private final GeometryFactory geometryFactory; private final RabbitTemplate rabbitTemplate; private final UserNotificationService userNotificationService; + private final GeoLocationTimezoneService timezoneService; private final long mergeThresholdSeconds; private final long mergeThresholdMeters; private final int searchRangeExtensionInHours; @@ -52,6 +55,7 @@ public class VisitMergingService { RawLocationPointJdbcService rawLocationPointJdbcService, GeometryFactory geometryFactory, UserNotificationService userNotificationService, + GeoLocationTimezoneService timezoneService, @Value("${reitti.visit.merge-max-stay-search-extension-days:2}") int maxStaySearchExtensionInDays, @Value("${reitti.visit.merge-threshold-seconds:300}") long mergeThresholdSeconds, @Value("${reitti.visit.merge-threshold-meters:100}") long mergeThresholdMeters) { @@ -63,6 +67,7 @@ public class VisitMergingService { this.rawLocationPointJdbcService = rawLocationPointJdbcService; this.geometryFactory = geometryFactory; this.userNotificationService = userNotificationService; + this.timezoneService = timezoneService; this.mergeThresholdSeconds = mergeThresholdSeconds; this.mergeThresholdMeters = mergeThresholdMeters; this.searchRangeExtensionInHours = maxStaySearchExtensionInDays * 24; @@ -218,9 +223,11 @@ public class VisitMergingService { } private SignificantPlace createSignificantPlace(User user, Visit visit) { - Point point = geometryFactory.createPoint(new Coordinate(visit.getLongitude(), visit.getLatitude())); - SignificantPlace significantPlace = SignificantPlace.create(visit.getLatitude(), visit.getLongitude()); + Optional timezone = this.timezoneService.getTimezone(significantPlace); + if (timezone.isPresent()) { + significantPlace = significantPlace.withTimezone(timezone.get()); + } significantPlace = this.significantPlaceJdbcService.create(user, significantPlace); publishSignificantPlaceCreatedEvent(significantPlace); return significantPlace; diff --git a/src/main/resources/db/migration/V40__add_local_timezone_column.sql b/src/main/resources/db/migration/V40__add_local_timezone_column.sql new file mode 100644 index 00000000..44d48ba3 --- /dev/null +++ b/src/main/resources/db/migration/V40__add_local_timezone_column.sql @@ -0,0 +1 @@ +ALTER TABLE significant_places ADD COLUMN timezone TEXT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V41__extend_user_settings.sql b/src/main/resources/db/migration/V41__extend_user_settings.sql new file mode 100644 index 00000000..248f79ea --- /dev/null +++ b/src/main/resources/db/migration/V41__extend_user_settings.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_settings + ADD COLUMN time_zone_override TEXT NULL, + ADD COLUMN time_display_mode VARCHAR(255) NOT NULL DEFAULT 'DEFAULT' \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index ddb520fb..f6eda30b 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -103,6 +103,20 @@ users.oidc.managed.message=This user is managed by an external OIDC provider. Se users.oidc.view.profile=View external profile users.avatar.oidc.managed=Avatar is managed by your OIDC provider and will be updated automatically. +time.title=Time +time.display.mode.label=Time Display Mode +time.display.mode.default=Default +time.display.mode.geo.local=Geo Local +time.display.mode.description=Choose how times are displayed throughout the application. +time.display.mode.default.description=Default: All times are displayed in your timezone (from browser or timezone override below) +time.display.mode.geo.local.description=Geo Local: All times are displayed in the timezone where the location is +time.timezone.override.label=Timezone Override +time.timezone.override.none=Use browser timezone +time.timezone.override.description=Override your timezone instead of using the browser's detected timezone. This affects how times are displayed when using Default mode. + +timeline.time.your=Your time +timeline.time.local=Local time + form.remove=Remove users.home.location.label=Home Location diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties index 34390c86..fe231fdd 100644 --- a/src/main/resources/messages_de.properties +++ b/src/main/resources/messages_de.properties @@ -110,6 +110,19 @@ users.home.longitude.placeholder=L\u00E4ngengrad eingeben (-180 bis 180) users.home.location.clear=L\u00F6schen +time.title=Zeit +time.display.mode.label=Zeitanzeige-Modus +time.display.mode.default=Standard +time.display.mode.geo.local=Geo-Lokal +time.display.mode.description=W\u00E4hlen Sie, wie Zeiten in der Anwendung angezeigt werden. +time.display.mode.default.description=Standard: Alle Zeiten werden in Ihrer Zeitzone angezeigt (vom Browser oder der Zeitzone-\u00DCberschreibung unten) +time.display.mode.geo.local.description=Geo-Lokal: Alle Zeiten werden in der Zeitzone angezeigt, in der sich der Ort befindet +time.timezone.override.label=Zeitzone \u00FCberschreiben +time.timezone.override.none=Browser-Zeitzone verwenden +time.timezone.override.description=\u00DCberschreiben Sie Ihre Zeitzone anstatt die vom Browser erkannte Zeitzone zu verwenden. Dies beeinflusst, wie Zeiten im Standard-Modus angezeigt werden. + +timeline.time.your=Ihre Zeit +timeline.time.local=Ortszeit # Avatar users.avatar.label=Profilbild users.avatar.upload=Bild ausw\u00E4hlen diff --git a/src/main/resources/messages_fi.properties b/src/main/resources/messages_fi.properties index e5d29afd..270104b6 100644 --- a/src/main/resources/messages_fi.properties +++ b/src/main/resources/messages_fi.properties @@ -99,6 +99,21 @@ users.role.label=Rooli users.role.admin=Yll\u00E4pit\u00E4j\u00E4 users.role.user=K\u00E4ytt\u00E4j\u00E4 users.delete.confirm=Oletko varma, ett\u00E4 haluat poistaa t\u00E4m\u00E4n k\u00E4ytt\u00E4j\u00E4n? T\u00E4m\u00E4 poistaa kaikki heid\u00E4n tietonsa. + +time.title=Aika +time.display.mode.label=Ajan n\u00E4ytt\u00F6tapa +time.display.mode.default=Oletus +time.display.mode.geo.local=Geo-paikallinen +time.display.mode.description=Valitse, miten ajat n\u00E4ytet\u00E4\u00E4n sovelluksessa. +time.display.mode.default.description=Oletus: Kaikki ajat n\u00E4ytet\u00E4\u00E4n aikavy\u00F6hykkeess\u00E4si (selaimesta tai aikavy\u00F6hykkeen ohituksesta alla) +time.display.mode.geo.local.description=Geo-paikallinen: Kaikki ajat n\u00E4ytet\u00E4\u00E4n siin\u00E4 aikavy\u00F6hykkeess\u00E4, jossa sijainti on +time.timezone.override.label=Aikavy\u00F6hykkeen ohitus +time.timezone.override.none=K\u00E4yt\u00E4 selaimen aikavy\u00F6hykett\u00E4 +time.timezone.override.description=Ohita aikavy\u00F6hykkeesi sen sijaan, ett\u00E4 k\u00E4ytt\u00E4isit selaimen havaitsemaa aikavy\u00F6hykett\u00E4. T\u00E4m\u00E4 vaikuttaa siihen, miten ajat n\u00E4ytet\u00E4\u00E4n Oletus-tilassa. + +timeline.time.your=Sinun aikasi +timeline.time.local=Paikallinen aika + form.remove=Poista # Avatar diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties index 4de1af75..08c509f5 100644 --- a/src/main/resources/messages_fr.properties +++ b/src/main/resources/messages_fr.properties @@ -99,8 +99,22 @@ users.role.label=R\u00F4le users.role.admin=Administrateur users.role.user=Utilisateur users.delete.confirm=\u00CAtes-vous s\u00FBr de vouloir supprimer cet utilisateur ? Cela supprimera toutes leurs donn\u00E9es. + +time.title=Heure +time.display.mode.label=Mode d'affichage de l'heure +time.display.mode.default=Par d\u00E9faut +time.display.mode.geo.local=G\u00E9o-local +time.display.mode.description=Choisissez comment les heures sont affich\u00E9es dans l'application. +time.display.mode.default.description=Par d\u00E9faut : Toutes les heures sont affich\u00E9es dans votre fuseau horaire (du navigateur ou du remplacement de fuseau horaire ci-dessous) +time.display.mode.geo.local.description=G\u00E9o-local : Toutes les heures sont affich\u00E9es dans le fuseau horaire o\u00F9 se trouve l'emplacement +time.timezone.override.label=Remplacement du fuseau horaire +time.timezone.override.none=Utiliser le fuseau horaire du navigateur +time.timezone.override.description=Remplacez votre fuseau horaire au lieu d'utiliser le fuseau horaire d\u00E9tect\u00E9 par le navigateur. Cela affecte la fa\u00E7on dont les heures sont affich\u00E9es en mode Par d\u00E9faut. + form.remove=Supprimer +timeline.time.your=Votre heure +timeline.time.local=Heure locale # Avatar users.avatar.label=Photo de profil diff --git a/src/main/resources/static/js/photo-client.js b/src/main/resources/static/js/photo-client.js index 37963e90..7bba1d16 100644 --- a/src/main/resources/static/js/photo-client.js +++ b/src/main/resources/static/js/photo-client.js @@ -9,6 +9,7 @@ class PhotoClient { /** * Update photos for the selected date * @param {string} date - Date in YYYY-MM-DD format + * @param timezone */ async updatePhotosForDate(date, timezone) { this.currentDate = date; diff --git a/src/main/resources/templates/fragments/timeline.html b/src/main/resources/templates/fragments/timeline.html index 6a497a9c..30dcb29f 100644 --- a/src/main/resources/templates/fragments/timeline.html +++ b/src/main/resources/templates/fragments/timeline.html @@ -87,7 +87,12 @@
- 09:00 - 10:30 + 09:00 - 10:30 + 09:00 - 10:30
diff --git a/src/main/resources/templates/fragments/user-management.html b/src/main/resources/templates/fragments/user-management.html index 7f9e5ca5..832dc227 100644 --- a/src/main/resources/templates/fragments/user-management.html +++ b/src/main/resources/templates/fragments/user-management.html @@ -214,6 +214,39 @@
+
+

Time

+ +
+ +
+ +
+

Choose how times are displayed throughout the application.

+
    +
  • Default: All times are displayed in your timezone (from browser or timezone override below)
  • +
  • Geo Local: All times are displayed in the timezone where the location is
  • +
+
+ +
+ +
+ +
+

Override your timezone instead of using the browser's detected timezone. This affects how times are displayed when using Default mode.

+
+
+