336 allow editing the transport mode of a trip and offer more options (#352)

This commit is contained in:
Daniel Graf
2025-10-25 14:31:09 +02:00
committed by GitHub
parent 4e7b2988f2
commit 625645598b
26 changed files with 1312 additions and 109 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package com.dedicatedcode.reitti.model.geo;
public enum TransportMode {
WALKING, CYCLING, DRIVING, TRANSIT, UNKNOWN;
}

View File

@@ -0,0 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
import java.io.Serializable;
public record TransportModeConfig(TransportMode mode, Double maxKmh) implements Serializable {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -480,6 +480,7 @@ tr:hover {
font-weight: lighter;
pointer-events: all;
cursor: pointer;
position: relative;
}
.timeline-entry.active {

View File

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

View File

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

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

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

View File

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

View File

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

View File

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