mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 17:37:57 -05:00
505 add remove places (#581)
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 (city‑scale 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE visits;
|
||||
DROP TABLE preview_visits;
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
border: 1px solid wheat;
|
||||
color: wheat;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline-entry.trip.active:hover .edit-icon,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('#', '');
|
||||
|
||||
252
src/main/resources/static/js/polygon-editor.js
Normal file
252
src/main/resources/static/js/polygon-editor.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
483
src/main/resources/templates/settings/edit-place.html
Normal file
483
src/main/resources/templates/settings/edit-place.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user