505 add remove places (#581)

This commit is contained in:
Daniel Graf
2025-12-25 16:16:43 +01:00
committed by GitHub
parent 69e5d595ed
commit c909d97bfc
52 changed files with 3799 additions and 828 deletions

View File

@@ -2,4 +2,5 @@
- prefer reusing existing styles instead of creating new ones
- do not add inline styles except it is a one of a kind component
- when creating tests for classes, prefer the @IntegrationTest instead of mocking
- all rules presented here, are soft rules. We try to follow them but are also allowed to break them if needed
- all rules presented here are soft rules. We try to follow them but are also allowed to break them if needed
- we are using maven for building the project

View File

@@ -88,7 +88,7 @@ services:
location / {
proxy_pass https://tile.openstreetmap.org/;
proxy_set_header Host tile.openstreetmap.org;
proxy_set_header User-Agent "Reitti/1.0";
proxy_set_header User-Agent "Reitti/1.0 \\(+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com\\)";
proxy_cache tiles;
proxy_cache_valid 200 30d;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

View File

@@ -76,13 +76,13 @@ services:
}
http {
proxy_cache_path /var/cache/nginx/tiles levels=1:2 keys_zone=tiles:10m max_size=1g inactive=30d use_temp_path=off;
server {
listen 80;
location / {
proxy_pass https://tile.openstreetmap.org/;
proxy_set_header Host tile.openstreetmap.org;
proxy_set_header User-Agent "Reitti/1.0";
proxy_set_header User-Agent "Reitti/1.0 \\(+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com\\)";
proxy_cache tiles;
proxy_cache_valid 200 30d;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

View File

@@ -72,63 +72,6 @@ public class TimelineController {
return getTimelineContentRange(startDate, endDate, timezone, principal, model, null);
}
@GetMapping("/places/edit-form/{id}")
public String getPlaceEditForm(@PathVariable Long id,
@RequestParam(required = false) String date,
@RequestParam(required = false) String timezone,
Model model) {
SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("place", place);
model.addAttribute("placeTypes", SignificantPlace.PlaceType.values());
model.addAttribute("date", date);
model.addAttribute("timezone", timezone);
return "fragments/place-edit :: edit-form";
}
@PutMapping("/places/{id}")
public String updatePlace(@PathVariable Long id,
@RequestParam String name,
@RequestParam(required = false) String type,
@RequestParam(required = false) String date,
@RequestParam(required = false, defaultValue = "UTC") String timezone,
Authentication principal,
Model model) {
SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
SignificantPlace updatedPlace = place.withName(name);
if (type != null && !type.isEmpty()) {
try {
SignificantPlace.PlaceType placeType = SignificantPlace.PlaceType.valueOf(type);
updatedPlace = updatedPlace.withType(placeType);
} catch (IllegalArgumentException e) {
// Invalid place type, ignore and just update name
}
}
User user = this.userJdbcService.findByUsername(principal.getName()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
placeService.update(updatedPlace);
placeOverrideJdbcService.insertOverride(user, updatedPlace);
// If we have timeline context, reload the entire timeline with the edited place selected
if (date != null) {
return getTimelineContent(date, timezone, principal, model, id);
}
// Otherwise just return the updated place view
model.addAttribute("place", updatedPlace);
return "fragments/place-edit :: view-mode";
}
@GetMapping("/places/view/{id}")
public String getPlaceView(@PathVariable Long id,
@RequestParam(required = false) String date,
Model model) {
SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("place", place);
model.addAttribute("date", date);
return "fragments/place-edit :: view-mode";
}
@GetMapping("/trips/edit-form/{id}")
public String getTripEditForm(@PathVariable Long id,
Model model) {

View File

@@ -1,5 +1,6 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.dto.PlaceInfo;
import com.dedicatedcode.reitti.dto.ProcessedVisitResponse;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
@@ -125,7 +126,7 @@ public class ProcessedVisitApiController {
List<ProcessedVisit> placeVisits = entry.getValue();
// Create PlaceInfo DTO
ProcessedVisitResponse.PlaceInfo placeInfo = new ProcessedVisitResponse.PlaceInfo(
PlaceInfo placeInfo = new PlaceInfo(
place.getId(),
place.getName(),
place.getAddress(),
@@ -133,7 +134,8 @@ public class ProcessedVisitApiController {
place.getCountryCode(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
place.getType() != null ? place.getType().toString() : null
place.getType(),
place.getPolygon()
);
// Create VisitDetail DTOs

View File

@@ -12,8 +12,13 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@RestController
@@ -22,40 +27,60 @@ import java.util.concurrent.TimeUnit;
public class TileProxyController {
private static final Logger log = LoggerFactory.getLogger(TileProxyController.class);
private final RestTemplate restTemplate;
private final HttpClient httpClient;
private final String tileCacheUrl;
public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl) {
this.tileCacheUrl = tileCacheUrl;
this.restTemplate = new RestTemplate();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
@GetMapping("/{z}/{x}/{y}.png")
public ResponseEntity<byte[]> getTile(
@PathVariable int z,
@PathVariable int x,
@PathVariable int y) {
@PathVariable int y,
HttpServletRequest request) {
String tileUrl = String.format("%s/%d/%d/%d.png", tileCacheUrl, z, x, y);
try {
log.trace("Fetching tile: {}/{}/{}", z, x, y);
ResponseEntity<byte[]> response = restTemplate.getForEntity(tileUrl, byte[].class);
// Build HTTP request
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(tileUrl))
.timeout(Duration.ofSeconds(30))
.GET();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_PNG);
headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
// Add referer header if present
String referer = request.getHeader("Referer");
if (referer != null) {
requestBuilder.header("Referer", referer);
}
headers.add("Access-Control-Allow-Origin", "*");
HttpRequest httpRequest = requestBuilder.build();
HttpResponse<byte[]> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray());
return ResponseEntity.ok()
.headers(headers)
.body(response.getBody());
if (response.statusCode() == 200) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_PNG);
headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
headers.add("Access-Control-Allow-Origin", "*");
return ResponseEntity.ok()
.headers(headers)
.body(response.body());
} else {
log.warn("Failed to fetch tile {}/{}/{}: HTTP {}", z, x, y, response.statusCode());
return ResponseEntity.notFound().build();
}
} catch (Exception e) {
log.warn("Failed to fetch tile {}/{}/{}: {}", z, x, y, e.getMessage());
return ResponseEntity.notFound().build();
}
}
}
}

View File

@@ -17,7 +17,6 @@ import org.springframework.web.bind.annotation.PostMapping;
public class ManageDataController {
private final boolean dataManagementEnabled;
private final VisitJdbcService visitJdbcService;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final ProcessingPipelineTrigger processingPipelineTrigger;
@@ -26,7 +25,6 @@ public class ManageDataController {
private final MessageSource messageSource;
public ManageDataController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
VisitJdbcService visitJdbcService,
TripJdbcService tripJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
ProcessingPipelineTrigger processingPipelineTrigger,
@@ -34,7 +32,6 @@ public class ManageDataController {
UserSettingsJdbcService userSettingsJdbcService,
MessageSource messageSource) {
this.dataManagementEnabled = dataManagementEnabled;
this.visitJdbcService = visitJdbcService;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.processingPipelineTrigger = processingPipelineTrigger;
@@ -115,7 +112,6 @@ public class ManageDataController {
private void clearProcessedDataExceptPlaces(User user) {
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
visitJdbcService.deleteAllForUser(user);
}
private void markRawLocationPointsAsUnprocessed(User user) {
@@ -126,7 +122,6 @@ public class ManageDataController {
this.userSettingsJdbcService.deleteNewestData(user);
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
visitJdbcService.deleteAllForUser(user);
rawLocationPointJdbcService.deleteAllForUser(user);
}

View File

@@ -3,20 +3,30 @@ package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.PlaceInfo;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.AvailableCountry;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.GeoUtils;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.geocoding.GeocodingResponse;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.GeocodingResponseJdbcService;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService;
import com.dedicatedcode.reitti.service.DataCleanupService;
import com.dedicatedcode.reitti.service.I18nService;
import com.dedicatedcode.reitti.service.PlaceService;
import com.dedicatedcode.reitti.service.PlaceChangeDetectionService;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -24,45 +34,69 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.ArrayList;
import java.util.stream.Collectors;
@Controller
@RequestMapping("/settings/places")
public class PlacesSettingsController {
private static final Logger log = LoggerFactory.getLogger(PlacesSettingsController.class);
private final PlaceService placeService;
private final SignificantPlaceJdbcService placeJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService;
private final GeocodingResponseJdbcService geocodingResponseJdbcService;
private final RabbitTemplate rabbitTemplate;
private final MessageSource messageSource;
private final GeometryFactory geometryFactory;
private final I18nService i18nService;
private final PlaceChangeDetectionService placeChangeDetectionService;
private final DataCleanupService dataCleanupService;
private final boolean dataManagementEnabled;
private final ObjectMapper objectMapper;
public PlacesSettingsController(PlaceService placeService,
SignificantPlaceJdbcService placeJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService,
GeocodingResponseJdbcService geocodingResponseJdbcService,
RabbitTemplate rabbitTemplate,
MessageSource messageSource,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
GeometryFactory geometryFactory,
I18nService i18nService,
PlaceChangeDetectionService placeChangeDetectionService,
DataCleanupService dataCleanupService,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
ObjectMapper objectMapper) {
this.placeService = placeService;
this.placeJdbcService = placeJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService;
this.geocodingResponseJdbcService = geocodingResponseJdbcService;
this.rabbitTemplate = rabbitTemplate;
this.messageSource = messageSource;
this.geometryFactory = geometryFactory;
this.i18nService = i18nService;
this.placeChangeDetectionService = placeChangeDetectionService;
this.dataCleanupService = dataCleanupService;
this.dataManagementEnabled = dataManagementEnabled;
this.objectMapper = objectMapper;
}
@GetMapping
public String getPage(@AuthenticationPrincipal User user, Model model) {
public String getPage(@AuthenticationPrincipal User user,
Model model,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "") String search) {
model.addAttribute("activeSection", "places");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
getPlacesContent(user, 0, "", model);
getPlacesContent(user, page, search, model);
return "settings/places";
}
@@ -75,76 +109,46 @@ public class PlacesSettingsController {
// Convert to PlaceInfo objects
List<PlaceInfo> places = placesPage.getContent().stream()
.map(place -> new PlaceInfo(
place.getId(),
place.getName(),
place.getAddress(),
place.getType(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid()
))
.map(PlacesSettingsController::convertToPlaceInfo)
.collect(Collectors.toList());
// Add pagination info to model
model.addAttribute("currentPage", placesPage.getNumber());
model.addAttribute("totalPages", placesPage.getTotalPages());
model.addAttribute("places", places);
model.addAttribute("isEmpty", places.isEmpty());
model.addAttribute("placeTypes", SignificantPlace.PlaceType.values());
model.addAttribute("search", search);
model.addAttribute("returnUrl", "/settings/places?search=" + search + "&page=" + page);
return "settings/places :: places-content";
}
@GetMapping("/{placeId}/edit")
public String editPlace(@PathVariable Long placeId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "") String search,
Authentication authentication,
Model model) {
@PostMapping("/{placeId}/check-update")
@ResponseBody
public CheckUpdateResponse checkUpdate(@PathVariable Long placeId,
@RequestParam(required = false) String polygonData,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
if (!this.placeJdbcService.exists(user, placeId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
try {
SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow();
// Convert to PlaceInfo for the template
PlaceInfo placeInfo = new PlaceInfo(
place.getId(),
place.getName(),
place.getAddress(),
place.getType(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid()
);
// Get visit statistics for this place
var visitStats = placeService.getVisitStatisticsForPlace(user, placeId);
model.addAttribute("place", placeInfo);
model.addAttribute("currentPage", page);
model.addAttribute("search", search);
model.addAttribute("placeTypes", SignificantPlace.PlaceType.values());
model.addAttribute("visitStats", visitStats);
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage()));
return getPlacesContent(user, page, search, model);
}
return "fragments/places :: edit-place-content";
PlaceChangeDetectionService.PlaceChangeAnalysis analysis =
placeChangeDetectionService.analyzeChanges(user, placeId, polygonData);
return new CheckUpdateResponse(analysis.isCanProceed(), analysis.getWarnings());
}
@PostMapping("/{placeId}/update")
public String updatePlace(@PathVariable Long placeId,
@RequestParam String name,
@RequestParam(required = false) String address,
@RequestParam(required = false) String city,
@RequestParam(required = false) String countryCode,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "") String search,
@RequestParam(required = false) String polygonData,
@RequestParam(required = false) String returnUrl,
Authentication authentication,
Model model) {
@@ -153,27 +157,67 @@ public class PlacesSettingsController {
try {
SignificantPlace significantPlace = placeJdbcService.findById(placeId).orElseThrow();
SignificantPlace updatedPlace = significantPlace.withName(name);
if (address != null) {
updatedPlace = updatedPlace.withAddress(address.trim().isEmpty() ? null : address.trim());
}
if (city != null) {
updatedPlace = updatedPlace.withCity(city.trim().isEmpty() ? null : city.trim());
}
if (countryCode != null) {
updatedPlace = updatedPlace.withCountryCode(countryCode.trim().isEmpty() ? null : countryCode.trim());
}
if (type != null && !type.isEmpty()) {
try {
SignificantPlace.PlaceType placeType = SignificantPlace.PlaceType.valueOf(type);
updatedPlace = updatedPlace.withType(placeType);
} catch (IllegalArgumentException e) {
model.addAttribute("errorMessage", getMessage("message.error.place.update", "Invalid place type"));
return editPlace(placeId, page, search, authentication, model);
model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", "Invalid place type"));
return editPolygon(placeId, returnUrl, authentication, model);
}
}
placeJdbcService.update(updatedPlace);
// Parse polygon data if provided
if (polygonData != null && !polygonData.trim().isEmpty()) {
try {
List<GeoPoint> polygon = parsePolygonData(polygonData);
updatedPlace = updatedPlace.withPolygon(polygon);
// Calculate and update the centroid
GeoPoint centroid = GeoUtils.calculatePolygonCentroid(polygon);
updatedPlace = updatedPlace.withLatitudeCentroid(centroid.latitude())
.withLongitudeCentroid(centroid.longitude());
} catch (Exception e) {
model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", "Invalid polygon data: " + e.getMessage()));
return editPolygon(placeId, returnUrl, authentication, model);
}
} else {
updatedPlace = updatedPlace.withPolygon(null);
}
if (!this.placeChangeDetectionService.analyzeChanges(user, placeId, polygonData).isCanProceed()) {
placeJdbcService.update(updatedPlace);
log.info("Significant change detected for place [{}]. Will issue a recalculation of all affected dates", significantPlace);
List<SignificantPlace> placesToRemove = placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, updatedPlace.getPolygon());
List<SignificantPlace> placesToCheck = new ArrayList<>(placesToRemove);
placesToCheck.add(updatedPlace);
List<LocalDate> affectedDays = this.processedVisitJdbcService.getAffectedDays(placesToCheck);
this.dataCleanupService.cleanupForGeometryChange(user, placesToRemove, affectedDays);
} else {
placeJdbcService.update(updatedPlace);
}
significantPlaceOverrideJdbcService.insertOverride(user, updatedPlace);
model.addAttribute("successMessage", getMessage("message.success.place.updated"));
return editPlace(placeId, page, search, authentication, model);
return "redirect:" + returnUrl;
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage()));
return editPlace(placeId, page, search, authentication, model);
model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage()));
return editPolygon(placeId, returnUrl, authentication, model);
}
} else {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
@@ -182,10 +226,11 @@ public class PlacesSettingsController {
@PostMapping("/{placeId}/geocode")
public String geocodePlace(@PathVariable Long placeId,
@RequestParam(required = false) String returnUrl,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "") String search,
Authentication authentication,
Model model) {
RedirectAttributes redirectAttributes) {
User user = (User) authentication.getPrincipal();
if (this.placeJdbcService.exists(user, placeId)) {
@@ -207,15 +252,17 @@ public class PlacesSettingsController {
);
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event);
model.addAttribute("successMessage", getMessage("places.geocode.success"));
redirectAttributes.addFlashAttribute("successMessage", i18nService.translate("places.geocode.success", new Object[]{}));
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("places.geocode.error", e.getMessage()));
redirectAttributes.addFlashAttribute("errorMessage", i18nService.translate("places.geocode.error", e.getMessage()));
}
} else {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return getPlacesContent(user, page, search, model);
// Redirect to returnUrl if provided, otherwise to places list
String redirectUrl = returnUrl != null ? returnUrl : "/settings/places?page=" + page + "&search=" + search;
return "redirect:" + redirectUrl;
}
@@ -235,15 +282,7 @@ public class PlacesSettingsController {
try {
SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow();
// Convert to PlaceInfo for the template
PlaceInfo placeInfo = new PlaceInfo(
place.getId(),
place.getName(),
place.getAddress(),
place.getType(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid()
);
PlaceInfo placeInfo = convertToPlaceInfo(place);
// Get all geocoding responses for this place
List<GeocodingResponse> geocodingResponses = geocodingResponseJdbcService.findBySignificantPlace(place);
@@ -255,15 +294,103 @@ public class PlacesSettingsController {
model.addAttribute("geocodingResponses", geocodingResponses);
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage()));
model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage()));
return getPlacesContent(user, page, search, model);
}
return "fragments/places :: geocoding-response-content";
}
@GetMapping("/{placeId}/edit")
public String editPolygon(@PathVariable Long placeId,
@RequestParam(required = false) String returnUrl,
Authentication authentication,
Model model) {
private String getMessage(String key, Object... args) {
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
User user = (User) authentication.getPrincipal();
if (!this.placeJdbcService.exists(user, placeId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
try {
SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow();
PlaceInfo placeInfo = convertToPlaceInfo(place);
model.addAttribute("place", placeInfo);
model.addAttribute("placeTypes", SignificantPlace.PlaceType.values());
model.addAttribute("returnUrl", returnUrl);
Point point = geometryFactory.createPoint(new Coordinate(place.getLongitudeCentroid(), place.getLatitudeCentroid()));
List<PlaceInfo> nearbyPlaces = this.placeJdbcService.findNearbyPlaces(user.getId(), point, 0.019).stream().map(PlacesSettingsController::convertToPlaceInfo).toList();
model.addAttribute("availableCountries", AvailableCountry.values());
model.addAttribute("nearbyPlaces", nearbyPlaces);
} catch (Exception e) {
model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage()));
return "redirect:/settings/places";
}
return "settings/edit-place";
}
private static PlaceInfo convertToPlaceInfo(SignificantPlace place) {
return new PlaceInfo(
place.getId(),
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
place.getType(),
place.getPolygon()
);
}
private List<GeoPoint> parsePolygonData(String polygonData) throws Exception {
JsonNode jsonNode = objectMapper.readTree(polygonData);
List<GeoPoint> geoPoints = new ArrayList<>();
if (jsonNode.isArray()) {
for (JsonNode pointNode : jsonNode) {
if (pointNode.has("lat") && pointNode.has("lng")) {
double lat = pointNode.get("lat").asDouble();
double lng = pointNode.get("lng").asDouble();
geoPoints.add(new GeoPoint(lat, lng));
} else {
throw new IllegalArgumentException("Each point must have 'lat' and 'lng' properties");
}
}
} else {
throw new IllegalArgumentException("Polygon data must be an array of coordinate objects");
}
if (geoPoints.size() < 3) {
throw new IllegalArgumentException("Polygon must have at least 3 points");
}
return geoPoints;
}
public static class CheckUpdateResponse {
private final boolean canProceed;
private final List<String> warnings;
public CheckUpdateResponse(boolean canProceed, List<String> warnings) {
this.canProceed = canProceed;
this.warnings = warnings;
}
public boolean isCanProceed() {
return canProceed;
}
public List<String> getWarnings() {
return warnings;
}
}
}

View File

@@ -37,7 +37,6 @@ public class SettingsVisitSensitivityController {
private final ProcessingPipelineTrigger processingPipelineTrigger;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final VisitJdbcService visitJdbcService;
private final MessageSource messageSource;
private final boolean dataManagementEnabled;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
@@ -48,7 +47,6 @@ public class SettingsVisitSensitivityController {
ProcessingPipelineTrigger processingPipelineTrigger,
TripJdbcService tripJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
VisitJdbcService visitJdbcService,
MessageSource messageSource,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, RawLocationPointJdbcService rawLocationPointJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService) {
this.configurationService = configurationService;
@@ -56,7 +54,6 @@ public class SettingsVisitSensitivityController {
this.processingPipelineTrigger = processingPipelineTrigger;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.visitJdbcService = visitJdbcService;
this.messageSource = messageSource;
this.dataManagementEnabled = dataManagementEnabled;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
@@ -271,7 +268,6 @@ public class SettingsVisitSensitivityController {
try {
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
visitJdbcService.deleteAllForUser(user);
significantPlaceJdbcService.deleteForUser(user);
rawLocationPointJdbcService.markAllAsUnprocessedForUser(user);
allConfigurationsForUser.forEach(config -> this.configurationService.updateConfiguration(config.withRecalculationState(RecalculationState.DONE)));

View File

@@ -1,6 +1,9 @@
package com.dedicatedcode.reitti.dto;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
public record PlaceInfo(Long id, String name, String address, SignificantPlace.PlaceType type, Double latitude, Double longitude) {
import java.util.List;
public record PlaceInfo(Long id, String name, String address, String city, String countryCode, Double lat, Double lng, SignificantPlace.PlaceType type, List<GeoPoint> polygon) {
}

View File

@@ -4,60 +4,6 @@ import java.util.List;
public class ProcessedVisitResponse {
public static class PlaceInfo {
private final Long id;
private final String name;
private final String address;
private final String city;
private final String countryCode;
private final Double lat;
private final Double lng;
private final String type;
public PlaceInfo(Long id, String name, String address, String city, String countryCode, Double lat, Double lng, String type) {
this.id = id;
this.name = name;
this.address = address;
this.city = city;
this.countryCode = countryCode;
this.lat = lat;
this.lng = lng;
this.type = type;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public String getCity() {
return city;
}
public String getCountryCode() {
return countryCode;
}
public Double getLat() {
return lat;
}
public Double getLng() {
return lng;
}
public String getType() {
return type;
}
}
public static class PlaceVisitSummary {
private final PlaceInfo place;
private final List<VisitDetail> visits;
@@ -73,8 +19,8 @@ public class ProcessedVisitResponse {
this.totalDurationMs = totalDurationMs;
this.visitCount = visitCount;
this.color = color;
this.lat = place.getLat();
this.lng = place.getLng();
this.lat = place.lat();
this.lng = place.lng();
}
public PlaceInfo getPlace() {

View File

@@ -0,0 +1,263 @@
package com.dedicatedcode.reitti.model;
public enum AvailableCountry {
AF("country.af.label"),
AL("country.al.label"),
DZ("country.dz.label"),
AS("country.as.label"),
AD("country.ad.label"),
AO("country.ao.label"),
AI("country.ai.label"),
AQ("country.aq.label"),
AG("country.ag.label"),
AR("country.ar.label"),
AM("country.am.label"),
AW("country.aw.label"),
AU("country.au.label"),
AT("country.at.label"),
AZ("country.az.label"),
BS("country.bs.label"),
BH("country.bh.label"),
BD("country.bd.label"),
BB("country.bb.label"),
BY("country.by.label"),
BE("country.be.label"),
BZ("country.bz.label"),
BJ("country.bj.label"),
BM("country.bm.label"),
BT("country.bt.label"),
BO("country.bo.label"),
BA("country.ba.label"),
BW("country.bw.label"),
BV("country.bv.label"),
BR("country.br.label"),
IO("country.io.label"),
BN("country.bn.label"),
BG("country.bg.label"),
BF("country.bf.label"),
BI("country.bi.label"),
KH("country.kh.label"),
CM("country.cm.label"),
CA("country.ca.label"),
CV("country.cv.label"),
KY("country.ky.label"),
CF("country.cf.label"),
TD("country.td.label"),
CL("country.cl.label"),
CN("country.cn.label"),
CX("country.cx.label"),
CC("country.cc.label"),
CO("country.co.label"),
KM("country.km.label"),
CG("country.cg.label"),
CD("country.cd.label"),
CK("country.ck.label"),
CR("country.cr.label"),
CI("country.ci.label"),
HR("country.hr.label"),
CU("country.cu.label"),
CY("country.cy.label"),
CZ("country.cz.label"),
DK("country.dk.label"),
DJ("country.dj.label"),
DM("country.dm.label"),
DO("country.do.label"),
EC("country.ec.label"),
EG("country.eg.label"),
SV("country.sv.label"),
GQ("country.gq.label"),
ER("country.er.label"),
EE("country.ee.label"),
ET("country.et.label"),
FK("country.fk.label"),
FO("country.fo.label"),
FJ("country.fj.label"),
FI("country.fi.label"),
FR("country.fr.label"),
GF("country.gf.label"),
PF("country.pf.label"),
TF("country.tf.label"),
GA("country.ga.label"),
GM("country.gm.label"),
GE("country.ge.label"),
DE("country.de.label"),
GH("country.gh.label"),
GI("country.gi.label"),
GR("country.gr.label"),
GL("country.gl.label"),
GD("country.gd.label"),
GP("country.gp.label"),
GU("country.gu.label"),
GT("country.gt.label"),
GG("country.gg.label"),
GN("country.gn.label"),
GW("country.gw.label"),
GY("country.gy.label"),
HT("country.ht.label"),
HM("country.hm.label"),
VA("country.va.label"),
HN("country.hn.label"),
HK("country.hk.label"),
HU("country.hu.label"),
IS("country.is.label"),
IN("country.in.label"),
ID("country.id.label"),
IR("country.ir.label"),
IQ("country.iq.label"),
IE("country.ie.label"),
IM("country.im.label"),
IL("country.il.label"),
IT("country.it.label"),
JM("country.jm.label"),
JP("country.jp.label"),
JE("country.je.label"),
JO("country.jo.label"),
KZ("country.kz.label"),
KE("country.ke.label"),
KI("country.ki.label"),
KP("country.kp.label"),
KR("country.kr.label"),
KW("country.kw.label"),
KG("country.kg.label"),
LA("country.la.label"),
LV("country.lv.label"),
LB("country.lb.label"),
LS("country.ls.label"),
LR("country.lr.label"),
LY("country.ly.label"),
LI("country.li.label"),
LT("country.lt.label"),
LU("country.lu.label"),
MO("country.mo.label"),
MK("country.mk.label"),
MG("country.mg.label"),
MW("country.mw.label"),
MY("country.my.label"),
MV("country.mv.label"),
ML("country.ml.label"),
MT("country.mt.label"),
MH("country.mh.label"),
MQ("country.mq.label"),
MR("country.mr.label"),
MU("country.mu.label"),
YT("country.yt.label"),
MX("country.mx.label"),
FM("country.fm.label"),
MD("country.md.label"),
MC("country.mc.label"),
MN("country.mn.label"),
ME("country.me.label"),
MS("country.ms.label"),
MA("country.ma.label"),
MZ("country.mz.label"),
MM("country.mm.label"),
NA("country.na.label"),
NR("country.nr.label"),
NP("country.np.label"),
NL("country.nl.label"),
AN("country.an.label"),
NC("country.nc.label"),
NZ("country.nz.label"),
NI("country.ni.label"),
NE("country.ne.label"),
NG("country.ng.label"),
NU("country.nu.label"),
NF("country.nf.label"),
MP("country.mp.label"),
NO("country.no.label"),
OM("country.om.label"),
PK("country.pk.label"),
PW("country.pw.label"),
PS("country.ps.label"),
PA("country.pa.label"),
PG("country.pg.label"),
PY("country.py.label"),
PE("country.pe.label"),
PH("country.ph.label"),
PN("country.pn.label"),
PL("country.pl.label"),
PT("country.pt.label"),
PR("country.pr.label"),
QA("country.qa.label"),
RE("country.re.label"),
RO("country.ro.label"),
RU("country.ru.label"),
RW("country.rw.label"),
BL("country.bl.label"),
SH("country.sh.label"),
KN("country.kn.label"),
LC("country.lc.label"),
MF("country.mf.label"),
PM("country.pm.label"),
VC("country.vc.label"),
WS("country.ws.label"),
SM("country.sm.label"),
ST("country.st.label"),
SA("country.sa.label"),
SN("country.sn.label"),
RS("country.rs.label"),
SC("country.sc.label"),
SL("country.sl.label"),
SG("country.sg.label"),
SK("country.sk.label"),
SI("country.si.label"),
SB("country.sb.label"),
SO("country.so.label"),
ZA("country.za.label"),
GS("country.gs.label"),
ES("country.es.label"),
LK("country.lk.label"),
SD("country.sd.label"),
SR("country.sr.label"),
SJ("country.sj.label"),
SZ("country.sz.label"),
SE("country.se.label"),
CH("country.ch.label"),
SY("country.sy.label"),
TW("country.tw.label"),
TJ("country.tj.label"),
TZ("country.tz.label"),
TH("country.th.label"),
TL("country.tl.label"),
TG("country.tg.label"),
TK("country.tk.label"),
TO("country.to.label"),
TT("country.tt.label"),
TN("country.tn.label"),
TR("country.tr.label"),
TM("country.tm.label"),
TC("country.tc.label"),
TV("country.tv.label"),
UG("country.ug.label"),
UA("country.ua.label"),
AE("country.ae.label"),
GB("country.gb.label"),
US("country.us.label"),
UM("country.um.label"),
UY("country.uy.label"),
UZ("country.uz.label"),
VU("country.vu.label"),
VE("country.ve.label"),
VN("country.vn.label"),
VG("country.vg.label"),
VI("country.vi.label"),
WF("country.wf.label"),
EH("country.eh.label"),
YE("country.ye.label"),
ZM("country.zm.label"),
ZW("country.zw.label");
private final String messageKey;
AvailableCountry(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
public String getCode() {
return this.name().toLowerCase();
}
}

View File

@@ -2,7 +2,9 @@ package com.dedicatedcode.reitti.model.geo;
import org.locationtech.jts.geom.Point;
public record GeoPoint(double latitude, double longitude) {
import java.io.Serializable;
public record GeoPoint(double latitude, double longitude) implements Serializable {
public static GeoPoint from(Point point) {
return new GeoPoint(point.getY(), point.getX());
}

View File

@@ -63,8 +63,8 @@ public final class GeoUtils {
/**
* Converts a distance in meters to degrees of latitude and longitude at a given position.
* The conversion varies based on the latitude because longitude degrees get closer together as you move away from the equator.
*
* @param meters The distance in meters to convert
*
* @param meters The distance in meters to convert
* @param latitude The latitude at which to calculate the conversion
* @return An array where index 0 is the latitude degrees and index 1 is the longitude degrees
*/
@@ -91,4 +91,86 @@ public final class GeoUtils {
return totalDistance;
}
public static GeoPoint calculatePolygonCentroid(List<GeoPoint> polygon) {
if (polygon == null || polygon.isEmpty()) {
throw new IllegalArgumentException("Polygon cannot be null or empty");
}
// Remove duplicate points (especially the closing point that duplicates the first point)
List<GeoPoint> uniquePoints = removeDuplicates(polygon);
// Calculate centroid as the arithmetic mean of unique vertices
double avgLat = uniquePoints.stream().mapToDouble(GeoPoint::latitude).average().orElse(0.0);
double avgLng = uniquePoints.stream().mapToDouble(GeoPoint::longitude).average().orElse(0.0);
return new GeoPoint(avgLat, avgLng);
}
/**
* Calculates the area of a polygon defined by a list of {@link GeoPoint}s.
* <p>
* The calculation uses a planar approximation (Shoelace formula) after converting
* latitude/longitude to meters using an equirectangular projection. This is sufficient
* for relatively small polygons (cityscale or smaller) where the curvature of the
* Earth can be ignored.
*
* @param polygon list of points defining the polygon. The points may be closed
* (first point repeated as last) duplicates are ignored.
* @return area in square meters
* @throws IllegalArgumentException if the polygon is {@code null} or has fewer than
* three distinct points
*/
public static double calculatePolygonArea(List<GeoPoint> polygon) {
if (polygon == null) {
throw new IllegalArgumentException("Polygon cannot be null");
}
// Remove duplicate points (including possible closing point)
List<GeoPoint> uniquePoints = removeDuplicates(polygon);
if (uniquePoints.size() < 3) {
throw new IllegalArgumentException("Polygon must contain at least three distinct points");
}
// Convert each point to a Cartesian coordinate system in meters.
// Latitude: approx 111,320 meters per degree.
// Longitude: 111,320 * cos(latitude) meters per degree.
double[] xs = new double[uniquePoints.size()];
double[] ys = new double[uniquePoints.size()];
for (int i = 0; i < uniquePoints.size(); i++) {
GeoPoint p = uniquePoints.get(i);
double latMeters = p.latitude() * 111320.0;
double lonMeters = p.longitude() * 111320.0 * Math.cos(Math.toRadians(p.latitude()));
xs[i] = lonMeters;
ys[i] = latMeters;
}
// Shoelace formula
double sum = 0.0;
int n = uniquePoints.size();
for (int i = 0; i < n; i++) {
int j = (i + 1) % n;
sum += xs[i] * ys[j] - xs[j] * ys[i];
}
return Math.abs(sum) / 2.0;
}
private static List<GeoPoint> removeDuplicates(List<GeoPoint> polygon) {
List<GeoPoint> uniquePoints = new ArrayList<>();
for (GeoPoint point : polygon) {
boolean isDuplicate = uniquePoints.stream().anyMatch(existing ->
Math.abs(existing.latitude() - point.latitude()) < 0.000001 &&
Math.abs(existing.longitude() - point.longitude()) < 0.000001
);
if (!isDuplicate) {
uniquePoints.add(point);
}
}
return uniquePoints;
}
}

View File

@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.model.geo;
import java.io.Serializable;
import java.time.ZoneId;
import java.util.List;
import java.util.Objects;
public class SignificantPlace implements Serializable {
@@ -13,13 +14,14 @@ public class SignificantPlace implements Serializable {
private final String countryCode;
private final Double latitudeCentroid;
private final Double longitudeCentroid;
private final List<GeoPoint> polygon;
private final PlaceType type;
private final ZoneId timezone;
private final boolean geocoded;
private final Long version;
public static SignificantPlace create(Double latitude, Double longitude) {
return new SignificantPlace(null, null, null, null,null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
return new SignificantPlace(null, null, null, null, null, latitude, longitude, null, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
}
public SignificantPlace(Long id,
@@ -28,7 +30,7 @@ public class SignificantPlace implements Serializable {
String city,
String countryCode,
Double latitudeCentroid,
Double longitudeCentroid,
Double longitudeCentroid, List<GeoPoint> polygon,
PlaceType type, ZoneId timezone,
boolean geocoded,
Long version) {
@@ -39,6 +41,7 @@ public class SignificantPlace implements Serializable {
this.countryCode = countryCode;
this.latitudeCentroid = latitudeCentroid;
this.longitudeCentroid = longitudeCentroid;
this.polygon = polygon;
this.type = type;
this.timezone = timezone;
this.geocoded = geocoded;
@@ -69,6 +72,10 @@ public class SignificantPlace implements Serializable {
return longitudeCentroid;
}
public List<GeoPoint> getPolygon() {
return polygon;
}
public PlaceType getType() {
return type;
}
@@ -87,35 +94,47 @@ public class SignificantPlace implements Serializable {
// Wither methods
public SignificantPlace withGeocoded(boolean geocoded) {
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, geocoded, this.version);
}
public SignificantPlace withName(String name) {
return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withAddress(String address) {
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withCountryCode(String countryCode) {
return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withType(PlaceType type) {
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, type, timezone, this.geocoded, this.version);
}
public SignificantPlace withId(Long id) {
return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withTimezone(ZoneId timezone) {
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withCity(String city) {
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withPolygon(List<GeoPoint> polygon) {
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withLatitudeCentroid(Double latitudeCentroid) {
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, latitudeCentroid, this.longitudeCentroid, this.polygon, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withLongitudeCentroid(Double longitudeCentroid) {
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, longitudeCentroid, this.polygon, this.type, timezone, this.geocoded, this.version);
}
@Override

View File

@@ -8,6 +8,9 @@ import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class PointReaderWriter {
@@ -52,4 +55,51 @@ public class PointReaderWriter {
public String write(GeoPoint point) {
return write(point.longitude(), point.latitude());
}
public String polygonToWkt(List<GeoPoint> polygon) {
if (polygon == null || polygon.isEmpty()) {
return null;
}
StringBuilder wkt = new StringBuilder("POLYGON((");
for (int i = 0; i < polygon.size(); i++) {
GeoPoint point = polygon.get(i);
wkt.append(point.longitude()).append(" ").append(point.latitude());
if (i < polygon.size() - 1) {
wkt.append(", ");
}
}
// Close the polygon by adding the first point again if not already closed
GeoPoint first = polygon.getFirst();
GeoPoint last = polygon.getLast();
if (!first.equals(last)) {
wkt.append(", ").append(first.longitude()).append(" ").append(first.latitude());
}
wkt.append("))");
return wkt.toString();
}
public List<GeoPoint> wktToPolygon(String wkt) {
if (wkt == null || wkt.trim().isEmpty()) {
return null;
}
// Parse WKT format: POLYGON((lon1 lat1, lon2 lat2, ...))
String coordinates = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))"));
String[] points = coordinates.split(",");
List<GeoPoint> polygon = new ArrayList<>();
for (String point : points) {
String[] coords = point.trim().split("\\s+");
if (coords.length >= 2) {
double longitude = Double.parseDouble(coords[0]);
double latitude = Double.parseDouble(coords[1]);
polygon.add(GeoPoint.from(latitude, longitude));
}
}
return polygon.isEmpty() ? null : polygon;
}
}

View File

@@ -1,12 +1,8 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import org.locationtech.jts.geom.Point;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@@ -22,72 +18,113 @@ public class PreviewSignificantPlaceJdbcService {
private final JdbcTemplate jdbcTemplate;
private final PointReaderWriter pointReaderWriter;
private final RowMapper<SignificantPlace> significantPlaceRowMapper;
public PreviewSignificantPlaceJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) {
this.jdbcTemplate = jdbcTemplate;
this.pointReaderWriter = pointReaderWriter;
significantPlaceRowMapper = (rs, _) -> new SignificantPlace(
rs.getLong("id"),
rs.getString("name"),
rs.getString("address"),
rs.getString("city"),
rs.getString("country_code"),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
this.pointReaderWriter.wktToPolygon(rs.getString("polygon")),
SignificantPlace.PlaceType.valueOf(rs.getString("type")),
rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null,
rs.getBoolean("geocoded"),
rs.getLong("version"));
}
private final RowMapper<SignificantPlace> significantPlaceRowMapper = (rs, _) -> new SignificantPlace(
rs.getLong("id"),
rs.getString("name"),
rs.getString("address"),
rs.getString("city"),
rs.getString("country_code"),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
SignificantPlace.PlaceType.valueOf(rs.getString("type")),
rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null,
rs.getBoolean("geocoded"),
rs.getLong("version"));
public List<SignificantPlace> findNearbyPlaces(Long userId, Point point, double distanceInMeters, String previewId) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM preview_significant_places sp " +
"WHERE sp.user_id = ? AND preview_id = ? " +
"AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)";
String sql = """
SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type,
sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id,
ST_AsText(sp.geom) as geom, ST_AsText(sp.polygon) as polygon,
sp.timezone, sp.geocoded, sp.version
FROM preview_significant_places sp
WHERE sp.user_id = ?
AND ST_DWithin(
COALESCE(sp.polygon, ST_Buffer(sp.geom, ?)),
ST_GeomFromText(?, '4326'),
0
)
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper,
userId, previewId, point.toString(), distanceInMeters);
userId, distanceInMeters, point.toString());
}
public SignificantPlace create(User user, String previewId, SignificantPlace place) {
String sql = "INSERT INTO preview_significant_places (user_id, preview_id, name, latitude_centroid, longitude_centroid, timezone, geom) " +
"VALUES (?, ?, ?, ?, ?, ?, ST_GeomFromText(?, '4326')) RETURNING id";
String sql = "INSERT INTO preview_significant_places (user_id, preview_id, name, latitude_centroid, longitude_centroid, timezone, geom, polygon) " +
"VALUES (?, ?, ?, ?, ?, ?, ST_GeomFromText(?, '4326'), " +
"CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END) RETURNING id";
String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon());
Long id = jdbcTemplate.queryForObject(sql, Long.class,
user.getId(),
previewId,
place.getName(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
place.getTimezone().getId(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid())
user.getId(),
previewId,
place.getName(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
place.getTimezone().getId(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
polygonWkt,
polygonWkt
);
return findById(id).orElseThrow();
}
public Optional<SignificantPlace> findById(Long id) {
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM preview_significant_places sp " +
"WHERE sp.id = ?";
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM preview_significant_places sp
WHERE sp.id = ?
""";
List<SignificantPlace> results = jdbcTemplate.query(sql, significantPlaceRowMapper, id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
}
public SignificantPlace update(SignificantPlace place) {
String sql = "UPDATE preview_significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
String sql = "UPDATE preview_significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, " +
"latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), " +
"polygon = CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') ELSE NULL END, " +
"timezone = ?, geocoded = ? WHERE id = ?";
String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon());
jdbcTemplate.update(sql,
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getType().name(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
place.getTimezone() != null ? place.getTimezone().getId() : null,
place.isGeocoded(),
place.getId()
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getType().name(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
polygonWkt,
polygonWkt,
place.getTimezone() != null ? place.getTimezone().getId() : null,
place.isGeocoded(),
place.getId()
);
return findById(place.getId()).orElseThrow();
}

View File

@@ -1,53 +0,0 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.geo.Visit;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
@Service
@Transactional
public class PreviewVisitJdbcService {
private final JdbcTemplate jdbcTemplate;
public PreviewVisitJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<Visit> VISIT_ROW_MAPPER = (rs, _) -> new Visit(
rs.getLong("id"),
rs.getDouble("longitude"),
rs.getDouble("latitude"),
rs.getTimestamp("start_time").toInstant(),
rs.getTimestamp("end_time").toInstant(),
rs.getLong("duration_seconds"),
rs.getBoolean("processed"),
rs.getLong("version")
);
public List<Visit> findByUserAndTimeAfterAndStartTimeBefore(User user, String previewId, Instant windowStart, Instant windowEnd) {
String sql = "SELECT v.* " +
"FROM preview_visits v " +
"WHERE v.user_id = ? AND v.end_time >= ? AND v.start_time <= ? AND preview_id = ? " +
"ORDER BY v.start_time";
return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(),
Timestamp.from(windowStart), Timestamp.from(windowEnd), previewId);
}
public void delete(List<Visit> affectedVisits) throws OptimisticLockException {
if (affectedVisits == null || affectedVisits.isEmpty()) {
return;
}
String placeholders = String.join(",", affectedVisits.stream().map(_ -> "?").toList());
String sql = "DELETE FROM preview_visits WHERE id IN (" + placeholders + ")";
Object[] ids = affectedVisits.stream().map(Visit::getId).toArray();
jdbcTemplate.update(sql, ids);
}
}

View File

@@ -13,6 +13,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -195,4 +196,37 @@ public class ProcessedVisitJdbcService {
public void deleteAllForUser(User user) {
jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ?", user.getId());
}
public List<LocalDate> getAffectedDays(List<SignificantPlace> places) {
if (places.isEmpty()) {
return Collections.emptyList();
}
List<Long> placeIds = places.stream()
.map(SignificantPlace::getId)
.toList();
String placeholders = String.join(",", placeIds.stream().map(id -> "?").toList());
String sql = """
SELECT DISTINCT DATE(pv.start_time) AS affected_day
FROM processed_visits pv
WHERE pv.place_id IN (%s)
UNION
SELECT DISTINCT DATE(pv.end_time) AS affected_day
FROM processed_visits pv
WHERE pv.place_id IN (%s)
ORDER BY affected_day;
""".formatted(placeholders, placeholders);
List<Object> params = new ArrayList<>();
params.addAll(placeIds);
params.addAll(placeIds);
return jdbcTemplate.query(sql, (rs, rowNum) -> rs.getDate("affected_day").toLocalDate(), params.toArray());
}
public void deleteFor(User user, List<SignificantPlace> placesToRemove) {
Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toList().toArray(Long[]::new);
this.jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ? AND place_id = ANY(?)", user.getId(), idList);
}
}

View File

@@ -14,10 +14,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -452,6 +449,12 @@ public class RawLocationPointJdbcService {
jdbcTemplate.update(sql, user.getId());
}
public void markAllAsUnprocessedForUser(User user, List<LocalDate> affectedDays) {
this.jdbcTemplate.update("UPDATE raw_location_points SET processed = false WHERE user_id = ? AND date_trunc('day', timestamp) = ANY(?)",
user.getId(),
affectedDays.stream().map(d -> Timestamp.valueOf(d.atStartOfDay())).toList().toArray(new Timestamp[0]));
}
public void deleteAllForUser(User user) {
String sql = "DELETE FROM raw_location_points WHERE user_id = ?";
jdbcTemplate.update(sql, user.getId());
@@ -513,7 +516,7 @@ public class RawLocationPointJdbcService {
return;
}
String sql = "UPDATE raw_location_points SET ignored = ? WHERE id = ?";
String sql = "UPDATE raw_location_points SET ignored = ?, processed = true WHERE id = ?";
List<Object[]> batchArgs = pointIds.stream()
.map(pointId -> new Object[]{ignored, pointId})

View File

@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import org.locationtech.jts.geom.Point;
@@ -17,39 +18,60 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service class for managing and accessing significant places using JDBC.
* Provides methods for CRUD operations and queries related to significant places.
* Includes support for handling geographical data and pagination.
*/
@Service
@Transactional
public class SignificantPlaceJdbcService {
private final JdbcTemplate jdbcTemplate;
private final PointReaderWriter pointReaderWriter;
private final RowMapper<SignificantPlace> significantPlaceRowMapper;
public SignificantPlaceJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) {
this.jdbcTemplate = jdbcTemplate;
this.pointReaderWriter = pointReaderWriter;
this.significantPlaceRowMapper = (rs, _) -> new SignificantPlace(
rs.getLong("id"),
rs.getString("name"),
rs.getString("address"),
rs.getString("city"),
rs.getString("country_code"),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
pointReaderWriter.wktToPolygon(rs.getString("polygon")),
SignificantPlace.PlaceType.valueOf(rs.getString("type")),
rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null,
rs.getBoolean("geocoded"),
rs.getLong("version"));
}
private final RowMapper<SignificantPlace> significantPlaceRowMapper = (rs, _) -> new SignificantPlace(
rs.getLong("id"),
rs.getString("name"),
rs.getString("address"),
rs.getString("city"),
rs.getString("country_code"),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
SignificantPlace.PlaceType.valueOf(rs.getString("type")),
rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null,
rs.getBoolean("geocoded"),
rs.getLong("version"));
public Page<SignificantPlace> findByUser(User user, PageRequest pageable) {
String countSql = "SELECT COUNT(*) FROM significant_places WHERE user_id = ?";
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, user.getId());
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
" FROM significant_places sp " +
"WHERE sp.user_id = ? ORDER BY sp.id " +
"LIMIT ? OFFSET ? ";
String sql = """
SELECT sp.id,
sp.address,
sp.country_code,
sp.city,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ? ORDER BY sp.id
LIMIT ? OFFSET ?
""";
List<SignificantPlace> content = jdbcTemplate.query(sql, significantPlaceRowMapper,
user.getId(), pageable.getPageSize(), pageable.getOffset());
@@ -67,7 +89,20 @@ public class SignificantPlaceJdbcService {
}
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, countParams.toArray());
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
String sql = "SELECT sp.id,\n" +
" sp.address,\n" +
" sp.country_code,\n" +
" sp.city,\n" +
" sp.type,\n" +
" sp.latitude_centroid,\n" +
" sp.longitude_centroid,\n" +
" sp.name,\n" +
" sp.user_id,\n" +
" ST_AsText(sp.geom) as geom,\n" +
" ST_AsText(sp.polygon) as polygon,\n" +
" sp.timezone,\n" +
" sp.geocoded,\n" +
" sp.version" +
" FROM significant_places sp " +
"WHERE sp.user_id = ? " + searchCondition + " ORDER BY sp.id " +
"LIMIT ? OFFSET ? ";
@@ -79,53 +114,151 @@ public class SignificantPlaceJdbcService {
return new Page<>(content, pageable, total != null ? total : 0);
}
public List<SignificantPlace> findNearbyPlaces(Long userId, Point point, double distanceInMeters) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? " +
"AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)";
/**
* Searches for SignificantPlaces that are nearby to this point. This includes places with polygons
* that are within the specified distance range of the given point, as well as places without polygons
* whose center points are within the distance range.
*
* @param userId - the user to load the places for.
* @param point - the point to search near.
* @param distanceInDegrees - distance in degrees to search within.
* @return list of nearby SignificantPlaces.
*/
public List<SignificantPlace> findNearbyPlaces(Long userId, Point point, double distanceInDegrees) {
String sql = """
SELECT sp.id,
sp.address,
sp.country_code,
sp.city,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ?
AND ST_DWithin(
COALESCE(sp.polygon, sp.geom),
ST_GeomFromText(?, '4326'),
?
)
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper,
userId, point.toString(), distanceInMeters);
userId, point.toString(), distanceInDegrees);
}
/**
* Searches for SignificantPlaces which contain this point. Either by having a polygon which contains that point or
* by extending the center point by distanceInDegrees.
*
* @param userId - the user to load the places for.
* @param point - the point to search for.
* @param distanceInDegrees - meters in degrees to extend the search radius for points without a polygon.
* @return list of SignificantPlaces.
*/
public List<SignificantPlace> findEnclosingPlaces(Long userId, Point point, double distanceInDegrees) {
String sql = """
SELECT sp.id,
sp.address,
sp.country_code,
sp.city,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ?
AND ST_DWithin(
COALESCE(sp.polygon, ST_Buffer(sp.geom, ?)),
ST_GeomFromText(?, '4326'),
0
)
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper,
userId, distanceInDegrees, point.toString());
}
public SignificantPlace create(User user, SignificantPlace place) {
String sql = "INSERT INTO significant_places (user_id, name, latitude_centroid, longitude_centroid, timezone, geom) " +
"VALUES (?, ?, ?, ?, ?, ST_GeomFromText(?, '4326')) RETURNING id";
String sql = "INSERT INTO significant_places (user_id, name, latitude_centroid, longitude_centroid, timezone, geom, polygon) " +
"VALUES (?, ?, ?, ?, ?, ST_GeomFromText(?, '4326'), " +
"CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END) RETURNING id";
;
String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon());
Long id = jdbcTemplate.queryForObject(sql, Long.class,
user.getId(),
place.getName(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
place.getTimezone().getId(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid())
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
polygonWkt,
polygonWkt
);
return findById(id).orElseThrow();
}
@CacheEvict(cacheNames = "significant-places", key = "#place.id")
public SignificantPlace update(SignificantPlace place) {
String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, " +
"latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), " +
"polygon = CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END, " +
"timezone = ?, geocoded = ? WHERE id = ?";
String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon());
jdbcTemplate.update(sql,
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getType().name(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
place.getTimezone() != null ? place.getTimezone().getId() : null,
place.isGeocoded(),
place.getId()
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getType().name(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid(),
this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()),
polygonWkt,
polygonWkt,
place.getTimezone() != null ? place.getTimezone().getId() : null,
place.isGeocoded(),
place.getId()
);
return findById(place.getId()).orElseThrow();
}
return findById(place.getId()).orElseThrow(); }
@Cacheable("significant-places")
public Optional<SignificantPlace> findById(Long id) {
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.id = ?";
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.id = ?
""";
List<SignificantPlace> results = jdbcTemplate.query(sql, significantPlaceRowMapper, id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
}
@@ -135,26 +268,71 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> findNonGeocodedByUser(User user) {
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? AND sp.geocoded = false " +
"ORDER BY sp.id";
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ? AND sp.geocoded = false
ORDER BY sp.id
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId());
}
public List<SignificantPlace> findAllByUser(User user) {
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? " +
"ORDER BY sp.id";
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ?
ORDER BY sp.id
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId());
}
public List<SignificantPlace> findWithMissingTimezone() {
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.timezone IS NULL " +
"ORDER BY sp.id";
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.timezone IS NULL
ORDER BY sp.id
""";
return jdbcTemplate.query(sql, significantPlaceRowMapper);
}
@@ -163,4 +341,49 @@ public class SignificantPlaceJdbcService {
this.jdbcTemplate.update("DELETE FROM geocoding_response WHERE significant_place_id IN (SELECT id FROM significant_places WHERE user_id = ?)", user.getId());
this.jdbcTemplate.update("DELETE FROM significant_places WHERE user_id = ?", user.getId());
}
public void deleteForUser(User user, List<SignificantPlace> placesToRemove) {
Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toList().toArray(Long[]::new);
this.jdbcTemplate.update("DELETE FROM geocoding_response WHERE significant_place_id = ANY(?)", (Object) idList);
this.jdbcTemplate.update("DELETE FROM significant_places WHERE user_id = ? AND id = ANY(?)", user.getId(), idList);
}
public List<SignificantPlace> findPlacesOverlappingWithPolygon(Long userId, Long excludePlaceId, List<GeoPoint> polygon) {
if (polygon == null || polygon.size() < 3) {
return List.of();
}
String sql = """
SELECT sp.id,
sp.address,
sp.city,
sp.country_code,
sp.type,
sp.latitude_centroid,
sp.longitude_centroid,
sp.name,
sp.user_id,
ST_AsText(sp.geom) as geom,
ST_AsText(sp.polygon) as polygon,
sp.timezone,
sp.geocoded,
sp.version
FROM significant_places sp
WHERE sp.user_id = ?
AND sp.id != ?
AND (
-- Check if the new polygon overlaps with existing place's polygon
(sp.polygon IS NOT NULL AND ST_Overlaps(sp.polygon, ST_GeomFromText(?, 4326)))
OR
-- Check if the new polygon contains the existing place's centroid
ST_Contains(ST_GeomFromText(?, 4326), sp.geom)
OR
-- Check if existing place's polygon contains any part of the new polygon
(sp.polygon IS NOT NULL AND ST_Overlaps(ST_GeomFromText(?, 4326), sp.polygon))
)
""";
String polygonWkt = this.pointReaderWriter.polygonToWkt(polygon);
return jdbcTemplate.query(sql, significantPlaceRowMapper, userId, excludePlaceId, polygonWkt, polygonWkt, polygonWkt);
}
}

View File

@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
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;
@@ -214,4 +215,20 @@ public class TripJdbcService {
jdbcTemplate.update(sql, ids.toArray());
}
public void deleteFor(User user, List<SignificantPlace> placesToRemove) {
if (placesToRemove == null || placesToRemove.isEmpty()) {
return;
}
Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toArray(Long[]::new);
this.jdbcTemplate.update("""
DELETE FROM trips
WHERE user_id = ?
AND (start_visit_id IN (SELECT id FROM processed_visits WHERE place_id = ANY(?))
OR end_visit_id IN (SELECT id FROM processed_visits WHERE place_id = ANY(?)))
""",
user.getId(),
idList,
idList);
}
}

View File

@@ -1,205 +0,0 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.geo.Visit;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Transactional
public class VisitJdbcService {
private final JdbcTemplate jdbcTemplate;
public VisitJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<Visit> VISIT_ROW_MAPPER = (rs, _) -> new Visit(
rs.getLong("id"),
rs.getDouble("longitude"),
rs.getDouble("latitude"),
rs.getTimestamp("start_time").toInstant(),
rs.getTimestamp("end_time").toInstant(),
rs.getLong("duration_seconds"),
rs.getBoolean("processed"),
rs.getLong("version")
);
public List<Visit> findByUser(User user) {
String sql = "SELECT v.* " +
"FROM visits v " +
"WHERE v.user_id = ? ORDER BY start_time";
return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId());
}
public List<Visit> findByUserAndStartTimeAndEndTime(User user, Instant startTime, Instant endTime) {
String sql = "SELECT v.* " +
"FROM visits v " +
"WHERE v.user_id = ? AND v.start_time = ? AND v.end_time = ?";
return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(),
Timestamp.from(startTime), Timestamp.from(endTime));
}
public Visit create(User user, Visit visit) {
String sql = "INSERT INTO visits (user_id, longitude, latitude, start_time, end_time, duration_seconds, processed, version) " +
"VALUES (?, ?, ?, ?, ?, ?, ?,?) RETURNING id";
Long id = jdbcTemplate.queryForObject(sql, Long.class,
user.getId(),
visit.getLongitude(),
visit.getLatitude(),
Timestamp.from(visit.getStartTime()),
Timestamp.from(visit.getEndTime()),
visit.getDurationSeconds(),
visit.isProcessed(),
visit.getVersion()
);
return visit.withId(id);
}
public Visit update(Visit visit) throws OptimisticLockException {
String sql = "UPDATE visits SET longitude = ?, latitude = ?, start_time = ?, end_time = ?, duration_seconds = ?, processed = ?, version = version + 1 WHERE id = ? AND version = ?";
int rowsUpdated = jdbcTemplate.update(sql,
visit.getLongitude(),
visit.getLatitude(),
Timestamp.from(visit.getStartTime()),
Timestamp.from(visit.getEndTime()),
visit.getDurationSeconds(),
visit.isProcessed(),
visit.getId(),
visit.getVersion()
);
if (rowsUpdated == 0) {
throw new OptimisticLockException("Visit with id " + visit.getId() + " was modified by another transaction or does not exist");
}
return visit.withVersion(visit.getVersion() + 1);
}
public Optional<Visit> findById(Long id) {
String sql = "SELECT v.* " +
"FROM visits v " +
"WHERE v.id = ?";
List<Visit> results = jdbcTemplate.query(sql, VISIT_ROW_MAPPER, id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
}
public List<Visit> findAllByIds(List<Long> visitIds) {
if (visitIds == null || visitIds.isEmpty()) {
return List.of();
}
String placeholders = String.join(",", visitIds.stream().map(_ -> "?").toList());
String sql = "SELECT v.* FROM visits v WHERE v.id IN (" + placeholders + ")";
return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, visitIds.toArray());
}
@SuppressWarnings("SqlWithoutWhere")
public void deleteAll() {
String sql = "DELETE FROM visits";
jdbcTemplate.update(sql);
}
public void deleteAllForUser(User user) {
String sql = "DELETE FROM visits WHERE user_id = ?";
jdbcTemplate.update(sql, user.getId());
}
public void deleteAllForUserBetween(User user, Instant start, Instant end) {
jdbcTemplate.update("DELETE FROM visits WHERE user_id = ? AND start_time <= ? AND end_time >= ?", user.getId(), Timestamp.from(end), Timestamp.from(start));
}
public List<Visit> findByUserAndTimeAfterAndStartTimeBefore(User user, Instant windowStart, Instant windowEnd) {
String sql = "SELECT v.* " +
"FROM visits v " +
"WHERE v.user_id = ? AND v.end_time >= ? AND v.start_time <= ? " +
"ORDER BY v.start_time";
return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(),
Timestamp.from(windowStart), Timestamp.from(windowEnd));
}
public List<Visit> bulkInsert(User user, List<Visit> visitsToInsert) {
if (visitsToInsert.isEmpty()) {
return new ArrayList<>();
}
List<Visit> createdVisits = new ArrayList<>();
String sql = """
INSERT INTO visits (user_id, latitude, longitude, start_time, end_time, duration_seconds, processed, version)
VALUES (?, ?, ?, ?, ?, ?, false, 1) ON CONFLICT (user_id, start_time, end_time) DO UPDATE SET
user_id = EXCLUDED.user_id,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
start_time = EXCLUDED.start_time,
end_time = EXCLUDED.end_time,
duration_seconds = EXCLUDED.duration_seconds,
processed = EXCLUDED.processed,
id = visits.id,
version = visits.version + 1;
""";
List<Object[]> batchArgs = visitsToInsert.stream()
.map(visit -> new Object[]{
user.getId(),
visit.getLatitude(),
visit.getLongitude(),
Timestamp.from(visit.getStartTime()),
Timestamp.from(visit.getEndTime()),
visit.getDurationSeconds()
})
.collect(Collectors.toList());
int[] updateCounts = jdbcTemplate.batchUpdate(sql, batchArgs);
for (int i = 0; i < updateCounts.length; i++) {
int updateCount = updateCounts[i];
if (updateCount > 0) {
createdVisits.addAll(this.findByUserAndStartTimeAndEndTime(user, visitsToInsert.get(i).getStartTime(), visitsToInsert.get(i).getEndTime()));
}
}
return createdVisits;
}
public void delete(List<Visit> affectedVisits) throws OptimisticLockException {
if (affectedVisits == null || affectedVisits.isEmpty()) {
return;
}
// Check versions for all visits before deleting any
for (Visit visit : affectedVisits) {
String checkSql = "SELECT version FROM visits WHERE id = ?";
List<Long> versions = jdbcTemplate.queryForList(checkSql, Long.class, visit.getId());
if (versions.isEmpty()) {
throw new OptimisticLockException("Visit with id " + visit.getId() + " does not exist");
}
if (!versions.getFirst().equals(visit.getVersion())) {
throw new OptimisticLockException("Visit with id " + visit.getId() + " was modified by another transaction");
}
}
String placeholders = String.join(",", affectedVisits.stream().map(_ -> "?").toList());
String sql = "DELETE FROM visits WHERE id IN (" + placeholders + ")";
Object[] ids = affectedVisits.stream().map(Visit::getId).toArray();
jdbcTemplate.update(sql, ids);
}
public void deleteAllForUserAfter(User user, Instant start) {
jdbcTemplate.update("DELETE FROM visits WHERE user_id = ? AND end_time >= ?", user.getId(), Timestamp.from(start));
}
public long count() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM visits", Long.class);
}
}

View File

@@ -0,0 +1,55 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
@Service
public class DataCleanupService {
private static final Logger log = LoggerFactory.getLogger(DataCleanupService.class);
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final SignificantPlaceJdbcService significantPlaceJdbcService;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final DefaultImportProcessor defaultImportProcessor;
public DataCleanupService(TripJdbcService tripJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
SignificantPlaceJdbcService significantPlaceJdbcService,
RawLocationPointJdbcService rawLocationPointJdbcService,
DefaultImportProcessor defaultImportProcessor) {
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.significantPlaceJdbcService = significantPlaceJdbcService;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.defaultImportProcessor = defaultImportProcessor;
}
public void cleanupForGeometryChange(User user, List<SignificantPlace> placesToRemove, List<LocalDate> affectedDays) {
long start = System.nanoTime();
log.info("Cleanup for geometry change. Removing [{}] places and starting recalculation for days [{}]", placesToRemove.size(), affectedDays);
log.debug("Removing affected trips for places [{}]", placesToRemove);
this.tripJdbcService.deleteFor(user, placesToRemove);
log.debug("Removing affected visits for places [{}]", placesToRemove);
this.processedVisitJdbcService.deleteFor(user, placesToRemove);
log.debug("Removing places [{}]", placesToRemove);
this.significantPlaceJdbcService.deleteForUser(user, placesToRemove);
log.info("Cleanup for geometry change completed in {}ms", (System.nanoTime() - start) / 1000000);
start = System.nanoTime();
this.rawLocationPointJdbcService.markAllAsUnprocessedForUser(user, affectedDays);
log.info("clearing processed points for days [{}] completed in {}ms", affectedDays, (System.nanoTime() - start) / 1000000);
this.defaultImportProcessor.scheduleProcessingTrigger(user.getUsername());
}
}

View File

@@ -0,0 +1,179 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.GeoUtils;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Service
public class PlaceChangeDetectionService {
private static final Logger log = LoggerFactory.getLogger(PlaceChangeDetectionService.class);
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final SignificantPlaceJdbcService placeJdbcService;
private final I18nService i18nService;
private final ObjectMapper objectMapper;
public PlaceChangeDetectionService(ProcessedVisitJdbcService processedVisitJdbcService,
SignificantPlaceJdbcService placeJdbcService,
I18nService i18nService,
ObjectMapper objectMapper) {
this.processedVisitJdbcService = processedVisitJdbcService;
this.placeJdbcService = placeJdbcService;
this.i18nService = i18nService;
this.objectMapper = objectMapper;
}
public PlaceChangeAnalysis analyzeChanges(User user, Long placeId, String polygonData) {
try {
SignificantPlace currentPlace = placeJdbcService.findById(placeId).orElseThrow();
List<String> warnings = new ArrayList<>();
// Analyze polygon changes
boolean changed = analyzePolygonChanges(currentPlace, polygonData, warnings);
// Check for overlapping places
changed = changed | analyzeOverlappingPlaces(user, placeId, polygonData, warnings);
//check for affected days only when places got merged or the polygon changed significantly
if (changed) {
calculateAffectedDays(user, currentPlace, polygonData, warnings);
}
return new PlaceChangeAnalysis(warnings.isEmpty(), warnings);
} catch (Exception e) {
return new PlaceChangeAnalysis(false, List.of(i18nService.translate("places.warning.general_error", e.getMessage())));
}
}
private void calculateAffectedDays(User user, SignificantPlace currentPlace, String polygonData, List<String> warnings) throws Exception {
List<SignificantPlace> overlappingPlaces;
if (polygonData != null) {
overlappingPlaces = new ArrayList<>(checkForOverlappingPlaces(user, currentPlace.getId(), Collections.emptyList()));
overlappingPlaces.add(currentPlace);
} else {
overlappingPlaces = List.of(currentPlace);
}
int affectedDays = this.processedVisitJdbcService.getAffectedDays(overlappingPlaces).size();
if (affectedDays > 0) {
warnings.add(i18nService.translate("places.warning.overlapping.recalculation_hint", affectedDays));
}
}
private boolean analyzePolygonChanges(SignificantPlace currentPlace, String polygonData, List<String> warnings) {
boolean changed = false;
boolean hadPolygon = currentPlace.getPolygon() != null && !currentPlace.getPolygon().isEmpty();
boolean willHavePolygon = polygonData != null && !polygonData.trim().isEmpty();
if (hadPolygon && !willHavePolygon) {
warnings.add(i18nService.translate("places.warning.polygon.removal"));
changed = true;
}
if (!hadPolygon && willHavePolygon) {
warnings.add(i18nService.translate("places.warning.polygon.addition"));
changed = true;
}
// Check if polygon is being significantly changed
if (hadPolygon && willHavePolygon) {
try {
List<GeoPoint> newPolygon = parsePolygonData(polygonData);
GeoPoint newCentroid = GeoUtils.calculatePolygonCentroid(newPolygon);
GeoPoint currentCentroid = new GeoPoint(currentPlace.getLatitudeCentroid(), currentPlace.getLongitudeCentroid());
double currentArea = GeoUtils.calculatePolygonArea(currentPlace.getPolygon());
double newArea = GeoUtils.calculatePolygonArea(newPolygon);
// Calculate distance between centroids (rough approximation)
double latDiff = Math.abs(newCentroid.latitude() - currentCentroid.latitude());
double lngDiff = Math.abs(newCentroid.longitude() - currentCentroid.longitude());
// If centroid moved significantly (more than ~10m at typical latitudes)
if (latDiff > 0.0001 || lngDiff > 0.0001 || Math.abs(newArea - currentArea) > 1) {
warnings.add(i18nService.translate("places.warning.polygon.significant_change"));
changed = true;
}
} catch (Exception e) {
// If polygon parsing fails, we'll catch it in the actual update
}
}
return changed;
}
private boolean analyzeOverlappingPlaces(User user, Long placeId, String polygonData, List<String> warnings) {
boolean willHavePolygon = polygonData != null && !polygonData.trim().isEmpty();
if (willHavePolygon) {
try {
List<GeoPoint> newPolygon = parsePolygonData(polygonData);
int overlappingPlaces = checkForOverlappingPlaces(user, placeId, newPolygon).size();
if (overlappingPlaces > 0) {
warnings.add(i18nService.translate("places.warning.overlapping.visits", overlappingPlaces));
return true;
}
} catch (Exception e) {
throw new IllegalStateException("Failed to parse polygon data: " + e.getMessage(), e);
}
}
return false;
}
private List<GeoPoint> parsePolygonData(String polygonData) throws Exception {
JsonNode jsonNode = objectMapper.readTree(polygonData);
List<GeoPoint> geoPoints = new ArrayList<>();
if (jsonNode.isArray()) {
for (JsonNode pointNode : jsonNode) {
if (pointNode.has("lat") && pointNode.has("lng")) {
double lat = pointNode.get("lat").asDouble();
double lng = pointNode.get("lng").asDouble();
geoPoints.add(new GeoPoint(lat, lng));
} else {
throw new IllegalArgumentException("Each point must have 'lat' and 'lng' properties");
}
}
} else {
throw new IllegalArgumentException("Polygon data must be an array of coordinate objects");
}
if (geoPoints.size() < 3) {
throw new IllegalArgumentException("Polygon must have at least 3 points");
}
return geoPoints;
}
private List<SignificantPlace> checkForOverlappingPlaces(User user, Long placeId, List<GeoPoint> newPolygon) {
return placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, newPolygon);
}
public static class PlaceChangeAnalysis {
private final boolean canProceed;
private final List<String> warnings;
public PlaceChangeAnalysis(boolean canProceed, List<String> warnings) {
this.canProceed = canProceed;
this.warnings = warnings;
}
public boolean isCanProceed() {
return canProceed;
}
public List<String> getWarnings() {
return warnings;
}
}
}

View File

@@ -27,7 +27,6 @@ public class UserService {
private final VisitDetectionParametersJdbcService visitDetectionParametersJdbcService;
private final TransportModeJdbcService transportModeJdbcService;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final VisitJdbcService visitJdbcService;
private final SignificantPlaceJdbcService significantPlaceJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final GeocodingResponseJdbcService geocodingResponseJdbcService;
@@ -39,7 +38,6 @@ public class UserService {
VisitDetectionParametersJdbcService visitDetectionParametersJdbcService,
TransportModeJdbcService transportModeJdbcService,
RawLocationPointJdbcService rawLocationPointJdbcService,
VisitJdbcService visitJdbcService,
SignificantPlaceJdbcService significantPlaceJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
GeocodingResponseJdbcService geocodingResponseJdbcService,
@@ -50,7 +48,6 @@ public class UserService {
this.visitDetectionParametersJdbcService = visitDetectionParametersJdbcService;
this.transportModeJdbcService = transportModeJdbcService;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.visitJdbcService = visitJdbcService;
this.significantPlaceJdbcService = significantPlaceJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.geocodingResponseJdbcService = geocodingResponseJdbcService;
@@ -138,7 +135,6 @@ public class UserService {
this.processedVisitJdbcService.deleteAllForUser(user);
this.significantPlaceJdbcService.deleteForUser(user);
this.significantPlaceJdbcService.deleteForUser(user);
this.visitJdbcService.deleteAllForUser(user);
this.rawLocationPointJdbcService.deleteAllForUser(user);
this.rawLocationPointJdbcService.deleteAllForUser(user);
this.apiTokenJdbcService.deleteForUser(user);

View File

@@ -1,6 +1,8 @@
package com.dedicatedcode.reitti.service.integration;
import com.dedicatedcode.reitti.dto.*;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.integration.ReittiIntegration;
import com.dedicatedcode.reitti.model.security.RemoteUser;
import com.dedicatedcode.reitti.model.security.User;
@@ -492,7 +494,9 @@ public class ReittiIntegrationService {
private ProcessedVisitResponse.PlaceVisitSummary parsePlaceVisitSummary(Map<String, Object> placeData) {
// Parse place info
Map<String, Object> placeInfo = (Map<String, Object>) placeData.get("place");
ProcessedVisitResponse.PlaceInfo place = new ProcessedVisitResponse.PlaceInfo(
List<GeoPoint> polygon = mapToPolygon(placeInfo.get("polygon"));
PlaceInfo place = new PlaceInfo(
getLongValue(placeInfo, "id"),
(String) placeInfo.get("name"),
(String) placeInfo.get("address"),
@@ -500,7 +504,8 @@ public class ReittiIntegrationService {
(String) placeInfo.get("countryCode"),
getDoubleValue(placeInfo, "lat"),
getDoubleValue(placeInfo, "lng"),
(String) placeInfo.get("type")
SignificantPlace.PlaceType.valueOf(placeInfo.get("type").toString()),
polygon
);
// Parse visits
@@ -521,6 +526,23 @@ public class ReittiIntegrationService {
return new ProcessedVisitResponse.PlaceVisitSummary(place, visits, totalDurationMs, visitCount, color);
}
private List<GeoPoint> mapToPolygon(Object polygonObj) {
if (polygonObj == null) {
return null;
}
// The remote JSON is deserialized by RestTemplate into a List of LinkedHashMap
List<Map<String, Object>> rawList = (List<Map<String, Object>>) polygonObj;
List<GeoPoint> polygon = new ArrayList<>(rawList.size());
for (Map<String, Object> pointMap : rawList) {
Double lat = getDoubleValue(pointMap, "latitude");
Double lng = getDoubleValue(pointMap, "longitude");
if (lat != null && lng != null) {
polygon.add(GeoPoint.from(lat, lng));
}
}
return polygon;
}
private Long getLongValue(Map<String, Object> map, String key) {
Object value = map.get(key);

View File

@@ -40,8 +40,6 @@ public class UnifiedLocationProcessingService {
private final UserJdbcService userJdbcService;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService;
private final VisitJdbcService visitJdbcService;
private final PreviewVisitJdbcService previewVisitJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final PreviewProcessedVisitJdbcService previewProcessedVisitJdbcService;
private final TripJdbcService tripJdbcService;
@@ -61,8 +59,6 @@ public class UnifiedLocationProcessingService {
UserJdbcService userJdbcService,
RawLocationPointJdbcService rawLocationPointJdbcService,
PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService,
VisitJdbcService visitJdbcService,
PreviewVisitJdbcService previewVisitJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
PreviewProcessedVisitJdbcService previewProcessedVisitJdbcService,
TripJdbcService tripJdbcService,
@@ -70,7 +66,8 @@ public class UnifiedLocationProcessingService {
SignificantPlaceJdbcService significantPlaceJdbcService,
PreviewSignificantPlaceJdbcService previewSignificantPlaceJdbcService,
SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService,
VisitDetectionParametersService visitDetectionParametersService, PreviewVisitDetectionParametersJdbcService previewVisitDetectionParametersJdbcService,
VisitDetectionParametersService visitDetectionParametersService,
PreviewVisitDetectionParametersJdbcService previewVisitDetectionParametersJdbcService,
TransportModeService transportModeService,
UserNotificationService userNotificationService,
GeoLocationTimezoneService timezoneService,
@@ -79,8 +76,6 @@ public class UnifiedLocationProcessingService {
this.userJdbcService = userJdbcService;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.previewRawLocationPointJdbcService = previewRawLocationPointJdbcService;
this.visitJdbcService = visitJdbcService;
this.previewVisitJdbcService = previewVisitJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.previewProcessedVisitJdbcService = previewProcessedVisitJdbcService;
this.tripJdbcService = tripJdbcService;
@@ -238,28 +233,27 @@ public class UnifiedLocationProcessingService {
}
detectionParams = currentConfiguration.getVisitDetection();
// Find and delete affected visits
List<Visit> affectedVisits;
List<ProcessedVisit> existingProcessedVisits;
if (previewId == null) {
affectedVisits = visitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, windowStart, windowEnd);
visitJdbcService.delete(affectedVisits);
existingProcessedVisits = processedVisitJdbcService
.findByUserAndStartTimeBeforeEqualAndEndTimeAfterEqual(user, windowEnd, windowStart);
} else {
affectedVisits = previewVisitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, previewId, windowStart, windowEnd);
previewVisitJdbcService.delete(affectedVisits);
existingProcessedVisits = previewProcessedVisitJdbcService
.findByUserAndStartTimeBeforeEqualAndEndTimeAfterEqual(user, previewId, windowEnd, windowStart);
}
// Expand the window based on deleted visits
if (!affectedVisits.isEmpty()) {
if (affectedVisits.getFirst().getStartTime().isBefore(windowStart)) {
windowStart = affectedVisits.getFirst().getStartTime();
// Expand window based on deleted processed visits
if (!existingProcessedVisits.isEmpty()) {
if (existingProcessedVisits.getFirst().getStartTime().isBefore(windowStart)) {
windowStart = existingProcessedVisits.getFirst().getStartTime();
}
if (affectedVisits.getLast().getEndTime().isAfter(windowEnd)) {
windowEnd = affectedVisits.getLast().getEndTime();
if (existingProcessedVisits.getLast().getEndTime().isAfter(windowEnd)) {
windowEnd = existingProcessedVisits.getLast().getEndTime();
}
}
// Get clustered points
double baseLatitude = affectedVisits.isEmpty() ? 50 : affectedVisits.getFirst().getLatitude();
double baseLatitude = existingProcessedVisits.isEmpty() ? 50 : existingProcessedVisits.getFirst().getPlace().getLatitudeCentroid();
double metersAsDegrees = GeoUtils.metersToDegreesAtPosition((double) currentConfiguration.getVisitMerging().getMinDistanceBetweenVisits() / 2, baseLatitude);
List<ClusteredPoint> clusteredPoints;
@@ -786,7 +780,7 @@ public class UnifiedLocationProcessingService {
Point point = geometryFactory.createPoint(new Coordinate(longitude, latitude));
// Find places within the merge distance
if (previewId == null) {
return significantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() / 2, latitude));
return significantPlaceJdbcService.findEnclosingPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() / 2, latitude));
} else {
return previewSignificantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() /2, latitude), previewId);
}

View File

@@ -0,0 +1,6 @@
ALTER TABLE significant_places ADD COLUMN polygon GEOMETRY(POLYGON, 4326);
ALTER TABLE preview_significant_places ADD COLUMN polygon GEOMETRY(POLYGON, 4326);
CREATE INDEX idx_significant_places_polygon ON significant_places USING GIST (polygon);
CREATE INDEX idx_preview_significant_places_polygon ON preview_significant_places USING GIST (polygon);

View File

@@ -0,0 +1,2 @@
DROP TABLE visits;
DROP TABLE preview_visits;

View File

@@ -1,7 +1,7 @@
# Page titles
page.title=Reitti - Your Location Timeline
statistics.page.title=Statistics - Reitti
edit-place.page.title=Edit Place - Reitti
# Navigation
nav.timeline=Timeline
nav.statistics=Statistics
@@ -461,10 +461,12 @@ units.imperial.description=(mi, ft)
# Places
places.title=Significant Places
places.no.places=No significant places found.
places.no.places=No significant places were found.
places.page.info=Page {0} of {1}
places.name.label=Name
places.address.label=Address
places.city.label=City
places.country.label=Country
places.category.label=Category
places.coordinates.label=Coordinates
places.address.not.available=Not available
@@ -474,9 +476,9 @@ places.geocode.confirm=Are you sure you want to re-geocode this place? This will
places.geocode.success=Place scheduled for geocoding
places.geocode.error=Error scheduling place for geocoding: {0}
places.address.placeholder=Enter address
places.geocoding.response.button=View Geocoding
places.geocoding.response.button=View Geocoding Response
places.geocoding.response.title=Geocoding Response for {0}
places.geocoding.response.no.data=No geocoding response available for this place
places.geocoding.response.no.data=No geocoding response is available for this place
places.geocoding.response.back=Back to Places
places.geocoding.response.provider=Provider
places.geocoding.response.status=Status
@@ -491,6 +493,16 @@ places.edit.visit.stats.title=Visit Statistics
places.edit.visit.summary=You visited {0} {1} times.
places.edit.visit.complete=You visited {0} {1} times. Your first visit was on {2} and your most recent visit was on {3}.
places.edit.no.visits=No visits recorded for this place yet.
places.polygon.remove=Remove Polygon
places.polygon.editor.subtitle=Edit Place boundaries
places.polygon.editor.instructions=Click and drag to draw a polygon around the place. Click an existing point to remove it.
places.warning.polygon.removal=The polygon boundary will be removed from this place, this may affect visit detection.
places.warning.polygon.addition=The polygon boundary will be added to this place, this may affect visit detection.
places.warning.polygon.significant_change=The polygon boundary will be significantly changed, which may affect visit detection.
places.warning.overlapping.visits=The new boundary will overlap with {0,choice,1#1 existing place|1<{0,number,integer} existing places}, which may cause visits to be reassigned between places and affect trip calculations
places.warning.overlapping.recalculation_hint=The new boundary will trigger recalulation of {0,choice,1#1 day|1<{0,number,integer} days} of data, which may take a few minutes to complete.
places.warning.general_error=An error occurred while checking the update: {0}
places.update.confirmation.message=The following changes will be made:\n\n{0}\n\nDo you want to continue?
place.type.train_station=Train Station
place.type.gas_station=Gas Station
place.type.restaurant=Restaurant
@@ -526,6 +538,7 @@ place.unknown.label=Unknown Place
form.create=Create
form.update=Update
form.delete=Delete
form.select.placeholder=Select...
form.cancel=Cancel
form.save.changes=Save Changes
form.save=Save

View File

@@ -16,6 +16,7 @@
border: 1px solid wheat;
color: wheat;
border-radius: 4px;
text-decoration: none;
}
.timeline-entry.trip.active:hover .edit-icon,

View File

@@ -178,10 +178,21 @@ header {
font-family: var(--sans-font);
}
a.btn-default,
.btn-default {
border: 1px solid var(--color-highlight);
}
.btn-block + .btn-block {
margin-top: 10px;
}
.btn.btn-block {
width: 100%;
display: inline-block;
box-sizing: border-box;}
box-sizing: border-box;
text-align: center;
}
.btn.btn-danger {
background: #995353;
@@ -353,6 +364,11 @@ tr:hover {
.form-group {
margin-bottom: 15px;
}
.form-group label {
color: var(--color-text-white);
}
.queue-status {
display: flex;
gap: 20px;
@@ -468,9 +484,6 @@ tr:hover {
color: var(--color-text-white);
}
.place-details {
padding: 15px;
}
.place-info {
margin: 15px 0;
@@ -948,6 +961,19 @@ body.auto-update-mode .today-fab {
padding: 1rem;
text-align: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
}
.place-stats-card .place-details {
padding: 15px;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.place-stats-card .place-info {
flex-grow: 1;
}
.place-stats-card:hover {
@@ -1813,7 +1839,6 @@ button:disabled {
border-collapse: collapse;
}
.export-data-table-container table thead tr {
position: sticky;
top: 0;

View File

@@ -26,6 +26,11 @@ class CanvasVisitRenderer {
this.map.on('zoomend', () => {
this.updateVisibleVisits();
});
// Listen for zoom changes to re-render markers (for polygon/circle switching)
this.map.on('zoomend', () => {
this.renderVisibleVisits();
});
}
setVisits(visits) {
@@ -51,11 +56,10 @@ class CanvasVisitRenderer {
updateVisibleVisits() {
const zoom = this.map.getZoom();
// Filter visits based on zoom level and duration
let minDurationMs;
let minDurationMs = 0;
if (zoom >= 15) {
minDurationMs = 5 * 60 * 1000; // 5 minutes at high zoom
minDurationMs = 60 * 1000; // 1 minute at high zoom
} else if (zoom >= 12) {
minDurationMs = 30 * 60 * 1000; // 30 minutes at medium zoom
} else if (zoom >= 10) {
@@ -63,11 +67,11 @@ class CanvasVisitRenderer {
} else {
minDurationMs = 6 * 60 * 60 * 1000; // 6+ hours at very low zoom
}
this.visibleVisits = this.allVisits.filter(visit =>
this.visibleVisits = this.allVisits.filter(visit =>
visit.totalDurationMs >= minDurationMs
);
this.renderVisibleVisits();
}
@@ -84,13 +88,17 @@ class CanvasVisitRenderer {
});
}
createVisitMarkers() {
this.visibleVisits.forEach(visit => {
this.createVisitMarker(visit);
});
createVisitMarker(visit) {
const zoom = this.map.getZoom();
const showPolygons = zoom >= 18; // Show polygons at zoom level 16 and above
if (showPolygons && visit.place.polygon) {
this.createPolygonMarker(visit);
} else {
this.createCircleMarker(visit);
}
}
createVisitMarker(visit) {
createCircleMarker(visit) {
// Calculate radius using logarithmic scale
const durationHours = visit.totalDurationMs / (1000 * 60 * 60);
const baseRadius = 15;
@@ -102,7 +110,7 @@ class CanvasVisitRenderer {
// Create outer circle (visit area)
const outerCircle = L.circle([visit.lat, visit.lng], {
radius: radius * 5, // Convert to meters (approximate)
radius: radius, // Convert to meters (approximate)
fillColor: this.lightenHexColor(visit.color, 20),
fillOpacity: 0.1,
color: visit.color,
@@ -145,6 +153,87 @@ class CanvasVisitRenderer {
this.visitMarkers.push(outerCircle, innerMarker);
}
createPolygonMarker(visit) {
// Parse polygon coordinates from array format (same as polygon-editor.js)
const polygonCoords = this.parsePolygonData(visit.place.polygon);
if (!polygonCoords || polygonCoords.length === 0) {
// Fallback to circle marker if polygon parsing fails
this.createCircleMarker(visit);
return;
}
// Create polygon
const polygon = L.polygon(polygonCoords, {
fillColor: this.lightenHexColor(visit.color, 20),
fillOpacity: 0.3,
color: visit.color,
weight: 2,
renderer: this.canvasRenderer,
interactive: true
});
const centerMarker = L.circleMarker([visit.lat, visit.lng], {
radius: 5,
fillOpacity: 1,
fillColor: this.lightenHexColor(visit.color, 80),
color: '#000',
weight: 1,
renderer: this.canvasRenderer,
interactive: true
});
// Create tooltip content
const totalDurationText = this.humanizeDuration(visit.totalDurationMs);
const visitCount = visit.visits.length;
const visitText = visitCount === 1 ? 'visit' : 'visits';
let tooltip = L.tooltip({
content: `<div class="visit-title">${visit.place.name}</div>
<div class="visit-description">
${visitCount} ${visitText} - Total: ${totalDurationText}
</div>`,
className: 'visit-popup',
permanent: false
});
polygon.bindTooltip(tooltip);
centerMarker.bindTooltip(tooltip);
this.map.addLayer(polygon);
this.map.addLayer(centerMarker);
// Store references for cleanup
this.visitMarkers.push(polygon, centerMarker);
}
parsePolygonData(polygonData) {
if (!polygonData) return null;
try {
// Handle both array format and JSON string format
let coords;
if (typeof polygonData === 'string') {
coords = JSON.parse(polygonData);
} else if (Array.isArray(polygonData)) {
coords = polygonData;
} else {
return null;
}
// Convert to Leaflet format [lat, lng]
return coords.map(point => {
// Handle both GeoPoint format (latitude/longitude) and simple lat/lng format
const lat = point.latitude || point.lat;
const lng = point.longitude || point.lng;
return [lat, lng];
});
} catch (error) {
console.warn('Failed to parse polygon data:', polygonData, error);
return null;
}
}
lightenHexColor(hex, percent) {
// Remove # if present
hex = hex.replace('#', '');

View File

@@ -0,0 +1,252 @@
/**
* Polygon Editor for SignificantPlaces
*/
class PolygonEditor {
constructor(map, centerLat, centerLng, placeName) {
this.map = map;
this.centerLat = centerLat;
this.centerLng = centerLng;
this.placeName = placeName;
this.polygonPoints = [];
this.polygonMarkers = [];
this.polygonLayer = null;
this.previewLine = null;
this.isDragging = false;
this.init();
}
init() {
// Add center marker for the place
this.centerMarker = L.marker([this.centerLat, this.centerLng], {
icon: L.divIcon({
className: 'center-marker',
html: '<div style="background: var(--color-highlight); width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(this.map);
this.centerMarker.bindTooltip(this.placeName + ' (center)', {
permanent: false,
direction: 'top'
});
// Add click handler for adding polygon points
this.map.on('click', (e) => {
// Don't add point if we're dragging
if (!this.isDragging) {
this.addPolygonPoint(e.latlng);
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.clearPolygon();
} else if (e.key === 'Enter' && e.ctrlKey) {
this.savePolygon();
} else if (e.key === 'z' && e.ctrlKey) {
e.preventDefault();
this.undoLastPoint();
}
});
}
addPolygonPoint(latlng) {
this.polygonPoints.push(latlng);
// Add marker for the point
const marker = L.marker(latlng, {
draggable: true,
icon: L.divIcon({
className: 'polygon-point-marker',
html: '<div style="background: var(--color-highlight); width: 8px; height: 8px; border-radius: 50%; border: 2px solid #daa520; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(this.map);
marker.bindTooltip(`Point ${this.polygonPoints.length}`, {
permanent: false,
direction: 'top'
});
// Add click handler to remove point
marker.on('click', (e) => {
L.DomEvent.stopPropagation(e);
const index = this.polygonMarkers.indexOf(marker);
this.removePolygonPoint(index);
});
// Add drag handlers to update polygon when point is moved
marker.on('dragstart', (e) => {
this.isDragging = true;
});
marker.on('drag', (e) => {
const index = this.polygonMarkers.indexOf(marker);
if (index >= 0) {
this.polygonPoints[index] = e.target.getLatLng();
this.updatePolygonDisplay();
}
});
marker.on('dragend', (e) => {
this.isDragging = false;
const index = this.polygonMarkers.indexOf(marker);
if (index >= 0) {
this.polygonPoints[index] = e.target.getLatLng();
this.updatePolygonDisplay();
}
});
this.polygonMarkers.push(marker);
this.updatePolygonDisplay();
}
removePolygonPoint(index) {
if (index >= 0 && index < this.polygonPoints.length) {
this.polygonPoints.splice(index, 1);
this.map.removeLayer(this.polygonMarkers[index]);
this.polygonMarkers.splice(index, 1);
// Update tooltips
this.polygonMarkers.forEach((marker, i) => {
marker.setTooltipContent(`Point ${i + 1}`);
});
this.updatePolygonDisplay();
}
}
undoLastPoint() {
if (this.polygonPoints.length > 0) {
this.removePolygonPoint(this.polygonPoints.length - 1);
}
}
updatePolygonDisplay() {
// Remove existing polygon
if (this.polygonLayer) {
this.map.removeLayer(this.polygonLayer);
this.polygonLayer = null;
}
// Remove preview line
if (this.previewLine) {
this.map.removeLayer(this.previewLine);
this.previewLine = null;
}
if (this.polygonPoints.length >= 3) {
// Create polygon
this.polygonLayer = L.polygon(this.polygonPoints, {
color: 'var(--color-highlight)',
weight: 2,
fillColor: 'var(--color-highlight)',
fillOpacity: 0.3
}).addTo(this.map);
} else if (this.polygonPoints.length === 2) {
// Show preview line to first point
const previewPoints = [...this.polygonPoints, this.polygonPoints[0]];
this.previewLine = L.polyline(previewPoints, {
color: '#6b7280',
weight: 2,
dashArray: '5, 5'
}).addTo(this.map);
}
this.updateSaveButton();
}
updateSaveButton() {
const saveBtn = document.getElementById('save-btn');
const polygonDataInput = document.getElementById('polygonData');
const saveStatusElement = document.getElementById('save-status');
if (this.polygonPoints.length === 0) {
// No polygon - this is valid, allow saving
saveBtn.disabled = false;
polygonDataInput.value = '';
if (saveStatusElement) {
saveStatusElement.textContent = '';
saveStatusElement.style.display = 'none';
}
} else if (this.polygonPoints.length >= 3) {
// Valid polygon - allow saving
saveBtn.disabled = false;
const polygonData = this.polygonPoints.map(point => ({
lat: point.lat,
lng: point.lng
}));
polygonDataInput.value = JSON.stringify(polygonData);
if (saveStatusElement) {
saveStatusElement.textContent = '';
saveStatusElement.style.display = 'none';
}
} else {
// Invalid polygon (1-2 points) - disable saving with explanation
saveBtn.disabled = true;
polygonDataInput.value = '';
if (saveStatusElement) {
saveStatusElement.textContent = `Polygon needs at least 3 points (currently ${this.polygonPoints.length})`;
saveStatusElement.style.display = 'block';
}
}
}
clearPolygon() {
this.polygonPoints = [];
this.polygonMarkers.forEach(marker => this.map.removeLayer(marker));
this.polygonMarkers = [];
if (this.polygonLayer) {
this.map.removeLayer(this.polygonLayer);
this.polygonLayer = null;
}
if (this.previewLine) {
this.map.removeLayer(this.previewLine);
this.previewLine = null;
}
this.updateSaveButton();
}
savePolygon() {
if (!document.getElementById('save-btn').disabled) {
document.getElementById('polygon-form').submit();
}
}
loadExistingPolygon(polygonData) {
if (polygonData && polygonData.length >= 3) {
polygonData.forEach(point => {
const lat = point.latitude || point.lat;
const lng = point.longitude || point.lng;
this.addPolygonPoint(L.latLng(lat, lng));
});
}
}
loadNearbyPlaces(nearbyPlaces) {
nearbyPlaces.forEach(place => {
if (place.id !== this.placeId) {
const marker = L.circleMarker([place.lat, place.lng], {
radius: 6,
fillColor: '#ffcccb',
color: '#ff6b6b',
weight: 1,
fillOpacity: 0.7
}).addTo(this.map);
marker.bindTooltip(place.name, {
permanent: false,
direction: 'top'
});
}
});
}
}

View File

@@ -234,12 +234,12 @@ class RawLocationLoader {
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
// Use polyline for segments with many points (>100), geodesic for fewer points
const weight = this.map.getZoom() >= 18 ? 2 : 6;
let rawPointsPath;
if (segment.points.length > 100) {
rawPointsPath = L.polyline(rawPointsCoords, {
color: color == null ? '#f1ba63' : color,
weight: 6,
weight: weight,
opacity: 0.9,
lineJoin: 'round',
lineCap: 'round',
@@ -248,7 +248,7 @@ class RawLocationLoader {
} else {
rawPointsPath = L.geodesic(rawPointsCoords, {
color: color == null ? '#f1ba63' : color,
weight: 6,
weight: weight,
opacity: 0.9,
lineJoin: 'round',
lineCap: 'round',

View File

@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
</head>
<body>
<!-- Edit form fragment -->
<span th:fragment="edit-form" class="place-name-container editing">
<form th:hx-put="@{/timeline/places/{id}(id=${place.id})}"
hx-target="closest .place-name-container"
hx-swap="outerHTML"
class="inline-edit-form">
<div class="form-group">
<label for="place-name" th:text="#{places.name.label}">Name</label>
<input type="text"
id="place-name"
name="name"
th:value="${place.name}"
class="place-name-input"
autofocus
required>
</div>
<div class="form-group">
<label for="category" th:text="#{places.category.label}">Category</label>
<select id="category" name="type" class="form-select">
<option th:each="placeType : ${placeTypes}"
th:value="${placeType.name()}"
th:selected="${placeType == place.type}"
th:text="#{${placeType.messageKey}}">Place Type</option>
</select>
</div>
<div class="form-buttons">
<button type="submit" class="btn" th:text="#{form.update}">Save</button>
<button type="button"
class="btn"
th:hx-get="@{/timeline/places/view/{id}(id=${place.id})}"
hx-target="closest .place-name-container"
hx-swap="outerHTML"
th:text="#{form.cancel}">Cancel</button>
</div>
</form>
</span>
<!-- View mode fragment -->
<span th:fragment="view-mode" class="place-name-container">
<span class="place-name" th:text="${place.name}">Place Name</span>
<i class="lni lni-pencil-1 edit-icon"
th:hx-get="@{/timeline/places/edit-form/{id}(id=${place.id})}"
hx-target="closest .place-name-container"
hx-swap="outerHTML"></i>
</span>
</body>
</html>

View File

@@ -2,22 +2,30 @@
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Geocoding Response Content Fragment -->
<div th:fragment="geocoding-response-content">
<h2 th:text="#{places.geocoding.response.title(${place.name()})}">Geocoding Response for Place</h2>
<div style="margin-bottom: 20px;">
<button class="btn"
th:attr="hx-get=${context == 'edit' ? '/settings/places/' + place.id() + '/edit?page=' + currentPage + '&search=' + search : '/settings/places/places-content?page=' + currentPage + '&search=' + search}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{places.geocoding.response.back}">Back to Places</button>
<div th:if="${context == 'edit'}" class="drawer-header">
<h3 class="drawer-title" th:text="#{places.geocoding.response.title(${place.name()})}">Geocoding Response for Place</h3>
<button type="button" class="drawer-close">&times;</button>
</div>
<div th:if="${context != 'edit'}">
<h2 th:text="#{places.geocoding.response.title(${place.name()})}">Geocoding Response for Place</h2>
<div style="margin-bottom: 20px;">
<button class="btn"
th:attr="hx-get=${'/settings/places/places-content?page=' + currentPage + '&search=' + search}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{places.geocoding.response.back}">Back to Places</button>
</div>
</div>
<div th:class="${context == 'edit' ? 'drawer-content' : ''}">
<div class="settings-card">
<h3 th:text="${place.name()}">Place Name</h3>
<p><strong th:text="#{places.address.label}">Address:</strong> <span th:text="${place.address() ?: #messages.msg('places.address.not.available')}">Address</span></p>
<p><strong th:text="#{places.coordinates.label}">Coordinates:</strong> <span th:text="${place.latitude() + ', ' + place.longitude()}">Coordinates</span></p>
<p><strong th:text="#{places.coordinates.label}">Coordinates:</strong> <span th:text="${place.lat() + ', ' + place.lng()}">Coordinates</span></p>
</div>
<div th:if="${!geocodingResponses.isEmpty()}" class="settings-card">
@@ -50,38 +58,12 @@
</div>
<div th:if="${geocodingResponses.isEmpty()}" class="settings-card">
<p th:text="#{places.geocoding.response.no.data}">No geocoding response available for this place</p>
<p th:text="#{places.geocoding.response.no.data}">No geocoding response is available for this place</p>
</div>
</div>
</div>
<!-- Edit Place Content Fragment -->
<div th:fragment="edit-place-content">
<h2 th:text="#{places.edit.title(${place.name()})}">Edit Place</h2>
<div style="margin-bottom: 20px;">
<button class="btn"
th:attr="hx-get=@{/settings/places/places-content(page=${currentPage}, search=${search})}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{form.close}">Close</button>
</div>
<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>
<!-- Large Interactive Map -->
<div class="settings-card" style="margin-bottom: 20px; height: 500px;">
<div class="place-map" th:id="'edit-map-' + ${place.id()}"
th:data-lat="${place.latitude()}"
th:data-lng="${place.longitude()}"
style="height: 100%; width: 100%;"></div>
</div>
<!-- Visit Statistics Section -->
<div>
<div class="settings-card" style="margin-bottom: 20px;">
<h3 th:text="#{places.edit.visit.stats.title}">Visit Statistics</h3>
<div th:if="${visitStats != null && visitStats.totalVisits > 0}" class="visit-stats">
@@ -101,50 +83,6 @@
</div>
</div>
<!-- Edit Form -->
<div class="settings-card">
<h3 th:text="#{places.edit.details.title}">Place Details</h3>
<form th:id="'edit-place-form-' + ${place.id()}" autocomplete="off"
th:attr="hx-post=@{/settings/places/{id}/update(id=${place.id()}, page=${currentPage}, search=${search})}"
hx-target="#places-management"
hx-swap="innerHTML">
<div class="form-group">
<label th:for="'edit-name-' + ${place.id()}" th:text="#{places.name.label}">Name</label>
<input type="text" th:id="'edit-name-' + ${place.id()}" name="name" th:value="${place.name()}" required>
</div>
<div class="form-group">
<label th:for="'edit-address-' + ${place.id()}" th:text="#{places.address.label}">Address</label>
<input type="text" th:id="'edit-address-' + ${place.id()}" name="address" th:value="${place.address()}" th:placeholder="#{places.address.placeholder}">
</div>
<div class="form-group">
<label th:for="'edit-type-' + ${place.id()}" th:text="#{places.category.label}">Category</label>
<select th:id="'edit-type-' + ${place.id()}" name="type" class="form-select">
<option th:each="placeType : ${placeTypes}"
th:value="${placeType.name()}"
th:selected="${placeType == place.type()}"
th:text="#{${placeType.messageKey}}">Place Type</option>
</select>
</div>
<div class="place-info">
<div><strong th:text="#{places.coordinates.label}">Coordinates:</strong> <span th:text="${place.latitude() + ', ' + place.longitude()}"></span></div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="submit" class="btn" th:text="#{form.update}">Update</button>
<button type="button"
class="btn btn-secondary"
th:attr="hx-post=@{/settings/places/{id}/geocode(id=${place.id()}, page=${currentPage}, search=${search})}, hx-confirm=#{places.geocode.confirm}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{places.geocode.button}">Geocode</button>
<button type="button"
class="btn btn-secondary"
th:attr="hx-get=@{/settings/places/{id}/geocoding-response(id=${place.id()}, page=${currentPage}, context='edit', search=${search})}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{places.geocoding.response.button}">View Geocoding</button>
</div>
</form>
</div>
</div>
</body>

View File

@@ -54,11 +54,9 @@
<!-- 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>
<a class="lni lni-pencil-1 edit-icon"
th:href="@{/settings/places/{id}/edit(id=${entry.place?.id})}"
th:if="${entry.place?.id != null}"></a>
</div>
<!-- Trip description -->

View File

@@ -31,6 +31,7 @@
<script src="/js/htmx.min.js"></script>
<script src="/js/leaflet.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
<script src="/js/polygon-editor.js"></script>
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
<script src="/js/leaflet.markercluster.js"></script>
<script src="/js/util.js"></script>
@@ -411,6 +412,9 @@
}
window.timelineScrollIndicator = new TimelineScrollIndicator();
window.timelineScrollIndicator.init();
// Update edit place links with current URL
updateEditPlaceLinks();
}
});
// Function to update map markers from processed visits API
@@ -483,7 +487,8 @@
visits: placeSummary.visits.map(v => ({ id: v.id })),
place: {
name: place.name || 'Unknown Place',
address: place.address || ''
address: place.address || '',
polygon: place.polygon || null
},
color: color
};
@@ -1020,6 +1025,18 @@
window.horizontalDatePicker.setSelectedRange(getCurrentLocalDate(), null);
}
}
function updateEditPlaceLinks() {
const currentUrl = window.location.href;
const editLinks = document.querySelectorAll('.timeline-container .edit-icon[href*="/settings/places/"]');
editLinks.forEach(link => {
const href = link.getAttribute('href');
const url = new URL(href, window.location.origin);
url.searchParams.set('returnUrl', currentUrl);
link.setAttribute('href', url.toString());
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,483 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="#{edit-place.page.title}">Edit Place - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" href="/css/leaflet.css">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/lineicons.css">
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
#map {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.sidebar {
background: var(--color-background-dark);
border: 1px solid var(--color-highlight);
border-radius: 8px;
box-shadow: var(--box-shadow);
z-index: 1000;
overflow-y: auto;
max-height: calc(100vh - 40px);
}
.polygon-editor-sidebar {
position: absolute;
top: 24px;
left: 24px;
width: 324px;
}
.sidebar-header {
padding: 24px;
border-bottom: 1px solid var(--color-highlight);
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px 0;
color: var(--color-highlight);
}
.sidebar-subtitle {
font-size: 14px;
color: var(--color-text-white);
margin: 0;
opacity: 0.8;
}
.sidebar-content {
padding: 24px;
}
.polygon-info {
background: var(--color-background-dark-light);
border-radius: 6px;
padding: 12px;
font-size: 13px;
color: var(--color-text-white);
margin-bottom: 16px;
opacity: 0.9;
}
.coordinates-display {
font-size: 12px;
color: var(--color-text-white);
margin-top: 4px;
opacity: 0.7;
}
.geocoding-drawer {
position: absolute;
top: 24px;
right: 24px;
display: none;
width: calc(100vw - 390px);
}
.geocoding-drawer.active {
display: block;
}
.drawer-header {
padding: 24px;
border-bottom: 1px solid var(--color-highlight);
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--color-highlight);
}
.drawer-close {
background: none;
border: none;
color: var(--color-text-white);
font-size: 18px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.drawer-content {
padding: 24px;
}
</style>
<script src="/js/leaflet.js"></script>
<script src="/js/polygon-editor.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
<script src="/js/htmx.min.js"></script>
</head>
<body>
<div id="map"></div>
<div class="sidebar polygon-editor-sidebar">
<div class="sidebar-header">
<h1 class="sidebar-title" th:text="${place.name()}">Place Name</h1>
<p class="sidebar-subtitle" th:text="#{places.polygon.editor.subtitle}">Edit polygon boundary</p>
<div class="coordinates-display">
<span th:text="#{places.coordinates.label}">Coordinates</span>:
<span id="current-lat" th:text="${#numbers.formatDecimal(place.lat(), 1, 6)}">0.000000</span>,
<span id="current-lng" th:text="${#numbers.formatDecimal(place.lng(), 1, 6)}">0.000000</span>
</div>
</div>
<div class="sidebar-content">
<div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div>
<div class="polygon-info" th:text="#{places.polygon.editor.instructions}">
Click on the map to add polygon points. The polygon will automatically close when you have 3 or more points.
</div>
<form id="polygon-form" th:action="@{/settings/places/{placeId}/update(placeId=${place.id()})}" method="post">
<input type="hidden" name="returnUrl" th:value="${returnUrl}">
<input type="hidden" name="polygonData" id="polygonData">
<div class="form-group">
<label for="name" th:text="#{places.name.label}">Name</label>
<input id="name" type="text" name="name" th:value="${place.name()}" required>
</div>
<div class="form-group">
<label for="address" th:text="#{places.address.label}">Address</label>
<input id="address" type="text" name="address" th:value="${place.address()}">
</div>
<div class="form-group">
<label for="city" th:text="#{places.city.label}">City</label>
<input id="city" type="text" name="city" th:value="${place.city()}">
</div>
<div class="form-group">
<label for="country" th:text="#{places.country.label}">Country</label>
<select id="country" name="countryCode">
<option value="" th:text="#{form.select.placeholder}">Select...</option>
<option th:each="country : ${availableCountries}"
th:value="${country.code}"
th:text="#{${country.messageKey}}"
th:selected="${country.code == place.countryCode()}">Country</option>
</select>
</div>
<div class="form-group">
<label th:text="#{places.category.label}">Type</label>
<select name="type">
<option th:each="placeType : ${placeTypes}"
th:value="${placeType}"
th:text="#{${placeType.messageKey}}"
th:selected="${placeType == place.type()}">Type</option>
</select>
</div>
</form>
<div class="polygon-actions">
<button type="button" id="remove-polygon-btn" class="btn btn-danger"
th:text="#{places.polygon.remove}">Remove Polygon</button>
<form th:action="@{/settings/places/{placeId}/geocode(placeId=${place.id()})}" method="post" style="margin-top: 10px;">
<input type="hidden" name="returnUrl" th:value="${returnUrl}">
<button type="submit" class="btn btn-default btn-block"
th:text="#{places.geocode.button}">Geocode Place</button>
</form>
<button type="button" class="btn btn-default btn-block"
th:attr="hx-get=@{/settings/places/{placeId}/geocoding-response(placeId=${place.id()}, context='edit')}"
hx-target="#geocoding-drawer"
hx-swap="innerHTML"
style="margin-top: 10px;"
th:text="#{places.geocoding.response.button}">View Geocoding Response</button>
<div class="separator"></div>
<div>
<button type="button" id="save-btn" class="btn btn-default btn-block" th:text="#{form.save}">Save</button>
<a th:href="${returnUrl}" class="btn btn-default btn-block" th:text="#{form.cancel}">Cancel</a>
</div>
</div>
</div>
</div>
<div id="geocoding-drawer" class="sidebar geocoding-drawer">
<!-- Geocoding response content will be loaded here via HTMX -->
</div>
<script th:inline="javascript">
const placeData = /*[[${place}]]*/ {};
window.userSettings = /*[[${userSettings}]]*/ {};
// Initialize the map
const map = L.map('map', {
zoomControl: false,
attributionControl: false
}).setView([placeData.lat, placeData.lng], 25);
// Add tile layer based on user settings
const tilesUrl = window.userSettings.tiles.service;
const tilesAttribution = window.userSettings.tiles.attribution;
const tileLayer = window.userSettings.preferColoredMap ? L.tileLayer : L.tileLayer.grayscale;
tileLayer(tilesUrl, {
maxZoom: 19,
attribution: tilesAttribution
}).addTo(map);
L.control.attribution({position: 'topright'}).addAttribution(tilesAttribution).addTo(map);
// Initialize polygon editor
const polygonEditor = new PolygonEditor(map, placeData.lat, placeData.lng, placeData.name);
// Create centroid marker
let centroidMarker = L.circleMarker([placeData.lat, placeData.lng], {
radius: 8,
fillColor: '#ff0000',
color: '#ffffff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
centroidMarker.bindTooltip('Centroid', { permanent: false, direction: 'top' });
// Load existing polygon if it exists
if (placeData.polygon && placeData.polygon.length >= 3) {
polygonEditor.loadExistingPolygon(placeData.polygon);
// Calculate and show centroid for existing polygon
const centroid = calculatePolygonCentroid(placeData.polygon);
if (centroid && !isNaN(centroid.lat) && !isNaN(centroid.lng)) {
updateCoordinatesDisplay(centroid.lat, centroid.lng);
}
}
// Function to calculate polygon centroid
function calculatePolygonCentroid(points) {
if (!points || points.length === 0) {
return { lat: placeData.lat, lng: placeData.lng };
}
let validPoints = [];
// Handle different point formats and filter valid points
for (let point of points) {
let lat, lng;
if (point.lat !== undefined && point.lng !== undefined) {
lat = point.lat;
lng = point.lng;
} else if (point.latitude !== undefined && point.longitude !== undefined) {
lat = point.latitude;
lng = point.longitude;
} else if (Array.isArray(point) && point.length >= 2) {
lat = point[0];
lng = point[1];
} else {
continue; // Skip invalid points
}
// Validate that lat/lng are numbers
if (typeof lat === 'number' && typeof lng === 'number' &&
!isNaN(lat) && !isNaN(lng) &&
lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
validPoints.push({ lat: lat, lng: lng });
}
}
if (validPoints.length === 0) {
return { lat: placeData.lat, lng: placeData.lng };
}
// Remove duplicate points (especially the closing point that duplicates the first point)
let uniquePoints = [];
for (let i = 0; i < validPoints.length; i++) {
const point = validPoints[i];
const isDuplicate = uniquePoints.some(existing =>
Math.abs(existing.lat - point.lat) < 0.000001 &&
Math.abs(existing.lng - point.lng) < 0.000001
);
if (!isDuplicate) {
uniquePoints.push(point);
}
}
if (uniquePoints.length === 0) {
return { lat: placeData.lat, lng: placeData.lng };
}
const avgLat = uniquePoints.reduce((sum, point) => sum + point.lat, 0) / uniquePoints.length;
const avgLng = uniquePoints.reduce((sum, point) => sum + point.lng, 0) / uniquePoints.length;
return { lat: avgLat, lng: avgLng };
}
// Function to update coordinates display and centroid marker
function updateCoordinatesDisplay(lat, lng) {
// Validate coordinates before updating
if (typeof lat !== 'number' || typeof lng !== 'number' ||
isNaN(lat) || isNaN(lng) ||
lat < -90 || lat > 90 || lng < -180 || lng > 180) {
console.warn('Invalid coordinates:', lat, lng);
return;
}
document.getElementById('current-lat').textContent = lat.toFixed(6);
document.getElementById('current-lng').textContent = lng.toFixed(6);
// Update centroid marker position
if (centroidMarker) {
centroidMarker.setLatLng([lat, lng]);
}
}
if (typeof polygonEditor.onPolygonChange === 'function') {
const originalOnPolygonChange = polygonEditor.onPolygonChange;
polygonEditor.onPolygonChange = function(points) {
originalOnPolygonChange.call(this, points);
if (points && points.length >= 3) {
const centroid = calculatePolygonCentroid(points);
updateCoordinatesDisplay(centroid.lat, centroid.lng);
} else {
updateCoordinatesDisplay(placeData.lat, placeData.lng);
}
};
} else {
// If no callback exists, create one
polygonEditor.onPolygonChange = function(points) {
if (points && points.length >= 3) {
const centroid = calculatePolygonCentroid(points);
updateCoordinatesDisplay(centroid.lat, centroid.lng);
} else {
updateCoordinatesDisplay(placeData.lat, placeData.lng);
}
};
}
// Save button handler
document.getElementById('save-btn').addEventListener('click', function() {
checkBeforeSave();
});
function checkBeforeSave() {
const formData = new FormData(document.getElementById('polygon-form'));
fetch(`/settings/places/${placeData.id}/check-update`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.canProceed && data.warnings.length === 0) {
polygonEditor.savePolygon();
} else {
showConfirmationDialog(data.warnings);
}
})
.catch(error => {
console.error('Error checking update:', error);
// On error, proceed with save anyway
polygonEditor.savePolygon();
});
}
function showConfirmationDialog(warnings) {
const warningsList = warnings.map(warning => `${warning}`).join('\n');
const confirmMessage = /*[[#{places.update.confirmation.message}]]*/ 'The following changes will be made:\n\n{0}\n\nDo you want to continue?';
const message = confirmMessage.replace('{0}', warningsList);
if (confirm(message)) {
polygonEditor.savePolygon();
}
}
document.getElementById('remove-polygon-btn').addEventListener('click', function() {
polygonEditor.clearPolygon();
updateCoordinatesDisplay(placeData.lat, placeData.lng);
});
const nearbyPlaces = /*[[${nearbyPlaces}]]*/ [];
polygonEditor.loadNearbyPlaces(nearbyPlaces);
let lastPolygonPoints = null;
let lastPolygonString = '';
function checkPolygonChanges() {
let currentPoints = null;
// Try multiple methods to get polygon points
if (polygonEditor.getPolygonPoints) {
currentPoints = polygonEditor.getPolygonPoints();
} else if (polygonEditor.polygon && polygonEditor.polygon.getLatLngs) {
const latLngs = polygonEditor.polygon.getLatLngs();
if (latLngs && latLngs.length > 0) {
currentPoints = latLngs[0].map(latlng => ({ lat: latlng.lat, lng: latlng.lng }));
}
} else if (polygonEditor.polygonPoints) {
currentPoints = polygonEditor.polygonPoints;
}
const currentString = JSON.stringify(currentPoints);
// Check if polygon has changed
if (currentString !== lastPolygonString) {
lastPolygonString = currentString;
lastPolygonPoints = currentPoints;
if (currentPoints && currentPoints.length >= 3) {
const centroid = calculatePolygonCentroid(currentPoints);
updateCoordinatesDisplay(centroid.lat, centroid.lng);
} else {
updateCoordinatesDisplay(placeData.lat, placeData.lng);
}
}
}
// Check for changes every 300ms
setInterval(checkPolygonChanges, 300);
// Also listen for map events that might indicate polygon changes
map.on('click', function() {
setTimeout(checkPolygonChanges, 100);
});
map.on('contextmenu', function() {
setTimeout(checkPolygonChanges, 100);
});
// Handle geocoding drawer
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail.target.id === 'geocoding-drawer') {
const drawer = document.getElementById('geocoding-drawer');
drawer.classList.add('active');
// Add close button functionality
const closeBtn = drawer.querySelector('.drawer-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
drawer.classList.remove('active');
drawer.innerHTML = '';
});
}
}
});
</script>
</body>
</html>

View File

@@ -39,27 +39,24 @@
<div class="places-grid" th:if="${!isEmpty}">
<div class="place-stats-card" th:each="place : ${places}">
<div class="place-map-container">
<div class="place-map" th:id="'map-' + ${place.id()}" th:data-lat="${place.latitude()}" th:data-lng="${place.longitude()}" ></div>
<div class="place-map" th:id="'map-' + ${place.id()}" th:data-lat="${place.lat()}" th:data-lng="${place.lng()}" ></div>
</div>
<div class="place-details text-align-left">
<div class="place-info">
<div><strong th:text="#{places.name.label}">Name:</strong> <span th:text="${place.name()}"></span></div>
<div><strong th:text="#{places.address.label}">Address:</strong> <span th:text="${place.address() ?: #messages.msg('places.address.not.available')}"></span></div>
<div><strong th:text="#{places.category.label}">Category:</strong> <span th:text="#{${place.type().messageKey}}"></span></div>
<div><strong th:text="#{places.coordinates.label}">Coordinates:</strong> <span th:text="${place.latitude() + ', ' + place.longitude()}"></span></div>
<div><strong th:text="#{places.coordinates.label}">Coordinates:</strong> <span th:text="${place.lat() + ', ' + place.lng()}"></span></div>
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button type="button"
class="btn btn-block"
th:attr="hx-get=@{/settings/places/{id}/edit(id=${place.id()}, page=${currentPage}, search=${search})}"
hx-target="#places-management"
hx-swap="innerHTML"
th:text="#{places.edit.button}">Edit</button>
<a class="btn btn-default btn-block"
th:href="@{/settings/places/{id}/edit(id=${place.id()}, returnUrl=${returnUrl})}"
th:text="#{places.edit.button}">Edit</a>
</div>
</div>
</div>
</div>
<p th:if="${isEmpty}" th:text="#{places.no.places}">No significant places found.</p>
<p th:if="${isEmpty}" th:text="#{places.no.places}">No significant places were found.</p>
<div class="pagination-controls" style="text-align: right;">
<span th:text="#{places.page.info(${currentPage + 1}, ${totalPages})}">Page 1 of 1</span>

View File

@@ -3,7 +3,6 @@ package com.dedicatedcode.reitti;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;
@@ -15,7 +14,6 @@ import java.lang.annotation.RetentionPolicy;
@ActiveProfiles("test")
@Import({TestContainerConfiguration.class, TestConfiguration.class})
@Retention(RetentionPolicy.RUNTIME)
//@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@AutoConfigureMockMvc
public @interface IntegrationTest {
}

View File

@@ -48,8 +48,6 @@ public class TestingService {
@Autowired
private ProcessedVisitJdbcService processedVisitRepository;
@Autowired
private VisitJdbcService visitRepository;
@Autowired
private ProcessingPipelineTrigger trigger;
@Autowired
private UserService userService;
@@ -134,16 +132,13 @@ public class TestingService {
// Check if all counts are stable
long currentRawCount = rawLocationPointRepository.count();
long currentVisitCount = visitRepository.count();
long currentTripCount = tripRepository.count();
boolean countsStable =
currentRawCount == lastRawCount.get() &&
currentVisitCount == lastVisitCount.get() &&
currentTripCount == lastTripCount.get();
lastRawCount.set(currentRawCount);
lastVisitCount.set(currentVisitCount);
lastTripCount.set(currentTripCount);
if (countsStable && this.trigger.isIdle() && importBatchProcessor.isIdle()) {
@@ -166,7 +161,6 @@ public class TestingService {
//now clear the database
this.tripRepository.deleteAll();
this.processedVisitRepository.deleteAll();
this.visitRepository.deleteAll();
this.rawLocationPointRepository.deleteAll();
}

View File

@@ -0,0 +1,359 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
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.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class ProcessedVisitJdbcServiceTest {
@Autowired
private ProcessedVisitJdbcService processedVisitJdbcService;
@Autowired
private SignificantPlaceJdbcService placeJdbcService;
@Autowired
private TestingService testingService;
private User testUser;
private SignificantPlace testPlace;
private SignificantPlace anotherPlace;
@BeforeEach
void setUp() {
testUser = testingService.randomUser();
testPlace = createTestPlace("Home", 53.863149, 10.700927);
anotherPlace = createTestPlace("Work", 53.864149, 10.701927);
}
@Test
void create_ShouldCreateProcessedVisit() {
// Given
Instant startTime = Instant.now().minus(2, ChronoUnit.HOURS);
Instant endTime = Instant.now().minus(1, ChronoUnit.HOURS);
ProcessedVisit visit = new ProcessedVisit(testPlace, startTime, endTime, 3600L);
// When
ProcessedVisit created = processedVisitJdbcService.create(testUser, visit);
// Then
assertNotNull(created.getId());
assertEquals(1L, created.getVersion());
assertEquals(testPlace.getId(), created.getPlace().getId());
assertEquals(startTime, created.getStartTime());
assertEquals(endTime, created.getEndTime());
assertEquals(3600L, created.getDurationSeconds());
}
@Test
void findById_WithExistingId_ShouldReturnVisit() {
// Given
ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
// When
Optional<ProcessedVisit> found = processedVisitJdbcService.findById(visit.getId());
// Then
assertTrue(found.isPresent());
assertEquals(visit.getId(), found.get().getId());
assertEquals(testPlace.getId(), found.get().getPlace().getId());
}
@Test
void findById_WithNonExistingId_ShouldReturnEmpty() {
// When
Optional<ProcessedVisit> found = processedVisitJdbcService.findById(999L);
// Then
assertTrue(found.isEmpty());
}
@Test
void findByUser_ShouldReturnAllUserVisits() {
// Given
User anotherUser = testingService.randomUser();
createTestVisit(testPlace, Instant.now().minus(3, ChronoUnit.HOURS), Instant.now().minus(2, ChronoUnit.HOURS), 3600L);
createTestVisit(anotherPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
// Create visit for another user (should not be returned)
SignificantPlace anotherUserPlace = createTestPlaceForUser(anotherUser, "Other Place", 53.865149, 10.702927);
processedVisitJdbcService.create(anotherUser, new ProcessedVisit(anotherUserPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L));
// When
List<ProcessedVisit> visits = processedVisitJdbcService.findByUser(testUser);
// Then
assertEquals(2, visits.size());
assertTrue(visits.stream().allMatch(v -> v.getPlace().getId().equals(testPlace.getId()) || v.getPlace().getId().equals(anotherPlace.getId())));
}
@Test
void findByUserAndTimeOverlap_ShouldReturnOverlappingVisits() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
// Visit 1: 4-3 hours ago (should overlap)
createTestVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L);
// Visit 2: 2-1 hours ago (should overlap)
createTestVisit(anotherPlace, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS), 3600L);
// Visit 3: 6-5 hours ago (should not overlap)
createTestVisit(testPlace, baseTime.minus(2, ChronoUnit.HOURS), baseTime.minus(1, ChronoUnit.HOURS), 3600L);
// When - query for overlap with 3.5-1.5 hours ago
List<ProcessedVisit> visits = processedVisitJdbcService.findByUserAndTimeOverlap(
testUser,
baseTime.plus(30, ChronoUnit.MINUTES),
baseTime.plus(2, ChronoUnit.HOURS).plus(30, ChronoUnit.MINUTES)
);
// Then
assertEquals(2, visits.size());
}
@Test
void findByUserAndId_WithExistingVisit_ShouldReturnVisit() {
// Given
ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
// When
Optional<ProcessedVisit> found = processedVisitJdbcService.findByUserAndId(testUser, visit.getId());
// Then
assertTrue(found.isPresent());
assertEquals(visit.getId(), found.get().getId());
}
@Test
void findByUserAndId_WithDifferentUser_ShouldReturnEmpty() {
// Given
User anotherUser = testingService.randomUser();
ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
// When
Optional<ProcessedVisit> found = processedVisitJdbcService.findByUserAndId(anotherUser, visit.getId());
// Then
assertTrue(found.isEmpty());
}
@Test
void findTopPlacesByStayTimeWithLimit_ShouldReturnTopPlaces() {
// Given
// Create multiple visits to testPlace (total 7200 seconds)
createTestVisit(testPlace, Instant.now().minus(4, ChronoUnit.HOURS), Instant.now().minus(3, ChronoUnit.HOURS), 3600L);
createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L);
// Create one visit to anotherPlace (total 1800 seconds)
createTestVisit(anotherPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L);
// When
List<Object[]> topPlaces = processedVisitJdbcService.findTopPlacesByStayTimeWithLimit(testUser, 10);
// Then
assertEquals(2, topPlaces.size());
// First place should be testPlace with more stay time
Object[] firstPlace = topPlaces.get(0);
assertEquals("Home", firstPlace[0]);
assertEquals(7200L, firstPlace[1]);
assertEquals(2L, firstPlace[2]);
// Second place should be anotherPlace
Object[] secondPlace = topPlaces.get(1);
assertEquals("Work", secondPlace[0]);
assertEquals(1800L, secondPlace[1]);
assertEquals(1L, secondPlace[2]);
}
@Test
void findTopPlacesByStayTimeWithLimit_WithTimeRange_ShouldReturnFilteredPlaces() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
// Visit within range
createTestVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L);
// Visit outside range
createTestVisit(anotherPlace, baseTime.minus(2, ChronoUnit.HOURS), baseTime.minus(1, ChronoUnit.HOURS), 3600L);
// When
List<Object[]> topPlaces = processedVisitJdbcService.findTopPlacesByStayTimeWithLimit(
testUser,
baseTime.minus(30, ChronoUnit.MINUTES),
baseTime.plus(2, ChronoUnit.HOURS),
10
);
// Then
assertEquals(1, topPlaces.size());
assertEquals("Home", topPlaces.get(0)[0]);
}
@Test
void update_ShouldUpdateVisit() {
// Given
ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L);
Instant newStartTime = Instant.now().minus(3, ChronoUnit.HOURS);
Instant newEndTime = Instant.now().minus(30, ChronoUnit.MINUTES);
ProcessedVisit updatedVisit = new ProcessedVisit(
visit.getId(),
anotherPlace,
newStartTime,
newEndTime,
5400L,
visit.getVersion()
);
// When
ProcessedVisit result = processedVisitJdbcService.update(updatedVisit);
// Then
assertEquals(anotherPlace.getId(), result.getPlace().getId());
assertEquals(newStartTime, result.getStartTime());
assertEquals(newEndTime, result.getEndTime());
assertEquals(5400L, result.getDurationSeconds());
}
@Test
void deleteAll_WithVisitList_ShouldDeleteVisits() {
// Given
ProcessedVisit visit1 = createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L);
ProcessedVisit visit2 = createTestVisit(anotherPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
ProcessedVisit visit3 = createTestVisit(testPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L);
// When
processedVisitJdbcService.deleteAll(List.of(visit1, visit2));
// Then
assertTrue(processedVisitJdbcService.findById(visit1.getId()).isEmpty());
assertTrue(processedVisitJdbcService.findById(visit2.getId()).isEmpty());
assertTrue(processedVisitJdbcService.findById(visit3.getId()).isPresent());
}
@Test
void deleteAll_WithEmptyList_ShouldNotThrow() {
// When/Then
assertDoesNotThrow(() -> processedVisitJdbcService.deleteAll(List.of()));
assertDoesNotThrow(() -> processedVisitJdbcService.deleteAll(null));
}
@Test
void bulkInsert_ShouldInsertMultipleVisits() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
List<ProcessedVisit> visitsToInsert = List.of(
new ProcessedVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L),
new ProcessedVisit(anotherPlace, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS), 3600L)
);
// When
List<ProcessedVisit> inserted = processedVisitJdbcService.bulkInsert(testUser, visitsToInsert);
// Then
assertEquals(2, inserted.size());
assertTrue(inserted.stream().allMatch(v -> v.getId() != null));
List<ProcessedVisit> allVisits = processedVisitJdbcService.findByUser(testUser);
assertEquals(2, allVisits.size());
}
@Test
void bulkInsert_WithEmptyList_ShouldReturnEmptyList() {
// When
List<ProcessedVisit> result = processedVisitJdbcService.bulkInsert(testUser, List.of());
// Then
assertTrue(result.isEmpty());
}
@Test
void deleteAllForUser_ShouldDeleteOnlyUserVisits() {
// Given
User anotherUser = testingService.randomUser();
SignificantPlace anotherUserPlace = createTestPlaceForUser(anotherUser, "Other Place", 53.865149, 10.702927);
createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L);
processedVisitJdbcService.create(anotherUser, new ProcessedVisit(anotherUserPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L));
// When
processedVisitJdbcService.deleteAllForUser(testUser);
// Then
assertTrue(processedVisitJdbcService.findByUser(testUser).isEmpty());
assertEquals(1, processedVisitJdbcService.findByUser(anotherUser).size());
}
@Test
void getAffectedDays_ShouldReturnUniqueDates() {
// Given
Instant day1Start = Instant.parse("2023-12-01T10:00:00Z");
Instant day1End = Instant.parse("2023-12-01T15:00:00Z");
Instant day2Start = Instant.parse("2023-12-02T09:00:00Z");
Instant day2End = Instant.parse("2023-12-02T17:00:00Z");
createTestVisit(testPlace, day1Start, day1End, 18000L);
createTestVisit(testPlace, day2Start, day2End, 28800L);
createTestVisit(anotherPlace, day1Start, day1End, 18000L);
// When
List<LocalDate> affectedDays = processedVisitJdbcService.getAffectedDays(List.of(testPlace, anotherPlace));
// Then
assertEquals(2, affectedDays.size());
assertTrue(affectedDays.contains(LocalDate.of(2023, 12, 1)));
assertTrue(affectedDays.contains(LocalDate.of(2023, 12, 2)));
}
@Test
void getAffectedDays_WithEmptyPlaceList_ShouldReturnEmptyList() {
// When
List<LocalDate> affectedDays = processedVisitJdbcService.getAffectedDays(List.of());
// Then
assertTrue(affectedDays.isEmpty());
}
private ProcessedVisit createTestVisit(SignificantPlace place, Instant startTime, Instant endTime, Long duration) {
ProcessedVisit visit = new ProcessedVisit(place, startTime, endTime, duration);
return processedVisitJdbcService.create(testUser, visit);
}
private SignificantPlace createTestPlace(String name, double latitude, double longitude) {
return createTestPlaceForUser(testUser, name, latitude, longitude);
}
private SignificantPlace createTestPlaceForUser(User user, String name, double latitude, double longitude) {
return placeJdbcService.create(user, new SignificantPlace(
null,
name,
null,
null,
null,
latitude,
longitude,
null,
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
false,
0L
));
}
}

View File

@@ -0,0 +1,220 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
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.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@IntegrationTest
class RawLocationPointJdbcServiceTest {
@Autowired
private RawLocationPointJdbcService rawLocationPointJdbcService;
@Autowired
private TestingService testingService;
private User testUser;
private User anotherUser;
@BeforeEach
void setUp() {
testingService.clearData();
testUser = testingService.randomUser();
anotherUser = testingService.randomUser();
}
@Test
void markAllAsUnprocessedForUser_WithSpecificDates_ShouldOnlyMarkPointsOnThoseDays() {
// Given
Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC);
Instant day2 = LocalDate.of(2023, 12, 2).atStartOfDay().toInstant(ZoneOffset.UTC);
Instant day3 = LocalDate.of(2023, 12, 3).atStartOfDay().toInstant(ZoneOffset.UTC);
// Create points on different days, all initially processed
RawLocationPoint point1Day1 = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS));
RawLocationPoint point2Day1 = createProcessedPoint(testUser, day1.plus(14, ChronoUnit.HOURS));
RawLocationPoint point1Day2 = createProcessedPoint(testUser, day2.plus(9, ChronoUnit.HOURS));
RawLocationPoint point1Day3 = createProcessedPoint(testUser, day3.plus(11, ChronoUnit.HOURS));
// Create point for another user (should not be affected)
RawLocationPoint anotherUserPoint = createProcessedPoint(anotherUser, day1.plus(12, ChronoUnit.HOURS));
// Verify all points are initially processed
assertTrue(findPointById(point1Day1.getId()).isProcessed());
assertTrue(findPointById(point2Day1.getId()).isProcessed());
assertTrue(findPointById(point1Day2.getId()).isProcessed());
assertTrue(findPointById(point1Day3.getId()).isProcessed());
assertTrue(findPointById(anotherUserPoint.getId()).isProcessed());
// When - mark only day 1 and day 2 as unprocessed
List<LocalDate> affectedDays = List.of(
LocalDate.of(2023, 12, 1),
LocalDate.of(2023, 12, 2)
);
rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays);
// Then
// Points on day 1 and day 2 should be unprocessed
assertFalse(findPointById(point1Day1.getId()).isProcessed());
assertFalse(findPointById(point2Day1.getId()).isProcessed());
assertFalse(findPointById(point1Day2.getId()).isProcessed());
// Point on day 3 should still be processed
assertTrue(findPointById(point1Day3.getId()).isProcessed());
// Another user's point should not be affected
assertTrue(findPointById(anotherUserPoint.getId()).isProcessed());
}
@Test
void markAllAsUnprocessedForUser_WithEmptyDateList_ShouldNotMarkAnyPoints() {
// Given
Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC);
RawLocationPoint point = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS));
assertTrue(findPointById(point.getId()).isProcessed());
// When
rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, List.of());
// Then
assertTrue(findPointById(point.getId()).isProcessed());
}
@Test
void markAllAsUnprocessedForUser_WithNonExistentDates_ShouldNotMarkAnyPoints() {
// Given
Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC);
RawLocationPoint point = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS));
assertTrue(findPointById(point.getId()).isProcessed());
// When - mark a different date
List<LocalDate> affectedDays = List.of(LocalDate.of(2023, 12, 15));
rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays);
// Then
assertTrue(findPointById(point.getId()).isProcessed());
}
@Test
void markAllAsUnprocessedForUser_WithPointsAlreadyUnprocessed_ShouldRemainUnprocessed() {
// Given
Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC);
RawLocationPoint unprocessedPoint = createUnprocessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS));
RawLocationPoint processedPoint = createProcessedPoint(testUser, day1.plus(14, ChronoUnit.HOURS));
assertFalse(findPointById(unprocessedPoint.getId()).isProcessed());
assertTrue(findPointById(processedPoint.getId()).isProcessed());
// When
List<LocalDate> affectedDays = List.of(LocalDate.of(2023, 12, 1));
rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays);
// Then
assertFalse(findPointById(unprocessedPoint.getId()).isProcessed());
assertFalse(findPointById(processedPoint.getId()).isProcessed());
}
@Test
void markAllAsUnprocessedForUser_WithMultipleDaysAndUsers_ShouldOnlyAffectCorrectUserAndDays() {
// Given
Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC);
Instant day2 = LocalDate.of(2023, 12, 2).atStartOfDay().toInstant(ZoneOffset.UTC);
Instant day3 = LocalDate.of(2023, 12, 3).atStartOfDay().toInstant(ZoneOffset.UTC);
// Test user points
RawLocationPoint testUserDay1 = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS));
RawLocationPoint testUserDay2 = createProcessedPoint(testUser, day2.plus(10, ChronoUnit.HOURS));
RawLocationPoint testUserDay3 = createProcessedPoint(testUser, day3.plus(10, ChronoUnit.HOURS));
// Another user points
RawLocationPoint anotherUserDay1 = createProcessedPoint(anotherUser, day1.plus(10, ChronoUnit.HOURS));
RawLocationPoint anotherUserDay2 = createProcessedPoint(anotherUser, day2.plus(10, ChronoUnit.HOURS));
// When - mark only day 1 and day 2 for test user
List<LocalDate> affectedDays = List.of(
LocalDate.of(2023, 12, 1),
LocalDate.of(2023, 12, 2)
);
rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays);
// Then
// Test user's points on affected days should be unprocessed
assertFalse(findPointById(testUserDay1.getId()).isProcessed());
assertFalse(findPointById(testUserDay2.getId()).isProcessed());
// Test user's point on unaffected day should remain processed
assertTrue(findPointById(testUserDay3.getId()).isProcessed());
// Another user's points should not be affected
assertTrue(findPointById(anotherUserDay1.getId()).isProcessed());
assertTrue(findPointById(anotherUserDay2.getId()).isProcessed());
}
private RawLocationPoint createProcessedPoint(User user, Instant timestamp) {
RawLocationPoint point = new RawLocationPoint(
null,
timestamp,
new GeoPoint(53.863149, 10.700927),
10.0,
null,
false, // will be set to true after creation
false,
false,
1L
);
RawLocationPoint created = rawLocationPointJdbcService.create(user, point);
// Mark as processed
RawLocationPoint processed = new RawLocationPoint(
created.getId(),
created.getTimestamp(),
created.getGeom(),
created.getAccuracyMeters(),
created.getElevationMeters(),
true, // processed = true
created.isSynthetic(),
created.isIgnored(),
created.getVersion()
);
return rawLocationPointJdbcService.update(processed);
}
private RawLocationPoint createUnprocessedPoint(User user, Instant timestamp) {
RawLocationPoint point = new RawLocationPoint(
null,
timestamp,
new GeoPoint(53.863149, 10.700927),
10.0,
null,
false, // processed = false
false,
false,
1L
);
return rawLocationPointJdbcService.create(user, point);
}
private RawLocationPoint findPointById(Long id) {
return rawLocationPointJdbcService.findById(id)
.orElseThrow(() -> new RuntimeException("Point not found: " + id));
}
}

View File

@@ -4,6 +4,7 @@ import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
@@ -83,7 +84,7 @@ class SignificantPlaceJdbcServiceTest {
}
@Test
void findNearbyPlaces_shouldReturnPlacesWithinDistance() {
void findEnclosingPlaces_shouldReturnPlacesWithinDistance() {
// Given
Point centerPoint = geometryFactory.createPoint(new Coordinate(10.700927, 53.863149));
SignificantPlace nearPlace = createTestPlace("Near Place", 53.863200, 10.701000); // ~50m away
@@ -93,7 +94,7 @@ class SignificantPlaceJdbcServiceTest {
significantPlaceJdbcService.create(testUser, farPlace);
// When
List<SignificantPlace> nearbyPlaces = significantPlaceJdbcService.findNearbyPlaces(
List<SignificantPlace> nearbyPlaces = significantPlaceJdbcService.findEnclosingPlaces(
testUser.getId(), centerPoint, 0.003);
// Then
@@ -130,6 +131,7 @@ class SignificantPlaceJdbcServiceTest {
"DE",
53.863149,
10.700927,
null,
SignificantPlace.PlaceType.RESTAURANT,
ZoneId.systemDefault(),
true,
@@ -215,6 +217,7 @@ class SignificantPlaceJdbcServiceTest {
"DE",
created1.getLatitudeCentroid(),
created1.getLongitudeCentroid(),
null,
SignificantPlace.PlaceType.HOME,
ZoneId.systemDefault(),
true, // geocoded = true
@@ -252,6 +255,291 @@ class SignificantPlaceJdbcServiceTest {
.containsExactly("Place 1", "Place 2");
}
@Test
void create_shouldPersistNewPlaceWithPolygon() {
// Given
List<GeoPoint> polygon = List.of(
GeoPoint.from(53.863149, 10.700927),
GeoPoint.from(53.863200, 10.701000),
GeoPoint.from(53.863100, 10.701100),
GeoPoint.from(53.863050, 10.700950),
GeoPoint.from(53.863149, 10.700927) // Close the polygon
);
SignificantPlace newPlace = new SignificantPlace(
null,
"Place with Polygon",
null,
null,
null,
53.863149,
10.700927,
polygon,
SignificantPlace.PlaceType.PARK,
ZoneId.systemDefault(),
false,
0L
);
// When
SignificantPlace created = significantPlaceJdbcService.create(testUser, newPlace);
// Then
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("Place with Polygon");
assertThat(created.getPolygon()).isNotNull();
assertThat(created.getPolygon()).hasSize(5);
assertThat(created.getPolygon().get(0).latitude()).isEqualTo(53.863149);
assertThat(created.getPolygon().get(0).longitude()).isEqualTo(10.700927);
}
@Test
void create_shouldPersistNewPlaceWithoutPolygon() {
// Given
SignificantPlace newPlace = new SignificantPlace(
null,
"Place without Polygon",
null,
null,
null,
53.863149,
10.700927,
null, // No polygon
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
false,
0L
);
// When
SignificantPlace created = significantPlaceJdbcService.create(testUser, newPlace);
// Then
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("Place without Polygon");
assertThat(created.getPolygon()).isNull();
}
@Test
void update_shouldAddPolygonToExistingPlace() {
// Given - Create place without polygon
SignificantPlace originalPlace = createTestPlace("Original Place", 53.863149, 10.700927);
SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace);
// Create polygon to add
List<GeoPoint> polygon = List.of(
GeoPoint.from(53.863149, 10.700927),
GeoPoint.from(53.863200, 10.701000),
GeoPoint.from(53.863100, 10.701100),
GeoPoint.from(53.863149, 10.700927) // Close the polygon
);
SignificantPlace updatedPlace = new SignificantPlace(
created.getId(),
created.getName(),
"Updated Address",
"Berlin",
"DE",
created.getLatitudeCentroid(),
created.getLongitudeCentroid(),
polygon, // Add polygon
SignificantPlace.PlaceType.PARK,
ZoneId.systemDefault(),
true,
created.getVersion()
);
// When
SignificantPlace result = significantPlaceJdbcService.update(updatedPlace);
// Then
assertThat(result.getPolygon()).isNotNull();
assertThat(result.getPolygon()).hasSize(4);
assertThat(result.getPolygon().get(0).latitude()).isEqualTo(53.863149);
assertThat(result.getPolygon().get(0).longitude()).isEqualTo(10.700927);
assertThat(result.getAddress()).isEqualTo("Updated Address");
assertThat(result.getType()).isEqualTo(SignificantPlace.PlaceType.PARK);
}
@Test
void update_shouldRemovePolygonFromExistingPlace() {
// Given - Create place with polygon
List<GeoPoint> originalPolygon = List.of(
GeoPoint.from(53.863149, 10.700927),
GeoPoint.from(53.863200, 10.701000),
GeoPoint.from(53.863100, 10.701100),
GeoPoint.from(53.863149, 10.700927)
);
SignificantPlace originalPlace = new SignificantPlace(
null,
"Place with Polygon",
null,
null,
null,
53.863149,
10.700927,
originalPolygon,
SignificantPlace.PlaceType.PARK,
ZoneId.systemDefault(),
false,
0L
);
SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace);
// Update to remove polygon
SignificantPlace updatedPlace = new SignificantPlace(
created.getId(),
created.getName(),
"Updated Address",
"Berlin",
"DE",
created.getLatitudeCentroid(),
created.getLongitudeCentroid(),
null, // Remove polygon
SignificantPlace.PlaceType.RESTAURANT,
ZoneId.systemDefault(),
true,
created.getVersion()
);
// When
SignificantPlace result = significantPlaceJdbcService.update(updatedPlace);
// Then
assertThat(result.getPolygon()).isNull();
assertThat(result.getAddress()).isEqualTo("Updated Address");
assertThat(result.getType()).isEqualTo(SignificantPlace.PlaceType.RESTAURANT);
}
@Test
void update_shouldModifyExistingPolygon() {
// Given - Create place with polygon
List<GeoPoint> originalPolygon = List.of(
GeoPoint.from(53.863149, 10.700927),
GeoPoint.from(53.863200, 10.701000),
GeoPoint.from(53.863100, 10.701100),
GeoPoint.from(53.863149, 10.700927)
);
SignificantPlace originalPlace = new SignificantPlace(
null,
"Place with Polygon",
null,
null,
null,
53.863149,
10.700927,
originalPolygon,
SignificantPlace.PlaceType.PARK,
ZoneId.systemDefault(),
false,
0L
);
SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace);
// Create modified polygon
List<GeoPoint> modifiedPolygon = List.of(
GeoPoint.from(53.863149, 10.700927),
GeoPoint.from(53.863250, 10.701050), // Different coordinates
GeoPoint.from(53.863150, 10.701150), // Different coordinates
GeoPoint.from(53.863080, 10.700980), // Different coordinates
GeoPoint.from(53.863149, 10.700927)
);
SignificantPlace updatedPlace = new SignificantPlace(
created.getId(),
created.getName(),
created.getAddress(),
created.getCity(),
created.getCountryCode(),
created.getLatitudeCentroid(),
created.getLongitudeCentroid(),
modifiedPolygon, // Modified polygon
created.getType(),
created.getTimezone(),
created.isGeocoded(),
created.getVersion()
);
// When
SignificantPlace result = significantPlaceJdbcService.update(updatedPlace);
// Then
assertThat(result.getPolygon()).isNotNull();
assertThat(result.getPolygon()).hasSize(5);
assertThat(result.getPolygon().get(1).latitude()).isEqualTo(53.863250);
assertThat(result.getPolygon().get(1).longitude()).isEqualTo(10.701050);
assertThat(result.getPolygon().get(2).latitude()).isEqualTo(53.863150);
assertThat(result.getPolygon().get(2).longitude()).isEqualTo(10.701150);
}
@Test
void findNearbyPlaces_shouldFindPlacesWithPolygons() {
// Given
Point searchPoint = geometryFactory.createPoint(new Coordinate(10.700950, 53.863120));
// Create a place with polygon that contains the search point
List<GeoPoint> polygon = List.of(
GeoPoint.from(53.863100, 10.700900),
GeoPoint.from(53.863200, 10.701000),
GeoPoint.from(53.863100, 10.701100),
GeoPoint.from(53.863000, 10.701000),
GeoPoint.from(53.863100, 10.700900) // Close the polygon
);
SignificantPlace placeWithPolygon = new SignificantPlace(
null,
"Place with Polygon",
null,
null,
null,
53.863100,
10.701000,
polygon,
SignificantPlace.PlaceType.PARK,
ZoneId.systemDefault(),
false,
0L
);
// Create a place without polygon that's far away
SignificantPlace farPlace = createTestPlace("Far Place", 53.870000, 10.720000);
significantPlaceJdbcService.create(testUser, placeWithPolygon);
significantPlaceJdbcService.create(testUser, farPlace);
// When
List<SignificantPlace> nearbyPlaces = significantPlaceJdbcService.findEnclosingPlaces(
testUser.getId(), searchPoint, 0.001); // Small buffer for places without polygons
// Then
assertThat(nearbyPlaces).hasSize(1);
assertThat(nearbyPlaces.get(0).getName()).isEqualTo("Place with Polygon");
assertThat(nearbyPlaces.get(0).getPolygon()).isNotNull();
assertThat(nearbyPlaces.get(0).getPolygon()).hasSize(5);
}
// Helper method to create test place with polygon
private SignificantPlace createTestPlaceWithPolygon(String name, double latitude, double longitude, List<GeoPoint> polygon) {
return new SignificantPlace(
null,
name,
null,
null,
null,
latitude,
longitude,
polygon,
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
false,
0L
);
}
private User createTestUser(String username, String displayName) {
Long userId = jdbcTemplate.queryForObject(
"INSERT INTO users (username, password, display_name, role) VALUES (?, ?, ?, ?) RETURNING id",
@@ -270,6 +558,7 @@ class SignificantPlaceJdbcServiceTest {
null,
latitude,
longitude,
null,
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault()
, false,
@@ -286,6 +575,7 @@ class SignificantPlaceJdbcServiceTest {
null,
latitude,
longitude,
null,
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
false,

View File

@@ -33,7 +33,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
GeoPoint point = new GeoPoint(40.7128, -74.0060); // Example: New York coordinates
// Create a SignificantPlace with the override details
SignificantPlace place = new SignificantPlace(1L, "Home Override", "123 Main St", "New York", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place = new SignificantPlace(1L, "Home Override", "123 Main St", "New York", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
// Insert the override using the service
significantPlaceOverrideJdbcService.insertOverride(user, place);
@@ -67,7 +67,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
User user = testingService.randomUser();
// Create a SignificantPlace
SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
// Insert the override
significantPlaceOverrideJdbcService.insertOverride(user, place);
@@ -88,7 +88,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
User user = testingService.randomUser();
// Create a SignificantPlace
SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
// Insert the override
significantPlaceOverrideJdbcService.insertOverride(user, place);
@@ -112,7 +112,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
User user = testingService.randomUser();
// Create a SignificantPlace at a specific location
SignificantPlace place = new SignificantPlace(1L, "Nearby Override", "456 Nearby St", "Nearby City", "US", 40.7128, -74.0060, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place = new SignificantPlace(1L, "Nearby Override", "456 Nearby St", "Nearby City", "US", 40.7128, -74.0060, null, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L);
significantPlaceOverrideJdbcService.insertOverride(user, place);
// Create a GeoPoint very close (within 5m) to the place
@@ -130,7 +130,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
User user = testingService.randomUser();
// Insert first override
SignificantPlace place1 = new SignificantPlace(1L, "First Override", "123 First St", "First City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place1 = new SignificantPlace(1L, "First Override", "123 First St", "First City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L);
significantPlaceOverrideJdbcService.insertOverride(user, place1);
// Verify first override exists
@@ -139,7 +139,7 @@ class SignificantPlaceOverrideJdbcServiceTest {
assertTrue(result1.isPresent());
// Insert second override very close (within 5m)
SignificantPlace place2 = new SignificantPlace(2L, "Second Override", "456 Second St", "Second City", "US", 40.7128442, -74.0060, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L);
SignificantPlace place2 = new SignificantPlace(2L, "Second Override", "456 Second St", "Second City", "US", 40.7128442, -74.0060, null, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L);
significantPlaceOverrideJdbcService.insertOverride(user, place2);
// Verify second override exists

View File

@@ -0,0 +1,332 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.geo.*;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
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.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@IntegrationTest
class DataCleanupServiceTest {
@Autowired
private DataCleanupService dataCleanupService;
@Autowired
private TripJdbcService tripJdbcService;
@Autowired
private ProcessedVisitJdbcService processedVisitJdbcService;
@Autowired
private SignificantPlaceJdbcService placeJdbcService;
@Autowired
private RawLocationPointJdbcService rawLocationPointJdbcService;
@Autowired
private TestingService testingService;
private User testUser;
private User anotherUser;
private SignificantPlace placeToRemove1;
private SignificantPlace placeToRemove2;
private SignificantPlace placeToKeep;
@BeforeEach
void setUp() {
testingService.clearData();
testUser = testingService.randomUser();
anotherUser = testingService.randomUser();
// Create test places
placeToRemove1 = createTestPlace(testUser, "Place to Remove 1", 53.863149, 10.700927);
placeToRemove2 = createTestPlace(testUser, "Place to Remove 2", 53.864149, 10.701927);
placeToKeep = createTestPlace(testUser, "Place to Keep", 53.865149, 10.702927);
}
@Test
void cleanupForGeometryChange_ShouldRemoveTripsForSpecifiedPlaces() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
// Create visits for places
ProcessedVisit visitToRemove1 = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
ProcessedVisit visitToRemove2 = createTestVisit(placeToRemove2, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS));
ProcessedVisit visitToKeep = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS));
// Create trips between places
Trip tripToRemove1 = createTestTrip(visitToRemove1, visitToRemove2);
Trip tripToRemove2 = createTestTrip(visitToRemove2, visitToKeep);
Trip tripToKeep = createTestTrip(visitToKeep, visitToKeep); // Self-trip or different scenario
// When
List<SignificantPlace> placesToRemove = List.of(placeToRemove1, placeToRemove2);
List<LocalDate> affectedDays = List.of(LocalDate.now());
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Trips involving removed places should be deleted
assertTrue(tripJdbcService.findById(tripToRemove1.getId()).isEmpty());
assertTrue(tripJdbcService.findById(tripToRemove2.getId()).isEmpty());
// Trips not involving removed places should remain
assertTrue(tripJdbcService.findById(tripToKeep.getId()).isPresent());
}
@Test
void cleanupForGeometryChange_ShouldRemoveVisitsForSpecifiedPlaces() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
ProcessedVisit visitToRemove1 = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
ProcessedVisit visitToRemove2 = createTestVisit(placeToRemove2, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS));
ProcessedVisit visitToKeep = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS));
// When
List<SignificantPlace> placesToRemove = List.of(placeToRemove1, placeToRemove2);
List<LocalDate> affectedDays = List.of(LocalDate.now());
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Visits for removed places should be deleted
assertTrue(processedVisitJdbcService.findById(visitToRemove1.getId()).isEmpty());
assertTrue(processedVisitJdbcService.findById(visitToRemove2.getId()).isEmpty());
// Visits for kept places should remain
assertTrue(processedVisitJdbcService.findById(visitToKeep.getId()).isPresent());
}
@Test
void cleanupForGeometryChange_ShouldNotRemoveVisitsForPlacesNotInRemovalList() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
// Create visits for all places
ProcessedVisit visitToRemove = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
ProcessedVisit visitToKeep1 = createTestVisit(placeToKeep, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS));
ProcessedVisit visitToKeep2 = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS));
// Create visit for another user (should not be affected)
SignificantPlace anotherUserPlace = createTestPlace(anotherUser, "Another User Place", 53.866149, 10.703927);
ProcessedVisit anotherUserVisit = createTestVisit(anotherUserPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
// When - only remove placeToRemove1
List<SignificantPlace> placesToRemove = List.of(placeToRemove1);
List<LocalDate> affectedDays = List.of(LocalDate.now());
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Only visit for removed place should be deleted
assertTrue(processedVisitJdbcService.findById(visitToRemove.getId()).isEmpty());
// Visits for places not in removal list should remain
assertTrue(processedVisitJdbcService.findById(visitToKeep1.getId()).isPresent());
assertTrue(processedVisitJdbcService.findById(visitToKeep2.getId()).isPresent());
// Another user's visits should not be affected
assertTrue(processedVisitJdbcService.findById(anotherUserVisit.getId()).isPresent());
}
@Test
void cleanupForGeometryChange_ShouldRemoveSpecifiedPlaces() {
// Given
SignificantPlace anotherUserPlace = createTestPlace(anotherUser, "Another User Place", 53.866149, 10.703927);
// When
List<SignificantPlace> placesToRemove = List.of(placeToRemove1, placeToRemove2);
List<LocalDate> affectedDays = List.of(LocalDate.now());
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Specified places should be deleted
assertTrue(placeJdbcService.findById(placeToRemove1.getId()).isEmpty());
assertTrue(placeJdbcService.findById(placeToRemove2.getId()).isEmpty());
// Places not in removal list should remain
assertTrue(placeJdbcService.findById(placeToKeep.getId()).isPresent());
// Another user's places should not be affected
assertTrue(placeJdbcService.findById(anotherUserPlace.getId()).isPresent());
}
@Test
void cleanupForGeometryChange_ShouldMarkRawLocationPointsAsUnprocessedForAffectedDays() {
// Given
LocalDate day1 = LocalDate.of(2023, 12, 1);
LocalDate day2 = LocalDate.of(2023, 12, 2);
LocalDate day3 = LocalDate.of(2023, 12, 3);
Instant day1Time = day1.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS);
Instant day2Time = day2.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS);
Instant day3Time = day3.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS);
// Create processed points on different days
RawLocationPoint pointDay1 = createProcessedPoint(testUser, day1Time);
RawLocationPoint pointDay2 = createProcessedPoint(testUser, day2Time);
RawLocationPoint pointDay3 = createProcessedPoint(testUser, day3Time);
// Create point for another user (should not be affected)
RawLocationPoint anotherUserPoint = createProcessedPoint(anotherUser, day1Time);
// Verify all points are initially processed
assertTrue(findPointById(pointDay1.getId()).isProcessed());
assertTrue(findPointById(pointDay2.getId()).isProcessed());
assertTrue(findPointById(pointDay3.getId()).isProcessed());
assertTrue(findPointById(anotherUserPoint.getId()).isProcessed());
// When - cleanup with day1 and day2 as affected days
List<SignificantPlace> placesToRemove = List.of(placeToRemove1);
List<LocalDate> affectedDays = List.of(day1, day2);
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Points on affected days should be marked as unprocessed
assertFalse(findPointById(pointDay1.getId()).isProcessed());
assertFalse(findPointById(pointDay2.getId()).isProcessed());
// Points on unaffected days should remain processed
assertTrue(findPointById(pointDay3.getId()).isProcessed());
// Another user's points should not be affected
assertTrue(findPointById(anotherUserPoint.getId()).isProcessed());
}
@Test
void cleanupForGeometryChange_WithEmptyPlacesList_ShouldOnlyMarkPointsAsUnprocessed() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
ProcessedVisit visit = createTestVisit(placeToKeep, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
RawLocationPoint point = createProcessedPoint(testUser, baseTime);
// When
List<SignificantPlace> placesToRemove = List.of(); // Empty list
List<LocalDate> affectedDays = List.of(LocalDate.now());
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Visit should remain (no places to remove)
assertTrue(processedVisitJdbcService.findById(visit.getId()).isPresent());
// Place should remain
assertTrue(placeJdbcService.findById(placeToKeep.getId()).isPresent());
// Point should be marked as unprocessed
assertFalse(findPointById(point.getId()).isProcessed());
}
@Test
void cleanupForGeometryChange_WithEmptyAffectedDays_ShouldRemovePlacesButNotMarkPoints() {
// Given
Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS);
ProcessedVisit visitToRemove = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS));
RawLocationPoint point = createProcessedPoint(testUser, baseTime);
// When
List<SignificantPlace> placesToRemove = List.of(placeToRemove1);
List<LocalDate> affectedDays = List.of(); // Empty list
dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays);
// Then
// Visit should be removed
assertTrue(processedVisitJdbcService.findById(visitToRemove.getId()).isEmpty());
// Place should be removed
assertTrue(placeJdbcService.findById(placeToRemove1.getId()).isEmpty());
// Point should remain processed (no affected days)
assertTrue(findPointById(point.getId()).isProcessed());
}
private SignificantPlace createTestPlace(User user, String name, double latitude, double longitude) {
return placeJdbcService.create(user, new SignificantPlace(
null,
name,
null,
null,
null,
latitude,
longitude,
List.of(),
SignificantPlace.PlaceType.OTHER,
ZoneId.systemDefault(),
true,
0L
));
}
private ProcessedVisit createTestVisit(SignificantPlace place, Instant startTime, Instant endTime) {
long duration = ChronoUnit.SECONDS.between(startTime, endTime);
ProcessedVisit visit = new ProcessedVisit(place, startTime, endTime, duration);
return processedVisitJdbcService.create(testUser, visit);
}
private Trip createTestTrip(ProcessedVisit startVisit, ProcessedVisit endVisit) {
long duration = ChronoUnit.SECONDS.between(startVisit.getEndTime(), endVisit.getStartTime());
Trip trip = new Trip(
null,
startVisit.getEndTime(),
endVisit.getStartTime(),
duration,
1000.0,
1200.0,
TransportMode.WALKING,
startVisit,
endVisit,
1L
);
return tripJdbcService.create(testUser, trip);
}
private RawLocationPoint createProcessedPoint(User user, Instant timestamp) {
RawLocationPoint point = new RawLocationPoint(
null,
timestamp,
new GeoPoint(53.863149, 10.700927),
10.0,
null,
false,
false,
false,
1L
);
RawLocationPoint created = rawLocationPointJdbcService.create(user, point);
// Mark as processed
RawLocationPoint processed = new RawLocationPoint(
created.getId(),
created.getTimestamp(),
created.getGeom(),
created.getAccuracyMeters(),
created.getElevationMeters(),
true,
created.isSynthetic(),
created.isIgnored(),
created.getVersion()
);
return rawLocationPointJdbcService.update(processed);
}
private RawLocationPoint findPointById(Long id) {
return rawLocationPointJdbcService.findById(id)
.orElseThrow(() -> new RuntimeException("Point not found: " + id));
}
}

View File

@@ -23,7 +23,7 @@ class HomeDetectionServiceTest {
@Test
void testFindAccommodation_SingleCandidate() {
// Create a significant place
SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L);
// Create a visit that spans sleeping hours
Instant start = Instant.parse("2023-10-01T20:00:00Z");
@@ -42,8 +42,8 @@ class HomeDetectionServiceTest {
@Test
void testFindAccommodation_MultipleCandidates_SameDuration_PickClosestToEnd() {
// Create two places
SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, null, HOME, ZoneId.of("America/New_York"), true, 1L);
// Create visits with same duration (8 hours sleeping), but different end times
// Visit1 ends closer to memoryEnd
@@ -68,8 +68,8 @@ class HomeDetectionServiceTest {
@Test
void testFindAccommodation_MultipleCandidates_DifferentDurations() {
// Similar setup, but place1 has higher duration
SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, null, HOME, ZoneId.of("America/New_York"), true, 1L);
// Visit1: higher duration
Instant start1 = Instant.parse("2023-10-01T20:00:00Z");
@@ -102,7 +102,7 @@ class HomeDetectionServiceTest {
@Test
void testFindAccommodation_VisitsOutsideMemoryRange() {
SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L);
SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L);
// Visit completely outside memory range
Instant start = Instant.parse("2023-09-30T20:00:00Z");

View File

@@ -0,0 +1,256 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.dedicatedcode.reitti.service.PlaceChangeDetectionService.PlaceChangeAnalysis;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.ZoneId;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class PlaceChangeDetectionServiceTest {
@Autowired
private PlaceChangeDetectionService placeChangeDetectionService;
@Autowired
private SignificantPlaceJdbcService placeJdbcService;
@Autowired
private TestingService testingService;
private User testUser;
@BeforeEach
void setUp() {
testingService.clearData();
testUser = testingService.randomUser();
}
@Test
void analyzeChanges_WithValidPolygonAddition_ShouldReturnWarning() {
// Given
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null);
String polygonData = """
[
{"lat": 53.863100, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.701000},
{"lat": 53.863100, "lng": 10.701000}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), polygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be added to this place, this may affect visit detection.")));
}
@Test
void analyzeChanges_WithPolygonRemoval_ShouldReturnWarning() {
// Given
List<GeoPoint> existingPolygon = List.of(
new GeoPoint(53.863100, 10.700900),
new GeoPoint(53.863200, 10.700900),
new GeoPoint(53.863200, 10.701000),
new GeoPoint(53.863100, 10.701000)
);
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon);
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), null);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be removed from this place, this may affect visit detection.")));
}
@Test
void analyzeChanges_WithSignificantPolygonChange_ShouldReturnWarning() {
// Given
List<GeoPoint> existingPolygon = List.of(
new GeoPoint(53.863100, 10.700900),
new GeoPoint(53.863200, 10.700900),
new GeoPoint(53.863200, 10.701000),
new GeoPoint(53.863100, 10.701000)
);
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon);
// Polygon moved significantly (more than 10m)
String newPolygonData = """
[
{"lat": 53.864100, "lng": 10.701900},
{"lat": 53.864200, "lng": 10.701900},
{"lat": 53.864200, "lng": 10.702000},
{"lat": 53.864100, "lng": 10.702000}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), newPolygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be significantly changed, which may affect visit detection.")));
}
@Test
void analyzeChanges_WithNoPolygonChange_ShouldNotReturnWarning() {
// Given
List<GeoPoint> existingPolygon = List.of(
new GeoPoint(53.863100, 10.700900),
new GeoPoint(53.863200, 10.700900),
new GeoPoint(53.863200, 10.701000),
new GeoPoint(53.863100, 10.701000)
);
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon);
// Very minor change (less than 10m)
String newPolygonData = """
[
{"lat": 53.863100, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.701000},
{"lat": 53.863100, "lng": 10.701000}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), newPolygonData);
// Then
assertTrue(result.isCanProceed());
assertTrue(result.getWarnings().isEmpty());
}
@Test
void analyzeChanges_WithOverlappingPlaces_ShouldReturnWarning() {
// Given
List<GeoPoint> existingPolygon = List.of(
new GeoPoint(53.863100, 10.700900),
new GeoPoint(53.863200, 10.700900),
new GeoPoint(53.863200, 10.701000),
new GeoPoint(53.863100, 10.701000)
);
createTestPlace("Existing Place", 53.863149, 10.700927, existingPolygon);
SignificantPlace newPlace = createTestPlace("New Place", 53.863149, 10.700927, null);
// Overlapping polygon
String overlappingPolygonData = """
[
{"lat": 53.863150, "lng": 10.700950},
{"lat": 53.863250, "lng": 10.700950},
{"lat": 53.863250, "lng": 10.701050},
{"lat": 53.863150, "lng": 10.701050}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, newPlace.getId(), overlappingPolygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The new boundary will overlap with 1 existing place, which may cause visits to be reassigned between places and affect trip calculations")));
}
@Test
void analyzeChanges_WithInvalidPolygonData_ShouldReturnError() {
// Given
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null);
String invalidPolygonData = "invalid json";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), invalidPolygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update")));
}
@Test
void analyzeChanges_WithInsufficientPolygonPoints_ShouldReturnError() {
// Given
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null);
String insufficientPolygonData = """
[
{"lat": 53.863100, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.700900}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), insufficientPolygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update")));
}
@Test
void analyzeChanges_WithMissingLatLngProperties_ShouldReturnError() {
// Given
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null);
String invalidPolygonData = """
[
{"latitude": 53.863100, "longitude": 10.700900},
{"lat": 53.863200, "lng": 10.700900},
{"lat": 53.863200, "lng": 10.701000}
]
""";
// When
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), invalidPolygonData);
// Then
assertFalse(result.isCanProceed());
assertFalse(result.getWarnings().isEmpty());
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update")));
}
@Test
void analyzeChanges_WithNoChanges_ShouldReturnNoWarnings() {
// Given
SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null);
// When - no polygon data provided for place that has no polygon
PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), null);
// Then
assertTrue(result.isCanProceed());
assertTrue(result.getWarnings().isEmpty());
}
private SignificantPlace createTestPlace(String name, double latitude, double longitude, List<GeoPoint> polygon) {
return placeJdbcService.create(testUser, new SignificantPlace(
null,
name,
null,
null,
null,
latitude,
longitude,
polygon,
SignificantPlace.PlaceType.HOME,
ZoneId.systemDefault(),
true,
0L
));
}
}

View File

@@ -63,8 +63,8 @@ public class ProcessingPipelineTest {
assertEquals(10, processedVisits.size());
//should not touch visits before the new data
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:40:26Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:00Z" , "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS);
@@ -75,7 +75,7 @@ public class ProcessingPipelineTest {
assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS);
assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
}
@@ -100,8 +100,8 @@ public class ProcessingPipelineTest {
assertEquals(10, processedVisits.size());
//new visits
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:40:26Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:00Z" , "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS);
@@ -112,7 +112,7 @@ public class ProcessingPipelineTest {
assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS);
assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
}