192 feature request all dates and times are utc UI user settings to show in local time in area (#225)

This commit is contained in:
Daniel Graf
2025-09-08 14:58:27 +02:00
committed by GitHub
parent 39f3ddeb7a
commit 4e65288aac
33 changed files with 526 additions and 152 deletions

View File

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

View File

@@ -90,6 +90,11 @@
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>net.iakovlev</groupId>
<artifactId>timeshape</artifactId>
<version>2025b.26</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.dedicatedcode.reitti.model;
public enum TimeDisplayMode {
DEFAULT,
GEO_LOCAL
}

View File

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

View File

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

View File

@@ -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<SignificantPlace> significantPlaceRowMapper;
private final RowMapper<SignificantPlace> 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<SignificantPlace> 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<SignificantPlace> 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<SignificantPlace> 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<SignificantPlace> results = jdbcTemplate.query(sql, significantPlaceRowMapper, id);
@@ -106,7 +108,7 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> 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<SignificantPlace> 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<SignificantPlace> 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);
}
}

View File

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

View File

@@ -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<SignificantPlace> places = significantPlaceJdbcService.findWithMissingTimezone();
log.info("Searching for SignificantPlaces without Timezone data. Found [{}]", places.size());
places.forEach(place -> {
Optional<ZoneId> 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<ZoneId> getTimezone(SignificantPlace place) {
return this.engine.query(place.getLatitudeCentroid(), place.getLongitudeCentroid());
}
}

View File

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

View File

@@ -52,16 +52,14 @@ public class TimelineService {
public List<TimelineEntry> buildTimelineEntries(User user, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay) {
// Get processed visits and trips for the user and date range
List<ProcessedVisit> processedVisits = processedVisitJdbcService.findByUserAndTimeOverlap(
user, startOfDay, endOfDay);
List<Trip> trips = tripJdbcService.findByUserAndTimeOverlap(
user, startOfDay, endOfDay);
List<ProcessedVisit> processedVisits = processedVisitJdbcService.findByUserAndTimeOverlap(user, startOfDay, endOfDay);
List<Trip> 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<TimelineEntry> buildTimelineEntries(User user, List<ProcessedVisit> processedVisits, List<Trip> trips, ZoneId timezone, LocalDate selectedDate, UnitSystem unitSystem) throws JsonProcessingException {
private List<TimelineEntry> buildTimelineEntries(User user, List<ProcessedVisit> processedVisits, List<Trip> trips, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings) throws JsonProcessingException {
List<TimelineEntry> 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<RawLocationPoint> path = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, trip.getStartTime(), trip.getEndTime());
List<PointInfo> 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)
*/

View File

@@ -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<SignificantPlace> placeOptional = significantPlaceJdbcService.findById(event.getPlaceId());
Optional<SignificantPlace> 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);

View File

@@ -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<ZoneId> timezone = this.timezoneService.getTimezone(significantPlace);
if (timezone.isPresent()) {
significantPlace = significantPlace.withTimezone(timezone.get());
}
significantPlace = this.significantPlaceJdbcService.create(user, significantPlace);
publishSignificantPlaceCreatedEvent(significantPlace);
return significantPlace;

View File

@@ -0,0 +1 @@
ALTER TABLE significant_places ADD COLUMN timezone TEXT NULL;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,12 @@
<!-- Time -->
<div class="entry-time">
<span th:text="${entry.formattedTimeRange}">09:00 - 10:30</span>
<span th:if="${timeDisplayMode.name() == 'GEO_LOCAL'}"
th:text="${entry.formattedLocalTimeRange}"
th:title="|#{timeline.time.your}: ${entry.formattedTimeRange}|">09:00 - 10:30</span>
<span th:if="${timeDisplayMode.name() == 'DEFAULT'}"
th:text="${entry.formattedTimeRange}"
th:title="|#{timeline.time.local}: ${entry.formattedLocalTimeRange}|">09:00 - 10:30</span>
</div>
</div>
</div>

View File

@@ -214,6 +214,39 @@
</div>
</div>
<div class="separator"></div>
<div class="form-group">
<h3 th:text="#{time.title}">Time</h3>
<div class="form-group">
<label for="timeDisplayMode" th:text="#{time.display.mode.label}">Time Display Mode</label>
<div class="select">
<select id="timeDisplayMode" name="time_display_mode">
<option value="DEFAULT" th:selected="${timeDisplayMode == 'DEFAULT' || timeDisplayMode == null}" th:text="#{time.display.mode.default}">Default</option>
<option value="GEO_LOCAL" th:selected="${timeDisplayMode == 'GEO_LOCAL'}" th:text="#{time.display.mode.geo.local}">Geo Local</option>
</select>
</div>
<p class="form-description" th:text="#{time.display.mode.description}">Choose how times are displayed throughout the application.</p>
<ul class="form-description">
<li th:text="#{time.display.mode.default.description}">Default: All times are displayed in your timezone (from browser or timezone override below)</li>
<li th:text="#{time.display.mode.geo.local.description}">Geo Local: All times are displayed in the timezone where the location is</li>
</ul>
</div>
<div class="form-group">
<label for="timezoneOverride" th:text="#{time.timezone.override.label}">Timezone Override</label>
<div class="select">
<select id="timezoneOverride" name="timezone_override">
<option value="" th:text="#{time.timezone.override.none}">Use browser timezone</option>
<option th:each="timezone : ${availableTimezones}"
th:value="${timezone}"
th:text="${timezone}"
th:selected="${timeZoneOverride != null && timeZoneOverride.toString() == timezone}"></option>
</select>
</div>
<p class="form-description" th:text="#{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.</p>
</div>
</div>
<div class="separator"></div>
<div class="form-group">
<label class="checkbox-container">
<input type="checkbox" name="preferColoredMap" th:checked="${preferColoredMap}" class="checkbox-input">

View File

@@ -119,9 +119,13 @@
return new Date().toISOString().split('T')[0];
}
}
// Helper function for HTMX to get user timezone
function getUserTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
if (window.userSettings.timezoneOverride) {
return window.userSettings.timezoneOverride;
} else {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
function selectUser(userHeader) {
@@ -174,8 +178,7 @@
}
// Set date picker to URL date or today's date
const today = new Date();
const formattedDate = initialDate || today.toISOString().split('T')[0]; // YYYY-MM-DD format
const formattedDate = initialDate || new Date().toISOString().split('T')[0];
// Function to update URL with date parameter
function updateUrlWithDate(date) {

View File

@@ -1,7 +1,9 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
@@ -12,6 +14,7 @@ import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -127,6 +130,7 @@ class SignificantPlaceJdbcServiceTest {
53.863149,
10.700927,
SignificantPlace.PlaceType.RESTAURANT,
ZoneId.systemDefault(),
true,
created.getVersion()
);
@@ -139,6 +143,7 @@ class SignificantPlaceJdbcServiceTest {
assertThat(result.getAddress()).isEqualTo("Updated Address");
assertThat(result.getCountryCode()).isEqualTo("DE");
assertThat(result.getType()).isEqualTo(SignificantPlace.PlaceType.RESTAURANT);
assertThat(result.getTimezone()).isEqualTo(ZoneId.systemDefault());
assertThat(result.isGeocoded()).isTrue();
}
@@ -209,6 +214,7 @@ class SignificantPlaceJdbcServiceTest {
created1.getLatitudeCentroid(),
created1.getLongitudeCentroid(),
SignificantPlace.PlaceType.HOME,
ZoneId.systemDefault(),
true, // geocoded = true
created1.getVersion()
);
@@ -262,7 +268,8 @@ class SignificantPlaceJdbcServiceTest {
latitude,
longitude,
SignificantPlace.PlaceType.OTHER,
false,
ZoneId.systemDefault()
, false,
0L
);
}
@@ -276,6 +283,7 @@ class SignificantPlaceJdbcServiceTest {
latitude,
longitude,
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
false,
0L
);

View File

@@ -3,6 +3,7 @@ package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
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;
@@ -59,7 +60,7 @@ public class UserSettingsJdbcServiceTest {
@Test
void save_WhenCreatingNewUserSettings_ShouldInsertAndReturnWithId() {
UserSettings newSettings = new UserSettings(testUserId1, true, "fi", UnitSystem.METRIC, 60.1699, 24.9384, Instant.now());
UserSettings newSettings = new UserSettings(testUserId1, true, "fi", UnitSystem.METRIC, 60.1699, 24.9384, null, TimeDisplayMode.DEFAULT, Instant.now(), null);
UserSettings savedSettings = userSettingsJdbcService.save(newSettings);
@@ -74,7 +75,7 @@ public class UserSettingsJdbcServiceTest {
@Test
void save_WhenUpdatingExistingUserSettings_ShouldUpdateAndIncrementVersion() {
// Create initial settings
UserSettings initialSettings = new UserSettings(testUserId1, false, "en", UnitSystem.METRIC, null, null, Instant.now());
UserSettings initialSettings = new UserSettings(testUserId1, false, "en", UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, Instant.now(), null);
UserSettings savedSettings = userSettingsJdbcService.save(initialSettings);
// Update settings
@@ -85,9 +86,9 @@ public class UserSettingsJdbcServiceTest {
UnitSystem.IMPERIAL,
52.5200,
13.4050,
Instant.now(),
savedSettings.getVersion()
);
null,
TimeDisplayMode.DEFAULT,
Instant.now(), savedSettings.getVersion());
UserSettings result = userSettingsJdbcService.save(updatedSettings);
@@ -103,7 +104,7 @@ public class UserSettingsJdbcServiceTest {
@Test
void findByUserId_WhenUserSettingsExist_ShouldReturnSettings() {
// Create settings
UserSettings newSettings = new UserSettings(testUserId1, true, "fr", UnitSystem.METRIC, 48.8566, 2.3522, Instant.now());
UserSettings newSettings = new UserSettings(testUserId1, true, "fr", UnitSystem.METRIC, 48.8566, 2.3522, null, TimeDisplayMode.DEFAULT, Instant.now(), null);
userSettingsJdbcService.save(newSettings);
Optional<UserSettings> result = userSettingsJdbcService.findByUserId(testUserId1);
@@ -136,7 +137,7 @@ public class UserSettingsJdbcServiceTest {
@Test
void getOrCreateDefaultSettings_WhenUserSettingsExist_ShouldReturnExisting() {
// Create existing settings
UserSettings existingSettings = new UserSettings(testUserId1, true, "fi", UnitSystem.METRIC, 60.1699, 24.9384, Instant.now());
UserSettings existingSettings = new UserSettings(testUserId1, true, "fi", UnitSystem.METRIC, 60.1699, 24.9384, null, TimeDisplayMode.DEFAULT, Instant.now(), null);
userSettingsJdbcService.save(existingSettings);
UserSettings result = userSettingsJdbcService.getOrCreateDefaultSettings(testUserId1);

View File

@@ -0,0 +1,136 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.ZoneId;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class GeoLocationTimezoneServiceTest {
@InjectMocks
private GeoLocationTimezoneService geoLocationTimezoneService;
@BeforeEach
void setUp() {
// Initialize the service if needed
}
@Test
void testTimezoneForNewYorkCity() {
// New York City, USA - Eastern Time
SignificantPlace newYork = SignificantPlace.create(40.7128, -74.0060);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(newYork);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("America/New_York"), timezone.get());
}
@Test
void testTimezoneForLondon() {
// London, UK - Greenwich Mean Time
SignificantPlace london = SignificantPlace.create(51.5074, -0.1278);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(london);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("Europe/London"), timezone.get());
}
@Test
void testTimezoneForTokyo() {
// Tokyo, Japan - Japan Standard Time
SignificantPlace tokyo = SignificantPlace.create(35.6762, 139.6503);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(tokyo);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("Asia/Tokyo"), timezone.get());
}
@Test
void testTimezoneForSydney() {
// Sydney, Australia - Australian Eastern Time
SignificantPlace sydney = SignificantPlace.create(-33.8688, 151.2093);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(sydney);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("Australia/Sydney"), timezone.get());
}
@Test
void testTimezoneForSaoPaulo() {
// São Paulo, Brazil - Brasília Time
SignificantPlace saoPaulo = SignificantPlace.create(-23.5505, -46.6333);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(saoPaulo);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("America/Sao_Paulo"), timezone.get());
}
@Test
void testTimezoneForAntarctica() {
// McMurdo Station, Antarctica - Multiple timezones possible
// This location can have different timezone interpretations
SignificantPlace mcmurdo = SignificantPlace.create(-77.8419, 166.6863);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(mcmurdo);
assertTrue(timezone.isPresent());
// Antarctica/McMurdo is linked to Pacific/Auckland
assertTrue(timezone.get().equals(ZoneId.of("Antarctica/McMurdo")) ||
timezone.get().equals(ZoneId.of("Pacific/Auckland")));
}
@Test
void testTimezoneForNorthPole() {
// North Pole - Ambiguous timezone area
SignificantPlace northPole = SignificantPlace.create(90.0, 0.0);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(northPole);
// At the North Pole, timezone is ambiguous, but service should return something
assertTrue(timezone.isPresent());
}
@Test
void testTimezoneForInternationalDateLine() {
// Location near International Date Line - Fiji
SignificantPlace fiji = SignificantPlace.create(-18.1248, 178.4501);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(fiji);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("Pacific/Fiji"), timezone.get());
}
@Test
void testTimezoneForEquator() {
// Location on the Equator - Quito, Ecuador
SignificantPlace quito = SignificantPlace.create(-0.1807, -78.4678);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(quito);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("America/Guayaquil"), timezone.get());
}
@Test
void testTimezoneForPrimeMeridian() {
// Location on Prime Meridian - Greenwich, London
SignificantPlace greenwich = SignificantPlace.create(51.4769, 0.0005);
Optional<ZoneId> timezone = geoLocationTimezoneService.getTimezone(greenwich);
assertTrue(timezone.isPresent());
assertEquals(ZoneId.of("Europe/London"), timezone.get());
}
}