diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java index 3a9479cd..e94f7946 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java @@ -62,6 +62,13 @@ public class TimelineController { return getTimelineContent(date, timezone, principal, model, null); } + @GetMapping("/content/range") + public String getTimelineContentRange(@RequestParam String startDate, + @RequestParam String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone, + Authentication principal, Model model) { + return getTimelineContentRange(startDate, endDate, timezone, principal, model, null); + } @GetMapping("/places/edit-form/{id}") public String getPlaceEditForm(@PathVariable Long id, @@ -166,6 +173,62 @@ public class TimelineController { return "fragments/timeline :: timeline-content"; } + private String getTimelineContentRange(String startDate, + String endDate, + String timezone, + Authentication principal, Model model, + Long selectedPlaceId) { + + List authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); + + LocalDate selectedStartDate = LocalDate.parse(startDate); + LocalDate selectedEndDate = LocalDate.parse(endDate); + ZoneId userTimezone = ZoneId.of(timezone); + LocalDate now = LocalDate.now(userTimezone); + + // Check if any date in the range is not today + if (!selectedStartDate.isEqual(now) || !selectedEndDate.isEqual(now)) { + if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + } + + // Find the user by username + User user = userJdbcService.findByUsername(principal.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + + // Convert LocalDate to start and end Instant for the date range in user's timezone + Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant(); + Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + + // Build timeline data for current user and connected users + List allUsersData = new ArrayList<>(); + + // Add current user data first - for range, we'll use the start date for the timeline service + List currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange); + + String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone); + String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId())); + String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName()); + allUsersData.add(new UserTimelineData(user.getId() + "", user.getUsername(), currentUserInitials, currentUserAvatarUrl, null, currentUserEntries, currentUserRawLocationPointsUrl)); + + if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) { + allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, selectedStartDate, selectedEndDate, userTimezone)); + allUsersData.addAll(handleSharedUserDataRange(user, selectedStartDate, selectedEndDate, userTimezone)); + } + + TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList()); + + model.addAttribute("timelineData", timelineData); + model.addAttribute("selectedPlaceId", selectedPlaceId); + model.addAttribute("startDate", selectedStartDate); + model.addAttribute("endDate", selectedEndDate); + model.addAttribute("timezone", timezone); + model.addAttribute("isRange", true); + model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode()); + return "fragments/timeline :: timeline-content"; + } + private List handleSharedUserData(User user, LocalDate selectedDate, ZoneId userTimezone, Instant startOfDay, Instant endOfDay) { return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream() .map(u -> { @@ -190,4 +253,32 @@ public class TimelineController { .sorted(Comparator.comparing(UserTimelineData::displayName)) .toList(); } + + private List handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) { + return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream() + .map(u -> { + Optional sharedWithUserOpt = this.userJdbcService.findById(u.getSharingUserId()); + return sharedWithUserOpt.map(sharedWithUser -> { + Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant(); + Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + + List userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange); + String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); + String currentUserAvatarUrl = this.avatarService.getInfo(sharedWithUser.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", sharedWithUser.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", sharedWithUser.getId())); + String currentUserInitials = this.avatarService.generateInitials(sharedWithUser.getDisplayName()); + + return new UserTimelineData(sharedWithUser.getId() + "", + sharedWithUser.getDisplayName(), + currentUserInitials, + currentUserAvatarUrl, + u.getColor(), + userTimelineEntries, + currentUserRawLocationPointsUrl); + }); + }) + .filter(Optional::isPresent) + .map(Optional::get) + .sorted(Comparator.comparing(UserTimelineData::displayName)) + .toList(); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java index 136be674..59c62eef 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java @@ -6,6 +6,7 @@ import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; +import com.dedicatedcode.reitti.service.LocationPointsSimplificationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -18,10 +19,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeParseException; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; @RestController @RequestMapping("/api/v1") @@ -30,41 +28,67 @@ public class LocationDataApiController { private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class); private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final LocationPointsSimplificationService simplificationService; private final UserJdbcService userJdbcService; @Autowired public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService, - UserJdbcService userJdbcService) { + LocationPointsSimplificationService simplificationService, + UserJdbcService userJdbcService) { this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.simplificationService = simplificationService; this.userJdbcService = userJdbcService; } @GetMapping("/raw-location-points") public ResponseEntity getRawLocationPointsForCurrentUser(@AuthenticationPrincipal User user, - @RequestParam("date") String dateStr, - @RequestParam(required = false, defaultValue = "UTC") String timezone) { - return this.getRawLocationPoints(user.getId(), dateStr, timezone); + @RequestParam(required = false) String date, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone, + @RequestParam(required = false) Integer zoom) { + return this.getRawLocationPoints(user.getId(), date, startDate, endDate, timezone, zoom); } @GetMapping("/raw-location-points/{userId}") public ResponseEntity getRawLocationPoints(@PathVariable Long userId, - @RequestParam("date") String dateStr, - @RequestParam(required = false, defaultValue = "UTC") String timezone) { + @RequestParam(required = false) String date, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone, + @RequestParam(required = false) Integer zoom) { try { - LocalDate date = LocalDate.parse(dateStr); ZoneId userTimezone = ZoneId.of(timezone); + Instant startOfRange; + Instant endOfRange; - // Convert LocalDate to start and end Instant for the selected date in user's timezone - Instant startOfDay = date.atStartOfDay(userTimezone).toInstant(); - Instant endOfDay = date.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + // Support both single date and date range + if (startDate != null && endDate != null) { + // Date range mode + LocalDate selectedStartDate = LocalDate.parse(startDate); + LocalDate selectedEndDate = LocalDate.parse(endDate); + + startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant(); + endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + } else if (date != null) { + // Single date mode (backward compatibility) + LocalDate selectedDate = LocalDate.parse(date); + + startOfRange = selectedDate.atStartOfDay(userTimezone).toInstant(); + endOfRange = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + } else { + return ResponseEntity.badRequest().body(Map.of( + "error", "Either 'date' or both 'startDate' and 'endDate' must be provided" + )); + } // Get the user from the repository by userId User user = userJdbcService.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); // Get raw location points for the user and date range - List points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfDay, endOfDay).stream() - .filter(point -> !point.getTimestamp().isBefore(startOfDay) && point.getTimestamp().isBefore(endOfDay)) + List points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange).stream() + .filter(point -> !point.getTimestamp().isBefore(startOfRange) && point.getTimestamp().isBefore(endOfRange)) .sorted(Comparator.comparing(RawLocationPoint::getTimestamp)) .map(point -> { LocationDataRequest.LocationPoint p = new LocationDataRequest.LocationPoint(); @@ -76,7 +100,9 @@ public class LocationDataApiController { }) .toList(); - return ResponseEntity.ok(Map.of("points", points)); + List simplifiedPoints = simplificationService.simplifyPoints(points, zoom); + + return ResponseEntity.ok(Map.of("points", simplifiedPoints)); } catch (DateTimeParseException e) { return ResponseEntity.badRequest().body(Map.of( diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/PhotoApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/PhotoApiController.java index 9d41ccb1..bf902fe6 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/PhotoApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/PhotoApiController.java @@ -25,14 +25,15 @@ public class PhotoApiController { this.immichIntegrationService = immichIntegrationService; this.restTemplate = restTemplate; } - - @GetMapping("/day/{date}") - public ResponseEntity> getPhotosForDay( - @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + + @GetMapping("/range") + public ResponseEntity> getPhotosForRange( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @RequestParam(required = false, defaultValue = "UTC") String timezone, @AuthenticationPrincipal User user) { - List photos = immichIntegrationService.searchPhotosForDay(user, date, timezone); + List photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone); return ResponseEntity.ok(photos); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java index 1246342d..a3032c17 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java @@ -61,16 +61,34 @@ public class ReittiIntegrationApiController { @GetMapping("/timeline") public List getTimeline(@AuthenticationPrincipal User user, - @RequestParam String date, + @RequestParam(required = false) String date, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, @RequestParam(required = false, defaultValue = "UTC") String timezone) { - LocalDate selectedDate = LocalDate.parse(date); ZoneId userTimezone = ZoneId.of(timezone); - Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant(); - Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + // Support both single date and date range + if (startDate != null && endDate != null) { + // Date range mode + LocalDate selectedStartDate = LocalDate.parse(startDate); + LocalDate selectedEndDate = LocalDate.parse(endDate); - return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay); + Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant(); + Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + + return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange); + } else if (date != null) { + // Single date mode (backward compatibility) + LocalDate selectedDate = LocalDate.parse(date); + + Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant(); + Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + + return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay); + } else { + throw new IllegalArgumentException("Either 'date' or both 'startDate' and 'endDate' must be provided"); + } } @PostMapping("/subscribe") diff --git a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java index 66748412..7e75d361 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java @@ -15,6 +15,7 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -178,9 +179,9 @@ public class RawLocationPointJdbcService { return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ?", Long.class, user.getId()); } - public void bulkInsert(User user, List points) { + public int bulkInsert(User user, List points) { if (points.isEmpty()) { - return; + return -1; } String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, geom, processed) " + @@ -197,7 +198,8 @@ public class RawLocationPointJdbcService { geometryFactory.createPoint(new Coordinate(point.getLongitude(), point.getLatitude())).toString() }); } - jdbcTemplate.batchUpdate(sql, batchArgs); + int[] ints = jdbcTemplate.batchUpdate(sql, batchArgs); + return Arrays.stream(ints).sum(); } public void bulkUpdateProcessedStatus(List points) { diff --git a/src/main/java/com/dedicatedcode/reitti/service/LocationPointsSimplificationService.java b/src/main/java/com/dedicatedcode/reitti/service/LocationPointsSimplificationService.java new file mode 100644 index 00000000..8d4e632c --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/LocationPointsSimplificationService.java @@ -0,0 +1,188 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.dto.LocationDataRequest; +import org.slf4j.Logger; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class LocationPointsSimplificationService { + private static final Logger logger = org.slf4j.LoggerFactory.getLogger(LocationPointsSimplificationService.class); + + /** + * Simplify a list of location points using the Visvalingam-Whyatt algorithm + * + * @param points The original list of points + * @param zoom The current map zoom level (null means no simplification) + * @return Simplified list of points + */ + public List simplifyPoints(List points, Integer zoom) { + // If zoom is not provided or points are too few, return original + if (zoom == null || points.size() <= 2) { + return points; + } + + // Calculate target point count based on zoom level + // Higher zoom = more detail = more points + // Zoom levels typically range from 1-20 + int targetPointCount = calculateTargetPointCount(points.size(), zoom); + + + if (targetPointCount >= points.size()) { + return points; + } + + logger.debug("Simplifying {} points to {} points for zoom level {}", points.size(), targetPointCount, zoom); + + return visvalingamWhyatt(points, targetPointCount); + } + + /** + * Calculate target point count based on zoom level + */ + private int calculateTargetPointCount(int originalCount, int zoom) { + // Ensure we keep at least 10 points and at most the original count + // Zoom 1-5: very simplified (10-20% of points) + // Zoom 6-10: moderately simplified (20-40% of points) + // Zoom 11-15: lightly simplified (40-70% of points) + // Zoom 16+: minimal simplification (70-100% of points) + + double retentionRatio; + if (zoom <= 13) { + retentionRatio = 0.10 + (zoom - 1) * 0.025; // 10% to 20% + } else if (zoom <= 15) { + retentionRatio = 0.20 + (zoom - 6) * 0.04; // 20% to 40% + } else if (zoom <= 18) { + retentionRatio = 0.40 + (zoom - 11) * 0.06; // 40% to 70% + } else { + retentionRatio = 0.70 + Math.min(zoom - 16, 4) * 0.075; // 70% to 100% + } + + + int targetCount = (int) Math.ceil(originalCount * retentionRatio); + targetCount = Math.min(3000, targetCount); + return Math.max(10, Math.min(targetCount, originalCount)); + } + + /** + * Visvalingam-Whyatt algorithm implementation for polyline simplification + */ + private List visvalingamWhyatt(List points, int targetCount) { + if (points.size() <= targetCount) { + return points; + } + + // Create a list of triangles with their effective areas + List triangles = new ArrayList<>(); + + // Initialize triangles for all interior points + for (int i = 1; i < points.size() - 1; i++) { + Triangle triangle = new Triangle(i - 1, i, i + 1, points); + triangles.add(triangle); + } + + // Use a priority queue to efficiently find the triangle with minimum area + PriorityQueue heap = new PriorityQueue<>(Comparator.comparingDouble(t -> t.area)); + heap.addAll(triangles); + + // Track which points to keep + Set removedIndices = new HashSet<>(); + + // Remove points until we reach the target count + int pointsToRemove = points.size() - targetCount; + + while (pointsToRemove > 0 && !heap.isEmpty()) { + Triangle minTriangle = heap.poll(); + + // Skip if this triangle's center point was already removed + if (removedIndices.contains(minTriangle.centerIndex)) { + continue; + } + + // Mark the center point for removal + removedIndices.add(minTriangle.centerIndex); + pointsToRemove--; + + // Update neighboring triangles + updateNeighboringTriangles(heap, minTriangle, removedIndices, points); + } + + // Build the result list with remaining points + List result = new ArrayList<>(); + for (int i = 0; i < points.size(); i++) { + if (!removedIndices.contains(i)) { + result.add(points.get(i)); + } + } + + return result; + } + + /** + * Update neighboring triangles after removing a point + */ + private void updateNeighboringTriangles(PriorityQueue heap, Triangle removed, + Set removedIndices, List points) { + // Find the previous and next non-removed points + int prevIndex = removed.leftIndex; + while (prevIndex > 0 && removedIndices.contains(prevIndex)) { + prevIndex--; + } + + int nextIndex = removed.rightIndex; + while (nextIndex < points.size() - 1 && removedIndices.contains(nextIndex)) { + nextIndex++; + } + + // Create new triangles if possible + if (prevIndex > 0 && !removedIndices.contains(prevIndex - 1)) { + Triangle newTriangle = new Triangle(prevIndex - 1, prevIndex, nextIndex, points); + heap.add(newTriangle); + } + + if (nextIndex < points.size() - 1 && !removedIndices.contains(nextIndex + 1)) { + Triangle newTriangle = new Triangle(prevIndex, nextIndex, nextIndex + 1, points); + heap.add(newTriangle); + } + } + + /** + * Helper class to represent a triangle formed by three consecutive points + */ + private static class Triangle { + final int leftIndex; + final int centerIndex; + final int rightIndex; + final double area; + + Triangle(int leftIndex, int centerIndex, int rightIndex, List points) { + this.leftIndex = leftIndex; + this.centerIndex = centerIndex; + this.rightIndex = rightIndex; + this.area = calculateTriangleArea( + points.get(leftIndex), + points.get(centerIndex), + points.get(rightIndex) + ); + } + + /** + * Calculate the area of a triangle formed by three points using the cross product + */ + private static double calculateTriangleArea(LocationDataRequest.LocationPoint p1, + LocationDataRequest.LocationPoint p2, + LocationDataRequest.LocationPoint p3) { + // Using the cross product formula for triangle area + // Area = 0.5 * |x1(y2 - y3) + x2(y3 - y1) + x3(y1 - y2)| + double area = Math.abs( + p1.getLongitude() * (p2.getLatitude() - p3.getLatitude()) + + p2.getLongitude() * (p3.getLatitude() - p1.getLatitude()) + + p3.getLongitude() * (p1.getLatitude() - p2.getLatitude()) + ) / 2.0; + + return area; + } + } + +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/ImmichIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/ImmichIntegrationService.java index 0b034902..7d159b32 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/ImmichIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/ImmichIntegrationService.java @@ -97,7 +97,7 @@ public class ImmichIntegrationService { } } - public List searchPhotosForDay(User user, LocalDate date, String timezone) { + public List searchPhotosForRange(User user, LocalDate start, LocalDate end, String timezone) { Optional integrationOpt = getIntegrationForUser(user); if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) { @@ -113,8 +113,8 @@ public class ImmichIntegrationService { ZoneId userTimezone = ZoneId.of(timezone); // Convert LocalDate to start and end Instant for the selected date in user's timezone - Instant startOfDay = date.atStartOfDay(userTimezone).toInstant(); - Instant endOfDay = date.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + Instant startOfDay = start.atStartOfDay(userTimezone).toInstant(); + Instant endOfDay = end.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); ImmichSearchRequest searchRequest = new ImmichSearchRequest(DateTimeFormatter.ISO_INSTANT.format(startOfDay), DateTimeFormatter.ISO_INSTANT.format(endOfDay)); diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java index a8eab632..738b590a 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java @@ -78,6 +78,35 @@ public class ReittiIntegrationService { }).toList(); } + public List getTimelineDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) { + return this.jdbcService + .findAllByUser(user) + .stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus())) + .map(integration -> { + + log.debug("Fetching user timeline data range for [{}] from {} to {}", integration, startDate, endDate); + try { + RemoteUser remoteUser = handleRemoteUser(integration); + List timelineEntries = loadTimeLineEntriesRange(integration, startDate, endDate, userTimezone); + integration = update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now())); + return new UserTimelineData("remote:" + integration.getId(), + remoteUser.getDisplayName(), + this.avatarService.generateInitials(remoteUser.getDisplayName()), + "/reitti-integration/avatar/" + integration.getId(), + integration.getColor(), + timelineEntries, + String.format("/reitti-integration/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone)); + } catch (RequestFailedException e) { + log.error("couldn't fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); + } catch (RequestTemporaryFailedException e) { + log.warn("couldn't temporarily fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now())); + } + return null; + }).toList(); + } + public ReittiRemoteInfo getInfo(ReittiIntegration integration) throws RequestFailedException, RequestTemporaryFailedException { return getInfo(integration.getUrl(), integration.getToken()); } @@ -159,6 +188,53 @@ public class ReittiIntegrationService { } + public List getRawLocationDataRange(User user, Long integrationId, String startDateStr, String endDateStr, String timezone) { + return this.jdbcService + .findByIdAndUser(integrationId,user) + .stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus())) + .map(integration -> { + + log.debug("Fetching raw location data range for [{}] from {} to {}", integration, startDateStr, endDateStr); + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-API-TOKEN", integration.getToken()); + HttpEntity entity = new HttpEntity<>(headers); + + String rawLocationDataUrl = integration.getUrl().endsWith("/") ? + integration.getUrl() + "api/v1/raw-location-points?startDate={startDate}&endDate={endDate}&timezone={timezone}" : + integration.getUrl() + "/api/v1/raw-location-points?startDate={startDate}&endDate={endDate}&timezone={timezone}"; + ResponseEntity remoteResponse = restTemplate.exchange( + rawLocationDataUrl, + HttpMethod.GET, + entity, + Map.class, + startDateStr, + endDateStr, + timezone + ); + + if (remoteResponse.getStatusCode().is2xxSuccessful() && remoteResponse.getBody() != null && remoteResponse.getBody().containsKey("points")) { + update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now())); + return (List) remoteResponse.getBody().get("points"); + } else if (remoteResponse.getStatusCode().is4xxClientError()) { + throw new RequestFailedException(rawLocationDataUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } else { + throw new RequestTemporaryFailedException(rawLocationDataUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } + } catch (RequestFailedException e) { + log.error("couldn't fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); + } catch (RequestTemporaryFailedException e) { + log.warn("couldn't temporarily fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now())); + } + return null; + }) + .filter(Objects::nonNull) + .findFirst().orElse(Collections.emptyList()); + + } + private ReittiIntegration update(ReittiIntegration integration) { try { return this.jdbcService.update(integration).orElseThrow(); @@ -198,6 +274,37 @@ public class ReittiIntegrationService { } } + private List loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException { + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-API-TOKEN", integration.getToken()); + HttpEntity entity = new HttpEntity<>(headers); + + String timelineUrl = integration.getUrl().endsWith("/") ? + integration.getUrl() + "api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}" : + integration.getUrl() + "/api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}"; + + + ParameterizedTypeReference> typeRef = new ParameterizedTypeReference<>() {}; + ResponseEntity> remoteResponse = restTemplate.exchange( + timelineUrl, + HttpMethod.GET, + entity, + typeRef, + startDate, + endDate, + userTimezone.getId() + ); + + if (remoteResponse.getStatusCode().is2xxSuccessful()) { + return remoteResponse.getBody(); + } else if (remoteResponse.getStatusCode().is4xxClientError()) { + throw new RequestFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } else { + throw new RequestTemporaryFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } + } + private RemoteUser handleRemoteUser(ReittiIntegration integration) throws RequestFailedException, RequestTemporaryFailedException { ReittiRemoteInfo info = getInfo(integration); Optional persisted = this.jdbcService.findByIntegration(integration); @@ -363,4 +470,4 @@ public class ReittiIntegrationService { public Optional getUserIdForSubscription(String subscriptionId) { return Optional.ofNullable(this.userForSubscriptions.get(subscriptionId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java index e3f36427..c4686b45 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java @@ -51,9 +51,9 @@ public class LocationDataIngestPipeline { User user = userOpt.get(); List points = event.getPoints(); List filtered = this.geoPointAnomalyFilter.filterAnomalies(points); - rawLocationPointJdbcService.bulkInsert(user, filtered); + int updatedRows = rawLocationPointJdbcService.bulkInsert(user, filtered); userSettingsJdbcService.updateNewestData(user, filtered); userNotificationService.newRawLocationData(user, filtered); - logger.info("Finished storing points [{}] for user [{}] in [{}]ms. Filtered out [{}] points.", filtered.size(), event.getUsername(), System.currentTimeMillis() - start, points.size() - filtered.size()); + logger.info("Finished storing points [{}] for user [{}] in [{}]ms. Filtered out [{}] points before database and [{}] after database.", filtered.size(), event.getUsername(), System.currentTimeMillis() - start, points.size() - filtered.size(), filtered.size() - updatedRows); } } diff --git a/src/main/resources/static/css/date-picker.css b/src/main/resources/static/css/date-picker.css index 825a131b..ae102cf3 100644 --- a/src/main/resources/static/css/date-picker.css +++ b/src/main/resources/static/css/date-picker.css @@ -39,19 +39,43 @@ min-width: 80px; user-select: none; scroll-snap-align: center; + position: relative; } -.date-item:hover { - background-color: rgba(255, 255, 255, 0.2); -} .date-item.selected { - background-color: #4a89dc; + background-color: #fddca1; color: white; transform: scale(1.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } +.date-item.selected::before { + content: "\eb47"; + font-family: 'Lineicons'; + font-weight: 900; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 2rem; + color: rgba(255, 255, 255, 0.9); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 1; +} + +.date-item.selected:hover::before { + opacity: 1; +} + +.date-item.selected:hover .day-name, +.date-item.selected:hover .day-number, +.date-item.selected:hover .month-year-name { + opacity: 0.2; +} + .date-item.unavailable { opacity: 0.4; cursor: not-allowed; @@ -62,16 +86,134 @@ transform: none; } +/* Range mode styles */ +.date-item.range-start, +.date-item.range-end { + background-color: rgba(117, 117, 117, 0.67); + color: white; + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + position: relative; +} + +.date-item.range-start::before, +.date-item.range-end::before { + content: "\ec2a"; + font-family: 'Lineicons'; + font-weight: 900; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 2rem; + color: rgba(255, 255, 255, 0.9); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 1; +} + +.date-item.range-start:hover::before, +.date-item.range-end:hover::before { + opacity: 1; +} + +.date-item.range-start:hover .day-name, +.date-item.range-start:hover .day-number, +.date-item.range-end:hover .day-name, +.date-item.range-end:hover .day-number { + opacity: 0.2; +} + +.date-item.range-start::after { + content: 'Start'; + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + color: wheat; + font-weight: bold; + white-space: nowrap; + transition: opacity 0.3s ease; +} + +.date-item.range-start:hover::after { + opacity: 0.2; +} + +.date-item.range-end::after { + content: 'End'; + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + color: #fddca1; + font-weight: bold; + white-space: nowrap; + transition: opacity 0.3s ease; +} + +.date-item.range-end:hover::after { + opacity: 0.2; +} + +.date-item.in-range { + background-color: rgba(117, 117, 117, 0.67); + color: white; +} + +.date-item.in-range:hover { + background-color: rgba(253, 220, 161, 0.75); +} + +/* Range preview on hover */ +.date-item.range-preview { + background-color: rgba(117, 117, 117, 0.4); + color: white; +} + +/* Clear range button */ +.clear-range-button { + position: absolute; + top: 10px; + right: 10px; + background-color: rgba(220, 74, 74, 0.8); + color: white; + border: none; + border-radius: 20px; + padding: 6px 12px; + font-size: 0.85rem; + cursor: pointer; + z-index: 52; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.3s ease; +} + +.clear-range-button:hover { + background-color: rgba(220, 74, 74, 1); + transform: scale(1.05); +} + .date-item .day-name { font-size: 0.8rem; opacity: 0.8; display: block; + transition: opacity 0.3s ease; + position: relative; + z-index: 0; } .date-item .day-number { font-size: 1.2rem; font-weight: bold; display: block; + transition: opacity 0.3s ease; + position: relative; + z-index: 0; } .date-item .month-name { @@ -84,6 +226,9 @@ display: block; font-weight: bold; margin-top: 2px; + transition: opacity 0.3s ease; + position: relative; + z-index: 0; } .date-nav-button { @@ -246,6 +391,64 @@ border-radius: 10px; } +.date-item.selected { + background-color: unset; + color: var(--color-highlight); + transform: scale(1.05); + box-shadow: unset; +} + +.month-item.selected { + background-color: var(--color-highlight); + color: var(--color-highlight); + font-weight: bolder; + background: unset; +} + +.year-item.selected { + background-color: var(--color-highlight); + color: var(--color-highlight); + font-weight: bolder; + background: unset; +} + +.today-button { + background-color: unset; +} + +.date-item { + padding: unset; + margin: unset; + border-radius: 0; +} + +.date-item.selected:hover { + border-bottom: 0; +} + +.date-item .day-name, +.date-item .day-number { + font-size: 2rem; + font-weight: lighter; +} + +.horizontal-date-picker { + position: fixed; + font-family: var(--serif-font); + bottom: 0; + left: 0; + width: 100%; + background-color: rgba(59, 59, 59, 0.68); + backdrop-filter: blur(10px); + padding: 10px 0; + z-index: 50; + box-shadow: unset; +} + +.date-item:hover { + background-color: rgba(255, 255, 255, 0.2); +} + @media (max-width: 768px) { .date-item { min-width: 60px; @@ -266,4 +469,21 @@ justify-content: center; position: initial; } + + .clear-range-button { + top: 5px; + right: 5px; + padding: 4px 8px; + font-size: 0.75rem; + } + + .date-item.selected::before { + font-size: 1.5rem; + } + + .date-item.range-start::before, + .date-item.range-end::before { + font-size: 1.5rem; + } + } diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 285eaecb..34fc9f34 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -501,65 +501,6 @@ button:hover { background-color: #959595; } -.date-item.selected { - background-color: unset; - color: var(--color-highlight); - transform: scale(1.05); - box-shadow: unset; -} - -.month-item.selected { - background-color: var(--color-highlight); - color: var(--color-highlight); - font-weight: bolder; - background: unset; -} - -.year-item.selected { - background-color: var(--color-highlight); - color: var(--color-highlight); - font-weight: bolder; - background: unset; -} - -.today-button { - background-color: unset; -} - -.date-item { - padding: unset; - margin: unset; - border-radius: 0; -} - -.date-item:hover { - background-color: unset; - border-bottom: 1px solid white; -} - -.date-item.selected:hover { - border-bottom: 0; -} - -.date-item .day-name, -.date-item .day-number { - font-size: 2rem; - font-weight: lighter; -} - -.horizontal-date-picker { - position: fixed; - font-family: var(--serif-font); - bottom: 0; - left: 0; - width: 100%; - background-color: rgba(59, 59, 59, 0.68); - backdrop-filter: blur(10px); - padding: 10px 0; - z-index: 50; - box-shadow: unset; -} - .upload-options progress { display: block; width: 100%; diff --git a/src/main/resources/static/js/horizontal-date-picker.js b/src/main/resources/static/js/horizontal-date-picker.js index 2edc2a38..012ecba4 100644 --- a/src/main/resources/static/js/horizontal-date-picker.js +++ b/src/main/resources/static/js/horizontal-date-picker.js @@ -9,6 +9,7 @@ class HorizontalDatePicker { daysToShow: 15, daysBeforeToday: 7, onDateSelect: null, + onDateRangeSelect: null, selectedDate: new Date(), showNavButtons: true, // Option to show/hide navigation buttons minDate: null, // Minimum selectable date @@ -24,6 +25,14 @@ class HorizontalDatePicker { // Track the last valid date for reverting invalid selections this.lastValidDate = new Date(this.options.selectedDate); + // Range mode properties + this.rangeMode = false; + this.rangeStartDate = null; + this.rangeEndDate = null; + + // Track original month/year for hover restoration + this.originalSelectedDate = null; + this.init(); } @@ -81,6 +90,13 @@ class HorizontalDatePicker { this.element.appendChild(this.nextButton); } + // Create clear range button (initially hidden) + this.clearRangeButton = document.createElement('button'); + this.clearRangeButton.className = 'clear-range-button'; + this.clearRangeButton.innerHTML = ' Clear Range'; + this.clearRangeButton.style.display = 'none'; + this.element.appendChild(this.clearRangeButton); + // Append date container this.element.appendChild(this.dateContainer); @@ -125,8 +141,24 @@ class HorizontalDatePicker { const dateItem = e.target.closest('.date-item'); if (dateItem) { // Check if this date is already selected - if (dateItem.classList.contains('selected')) { - return; // Do nothing if clicking on already selected date + if (dateItem.classList.contains('selected') && !this.rangeMode) { + // Clicking on selected date enters range mode + this.enterRangeMode(dateItem); + return; + } + + if (this.rangeMode) { + // Check if clicking on range start or end date to exit range mode + const clickedDate = this.parseDate(dateItem.dataset.date); + if ((this.rangeStartDate && this.isSameDay(clickedDate, this.rangeStartDate)) || + (this.rangeEndDate && this.isSameDay(clickedDate, this.rangeEndDate))) { + this.exitRangeMode(); + return; + } + + // In range mode, select the end date + this.selectRangeEnd(dateItem); + return; } // Prevent auto-selection from interfering with manual clicks @@ -142,6 +174,36 @@ class HorizontalDatePicker { } }); + // Add hover listener for range preview + this.dateContainer.addEventListener('mouseover', (e) => { + const dateItem = e.target.closest('.date-item'); + if (dateItem) { + if (this.rangeMode && this.rangeStartDate) { + this.showRangePreview(dateItem); + } + + // Update month row for hovered date + this.updateMonthRowForHoveredDate(dateItem); + } + }); + + this.dateContainer.addEventListener('mouseout', (e) => { + const dateItem = e.target.closest('.date-item'); + if (dateItem && this.rangeMode && this.rangeStartDate) { + this.clearRangePreview(); + } + }); + + // Restore original month row when mouse leaves the date container + this.dateContainer.addEventListener('mouseleave', () => { + this.restoreOriginalMonthRow(); + }); + + // Clear range button + this.clearRangeButton.addEventListener('click', () => { + this.exitRangeMode(); + }); + // Navigation buttons (if enabled) if (this.options.showNavButtons) { this.prevButton.addEventListener('click', () => { @@ -227,8 +289,24 @@ class HorizontalDatePicker { if (dateItem) { // Check if this date is already selected - if (dateItem.classList.contains('selected')) { - return; // Do nothing if tapping on already selected date + if (dateItem.classList.contains('selected') && !this.rangeMode) { + // Clicking on selected date enters range mode + this.enterRangeMode(dateItem); + return; + } + + if (this.rangeMode) { + // Check if tapping on range start or end date to exit range mode + const tappedDate = this.parseDate(dateItem.dataset.date); + if ((this.rangeStartDate && this.isSameDay(tappedDate, this.rangeStartDate)) || + (this.rangeEndDate && this.isSameDay(tappedDate, this.rangeEndDate))) { + this.exitRangeMode(); + return; + } + + // In range mode, select the end date + this.selectRangeEnd(dateItem); + return; } // Prevent auto-selection from interfering with manual taps @@ -390,8 +468,26 @@ class HorizontalDatePicker { dateItem.classList.add('unavailable'); } - // Check if this date is selected - if (this.isSameDay(date, this.options.selectedDate)) { + // Check if this date is in a range + if (this.rangeMode && this.rangeStartDate && this.rangeEndDate) { + if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) { + dateItem.classList.add('in-range'); + } + if (this.isSameDay(date, this.rangeStartDate)) { + dateItem.classList.add('range-start'); + } + if (this.isSameDay(date, this.rangeEndDate)) { + dateItem.classList.add('range-end'); + } + } else if (this.rangeMode && this.rangeStartDate && !this.rangeEndDate) { + // Only start date is selected + if (this.isSameDay(date, this.rangeStartDate)) { + dateItem.classList.add('range-start'); + } + } + + // Check if this date is selected (for non-range mode) + if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) { dateItem.classList.add('selected'); this.selectedElement = dateItem; } @@ -417,8 +513,8 @@ class HorizontalDatePicker { dateItem.appendChild(monthName); } - // Add month and year for selected date - if (this.isSameDay(date, this.options.selectedDate)) { + // Add month and year for selected date (only in non-range mode) + if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) { const monthYearName = document.createElement('span'); monthYearName.className = 'month-year-name'; monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`; @@ -525,13 +621,353 @@ class HorizontalDatePicker { const event = new CustomEvent('dateSelected', { detail: { date: dateToSelect, - formattedDate: dateItem.dataset.date + formattedDate: dateItem.dataset.date, + isRange: false, + rangeStart: null, + rangeEnd: null } }); this.element.dispatchEvent(event); } } + // Enter range mode + enterRangeMode(dateItem) { + this.rangeMode = true; + this.rangeStartDate = this.parseDate(dateItem.dataset.date); + this.rangeEndDate = this.parseDate(dateItem.dataset.date); + + // Show clear range button + this.clearRangeButton.style.display = 'flex'; + + // Update all date items to show range mode + this.updateDateItemsForRange(); + + // Add visual feedback + dateItem.classList.add('range-start'); + dateItem.classList.remove('selected'); + + // Remove month-year-name from the start date + const monthYearEl = dateItem.querySelector('.month-year-name'); + if (monthYearEl) { + dateItem.removeChild(monthYearEl); + } + + console.log('Entered range mode, start date:', this.rangeStartDate); + } + + // Select range end date + selectRangeEnd(dateItem) { + const clickedDate = this.parseDate(dateItem.dataset.date); + + // Check if date is within min/max range + if ((this.options.minDate && clickedDate < new Date(this.options.minDate)) || + (this.options.maxDate && clickedDate > new Date(this.options.maxDate))) { + this.flashInvalidSelection(dateItem); + return; + } + + // Check if future dates are allowed + if (!this.options.allowFutureDates) { + const today = new Date(); + today.setHours(23, 59, 59, 59); + if (clickedDate > today) { + this.flashInvalidSelection(dateItem); + return; + } + } + + // Determine behavior based on where the clicked date is relative to the current range + if (clickedDate < this.rangeStartDate) { + // Clicking before the start: move the start date + this.rangeStartDate = clickedDate; + // Keep the end date as is (if it exists) + } else if (this.rangeEndDate && this.isDateInRange(clickedDate, this.rangeStartDate, this.rangeEndDate)) { + // Clicking inside the range: move the end date + this.rangeEndDate = clickedDate; + } else if (this.isSameDay(clickedDate, this.rangeStartDate)) { + // Clicking on the start date: exit range mode + this.exitRangeMode(); + return; + } else { + // Clicking after the start (or after the current end): move the end date + this.rangeEndDate = clickedDate; + } + + // Clear any preview + this.clearRangePreview(); + + // Update all date items to show the complete range + this.updateDateItemsForRange(); + + // Call the onDateRangeSelect callback with range information only if both dates are set + if (this.rangeStartDate && this.rangeEndDate) { + if (typeof this.options.onDateRangeSelect === 'function') { + this.options.onDateRangeSelect( + this.rangeStartDate, + this.rangeEndDate, + this.formatDate(this.rangeStartDate), + this.formatDate(this.rangeEndDate) + ); + } + + // Dispatch custom event + const event = new CustomEvent('dateSelected', { + detail: { + date: this.rangeStartDate, + formattedDate: this.formatDate(this.rangeStartDate), + isRange: true, + rangeStart: this.rangeStartDate, + rangeEnd: this.rangeEndDate, + formattedRangeStart: this.formatDate(this.rangeStartDate), + formattedRangeEnd: this.formatDate(this.rangeEndDate) + } + }); + this.element.dispatchEvent(event); + } + + console.log('Range selected:', this.rangeStartDate, 'to', this.rangeEndDate); + } + + // Exit range mode + exitRangeMode() { + this.rangeMode = false; + const lastStartDate = this.rangeStartDate; + this.rangeStartDate = null; + this.rangeEndDate = null; + + // Hide clear range button + this.clearRangeButton.style.display = 'none'; + + // Clear any preview + this.clearRangePreview(); + + // Update all date items to remove range styling + this.updateDateItemsForRange(); + + // Restore the original selected date + if (lastStartDate) { + this.options.selectedDate = lastStartDate; + const dateItems = this.dateContainer.querySelectorAll('.date-item'); + const formattedDate = this.formatDate(lastStartDate); + + for (const item of dateItems) { + if (item.dataset.date === formattedDate) { + this.selectDateItem(item, true); + break; + } + } + } + + console.log('Exited range mode'); + } + + // Show range preview on hover + showRangePreview(dateItem) { + const hoveredDate = this.parseDate(dateItem.dataset.date); + + // Don't show preview if hovering over the start date or end date + if (this.isSameDay(hoveredDate, this.rangeStartDate) || + (this.rangeEndDate && this.isSameDay(hoveredDate, this.rangeEndDate))) { + return; + } + + // Clear any existing preview + this.clearRangePreview(); + + // Determine the preview range based on where the hovered date is + let previewStart, previewEnd; + + if (hoveredDate < this.rangeStartDate) { + // Hovering before start: preview shows new start to current end (or current start if no end) + previewStart = hoveredDate; + previewEnd = this.rangeEndDate || this.rangeStartDate; + } else if (this.rangeEndDate && hoveredDate <= this.rangeEndDate) { + // Hovering inside the range: preview shows start to hovered date + previewStart = this.rangeStartDate; + previewEnd = hoveredDate; + } else { + // Hovering after start (or after end): preview shows start to hovered date + previewStart = this.rangeStartDate; + previewEnd = hoveredDate; + } + + // Apply preview styling to dates in the range + const dateItems = this.dateContainer.querySelectorAll('.date-item'); + dateItems.forEach(item => { + const date = this.parseDate(item.dataset.date); + + // Don't apply preview to start/end dates + if (this.isDateInRange(date, previewStart, previewEnd) && + !this.isSameDay(date, this.rangeStartDate) && + !(this.rangeEndDate && this.isSameDay(date, this.rangeEndDate))) { + item.classList.add('range-preview'); + } + }); + } + + // Clear range preview + clearRangePreview() { + const dateItems = this.dateContainer.querySelectorAll('.date-item'); + dateItems.forEach(item => { + item.classList.remove('range-preview'); + }); + } + + // Update all date items to reflect range mode + updateDateItemsForRange() { + const dateItems = this.dateContainer.querySelectorAll('.date-item'); + + dateItems.forEach(item => { + const date = this.parseDate(item.dataset.date); + + // Remove all range-related classes + item.classList.remove('in-range', 'range-start', 'range-end', 'selected', 'range-preview'); + + // Remove month-year-name if it exists + const monthYearEl = item.querySelector('.month-year-name'); + if (monthYearEl) { + item.removeChild(monthYearEl); + } + + // Restore month-name for first day of month + if (date.getDate() === 1 && !item.querySelector('.month-name')) { + const monthName = document.createElement('span'); + monthName.className = 'month-name'; + monthName.textContent = this.getMonthName(date); + item.appendChild(monthName); + } + + if (this.rangeMode) { + if (this.rangeStartDate && this.isSameDay(date, this.rangeStartDate)) { + item.classList.add('range-start'); + } + + if (this.rangeEndDate) { + if (this.isSameDay(date, this.rangeEndDate)) { + item.classList.add('range-end'); + } + + if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) { + item.classList.add('in-range'); + } + } + } else { + // Restore selected state for non-range mode + if (this.isSameDay(date, this.options.selectedDate)) { + item.classList.add('selected'); + this.selectedElement = item; + + // Remove month-name to avoid duplication + const monthNameEl = item.querySelector('.month-name'); + if (monthNameEl) { + item.removeChild(monthNameEl); + } + + // Add month-year-name + if (!item.querySelector('.month-year-name')) { + const monthYearName = document.createElement('span'); + monthYearName.className = 'month-year-name'; + monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`; + item.appendChild(monthYearName); + } + } + } + }); + } + + // Update month row for hovered date + updateMonthRowForHoveredDate(dateItem) { + if (!this.options.showMonthRow) return; + + const hoveredDate = this.parseDate(dateItem.dataset.date); + const hoveredYear = hoveredDate.getFullYear(); + const hoveredMonth = hoveredDate.getMonth(); + + // Store the original selected date if not already stored + if (!this.originalSelectedDate) { + this.originalSelectedDate = new Date(this.options.selectedDate); + } + + // Check if the hovered date is in a different month or year + const selectedYear = this.options.selectedDate.getFullYear(); + const selectedMonth = this.options.selectedDate.getMonth(); + + // Update year items + const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); + yearItems.forEach(item => { + const itemYear = parseInt(item.dataset.year); + item.classList.remove('selected'); + + if (itemYear === hoveredYear) { + item.classList.add('selected'); + } + }); + + // Update month items + const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); + monthItems.forEach(item => { + const itemYear = parseInt(item.dataset.year); + const itemMonth = parseInt(item.dataset.month); + item.classList.remove('selected'); + + if (itemYear === hoveredYear && itemMonth === hoveredMonth) { + item.classList.add('selected'); + + // Scroll to the hovered month + item.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'}); + } + }); + } + + // Restore original month row + restoreOriginalMonthRow() { + if (!this.options.showMonthRow || !this.originalSelectedDate) return; + + const originalYear = this.originalSelectedDate.getFullYear(); + const originalMonth = this.originalSelectedDate.getMonth(); + + // Restore year items + const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); + yearItems.forEach(item => { + const itemYear = parseInt(item.dataset.year); + item.classList.remove('selected'); + + if (itemYear === originalYear) { + item.classList.add('selected'); + } + }); + + // Restore month items + const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); + monthItems.forEach(item => { + const itemYear = parseInt(item.dataset.year); + const itemMonth = parseInt(item.dataset.month); + item.classList.remove('selected'); + + if (itemYear === originalYear && itemMonth === originalMonth) { + item.classList.add('selected'); + + // Scroll back to the original month + item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + }); + + // Clear the stored original date + this.originalSelectedDate = null; + } + + // Check if a date is in a range + isDateInRange(date, startDate, endDate) { + if (!startDate || !endDate) return false; + + const dateTime = date.getTime(); + const startTime = startDate.getTime(); + const endTime = endDate.getTime(); + + return dateTime >= startTime && dateTime <= endTime; + } + navigateDates(offset) { const firstDateElement = this.dateContainer.firstElementChild; const firstDate = this.parseDate(firstDateElement.dataset.date); @@ -969,7 +1405,10 @@ class HorizontalDatePicker { const event = new CustomEvent('dateSelected', { detail: { date: exactSelectedDate, - formattedDate: formattedDate + formattedDate: formattedDate, + isRange: false, + rangeStart: null, + rangeEnd: null } }); this.element.dispatchEvent(event); @@ -1069,7 +1508,10 @@ class HorizontalDatePicker { const event = new CustomEvent('dateSelected', { detail: { date: exactSelectedDate, - formattedDate: formattedDate + formattedDate: formattedDate, + isRange: false, + rangeStart: null, + rangeEnd: null } }); this.element.dispatchEvent(event); @@ -1193,7 +1635,10 @@ class HorizontalDatePicker { const event = new CustomEvent('dateSelected', { detail: { date: newDate, - formattedDate: formattedDate + formattedDate: formattedDate, + isRange: false, + rangeStart: null, + rangeEnd: null } }); this.element.dispatchEvent(event); @@ -1226,7 +1671,10 @@ class HorizontalDatePicker { const event = new CustomEvent('dateSelected', { detail: { date: today, - formattedDate: formattedDate + formattedDate: formattedDate, + isRange: false, + rangeStart: null, + rangeEnd: null } }); this.element.dispatchEvent(event); diff --git a/src/main/resources/static/js/photo-client.js b/src/main/resources/static/js/photo-client.js index a2f302a3..043624ad 100644 --- a/src/main/resources/static/js/photo-client.js +++ b/src/main/resources/static/js/photo-client.js @@ -2,20 +2,13 @@ class PhotoClient { constructor(map) { this.map = map; this.photoMarkers = []; - this.currentDate = null; this.photos = []; } - /** - * Update photos for the selected date - * @param {string} date - Date in YYYY-MM-DD format - * @param timezone - */ - async updatePhotosForDate(date, timezone) { - this.currentDate = date; - + async updatePhotosForRange(start, end, timezone) { + try { - const response = await fetch(`/api/v1/photos/day/${date}?timezone=${timezone}`); + const response = await fetch(`/api/v1/photos/range?timezone=${timezone}&startDate=${start}&endDate=${end}`); if (!response.ok) { console.warn('Could not fetch photos for date:', date); this.photos = []; @@ -30,14 +23,6 @@ class PhotoClient { this.updatePhotoMarkers(); } - /** - * Clear all photos (when date is deselected) - */ - clearPhotos() { - this.currentDate = null; - this.photos = []; - this.clearPhotoMarkers(); - } /** * Update photo markers based on current map bounds @@ -442,11 +427,8 @@ class PhotoClient { this.photoMarkers = []; } - /** - * Handle map move/zoom events to update visible photos - */ onMapMoveEnd() { - if (this.currentDate && this.photos.length > 0) { + if (this.photos.length > 0) { this.updatePhotoMarkers(); } } diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 6fd97c7b..c7c6eba5 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -56,9 +56,9 @@
Loading...
@@ -114,6 +114,12 @@ let firstEventTime = null; let reconnectTimeoutId = null; + // Store current date range selection + let currentDateRange = null; + + // Store current zoom level + let currentZoomLevel = null; + // Initialize the map const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12); @@ -136,6 +142,26 @@ } } + function getTimelineParams() { + const timezone = getUserTimezone(); + if (currentDateRange && currentDateRange.startDate && currentDateRange.endDate) { + // Range mode - use actual range + return { + startDate: currentDateRange.startDate, + endDate: currentDateRange.endDate, + timezone: timezone + }; + } else { + // Single date mode - use same date for both start and end + const selectedDate = getSelectedDate(); + return { + startDate: selectedDate, + endDate: selectedDate, + timezone: timezone + }; + } + } + function selectUser(userHeader) { // Remove active class from all user headers document.querySelectorAll('.user-header').forEach(header => { @@ -214,10 +240,87 @@ map.on('moveend zoomend', () => { photoClient.onMapMoveEnd(); }); + + // Listen for zoom end events to reload raw location points + map.on('zoomend', () => { + const newZoomLevel = Math.round(map.getZoom()); + + // Only reload if zoom level actually changed + if (currentZoomLevel !== null && currentZoomLevel !== newZoomLevel) { + console.log('Zoom level changed from', currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points'); + currentZoomLevel = newZoomLevel; + reloadRawLocationPoints(); + } else if (currentZoomLevel === null) { + currentZoomLevel = newZoomLevel; + } + }); - function loadTimelineData(date) { - // Load photos for the selected date - photoClient.updatePhotosForDate(date, getUserTimezone()); + function reloadRawLocationPoints() { + + // Reload raw location points with new zoom level + const timelineContainer = document.querySelectorAll('.user-timeline-section'); + let bounds = L.latLngBounds(); + const fetchPromises = []; + + for (let i = 0; i < timelineContainer.length; i++) { + const element = timelineContainer[i]; + const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl; + const color = element?.dataset.baseColor; + if (rawLocationPointsUrl) { + // Get current zoom level + const currentZoom = Math.round(map.getZoom()); + + // Append zoom parameter to URL + const urlWithZoom = rawLocationPointsUrl + (rawLocationPointsUrl.includes('?') ? '&' : '?') + 'zoom=' + currentZoom; + + // Create fetch promise for raw location points with index to maintain order + const fetchPromise = fetch(urlWithZoom).then(response => { + if (!response.ok) { + console.warn('Could not fetch raw location points'); + return { points: [], index: i, color: color }; + } + return response.json(); + }).then(rawPointsData => { + return { ...rawPointsData, index: i, color: color }; + }).catch(error => { + console.warn('Error fetching raw location points:', error); + return { points: [], index: i, color: color }; + }); + + fetchPromises.push(fetchPromise); + } + } + + // Wait for all fetch operations to complete, then update map in correct order + Promise.all(fetchPromises).then(results => { + for (const path of rawPointPaths) { + path.remove(); + } + rawPointPaths.length = 0; + + results.sort((a, b) => a.index - b.index); + + // Process results in order + results.forEach(result => { + const fetchBounds = updateMapWithRawPoints(result, result.color); + if (fetchBounds.isValid()) { + bounds.extend(fetchBounds); + } + }); + }); + } + + function loadTimelineData(startDate, endDate) { + // Load photos for the selected date or date range + if (startDate && endDate && startDate !== endDate) { + // Range mode + photoClient.updatePhotosForRange(startDate, endDate, getUserTimezone()); + } else { + // Single date mode + const date = startDate || getSelectedDate(); + photoClient.updatePhotosForRange(date, date, getUserTimezone()); + } + // Remove pulsating markers when loading new data removePulsatingMarkers(); // Get raw location points URL from timeline container @@ -234,8 +337,14 @@ const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl; const color = element?.dataset.baseColor; if (rawLocationPointsUrl) { + // Get current zoom level + const currentZoom = Math.round(map.getZoom()); + + // Append zoom parameter to URL + const urlWithZoom = rawLocationPointsUrl + (rawLocationPointsUrl.includes('?') ? '&' : '?') + 'zoom=' + currentZoom; + // Create fetch promise for raw location points with index to maintain order - const fetchPromise = fetch(rawLocationPointsUrl).then(response => { + const fetchPromise = fetch(urlWithZoom).then(response => { if (!response.ok) { console.warn('Could not fetch raw location points'); return { points: [], index: i, color: color }; @@ -245,7 +354,7 @@ return { ...rawPointsData, index: i, color: color }; }).catch(error => { console.warn('Error fetching raw location points:', error); - return { points: [], index: i, color: color }; // Return empty data with index on error + return { points: [], index: i, color: color }; }); fetchPromises.push(fetchPromise); @@ -329,7 +438,8 @@ document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.classList.contains('timeline-container')) { // Timeline content has been updated, update map markers - loadTimelineData(getSelectedDate()) + const params = getTimelineParams(); + loadTimelineData(params.startDate, params.endDate); updateMapFromTimeline(); // Initialize scroll indicator after timeline is updated if (window.timelineScrollIndicator) { @@ -514,7 +624,7 @@ // Load initial timeline data via HTMX (will be triggered by the hx-trigger="load") // Also load photos and raw points for the initial date - loadTimelineData(formattedDate); + loadTimelineData(formattedDate, formattedDate); // Parse the initial date properly to ensure correct date picker initialization let dateToUse = new Date(); @@ -529,22 +639,39 @@ window.horizontalDatePicker = new HorizontalDatePicker({ container: document.getElementById('horizontal-date-picker-container'), selectedDate: dateToUse, - showNavButtons: false, // Show navigation buttons - daysToShow: 21, // Show more days - showMonthRow: true, // Enable month selection row - showYearRow: true, // Enable year selection row - yearsToShow: 5, // Show 5 years in the year row - allowFutureDates: false, // Disable selection of future dates - showTodayButton: true, // Show the Today button - // No min/max date for infinite scrolling + showNavButtons: false, + daysToShow: 21, + showMonthRow: true, + showYearRow: true, + yearsToShow: 5, + allowFutureDates: false, + showTodayButton: true, onDateSelect: (date, formattedDate, isManualSelected) => { // Update URL updateUrlWithDate(formattedDate); + // Clear any existing date range + currentDateRange = null; // Trigger HTMX reload of timeline document.body.dispatchEvent(new CustomEvent('dateChanged')); if (isManualSelected) { disableAutoUpdate(); } + }, + onDateRangeSelect: (startDate, endDate, formattedStartDate, formattedEndDate) => { + // Store the date range + currentDateRange = { + startDate: formattedStartDate, + endDate: formattedEndDate + }; + // Update URL to show range + const url = new URL(window.location); + url.searchParams.set('startDate', formattedStartDate); + url.searchParams.set('endDate', formattedEndDate); + url.searchParams.delete('date'); + window.history.pushState({}, '', url); + // Trigger HTMX reload of timeline + document.body.dispatchEvent(new CustomEvent('dateChanged')); + disableAutoUpdate(); } }); });