152 feature request multi date selection (#325)

This commit is contained in:
Daniel Graf
2025-10-03 17:58:37 +02:00
committed by GitHub
parent 89eb3d45ac
commit 0015ab8c92
14 changed files with 1302 additions and 151 deletions

View File

@@ -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<String> 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<UserTimelineData> allUsersData = new ArrayList<>();
// Add current user data first - for range, we'll use the start date for the timeline service
List<TimelineEntry> 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<UserTimelineData> 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<UserTimelineData> handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
.map(u -> {
Optional<User> 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<TimelineEntry> 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();
}
}

View File

@@ -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<LocationDataRequest.LocationPoint> points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfDay, endOfDay).stream()
.filter(point -> !point.getTimestamp().isBefore(startOfDay) && point.getTimestamp().isBefore(endOfDay))
List<LocationDataRequest.LocationPoint> 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<LocationDataRequest.LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(points, zoom);
return ResponseEntity.ok(Map.of("points", simplifiedPoints));
} catch (DateTimeParseException e) {
return ResponseEntity.badRequest().body(Map.of(

View File

@@ -25,14 +25,15 @@ public class PhotoApiController {
this.immichIntegrationService = immichIntegrationService;
this.restTemplate = restTemplate;
}
@GetMapping("/day/{date}")
public ResponseEntity<List<PhotoResponse>> getPhotosForDay(
@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@GetMapping("/range")
public ResponseEntity<List<PhotoResponse>> 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<PhotoResponse> photos = immichIntegrationService.searchPhotosForDay(user, date, timezone);
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
return ResponseEntity.ok(photos);
}

View File

@@ -61,16 +61,34 @@ public class ReittiIntegrationApiController {
@GetMapping("/timeline")
public List<TimelineEntry> 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")

View File

@@ -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<LocationDataRequest.LocationPoint> points) {
public int bulkInsert(User user, List<LocationDataRequest.LocationPoint> 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<RawLocationPoint> points) {

View File

@@ -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<LocationDataRequest.LocationPoint> simplifyPoints(List<LocationDataRequest.LocationPoint> 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<LocationDataRequest.LocationPoint> visvalingamWhyatt(List<LocationDataRequest.LocationPoint> points, int targetCount) {
if (points.size() <= targetCount) {
return points;
}
// Create a list of triangles with their effective areas
List<Triangle> 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<Triangle> heap = new PriorityQueue<>(Comparator.comparingDouble(t -> t.area));
heap.addAll(triangles);
// Track which points to keep
Set<Integer> 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<LocationDataRequest.LocationPoint> 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<Triangle> heap, Triangle removed,
Set<Integer> removedIndices, List<LocationDataRequest.LocationPoint> 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<LocationDataRequest.LocationPoint> 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;
}
}
}

View File

@@ -97,7 +97,7 @@ public class ImmichIntegrationService {
}
}
public List<PhotoResponse> searchPhotosForDay(User user, LocalDate date, String timezone) {
public List<PhotoResponse> searchPhotosForRange(User user, LocalDate start, LocalDate end, String timezone) {
Optional<ImmichIntegration> 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));

View File

@@ -78,6 +78,35 @@ public class ReittiIntegrationService {
}).toList();
}
public List<UserTimelineData> 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<TimelineEntry> 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<LocationDataRequest.LocationPoint> 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<String> 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<Map> 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<LocationDataRequest.LocationPoint>) 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<TimelineEntry> loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException {
HttpHeaders headers = new HttpHeaders();
headers.set("X-API-TOKEN", integration.getToken());
HttpEntity<String> 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<List<TimelineEntry>> typeRef = new ParameterizedTypeReference<>() {};
ResponseEntity<List<TimelineEntry>> 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<RemoteUser> persisted = this.jdbcService.findByIntegration(integration);
@@ -363,4 +470,4 @@ public class ReittiIntegrationService {
public Optional<Long> getUserIdForSubscription(String subscriptionId) {
return Optional.ofNullable(this.userForSubscriptions.get(subscriptionId));
}
}
}

View File

@@ -51,9 +51,9 @@ public class LocationDataIngestPipeline {
User user = userOpt.get();
List<LocationDataRequest.LocationPoint> points = event.getPoints();
List<LocationDataRequest.LocationPoint> 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);
}
}

View File

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

View File

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

View File

@@ -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 = '<i class="fas fa-times"></i> 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);

View File

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

View File

@@ -56,9 +56,9 @@
</form>
</div>
<div class="timeline-container"
hx-get="/timeline/content"
hx-get="/timeline/content/range"
hx-trigger="load, dateChanged from:body"
hx-vals='js:{"date": getSelectedDate(), "timezone": getUserTimezone()}'
hx-vals="js:{startDate: getTimelineParams().startDate, endDate: getTimelineParams().endDate, timezone: getTimelineParams().timezone}"
hx-indicator="#loading-indicator">
<div id="loading-indicator" class="timeline-entry" th:text="#{timeline.loading}">Loading...</div>
</div>
@@ -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();
}
});
});