mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-10 09:57:57 -05:00
336 allow editing the transport mode of a trip and offer more options (#352)
This commit is contained in:
@@ -4,14 +4,14 @@ import com.dedicatedcode.reitti.dto.TimelineData;
|
||||
import com.dedicatedcode.reitti.dto.TimelineEntry;
|
||||
import com.dedicatedcode.reitti.dto.UserTimelineData;
|
||||
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
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.repository.UserSharingJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.*;
|
||||
import com.dedicatedcode.reitti.service.AvatarService;
|
||||
import com.dedicatedcode.reitti.service.TimelineService;
|
||||
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
|
||||
import com.dedicatedcode.reitti.service.processing.TransportModeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -38,6 +38,8 @@ public class TimelineController {
|
||||
private final UserSharingJdbcService userSharingJdbcService;
|
||||
private final TimelineService timelineService;
|
||||
private final UserSettingsJdbcService userSettingsJdbcService;
|
||||
private final TransportModeService transportModeService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
|
||||
@Autowired
|
||||
public TimelineController(SignificantPlaceJdbcService placeService,
|
||||
@@ -45,7 +47,9 @@ public class TimelineController {
|
||||
AvatarService avatarService,
|
||||
ReittiIntegrationService reittiIntegrationService, UserSharingJdbcService userSharingJdbcService,
|
||||
TimelineService timelineService,
|
||||
UserSettingsJdbcService userSettingsJdbcService) {
|
||||
UserSettingsJdbcService userSettingsJdbcService,
|
||||
TransportModeService transportModeService,
|
||||
TripJdbcService tripJdbcService) {
|
||||
this.placeService = placeService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.avatarService = avatarService;
|
||||
@@ -53,6 +57,8 @@ public class TimelineController {
|
||||
this.userSharingJdbcService = userSharingJdbcService;
|
||||
this.timelineService = timelineService;
|
||||
this.userSettingsJdbcService = userSettingsJdbcService;
|
||||
this.transportModeService = transportModeService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
}
|
||||
|
||||
@GetMapping("/content/range")
|
||||
@@ -115,6 +121,48 @@ public class TimelineController {
|
||||
return "fragments/place-edit :: view-mode";
|
||||
}
|
||||
|
||||
@GetMapping("/trips/edit-form/{id}")
|
||||
public String getTripEditForm(@PathVariable Long id,
|
||||
Model model) {
|
||||
Trip trip = tripJdbcService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
model.addAttribute("tripId", id);
|
||||
model.addAttribute("transportMode", trip.getTransportModeInferred());
|
||||
model.addAttribute("availableTransportModes", Arrays.stream(TransportMode.values()).filter(t -> t != TransportMode.UNKNOWN).toList());
|
||||
return "fragments/trip-edit :: edit-form";
|
||||
}
|
||||
|
||||
@PutMapping("/trips/{id}/transport-mode")
|
||||
public String updateTripTransportMode(@PathVariable Long id,
|
||||
@RequestParam String transportMode,
|
||||
Authentication principal,
|
||||
Model model) {
|
||||
// Find the user by username
|
||||
User user = userJdbcService.findByUsername(principal.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||
|
||||
Trip trip = tripJdbcService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
try {
|
||||
TransportMode mode = TransportMode.valueOf(transportMode);
|
||||
tripJdbcService.update(trip.withTransportMode(mode));
|
||||
transportModeService.overrideTransportMode(user, mode, trip);
|
||||
|
||||
model.addAttribute("tripId", id);
|
||||
model.addAttribute("transportMode", mode);
|
||||
return "fragments/trip-edit :: view-mode";
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid transport mode");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/trips/view/{id}")
|
||||
public String getTripView(@PathVariable Long id, Model model) {
|
||||
Trip trip = tripJdbcService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
model.addAttribute("tripId", id);
|
||||
model.addAttribute("transportMode", trip.getTransportModeInferred());
|
||||
model.addAttribute("availableTransportModes", Arrays.stream(TransportMode.values()).filter(t -> t != TransportMode.UNKNOWN).toList());
|
||||
return "fragments/trip-edit :: view-mode";
|
||||
}
|
||||
|
||||
private String getTimelineContent(String date,
|
||||
String timezone,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.dedicatedcode.reitti.controller.settings;
|
||||
|
||||
import com.dedicatedcode.reitti.model.Role;
|
||||
import com.dedicatedcode.reitti.model.UnitSystem;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportModeConfig;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSettings;
|
||||
import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TransportModeJdbcService;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/settings/transportation-modes")
|
||||
public class TransportationModesController {
|
||||
|
||||
private final TransportModeJdbcService transportModeJdbcService;
|
||||
private final UserSettingsJdbcService userSettingsJdbcService;
|
||||
|
||||
public TransportationModesController(TransportModeJdbcService transportModeJdbcService,
|
||||
UserSettingsJdbcService userSettingsJdbcService) {
|
||||
this.transportModeJdbcService = transportModeJdbcService;
|
||||
this.userSettingsJdbcService = userSettingsJdbcService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String transportationModes(@AuthenticationPrincipal User user, Model model) {
|
||||
UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
List<TransportMode> availableModes = getAvailableModesToAdd(configs);
|
||||
|
||||
model.addAttribute("configs", configs);
|
||||
model.addAttribute("availableModes", availableModes);
|
||||
model.addAttribute("activeSection", "transportation-modes");
|
||||
model.addAttribute("dataManagementEnabled", true);
|
||||
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
|
||||
model.addAttribute("unitSystem", userSettings.getUnitSystem());
|
||||
model.addAttribute("isImperial", userSettings.getUnitSystem() == UnitSystem.IMPERIAL);
|
||||
|
||||
return "settings/transportation-modes";
|
||||
}
|
||||
|
||||
@PostMapping("/add")
|
||||
public String addTransportMode(@AuthenticationPrincipal User user,
|
||||
@RequestParam TransportMode mode,
|
||||
@RequestParam(required = false) Double maxSpeed,
|
||||
@RequestParam(required = false) UnitSystem unitSystem,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
|
||||
// Check if mode already exists
|
||||
boolean exists = configs.stream().anyMatch(config -> config.mode() == mode);
|
||||
if (exists) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "transportation.modes.error.already.exists");
|
||||
return "redirect:/settings/transportation-modes";
|
||||
}
|
||||
|
||||
// Convert to km/h if input was in mph
|
||||
Double maxKmh;
|
||||
if (maxSpeed != null && unitSystem == UnitSystem.IMPERIAL) {
|
||||
maxKmh = mphToKmh(maxSpeed);
|
||||
} else {
|
||||
maxKmh = maxSpeed;
|
||||
}
|
||||
|
||||
// Check for duplicate maxKmh values (only if maxKmh is not null)
|
||||
boolean duplicateMaxKmh = configs.stream()
|
||||
.anyMatch(config -> Objects.equals(config.maxKmh(), maxKmh));
|
||||
if (duplicateMaxKmh) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "transportation.modes.error.duplicate.max.kmh");
|
||||
return "redirect:/settings/transportation-modes";
|
||||
}
|
||||
|
||||
configs.add(new TransportModeConfig(mode, maxKmh));
|
||||
transportModeJdbcService.setTransportModeConfigs(user, configs);
|
||||
|
||||
redirectAttributes.addFlashAttribute("successMessage", "transportation.modes.success.added");
|
||||
} catch (Exception e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "transportation.modes.error.add");
|
||||
}
|
||||
|
||||
return "redirect:/settings/transportation-modes";
|
||||
}
|
||||
|
||||
@PostMapping("/{mode}/update")
|
||||
public String updateTransportMode(@AuthenticationPrincipal User user,
|
||||
@PathVariable TransportMode mode,
|
||||
@RequestParam(required = false) Double maxSpeed,
|
||||
@RequestParam(required = false) UnitSystem unitSystem,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
|
||||
// Convert to km/h if input was in mph
|
||||
Double maxKmh;
|
||||
if (maxSpeed != null && unitSystem == UnitSystem.IMPERIAL) {
|
||||
maxKmh = mphToKmh(maxSpeed);
|
||||
} else {
|
||||
maxKmh = maxSpeed;
|
||||
}
|
||||
|
||||
List<TransportModeConfig> updatedConfigs = configs.stream()
|
||||
.map(config -> config.mode() == mode ? new TransportModeConfig(mode, maxKmh) : config)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
transportModeJdbcService.setTransportModeConfigs(user, updatedConfigs);
|
||||
|
||||
redirectAttributes.addFlashAttribute("successMessage", "transportation.modes.success.updated");
|
||||
} catch (Exception e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "transportation.modes.error.update");
|
||||
}
|
||||
|
||||
return "redirect:/settings/transportation-modes";
|
||||
}
|
||||
|
||||
@PostMapping("/{mode}/delete")
|
||||
public String deleteTransportMode(@AuthenticationPrincipal User user,
|
||||
@PathVariable TransportMode mode,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
|
||||
List<TransportModeConfig> filteredConfigs = configs.stream()
|
||||
.filter(config -> config.mode() != mode)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
transportModeJdbcService.setTransportModeConfigs(user, filteredConfigs);
|
||||
|
||||
redirectAttributes.addFlashAttribute("successMessage", "transportation.modes.success.deleted");
|
||||
} catch (Exception e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "transportation.modes.error.delete");
|
||||
}
|
||||
|
||||
return "redirect:/settings/transportation-modes";
|
||||
}
|
||||
|
||||
@PostMapping("/content")
|
||||
public String getTransportationModesContent(@AuthenticationPrincipal User user, Model model) {
|
||||
UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
List<TransportMode> availableModes = getAvailableModesToAdd(configs);
|
||||
|
||||
model.addAttribute("configs", configs);
|
||||
model.addAttribute("availableModes", availableModes);
|
||||
model.addAttribute("unitSystem", userSettings.getUnitSystem());
|
||||
model.addAttribute("isImperial", userSettings.getUnitSystem() == UnitSystem.IMPERIAL);
|
||||
|
||||
return "settings/transportation-modes :: transportation-modes-content";
|
||||
}
|
||||
|
||||
private List<TransportMode> getAvailableModesToAdd(List<TransportModeConfig> configs) {
|
||||
List<TransportMode> usedModes = configs.stream()
|
||||
.map(TransportModeConfig::mode)
|
||||
.toList();
|
||||
|
||||
return Arrays.stream(TransportMode.values())
|
||||
.filter(mode -> !usedModes.contains(mode))
|
||||
.filter(mode -> mode != TransportMode.UNKNOWN)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Double mphToKmh(Double mph) {
|
||||
return mph * 1.60934;
|
||||
}
|
||||
|
||||
private Double kmhToMph(Double kmh) {
|
||||
return kmh / 1.60934;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.dto;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
@@ -9,9 +10,12 @@ import java.time.ZoneId;
|
||||
* Inner class to represent timeline entries for the template
|
||||
*/
|
||||
public class TimelineEntry {
|
||||
public enum Type {VISIT, TRIP}
|
||||
|
||||
|
||||
public enum Type {VISIT, TRIP;}
|
||||
|
||||
private String id;
|
||||
private Long resourceId;
|
||||
private Type type;
|
||||
private SignificantPlace place;
|
||||
private String path;
|
||||
@@ -24,7 +28,7 @@ public class TimelineEntry {
|
||||
private String formattedDuration;
|
||||
private Double distanceMeters;
|
||||
private String formattedDistance;
|
||||
private String transportMode;
|
||||
private TransportMode transportMode;
|
||||
|
||||
// Getters and setters
|
||||
public String getId() {
|
||||
@@ -35,6 +39,14 @@ public class TimelineEntry {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getResourceId() {
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
public void setResourceId(Long resourceId) {
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
@@ -123,11 +135,11 @@ public class TimelineEntry {
|
||||
this.formattedDistance = formattedDistance;
|
||||
}
|
||||
|
||||
public String getTransportMode() {
|
||||
public TransportMode getTransportMode() {
|
||||
return transportMode;
|
||||
}
|
||||
|
||||
public void setTransportMode(String transportMode) {
|
||||
public void setTransportMode(TransportMode transportMode) {
|
||||
this.transportMode = transportMode;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.dedicatedcode.reitti.model.geo;
|
||||
|
||||
public enum TransportMode {
|
||||
WALKING, CYCLING, DRIVING, TRANSIT, UNKNOWN;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.dedicatedcode.reitti.model.geo;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public record TransportModeConfig(TransportMode mode, Double maxKmh) implements Serializable {
|
||||
|
||||
}
|
||||
@@ -11,16 +11,16 @@ public class Trip {
|
||||
private final Long durationSeconds;
|
||||
private final Double estimatedDistanceMeters;
|
||||
private final Double travelledDistanceMeters;
|
||||
private final String transportModeInferred;
|
||||
private final TransportMode transportModeInferred;
|
||||
private final ProcessedVisit startVisit;
|
||||
private final ProcessedVisit endVisit;
|
||||
private final Long version;
|
||||
|
||||
public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, String transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit) {
|
||||
public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit) {
|
||||
this(null, startTime, endTime, durationSeconds, estimatedDistanceMeters, travelledDistanceMeters, transportModeInferred, startVisit, endVisit, 1L);
|
||||
}
|
||||
|
||||
public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, String transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Long version) {
|
||||
public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Long version) {
|
||||
this.id = id;
|
||||
this.startTime = startTime;
|
||||
this.endTime = endTime;
|
||||
@@ -57,7 +57,7 @@ public class Trip {
|
||||
return travelledDistanceMeters;
|
||||
}
|
||||
|
||||
public String getTransportModeInferred() {
|
||||
public TransportMode getTransportModeInferred() {
|
||||
return transportModeInferred;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,14 @@ public class Trip {
|
||||
return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, this.version);
|
||||
}
|
||||
|
||||
public Trip withTransportMode(TransportMode mode) {
|
||||
return new Trip(this.id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, mode, this.startVisit, this.endVisit, this.version);
|
||||
}
|
||||
|
||||
public Trip withVersion(long version) {
|
||||
return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
@@ -88,4 +96,5 @@ public class Trip {
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -39,7 +40,7 @@ public class PreviewTripJdbcService {
|
||||
rs.getLong("duration_seconds"),
|
||||
rs.getDouble("estimated_distance_meters"),
|
||||
rs.getDouble("travelled_distance_meters"),
|
||||
rs.getString("transport_mode_inferred"),
|
||||
TransportMode.valueOf(rs.getString("transport_mode_inferred")),
|
||||
startVisit,
|
||||
endVisit,
|
||||
rs.getLong("version")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportModeConfig;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class TransportModeJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public TransportModeJdbcService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Cacheable(value = "transport-mode-configs", key = "#user.id")
|
||||
public List<TransportModeConfig> getTransportModeConfigs(User user) {
|
||||
String sql = """
|
||||
SELECT transport_mode, max_kmh
|
||||
FROM transport_mode_detection_configs
|
||||
WHERE user_id = ?
|
||||
ORDER BY max_kmh NULLS LAST
|
||||
""";
|
||||
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
TransportMode mode = TransportMode.valueOf(rs.getString("transport_mode"));
|
||||
Double maxKmh = Optional.ofNullable(rs.getObject("max_kmh")).map(BigDecimal.class::cast).map(BigDecimal::doubleValue).orElse(null);
|
||||
return new TransportModeConfig(mode, maxKmh);
|
||||
}, user.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "transport-mode-configs", key = "#user.id")
|
||||
public void setTransportModeConfigs(User user, List<TransportModeConfig> configs) {
|
||||
String deleteSql = "DELETE FROM transport_mode_detection_configs WHERE user_id = ?";
|
||||
jdbcTemplate.update(deleteSql, user.getId());
|
||||
|
||||
String insertSql = """
|
||||
INSERT INTO transport_mode_detection_configs (user_id, transport_mode, max_kmh)
|
||||
VALUES (?, ?, ?)
|
||||
""";
|
||||
|
||||
for (TransportModeConfig config : configs) {
|
||||
Double maxKmh = config.maxKmh();
|
||||
jdbcTemplate.update(insertSql, user.getId(), config.mode().name(), maxKmh);
|
||||
}
|
||||
}
|
||||
|
||||
@CacheEvict(value = "transport-mode-configs", key = "#user.id")
|
||||
public void deleteAllForUser(User user) {
|
||||
this.jdbcTemplate.update("DELETE FROM transport_mode_detection_configs WHERE user_id = ?", user.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class TransportModeOverrideJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public TransportModeOverrideJdbcService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "transport-mode-overrides", allEntries = true)
|
||||
public void addTransportModeOverride(User user, TransportMode transportMode, Instant start, Instant end) {
|
||||
Instant middleTime = Instant.ofEpochMilli((start.toEpochMilli() + end.toEpochMilli()) / 2);
|
||||
|
||||
// Delete any existing overrides for this user in the time range
|
||||
String deleteSql = """
|
||||
DELETE FROM transport_mode_overrides
|
||||
WHERE user_id = ?
|
||||
AND time BETWEEN ? AND ?
|
||||
""";
|
||||
jdbcTemplate.update(deleteSql, user.getId(), Timestamp.from(start), Timestamp.from(end));
|
||||
|
||||
// Insert the new override
|
||||
String insertSql = """
|
||||
INSERT INTO transport_mode_overrides (user_id, time, transport_mode)
|
||||
VALUES (?, ?, ?)
|
||||
""";
|
||||
jdbcTemplate.update(insertSql, user.getId(), Timestamp.from(middleTime), transportMode.name());
|
||||
}
|
||||
|
||||
@Cacheable(value = "transport-mode-overrides", key = "#user.id + '_' + #start.toEpochMilli() + '_' + #end.toEpochMilli()")
|
||||
public Optional<TransportMode> getTransportModeOverride(User user, Instant start, Instant end) {
|
||||
String sql = """
|
||||
SELECT transport_mode
|
||||
FROM transport_mode_overrides
|
||||
WHERE user_id = ?
|
||||
AND time BETWEEN ? AND ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
return TransportMode.valueOf(rs.getString("transport_mode"));
|
||||
}, user.getId(), Timestamp.from(start), Timestamp.from(end))
|
||||
.stream()
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "transport-mode-overrides", allEntries = true)
|
||||
public void deleteAllTransportModeOverrides(User user) {
|
||||
String deleteSql = "DELETE FROM transport_mode_overrides WHERE user_id = ?";
|
||||
jdbcTemplate.update(deleteSql, user.getId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -40,7 +41,7 @@ public class TripJdbcService {
|
||||
rs.getLong("duration_seconds"),
|
||||
rs.getDouble("estimated_distance_meters"),
|
||||
rs.getDouble("travelled_distance_meters"),
|
||||
rs.getString("transport_mode_inferred"),
|
||||
TransportMode.valueOf(rs.getString("transport_mode_inferred")),
|
||||
startVisit,
|
||||
endVisit,
|
||||
rs.getLong("version")
|
||||
@@ -134,7 +135,7 @@ public class TripJdbcService {
|
||||
Timestamp.from(trip.getEndTime()),
|
||||
trip.getDurationSeconds(),
|
||||
trip.getTravelledDistanceMeters(),
|
||||
trip.getTransportModeInferred(),
|
||||
trip.getTransportModeInferred().name(),
|
||||
trip.getStartVisit() != null ? trip.getStartVisit().getId() : null,
|
||||
trip.getEndVisit() != null ? trip.getEndVisit().getId() : null
|
||||
);
|
||||
@@ -142,18 +143,19 @@ public class TripJdbcService {
|
||||
}
|
||||
|
||||
public Trip update(Trip trip) {
|
||||
String sql = "UPDATE trips SET start_time = ?, end_time = ?, duration_seconds = ?, travelled_distance_meters = ?, transport_mode_inferred = ?, start_place_id = ?, end_place_id = ?, start_visit_id = ?, end_visit_id = ?, version = ? WHERE id = ?";
|
||||
String sql = "UPDATE trips SET start_time = ?, end_time = ?, duration_seconds = ?, travelled_distance_meters = ?, transport_mode_inferred = ?, start_visit_id = ?, end_visit_id = ?, version = ? WHERE id = ?";
|
||||
jdbcTemplate.update(sql,
|
||||
Timestamp.from(trip.getStartTime()),
|
||||
Timestamp.from(trip.getEndTime()),
|
||||
trip.getDurationSeconds(),
|
||||
trip.getTravelledDistanceMeters(),
|
||||
trip.getTransportModeInferred(),
|
||||
trip.getTransportModeInferred().name(),
|
||||
trip.getStartVisit() != null ? trip.getStartVisit().getId() : null,
|
||||
trip.getEndVisit() != null ? trip.getEndVisit().getId() : null,
|
||||
trip.getVersion() + 1,
|
||||
trip.getId()
|
||||
);
|
||||
return trip;
|
||||
return trip.withVersion(trip.getVersion() + 1);
|
||||
}
|
||||
|
||||
public Optional<Trip> findById(Long id) {
|
||||
@@ -185,7 +187,7 @@ public class TripJdbcService {
|
||||
trip.getDurationSeconds(),
|
||||
trip.getEstimatedDistanceMeters(),
|
||||
trip.getTravelledDistanceMeters(),
|
||||
trip.getTransportModeInferred(),
|
||||
trip.getTransportModeInferred().name(),
|
||||
trip.getVersion()
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -95,6 +95,7 @@ public class TimelineService {
|
||||
if (place != null) {
|
||||
TimelineEntry entry = new TimelineEntry();
|
||||
entry.setId("visit-" + visit.getId());
|
||||
entry.setResourceId(visit.getId());
|
||||
entry.setType(TimelineEntry.Type.VISIT);
|
||||
entry.setPlace(place);
|
||||
entry.setStartTime(visit.getStartTime());
|
||||
@@ -112,6 +113,7 @@ public class TimelineService {
|
||||
for (Trip trip : trips) {
|
||||
TimelineEntry entry = new TimelineEntry();
|
||||
entry.setId("trip-" + trip.getId());
|
||||
entry.setResourceId(trip.getId());
|
||||
entry.setType(TimelineEntry.Type.TRIP);
|
||||
entry.setStartTime(trip.getStartTime());
|
||||
entry.setStartTimezone(trip.getStartVisit().getPlace().getTimezone());
|
||||
|
||||
@@ -3,22 +3,27 @@ package com.dedicatedcode.reitti.service;
|
||||
import com.dedicatedcode.reitti.model.Role;
|
||||
import com.dedicatedcode.reitti.model.TimeDisplayMode;
|
||||
import com.dedicatedcode.reitti.model.UnitSystem;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportModeConfig;
|
||||
import com.dedicatedcode.reitti.model.processing.DetectionParameter;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSettings;
|
||||
import com.dedicatedcode.reitti.repository.*;
|
||||
import com.dedicatedcode.reitti.repository.TransportModeJdbcService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserJdbcService userJdbcService;
|
||||
private final UserSettingsJdbcService userSettingsJdbcService;
|
||||
private final VisitDetectionParametersJdbcService visitDetectionParametersJdbcService;
|
||||
private final TransportModeJdbcService transportModeJdbcService;
|
||||
private final RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
private final VisitJdbcService visitJdbcService;
|
||||
private final SignificantPlaceJdbcService significantPlaceJdbcService;
|
||||
@@ -27,10 +32,21 @@ public class UserService {
|
||||
private final ApiTokenJdbcService apiTokenJdbcService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserJdbcService userJdbcService, UserSettingsJdbcService userSettingsJdbcService, VisitDetectionParametersJdbcService visitDetectionParametersJdbcService, RawLocationPointJdbcService rawLocationPointJdbcService, VisitJdbcService visitJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, ApiTokenJdbcService apiTokenJdbcService, PasswordEncoder passwordEncoder) {
|
||||
public UserService(UserJdbcService userJdbcService,
|
||||
UserSettingsJdbcService userSettingsJdbcService,
|
||||
VisitDetectionParametersJdbcService visitDetectionParametersJdbcService,
|
||||
TransportModeJdbcService transportModeJdbcService,
|
||||
RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
VisitJdbcService visitJdbcService,
|
||||
SignificantPlaceJdbcService significantPlaceJdbcService,
|
||||
ProcessedVisitJdbcService processedVisitJdbcService,
|
||||
GeocodingResponseJdbcService geocodingResponseJdbcService,
|
||||
ApiTokenJdbcService apiTokenJdbcService,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.userSettingsJdbcService = userSettingsJdbcService;
|
||||
this.visitDetectionParametersJdbcService = visitDetectionParametersJdbcService;
|
||||
this.transportModeJdbcService = transportModeJdbcService;
|
||||
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
|
||||
this.visitJdbcService = visitJdbcService;
|
||||
this.significantPlaceJdbcService = significantPlaceJdbcService;
|
||||
@@ -50,6 +66,7 @@ public class UserService {
|
||||
.withExternalId(externalId)
|
||||
.withProfileUrl(profileUrl));
|
||||
saveDefaultVisitDetectionParameters(createdUser);
|
||||
saveDefaultTransportationModeDetectionParameters(createdUser);
|
||||
|
||||
return createdUser;
|
||||
|
||||
@@ -83,10 +100,21 @@ public class UserService {
|
||||
|
||||
|
||||
saveDefaultVisitDetectionParameters(createdUser);
|
||||
saveDefaultTransportationModeDetectionParameters(createdUser);
|
||||
userSettingsJdbcService.save(userSettings);
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
private void saveDefaultTransportationModeDetectionParameters(User createdUser) {
|
||||
this.transportModeJdbcService.setTransportModeConfigs(createdUser,
|
||||
List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 7.0),
|
||||
new TransportModeConfig(TransportMode.CYCLING, 20.0),
|
||||
new TransportModeConfig(TransportMode.DRIVING, 120.0),
|
||||
new TransportModeConfig(TransportMode.TRANSIT, null)
|
||||
));
|
||||
}
|
||||
|
||||
private void saveDefaultVisitDetectionParameters(User createdUser) {
|
||||
visitDetectionParametersJdbcService.saveConfiguration(createdUser, new DetectionParameter(null,
|
||||
new DetectionParameter.VisitDetection(100, 5, 300, 330),
|
||||
@@ -99,6 +127,7 @@ public class UserService {
|
||||
@Transactional
|
||||
public void deleteUser(User user) {
|
||||
this.visitDetectionParametersJdbcService.findAllConfigurationsForUser(user).forEach(detectionParameter -> this.visitDetectionParametersJdbcService.delete(detectionParameter.getId()));
|
||||
this.transportModeJdbcService.deleteAllForUser(user);
|
||||
this.userSettingsJdbcService.deleteFor(user);
|
||||
this.geocodingResponseJdbcService.deleteAllForUser(user);
|
||||
this.processedVisitJdbcService.deleteAllForUser(user);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.dedicatedcode.reitti.service.processing;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.TransportModeJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TransportModeOverrideJdbcService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TransportModeService {
|
||||
|
||||
private final TransportModeJdbcService transportModeJdbcService;
|
||||
private final TransportModeOverrideJdbcService transportModeOverrideJdbcService;
|
||||
|
||||
public TransportModeService(TransportModeJdbcService transportModeJdbcService,
|
||||
TransportModeOverrideJdbcService transportModeOverrideJdbcService) {
|
||||
this.transportModeJdbcService = transportModeJdbcService;
|
||||
this.transportModeOverrideJdbcService = transportModeOverrideJdbcService;
|
||||
}
|
||||
|
||||
public TransportMode inferTransportMode(User user, List<RawLocationPoint> tripPoints, Instant startTime, Instant endTime) {
|
||||
|
||||
Optional<TransportMode> override = this.transportModeOverrideJdbcService.getTransportModeOverride(user, startTime, endTime);
|
||||
if (override.isPresent()) {
|
||||
return override.get();
|
||||
}
|
||||
List<TransportModeConfig> config = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
return segmentAndClassifyTrip(tripPoints, config);
|
||||
}
|
||||
|
||||
public void overrideTransportMode(User user, TransportMode transportMode, Trip trip) {
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(user, transportMode, trip.getStartTime(), trip.getEndTime());
|
||||
}
|
||||
|
||||
public TransportMode segmentAndClassifyTrip(List<RawLocationPoint> points, List<TransportModeConfig> configs) {
|
||||
List<TripSegment> segments = new ArrayList<>();
|
||||
List<Double> speeds = calculateSpeeds(points); // Speeds between points
|
||||
|
||||
List<RawLocationPoint> currentSegmentPoints = new ArrayList<>();
|
||||
currentSegmentPoints.add(points.getFirst());
|
||||
|
||||
for (int i = 1; i < points.size(); i++) {
|
||||
double prevSpeed = (i > 1) ? speeds.get(i - 2) : 0; // Speed to previous point
|
||||
double currSpeed = speeds.get(i - 1); // Speed from current to next
|
||||
|
||||
if (prevSpeed > 0 && Math.abs(currSpeed - prevSpeed) / prevSpeed > 0.5) {
|
||||
TransportMode mode = classifySegment(currentSegmentPoints, configs);
|
||||
segments.add(new TripSegment(new ArrayList<>(currentSegmentPoints), mode));
|
||||
currentSegmentPoints.clear();
|
||||
}
|
||||
currentSegmentPoints.add(points.get(i));
|
||||
}
|
||||
|
||||
// Add the last segment
|
||||
TransportMode mode = classifySegment(currentSegmentPoints, configs);
|
||||
segments.add(new TripSegment(currentSegmentPoints, mode));
|
||||
|
||||
// Calculate duration in minutes for each transport mode and return the one with the most minutes
|
||||
Map<TransportMode, Double> modeDurationMinutes = new HashMap<>();
|
||||
|
||||
for (TripSegment segment : segments) {
|
||||
if (segment.points().size() < 2) continue;
|
||||
|
||||
Instant startTime = segment.points().getFirst().getTimestamp();
|
||||
Instant endTime = segment.points().getLast().getTimestamp();
|
||||
double durationMinutes = Duration.between(startTime, endTime).toMillis() / (1000.0 * 60.0);
|
||||
|
||||
modeDurationMinutes.merge(segment.dominantMode(), durationMinutes, Double::sum);
|
||||
}
|
||||
|
||||
return modeDurationMinutes.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse(TransportMode.UNKNOWN);
|
||||
}
|
||||
|
||||
private List<Double> calculateSpeeds(List<RawLocationPoint> points) {
|
||||
List<Double> speeds = new ArrayList<>();
|
||||
for (int i = 1; i < points.size(); i++) {
|
||||
double distanceKm = GeoUtils.distanceInMeters(points.get(i - 1), points.get(i)) / 1000.0;
|
||||
Duration timeDiff = Duration.between(points.get(i - 1).getTimestamp(), points.get(i).getTimestamp());
|
||||
double timeHours = timeDiff.toMillis() / (1000.0 * 3600.0);
|
||||
double speedKmH = timeHours > 0 ? distanceKm / timeHours : 0;
|
||||
speeds.add(speedKmH);
|
||||
}
|
||||
return speeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a segment based on average speed (simple thresholds).
|
||||
* Customize thresholds or add more modes/logic as needed.
|
||||
*/
|
||||
private TransportMode classifySegment(List<RawLocationPoint> segmentPoints, List<TransportModeConfig> configs) {
|
||||
List<Double> speeds = calculateSpeeds(segmentPoints);
|
||||
double avgSpeed = speeds.stream().mapToDouble(Double::doubleValue).average().orElse(0.0);
|
||||
for (TransportModeConfig transportModeConfig : configs) {
|
||||
if (transportModeConfig.maxKmh() == null || transportModeConfig.maxKmh() > avgSpeed) {
|
||||
return transportModeConfig.mode();
|
||||
}
|
||||
}
|
||||
return TransportMode.UNKNOWN;
|
||||
}
|
||||
|
||||
public record TripSegment(List<RawLocationPoint> points, TransportMode dominantMode) {
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class TripDetectionService {
|
||||
private final PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final PreviewTripJdbcService previewTripJdbcService;
|
||||
private final TransportModeService transportModeService;
|
||||
private final UserJdbcService userJdbcService;
|
||||
private final UserNotificationService userNotificationService;
|
||||
private final ConcurrentHashMap<String, ReentrantLock> userLocks = new ConcurrentHashMap<>();
|
||||
@@ -37,7 +38,7 @@ public class TripDetectionService {
|
||||
RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService,
|
||||
TripJdbcService tripJdbcService,
|
||||
PreviewTripJdbcService previewTripJdbcService,
|
||||
PreviewTripJdbcService previewTripJdbcService, TransportModeService transportModeService,
|
||||
UserJdbcService userJdbcService,
|
||||
UserNotificationService userNotificationService) {
|
||||
this.processedVisitJdbcService = processedVisitJdbcService;
|
||||
@@ -46,6 +47,7 @@ public class TripDetectionService {
|
||||
this.previewRawLocationPointJdbcService = previewRawLocationPointJdbcService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.previewTripJdbcService = previewTripJdbcService;
|
||||
this.transportModeService = transportModeService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.userNotificationService = userNotificationService;
|
||||
}
|
||||
@@ -156,7 +158,7 @@ public class TripDetectionService {
|
||||
double estimatedDistanceInMeters = calculateDistanceBetweenPlaces(startVisit.getPlace(), endVisit.getPlace());
|
||||
double travelledDistanceMeters = GeoUtils.calculateTripDistance(tripPoints);
|
||||
// Create a new trip
|
||||
String transportMode = inferTransportMode(travelledDistanceMeters != 0 ? travelledDistanceMeters : estimatedDistanceInMeters, tripStartTime, tripEndTime);
|
||||
TransportMode transportMode = this.transportModeService.inferTransportMode(user, tripPoints, tripStartTime, tripEndTime);
|
||||
Trip trip = new Trip(
|
||||
tripStartTime,
|
||||
tripEndTime,
|
||||
@@ -179,32 +181,4 @@ public class TripDetectionService {
|
||||
place1.getLatitudeCentroid(), place1.getLongitudeCentroid(),
|
||||
place2.getLatitudeCentroid(), place2.getLongitudeCentroid());
|
||||
}
|
||||
|
||||
private String inferTransportMode(double distanceMeters, Instant startTime, Instant endTime) {
|
||||
// Calculate duration in seconds
|
||||
long durationSeconds = endTime.getEpochSecond() - startTime.getEpochSecond();
|
||||
|
||||
// Avoid division by zero
|
||||
if (durationSeconds <= 0) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
// Calculate speed in meters per second
|
||||
double speedMps = distanceMeters / durationSeconds;
|
||||
|
||||
// Convert to km/h for easier interpretation
|
||||
double speedKmh = speedMps * 3.6;
|
||||
|
||||
// Simple transport mode inference based on average speed
|
||||
if (speedKmh < 7) {
|
||||
return "WALKING";
|
||||
} else if (speedKmh < 20) {
|
||||
return "CYCLING";
|
||||
} else if (speedKmh < 120) {
|
||||
return "DRIVING";
|
||||
} else {
|
||||
return "TRANSIT"; // High-speed transit like train
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ spring.rabbitmq.listener.simple.prefetch=10
|
||||
# to RabbitMQ, which then triggers the dead-letter-exchange (DLX) policy on the queue.
|
||||
spring.rabbitmq.listener.simple.default-requeue-rejected=false
|
||||
|
||||
spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations
|
||||
spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations,transport-mode-configs
|
||||
spring.cache.redis.time-to-live=1d
|
||||
|
||||
# Upload configuration
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE transport_mode_detection_configs (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
transport_mode VARCHAR(255) NOT NULL,
|
||||
max_kmh DECIMAL,
|
||||
PRIMARY KEY (user_id, transport_mode),
|
||||
CONSTRAINT transport_mode_detection_configs_unique_max_kmh_per_user UNIQUE NULLS NOT DISTINCT (user_id, max_kmh)
|
||||
);
|
||||
|
||||
CREATE TABLE transport_mode_overrides (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
time TIMESTAMP NOT NULL,
|
||||
transport_mode VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO transport_mode_detection_configs(user_id, transport_mode, max_kmh) SELECT id, 'WALKING', 7.0 FROM users;
|
||||
INSERT INTO transport_mode_detection_configs(user_id, transport_mode, max_kmh) SELECT id, 'CYCLING', 20.0 FROM users;
|
||||
INSERT INTO transport_mode_detection_configs(user_id, transport_mode, max_kmh) SELECT id, 'DRIVING', 120.0 FROM users;
|
||||
INSERT INTO transport_mode_detection_configs(user_id, transport_mode, max_kmh) SELECT id, 'TRANSIT', NULL FROM users;
|
||||
@@ -18,9 +18,18 @@ timeline.duration=Duration
|
||||
timeline.distance=Distance
|
||||
timeline.trip=Trip
|
||||
timeline.visit=Visit
|
||||
timeline.transport.walking=by foot
|
||||
timeline.transport.cycling=by bike
|
||||
timeline.transport.driving=by car
|
||||
|
||||
timeline.trip.transport.select=Select a transport mode
|
||||
|
||||
transportation.mode.WALKING.name=Walking
|
||||
transportation.mode.CYCLING.name=Cycling
|
||||
transportation.mode.DRIVING.name=Driving
|
||||
transportation.mode.TRANSIT.name=Transit
|
||||
|
||||
timeline.transport.WALKING.label=by foot
|
||||
timeline.transport.CYCLING.label=by bike
|
||||
timeline.transport.DRIVING.label=by car
|
||||
timeline.transport.TRANSIT.label=by transit
|
||||
|
||||
# Date picker
|
||||
datepicker.today=Today
|
||||
@@ -49,6 +58,7 @@ settings.title=Settings
|
||||
settings.api.tokens=API Tokens
|
||||
settings.user.management=User Management
|
||||
settings.places=Places
|
||||
settings.transportation-modes=Transportation Modes
|
||||
settings.geocoding=Geocoding
|
||||
settings.integrations=Integrations
|
||||
settings.manage.data=Manage Data
|
||||
@@ -1041,6 +1051,7 @@ settings.api.tokens.description=Create and manage API tokens for external applic
|
||||
settings.share.access.description=Create magic links to share your location data with others
|
||||
settings.user.management.description=Manage user accounts and permissions (admin only)
|
||||
settings.places.description=View and manage your significant places and their details
|
||||
settings.transportation-modes.description=View and manage your settings for transportation mode detection
|
||||
settings.geocoding.description=Configure geocoding services to convert coordinates to addresses
|
||||
settings.manage.data.description=Manually trigger data processing and manage your location data
|
||||
settings.integrations.description=Connect external services and mobile apps to automatically import location data
|
||||
@@ -1190,4 +1201,51 @@ memory.share.result.instructions.permissions=The link will work according to the
|
||||
memory.share.result.instructions.view=Recipients can view but not edit the memory
|
||||
memory.share.result.instructions.edit=Recipients can view and edit the memory
|
||||
memory.share.result.done=Done
|
||||
memory.share.result.another=Create Another Link
|
||||
memory.share.result.another=Create Another Link
|
||||
|
||||
|
||||
# Transportation Modes Page
|
||||
transportation.modes.title=Transportation Modes
|
||||
transportation.modes.all.configured=All available transportation modes have been configured.
|
||||
|
||||
# Table Headers
|
||||
transportation.modes.table.mode=Mode
|
||||
transportation.modes.table.max.kmh=Max Speed (km/h)
|
||||
transportation.modes.table.actions=Actions
|
||||
|
||||
# Add Form
|
||||
transportation.modes.add.title=Add Transportation Mode
|
||||
transportation.modes.mode.label=Transportation Mode
|
||||
transportation.modes.mode.select=Select a mode...
|
||||
transportation.modes.max.kmh.label=Max Speed (km/h)
|
||||
transportation.modes.max.placeholder=No limit
|
||||
transportation.modes.max.kmh.help=Leave empty for no speed limit
|
||||
transportation.modes.add.button=Add Mode
|
||||
|
||||
# Imperial unit labels
|
||||
transportation.modes.table.max.mph=Max Speed (mph)
|
||||
transportation.modes.max.mph.label=Max Speed (mph)
|
||||
transportation.modes.max.mph.placeholder=No limit
|
||||
transportation.modes.max.mph.help=Leave empty for no speed limit
|
||||
|
||||
# Transport Mode Names
|
||||
transportation.mode.walking=Walking
|
||||
transportation.mode.cycling=Cycling
|
||||
transportation.mode.driving=Driving
|
||||
transportation.mode.transit=Transit
|
||||
transportation.mode.unknown=Unknown
|
||||
|
||||
# Success Messages
|
||||
transportation.modes.success.added=Transportation mode added successfully
|
||||
transportation.modes.success.updated=Transportation mode updated successfully
|
||||
transportation.modes.success.deleted=Transportation mode deleted successfully
|
||||
|
||||
# Error Messages
|
||||
transportation.modes.error.already.exists=This transportation mode is already configured
|
||||
transportation.modes.error.add=Failed to add transportation mode
|
||||
transportation.modes.error.update=Failed to update transportation mode
|
||||
transportation.modes.error.delete=Failed to delete transportation mode
|
||||
transportation.modes.error.duplicate.max.kmh=A transportation mode with this max speed already exists
|
||||
|
||||
# Delete Confirmation
|
||||
transportation.modes.delete.confirm=Are you sure you want to delete this transportation mode?
|
||||
@@ -18,6 +18,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-entry.trip.active .edit-icon,
|
||||
.timeline-entry.active .place-name-container:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -32,9 +33,10 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.trip-transport-mode-container.editing,
|
||||
.place-name-container.editing {
|
||||
border-radius: 3px;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
float: left;
|
||||
min-width: 700px;
|
||||
}
|
||||
@@ -64,11 +66,9 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-edit-form select {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.trip-transport-mode-container.editing,
|
||||
.place-name-container.editing {
|
||||
border-radius: 3px;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -480,6 +480,7 @@ tr:hover {
|
||||
font-weight: lighter;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-entry.active {
|
||||
|
||||
@@ -60,7 +60,12 @@
|
||||
th:classappend="${activeSection == 'visit-sensitivity'} ? 'active' : ''"
|
||||
th:title="#{visit.sensitivity.title.description}"
|
||||
th:text="#{visit.sensitivity.title}">Visit Sensitivity</a>
|
||||
|
||||
<a href="/settings/transportation-modes"
|
||||
class="settings-nav-item"
|
||||
th:classappend="${activeSection == 'transportation-modes'} ? 'active' : ''"
|
||||
th:title="#{settings.transportation-modes.description}"
|
||||
th:text="#{settings.transportation-modes}">Transportion Modes</a>
|
||||
|
||||
<a href="/settings/geocode-services"
|
||||
class="settings-nav-item"
|
||||
th:classappend="${activeSection == 'geocode-services'} ? 'active' : ''"
|
||||
|
||||
@@ -46,60 +46,60 @@
|
||||
th:data-lng="${entry.place?.longitudeCentroid}"
|
||||
th:data-path="${entry.path}"
|
||||
th:data-user-id="${userData.userId}">
|
||||
<th:block th:fragment="timeline-entry">
|
||||
<!-- Entry Description -->
|
||||
<div class="entry-description">
|
||||
<!-- Visit with editable place name -->
|
||||
<div th:if="${entry.type.name() == 'VISIT'}" class="place-name-container">
|
||||
<span class="place-name" th:text="${entry.place?.name ?: 'Unknown Place'}">Place Name</span>
|
||||
<i class="lni lni-pencil-1 edit-icon"
|
||||
th:hx-get="@{/timeline/places/edit-form/{id}(id=${entry.place?.id}, date=${date}, timezone=${timezone})}"
|
||||
hx-target="closest .place-name-container"
|
||||
hx-swap="outerHTML"
|
||||
th:if="${entry.place?.id != null}"></i>
|
||||
</div>
|
||||
|
||||
<!-- Entry Description -->
|
||||
<div class="entry-description">
|
||||
<!-- Visit with editable place name -->
|
||||
<div th:if="${entry.type.name() == 'VISIT'}" class="place-name-container">
|
||||
<span class="place-name" th:text="${entry.place?.name ?: 'Unknown Place'}">Place Name</span>
|
||||
<i class="lni lni-pencil-1 edit-icon"
|
||||
th:hx-get="@{/timeline/places/edit-form/{id}(id=${entry.place?.id}, date=${date}, timezone=${timezone})}"
|
||||
hx-target="closest .place-name-container"
|
||||
hx-swap="outerHTML"
|
||||
th:if="${entry.place?.id != null}"></i>
|
||||
</div>
|
||||
|
||||
<!-- Trip description -->
|
||||
<span th:if="${entry.type.name() == 'TRIP'}" th:text="#{timeline.trip}">Trip</span>
|
||||
</div>
|
||||
<!-- Type -->
|
||||
<div class="entry-time" th:if="${entry.type.name() == 'VISIT'}">
|
||||
<span th:text="#{${entry.place.type.messageKey}}">Home</span>
|
||||
</div>
|
||||
<!-- Duration and Distance -->
|
||||
<span class="entry-duration">
|
||||
<span th:if="${entry.type.name() == 'VISIT'}">
|
||||
<span th:text="#{timeline.duration}">Duration</span>:
|
||||
<span th:text="${entry.formattedDuration}">1h 30m</span>
|
||||
</span>
|
||||
<span th:if="${entry.type.name() == 'TRIP'}">
|
||||
<span th:if="${entry.formattedDistance != null}">
|
||||
<span th:text="#{timeline.distance}">Distance</span>:
|
||||
<span th:text="${entry.formattedDistance}">5.2 km</span>
|
||||
</span>
|
||||
<span th:if="${entry.transportMode != null}">
|
||||
<span th:switch="${entry.transportMode}">
|
||||
<span th:case="'WALKING'" th:text="#{timeline.transport.walking}">by foot</span>
|
||||
<span th:case="'CYCLING'" th:text="#{timeline.transport.cycling}">by bike</span>
|
||||
<span th:case="'DRIVING'" th:text="#{timeline.transport.driving}">by car</span>
|
||||
<span th:case="*" th:text="${entry.transportMode}">unknown</span>
|
||||
<!-- Trip description -->
|
||||
<span th:if="${entry.type.name() == 'TRIP'}" th:text="#{timeline.trip}">Trip</span>
|
||||
</div>
|
||||
<!-- Type -->
|
||||
<div class="entry-time" th:if="${entry.type.name() == 'VISIT'}">
|
||||
<span th:text="#{${entry.place.type.messageKey}}">Home</span>
|
||||
</div>
|
||||
<!-- Duration and Distance -->
|
||||
<span class="entry-duration">
|
||||
<span th:if="${entry.type.name() == 'VISIT'}">
|
||||
<span th:text="#{timeline.duration}">Duration</span>:
|
||||
<span th:text="${entry.formattedDuration}">1h 30m</span>
|
||||
</span>
|
||||
<span th:if="${entry.type.name() == 'TRIP'}">
|
||||
<span th:if="${entry.formattedDistance != null}">
|
||||
<span th:text="#{timeline.distance}">Distance</span>:
|
||||
<span th:text="${entry.formattedDistance}">5.2 km</span>
|
||||
</span>
|
||||
<div th:if="${entry.transportMode != null}" class="trip-transport-mode-container">
|
||||
<span th:text="#{'timeline.transport.' + ${entry.transportMode} + '.label'}">by foot</span>
|
||||
<i class="lni lni-pencil-1 edit-icon"
|
||||
th:hx-get="@{/timeline/trips/edit-form/{id}(id=${entry.resourceId})}"
|
||||
hx-target="closest .trip-transport-mode-container"
|
||||
hx-swap="outerHTML"></i>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="entry-time">
|
||||
<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>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="entry-time">
|
||||
<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>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
src/main/resources/templates/fragments/trip-edit.html
Normal file
43
src/main/resources/templates/fragments/trip-edit.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div th:fragment="edit-form" class="inline-edit-form editing trip-transport-mode-container">
|
||||
<form th:hx-put="@{/timeline/trips/{id}/transport-mode(id=${tripId})}"
|
||||
hx-target="closest .trip-transport-mode-container"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="timezone" th:value="${timezone}">
|
||||
<div class="form-group">
|
||||
<select name="transportMode" th:placeholder="#{timeline.trip.transport.select}" required>
|
||||
<option th:each="mode : ${availableTransportModes}"
|
||||
th:value="${mode}"
|
||||
th:text="#{${'transportation.mode.' + mode + '.name'}}"
|
||||
th:selected="${mode == transportMode}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-buttons">
|
||||
<button type="submit" class="btn btn-sm" th:text="#{form.update}">Update</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
th:hx-get="@{/timeline/trips/view/{id}(id=${tripId}, transportMode=${param.currentTransportMode})}"
|
||||
hx-target="closest .trip-transport-mode-container"
|
||||
hx-swap="outerHTML"
|
||||
th:text="#{form.cancel}">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div th:fragment="view-mode" class="trip-transport-mode-container">
|
||||
<span th:text="#{'timeline.transport.' + ${transportMode} + '.label'}">by foot</span>
|
||||
</span>
|
||||
<i class="lni lni-pencil-1 edit-icon"
|
||||
th:hx-get="@{/timeline/trips/edit-form/{id}(id=${tripId}, date=${date}, timezone=${timezone})}"
|
||||
hx-target="closest .trip-transport-mode-container"
|
||||
hx-swap="outerHTML"></i>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
109
src/main/resources/templates/settings/transportation-modes.html
Normal file
109
src/main/resources/templates/settings/transportation-modes.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="#{settings.title}">Settings - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="apple-touch-icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="shortcut icon" type="image/x-icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/lineicons.css">
|
||||
<script src="/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="settings-page">
|
||||
<div class="settings-container">
|
||||
<div th:replace="~{fragments/settings-navigation :: settings-nav(${activeSection}, ${dataManagementEnabled}, ${isAdmin})}"></div>
|
||||
<div class="settings-content-area">
|
||||
<div id="transportation-modes" class="settings-section active">
|
||||
<div th:fragment="transportation-modes-content">
|
||||
<h2 th:text="#{transportation.modes.title}">Transportation Modes</h2>
|
||||
|
||||
<div th:if="${successMessage}" class="alert alert-success" style="display: block;">
|
||||
<span th:text="#{${successMessage}}">Success message</span>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger" style="display: block;">
|
||||
<span th:text="#{${errorMessage}}">Error message</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" th:if="${!configs.isEmpty()}">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{transportation.modes.table.mode}">Mode</th>
|
||||
<th th:text="#{${isImperial ? 'transportation.modes.table.max.mph' : 'transportation.modes.table.max.kmh'}}">Max Speed</th>
|
||||
<th th:text="#{transportation.modes.table.actions}">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="config : ${configs}">
|
||||
<td th:text="#{${'transportation.mode.' + config.mode().name().toLowerCase()}}"></td>
|
||||
<td>
|
||||
<form th:attr="hx-post=@{/settings/transportation-modes/{mode}/update(mode=${config.mode()})}"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change delay:500ms"
|
||||
style="display: inline;">
|
||||
<input type="hidden" name="unitSystem" th:value="${unitSystem.name()}">
|
||||
<input type="number"
|
||||
name="maxSpeed"
|
||||
th:value="${isImperial and config.maxKmh() != null ? #numbers.formatDecimal(config.maxKmh() / 1.60934, 1, 1) : config.maxKmh()}"
|
||||
step="0.1"
|
||||
min="0"
|
||||
th:placeholder="#{transportation.modes.max.placeholder}">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger"
|
||||
th:attr="hx-post=@{/settings/transportation-modes/{mode}/delete(mode=${config.mode()})}, hx-confirm=#{transportation.modes.delete.confirm}"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
th:text="#{form.delete}">Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" th:if="${!availableModes.isEmpty()}">
|
||||
<h3 th:text="#{transportation.modes.add.title}">Add Transportation Mode</h3>
|
||||
<form hx-post="/settings/transportation-modes/add"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
class="token-form"
|
||||
style="margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="mode" th:text="#{transportation.modes.mode.label}">Transportation Mode</label>
|
||||
<select id="mode" name="mode" required>
|
||||
<option value="" th:text="#{transportation.modes.mode.select}">Select a mode...</option>
|
||||
<option th:each="mode : ${availableModes}"
|
||||
th:value="${mode}"
|
||||
th:text="#{${'transportation.mode.' + mode.name().toLowerCase()}}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="maxSpeed" th:text="#{${isImperial ? 'transportation.modes.max.mph.label' : 'transportation.modes.max.kmh.label'}}">Max Speed</label>
|
||||
<input type="hidden" name="unitSystem" th:value="${unitSystem.name()}">
|
||||
<input type="number"
|
||||
id="maxSpeed"
|
||||
name="maxSpeed"
|
||||
step="0.1"
|
||||
min="0"
|
||||
th:placeholder="#{${isImperial ? 'transportation.modes.max.mph.placeholder' : 'transportation.modes.max.kmh.placeholder'}}">
|
||||
<small th:text="#{${isImperial ? 'transportation.modes.max.mph.help' : 'transportation.modes.max.kmh.help'}}">Leave empty for no speed limit</small>
|
||||
</div>
|
||||
<button type="submit" class="btn" th:text="#{transportation.modes.add.button}">Add Mode</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" th:if="${availableModes.isEmpty() && !configs.isEmpty()}">
|
||||
<p th:text="#{transportation.modes.all.configured}">All available transportation modes have been configured.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportModeConfig;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@IntegrationTest
|
||||
class TransportModeJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private TransportModeJdbcService transportModeJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUser = testingService.randomUser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetTransportModeConfigsSortedByMaxKmhNullsLast() {
|
||||
// Given - set up test configs for the random user
|
||||
List<TransportModeConfig> testConfigs = List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 7.0),
|
||||
new TransportModeConfig(TransportMode.CYCLING, 20.0),
|
||||
new TransportModeConfig(TransportMode.DRIVING, 120.0),
|
||||
new TransportModeConfig(TransportMode.TRANSIT, null)
|
||||
);
|
||||
transportModeJdbcService.setTransportModeConfigs(testUser, testConfigs);
|
||||
|
||||
// When
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(testUser);
|
||||
|
||||
// Then
|
||||
assertThat(configs).hasSize(4);
|
||||
assertThat(configs.get(0).mode()).isEqualTo(TransportMode.WALKING);
|
||||
assertThat(configs.get(0).maxKmh()).isEqualTo(7.0);
|
||||
assertThat(configs.get(1).mode()).isEqualTo(TransportMode.CYCLING);
|
||||
assertThat(configs.get(1).maxKmh()).isEqualTo(20.0);
|
||||
assertThat(configs.get(2).mode()).isEqualTo(TransportMode.DRIVING);
|
||||
assertThat(configs.get(2).maxKmh()).isEqualTo(120.0);
|
||||
assertThat(configs.get(3).mode()).isEqualTo(TransportMode.TRANSIT);
|
||||
assertThat(configs.get(3).maxKmh()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetTransportModeConfigsAndReplaceExisting() {
|
||||
// Given
|
||||
List<TransportModeConfig> newConfigs = List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 5.0),
|
||||
new TransportModeConfig(TransportMode.CYCLING, 25.0)
|
||||
);
|
||||
|
||||
// When
|
||||
transportModeJdbcService.setTransportModeConfigs(testUser, newConfigs);
|
||||
|
||||
// Then
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(testUser);
|
||||
assertThat(configs).hasSize(2);
|
||||
assertThat(configs.get(0).mode()).isEqualTo(TransportMode.WALKING);
|
||||
assertThat(configs.get(0).maxKmh()).isEqualTo(5.0);
|
||||
assertThat(configs.get(1).mode()).isEqualTo(TransportMode.CYCLING);
|
||||
assertThat(configs.get(1).maxKmh()).isEqualTo(25.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullMaxKmhValues() {
|
||||
// Given
|
||||
List<TransportModeConfig> configs = List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 7.0),
|
||||
new TransportModeConfig(TransportMode.TRANSIT, null)
|
||||
);
|
||||
|
||||
// When
|
||||
transportModeJdbcService.setTransportModeConfigs(testUser, configs);
|
||||
|
||||
// Then
|
||||
List<TransportModeConfig> retrievedConfigs = transportModeJdbcService.getTransportModeConfigs(testUser);
|
||||
assertThat(retrievedConfigs).hasSize(2);
|
||||
assertThat(retrievedConfigs.get(0).maxKmh()).isEqualTo(7.0);
|
||||
assertThat(retrievedConfigs.get(1).maxKmh()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListForUserWithNoConfigs() {
|
||||
// Given
|
||||
User randomUser = testingService.randomUser();
|
||||
|
||||
// When
|
||||
List<TransportModeConfig> configs = transportModeJdbcService.getTransportModeConfigs(randomUser);
|
||||
|
||||
// Then
|
||||
assertThat(configs).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCacheConfigsPerUser() {
|
||||
// Given
|
||||
User user1 = testingService.randomUser();
|
||||
User user2 = testingService.randomUser();
|
||||
|
||||
List<TransportModeConfig> user1Configs = List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 5.0)
|
||||
);
|
||||
List<TransportModeConfig> user2Configs = List.of(
|
||||
new TransportModeConfig(TransportMode.WALKING, 10.0)
|
||||
);
|
||||
transportModeJdbcService.setTransportModeConfigs(user1, user1Configs);
|
||||
transportModeJdbcService.setTransportModeConfigs(user2, user2Configs);
|
||||
|
||||
// When - get configs for both users
|
||||
List<TransportModeConfig> user1FirstCall = transportModeJdbcService.getTransportModeConfigs(user1);
|
||||
List<TransportModeConfig> user2FirstCall = transportModeJdbcService.getTransportModeConfigs(user2);
|
||||
List<TransportModeConfig> user1SecondCall = transportModeJdbcService.getTransportModeConfigs(user1);
|
||||
|
||||
// Then - each user should have their own cached configs
|
||||
assertThat(user1FirstCall).hasSize(1);
|
||||
assertThat(user2FirstCall).hasSize(1);
|
||||
assertThat(user1SecondCall).isEqualTo(user1FirstCall); // should be same cached result
|
||||
assertThat(user1FirstCall.get(0).maxKmh()).isEqualTo(5.0);
|
||||
assertThat(user2FirstCall.get(0).maxKmh()).isEqualTo(10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@IntegrationTest
|
||||
class TransportModeOverrideJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private TransportModeOverrideJdbcService transportModeOverrideJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUser = testingService.randomUser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAddTransportModeOverride() {
|
||||
// Given
|
||||
Instant start = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant end = Instant.now();
|
||||
TransportMode mode = TransportMode.CYCLING;
|
||||
|
||||
// When
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, mode, start, end);
|
||||
|
||||
// Then
|
||||
Optional<TransportMode> override =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start, end);
|
||||
|
||||
assertThat(override).isPresent();
|
||||
assertThat(override.get()).isEqualTo(TransportMode.CYCLING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplaceExistingOverrideInTimeRange() {
|
||||
// Given
|
||||
Instant start = Instant.now().minus(2, ChronoUnit.HOURS);
|
||||
Instant end = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
|
||||
// Add first override
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.WALKING, start, end);
|
||||
|
||||
// When - add another override in overlapping time range
|
||||
Instant newStart = start.plus(1, ChronoUnit.MINUTES);
|
||||
Instant newEnd = end.plus(1, ChronoUnit.MINUTES);
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.DRIVING, newStart, newEnd);
|
||||
|
||||
// Then - should only have the new override in the overlapping range
|
||||
Optional<TransportMode> override =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, newStart, newEnd);
|
||||
|
||||
assertThat(override).isPresent();
|
||||
assertThat(override.get()).isEqualTo(TransportMode.DRIVING);
|
||||
|
||||
Optional<TransportMode> originalOverride =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start, end);
|
||||
assertThat(originalOverride.get()).isEqualTo(TransportMode.DRIVING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowMultipleOverridesInDifferentTimeRanges() {
|
||||
// Given
|
||||
Instant start1 = Instant.now().minus(3, ChronoUnit.HOURS);
|
||||
Instant end1 = Instant.now().minus(2, ChronoUnit.HOURS);
|
||||
|
||||
Instant start2 = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant end2 = Instant.now();
|
||||
|
||||
// When
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.WALKING, start1, end1);
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.CYCLING, start2, end2);
|
||||
|
||||
// Then
|
||||
Optional<TransportMode> override1 =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start1, end1);
|
||||
Optional<TransportMode> override2 =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start2, end2);
|
||||
|
||||
assertThat(override1).isPresent();
|
||||
assertThat(override1.get()).isEqualTo(TransportMode.WALKING);
|
||||
|
||||
assertThat(override2).isPresent();
|
||||
assertThat(override2.get()).isEqualTo(TransportMode.CYCLING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteAllTransportModeOverrides() {
|
||||
// Given
|
||||
Instant start1 = Instant.now().minus(2, ChronoUnit.HOURS);
|
||||
Instant end1 = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant start2 = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant end2 = Instant.now();
|
||||
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.WALKING, start1, end1);
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.CYCLING, start2, end2);
|
||||
|
||||
// When
|
||||
transportModeOverrideJdbcService.deleteAllTransportModeOverrides(testUser);
|
||||
|
||||
// Then
|
||||
Optional<TransportMode> override1 =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start1, end1);
|
||||
Optional<TransportMode> override2 =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start2, end2);
|
||||
|
||||
assertThat(override1).isEmpty();
|
||||
assertThat(override2).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyOptionalForUserWithNoOverrides() {
|
||||
// Given
|
||||
Instant start = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant end = Instant.now();
|
||||
|
||||
// When
|
||||
Optional<TransportMode> override =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start, end);
|
||||
|
||||
// Then
|
||||
assertThat(override).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIsolateOverridesBetweenUsers() {
|
||||
// Given
|
||||
User user2 = testingService.randomUser();
|
||||
Instant start = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
Instant end = Instant.now();
|
||||
|
||||
// When
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(testUser, TransportMode.WALKING, start, end);
|
||||
transportModeOverrideJdbcService.addTransportModeOverride(user2, TransportMode.CYCLING, start, end);
|
||||
|
||||
// Then
|
||||
Optional<TransportMode> user1Override =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(testUser, start, end);
|
||||
Optional<TransportMode> user2Override =
|
||||
transportModeOverrideJdbcService.getTransportModeOverride(user2, start, end);
|
||||
|
||||
assertThat(user1Override).isPresent();
|
||||
assertThat(user1Override.get()).isEqualTo(TransportMode.WALKING);
|
||||
|
||||
assertThat(user2Override).isPresent();
|
||||
assertThat(user2Override.get()).isEqualTo(TransportMode.CYCLING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.Role;
|
||||
import com.dedicatedcode.reitti.model.TimeDisplayMode;
|
||||
import com.dedicatedcode.reitti.model.UnitSystem;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportModeConfig;
|
||||
import com.dedicatedcode.reitti.model.processing.DetectionParameter;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSettings;
|
||||
import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.VisitDetectionParametersJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TransportModeJdbcService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@IntegrationTest
|
||||
class UserServiceTest {
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private UserSettingsJdbcService userSettingsJdbcService;
|
||||
|
||||
@Autowired
|
||||
private VisitDetectionParametersJdbcService visitDetectionParametersJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TransportModeJdbcService transportModeJdbcService;
|
||||
|
||||
@Test
|
||||
void shouldCreateUserWithExternalIdAndDefaultSettings() {
|
||||
// When
|
||||
User user = userService.createNewUser(
|
||||
"testuser",
|
||||
"Test User",
|
||||
"external123",
|
||||
"https://example.com/profile.jpg"
|
||||
);
|
||||
|
||||
// Then
|
||||
assertThat(user).isNotNull();
|
||||
assertThat(user.getUsername()).isEqualTo("testuser");
|
||||
assertThat(user.getDisplayName()).isEqualTo("Test User");
|
||||
assertThat(user.getExternalId()).isEqualTo("external123");
|
||||
assertThat(user.getProfileUrl()).isEqualTo("https://example.com/profile.jpg");
|
||||
assertThat(user.getRole()).isEqualTo(Role.USER);
|
||||
assertThat(user.getPassword()).isEmpty();
|
||||
|
||||
// Verify default visit detection parameters are created
|
||||
List<DetectionParameter> detectionParams = visitDetectionParametersJdbcService.findAllConfigurationsForUser(user);
|
||||
assertThat(detectionParams).hasSize(1);
|
||||
|
||||
// Verify default transport mode configurations are created
|
||||
List<TransportModeConfig> transportConfigs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
assertThat(transportConfigs).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateUserWithPasswordAndCustomSettings() {
|
||||
// When
|
||||
User user = userService.createNewUser(
|
||||
"adminuser",
|
||||
"Admin User",
|
||||
"password123",
|
||||
Role.ADMIN,
|
||||
UnitSystem.IMPERIAL,
|
||||
true,
|
||||
"en",
|
||||
52.5200,
|
||||
13.4050,
|
||||
"Europe/Berlin",
|
||||
TimeDisplayMode.DEFAULT
|
||||
);
|
||||
|
||||
// Then
|
||||
assertThat(user).isNotNull();
|
||||
assertThat(user.getUsername()).isEqualTo("adminuser");
|
||||
assertThat(user.getDisplayName()).isEqualTo("Admin User");
|
||||
assertThat(user.getRole()).isEqualTo(Role.ADMIN);
|
||||
assertThat(user.getPassword()).isNotEmpty();
|
||||
assertThat(user.getPassword()).isNotEqualTo("password123"); // Should be encoded
|
||||
|
||||
// Verify user settings were created
|
||||
UserSettings settings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
assertThat(settings.getUnitSystem()).isEqualTo(UnitSystem.IMPERIAL);
|
||||
assertThat(settings.isPreferColoredMap()).isTrue();
|
||||
assertThat(settings.getSelectedLanguage()).isEqualTo("en");
|
||||
assertThat(settings.getHomeLatitude()).isEqualTo(52.5200);
|
||||
assertThat(settings.getHomeLongitude()).isEqualTo(13.4050);
|
||||
assertThat(settings.getTimeZoneOverride()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||
assertThat(settings.getTimeDisplayMode()).isEqualTo(TimeDisplayMode.DEFAULT);
|
||||
|
||||
// Verify default parameters are created
|
||||
List<DetectionParameter> detectionParams = visitDetectionParametersJdbcService.findAllConfigurationsForUser(user);
|
||||
assertThat(detectionParams).isNotEmpty();
|
||||
|
||||
List<TransportModeConfig> transportConfigs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
assertThat(transportConfigs).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateUserWithNullTimezoneOverride() {
|
||||
// When
|
||||
User user = userService.createNewUser(
|
||||
"usernotz",
|
||||
"User No TZ",
|
||||
"password123",
|
||||
Role.USER,
|
||||
UnitSystem.METRIC,
|
||||
false,
|
||||
"de",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
TimeDisplayMode.DEFAULT
|
||||
);
|
||||
|
||||
// Then
|
||||
UserSettings settings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
assertThat(settings.getTimeZoneOverride()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteUserAndAllRelatedData() {
|
||||
// Given
|
||||
User user = userService.createNewUser(
|
||||
"deleteuser",
|
||||
"Delete User",
|
||||
"external456",
|
||||
"https://example.com/delete.jpg"
|
||||
);
|
||||
|
||||
// Verify user has default data
|
||||
List<DetectionParameter> detectionParams = visitDetectionParametersJdbcService.findAllConfigurationsForUser(user);
|
||||
List<TransportModeConfig> transportConfigs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
assertThat(detectionParams).isNotEmpty();
|
||||
assertThat(transportConfigs).isNotEmpty();
|
||||
|
||||
// When
|
||||
userService.deleteUser(user);
|
||||
|
||||
// Then - all related data should be deleted
|
||||
List<DetectionParameter> remainingParams = visitDetectionParametersJdbcService.findAllConfigurationsForUser(user);
|
||||
List<TransportModeConfig> remainingConfigs = transportModeJdbcService.getTransportModeConfigs(user);
|
||||
|
||||
assertThat(remainingParams).isEmpty();
|
||||
assertThat(remainingConfigs).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user