366 feature request memories should not depend on existing visits and trips 2 (#368)

This commit is contained in:
Daniel Graf
2025-10-28 20:12:00 +01:00
committed by GitHub
parent 0ee50b3b20
commit 8049c1de0d
14 changed files with 535 additions and 143 deletions

View File

@@ -7,6 +7,8 @@ import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.TokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.MemoryTripJdbcService;
import com.dedicatedcode.reitti.repository.MemoryVisitJdbcService;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.service.MemoryService;
@@ -36,13 +38,17 @@ public class MemoryBlockController {
private final ImmichIntegrationService immichIntegrationService;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final MemoryVisitJdbcService memoryVisitJdbcService;
private final MemoryTripJdbcService memoryTripJdbcService;
private final StorageService storageService;
public MemoryBlockController(MemoryService memoryService, ImmichIntegrationService immichIntegrationService, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, StorageService storageService) {
public MemoryBlockController(MemoryService memoryService, ImmichIntegrationService immichIntegrationService, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, MemoryVisitJdbcService memoryVisitJdbcService, MemoryTripJdbcService memoryTripJdbcService, StorageService storageService) {
this.memoryService = memoryService;
this.immichIntegrationService = immichIntegrationService;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.memoryVisitJdbcService = memoryVisitJdbcService;
this.memoryTripJdbcService = memoryTripJdbcService;
this.storageService = storageService;
}
@@ -149,6 +155,7 @@ public class MemoryBlockController {
.orElseThrow(() -> new IllegalArgumentException("Cluster block not found"));
MemoryClusterBlock updated = block.withPartIds(selectedParts).withTitle(title);
//check if partId is known, else fetch visit or trip and create a new corrosponding MemoryVisit or MemoryTrip depending on the type, and take this id as partId
memoryService.updateClusterBlock(user, updated);
model.addAttribute("memory", memory);
@@ -173,11 +180,9 @@ public class MemoryBlockController {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlock block = memoryService.addBlock(user, memoryId, position, type);
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(block.getId(), selectedParts, title, null, type);
memoryService.createClusterBlock(user, clusterBlock);
MemoryClusterBlock clusterBlock = memoryService.createClusterBlock(user,memory, title, position, type, selectedParts);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, clusterBlock.getBlockId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";

View File

@@ -48,14 +48,6 @@ public class LocationDataApiController {
this.userJdbcService = userJdbcService;
}
private static LocationPoint toLocationPoint(RawLocationPoint point) {
LocationPoint p = new LocationPoint();
p.setLatitude(point.getLatitude());
p.setLongitude(point.getLongitude());
p.setAccuracyMeters(point.getAccuracyMeters());
p.setTimestamp(point.getTimestamp().toString());
return p;
}
@GetMapping("/raw-location-points/trips")
public ResponseEntity<?> getRawLocationPointsTrips(@AuthenticationPrincipal User user,
@@ -77,7 +69,7 @@ public class LocationDataApiController {
RawLocationDataResponse result = new RawLocationDataResponse(tmp.stream().map(s -> {
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
return new RawLocationDataResponse.Segment(simplifiedPoints);
}).toList(), latest.map(LocationDataApiController::toLocationPoint).orElse(null));
}).toList(), latest.map(this::toLocationPoint).orElse(null));
return ResponseEntity.ok(result);
}
@@ -151,7 +143,7 @@ public class LocationDataApiController {
}).toList();
Optional<RawLocationPoint> latest = this.rawLocationPointJdbcService.findLatest(user);
return ResponseEntity.ok(new RawLocationDataResponse(result, latest.map(LocationDataApiController::toLocationPoint).orElse(null)));
return ResponseEntity.ok(new RawLocationDataResponse(result, latest.map(this::toLocationPoint).orElse(null)));
} catch (DateTimeParseException e) {
return ResponseEntity.badRequest().body(Map.of(
@@ -253,5 +245,13 @@ public class LocationDataApiController {
point.getLongitude() <= maxLng;
}
private LocationPoint toLocationPoint(RawLocationPoint point) {
LocationPoint p = new LocationPoint();
p.setLatitude(point.getLatitude());
p.setLongitude(point.getLongitude());
p.setAccuracyMeters(point.getAccuracyMeters());
p.setTimestamp(point.getTimestamp().toString());
return p;
}
}

View File

@@ -65,7 +65,7 @@ public class MemoryClusterBlock implements MemoryBlockPart, Serializable {
public String toString() {
return "MemoryClusterBlock{" +
"blockId=" + blockId +
", tripIds=" + partIds +
", partIds=" + partIds +
", title='" + title + '\'' +
", description='" + description + '\'' +
'}';

View File

@@ -0,0 +1,61 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.Trip;
import java.time.Duration;
import java.time.Instant;
public class MemoryTrip {
private final Long id;
private final boolean connected;
private final MemoryVisit startVisit;
private final MemoryVisit endVisit;
private final Instant startTime;
private final Instant endTime;
public static MemoryTrip create(Trip trip) {
return new MemoryTrip(null, true, MemoryVisit.create(trip.getStartVisit()), MemoryVisit.create(trip.getEndVisit()), trip.getStartTime(), trip.getEndTime());
}
public MemoryTrip(Long id, boolean connected, MemoryVisit startVisit, MemoryVisit endVisit, Instant startTime, Instant endTime) {
this.id = id;
this.connected = connected;
this.startVisit = startVisit;
this.endVisit = endVisit;
this.startTime = startTime;
this.endTime = endTime;
}
public Long getId() {
return this.id;
}
public boolean isConnected() {
return this.connected;
}
public Instant getStartTime() {
return this.startTime;
}
public Instant getEndTime() {
return this.endTime;
}
public long getDurationSeconds() {
return Duration.between(startTime, endTime).getSeconds();
}
public MemoryVisit getStartVisit() {
return this.startVisit;
}
public MemoryVisit getEndVisit() {
return this.endVisit;
}
public MemoryTrip withId(Long generatedId) {
return new MemoryTrip(generatedId, this.connected, this.startVisit, this.endVisit, this.startTime, this.endTime);
}
}

View File

@@ -1,6 +1,5 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.Trip;
import java.io.Serializable;
import java.time.Instant;
@@ -11,14 +10,14 @@ import java.util.Objects;
public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable {
private final MemoryClusterBlock clusterBlock;
private final List<Trip> trips;
private final List<MemoryTrip> trips;
private final String rawLocationPointsUrl;
private final LocalDateTime adjustedStartTime;
private final LocalDateTime adjustedEndTime;
private final Long completeDuration;
private final Long movingDuration;
public MemoryTripClusterBlockDTO(MemoryClusterBlock clusterBlock, List<Trip> trips, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration, Long movingDuration) {
public MemoryTripClusterBlockDTO(MemoryClusterBlock clusterBlock, List<MemoryTrip> trips, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration, Long movingDuration) {
this.clusterBlock = clusterBlock;
this.trips = trips != null ? List.copyOf(trips) : List.of();
this.rawLocationPointsUrl = rawLocationPointsUrl;
@@ -32,7 +31,7 @@ public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable
return clusterBlock;
}
public List<Trip> getTrips() {
public List<MemoryTrip> getTrips() {
return trips;
}
@@ -61,7 +60,7 @@ public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable
public Instant getCombinedStartTime() {
if (trips == null || trips.isEmpty()) return null;
return trips.stream()
.map(Trip::getStartTime)
.map(MemoryTrip::getStartTime)
.min(Instant::compareTo)
.orElse(null);
}
@@ -69,7 +68,7 @@ public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable
public Instant getCombinedEndTime() {
if (trips == null || trips.isEmpty()) return null;
return trips.stream()
.map(Trip::getEndTime)
.map(MemoryTrip::getEndTime)
.max(Instant::compareTo)
.orElse(null);
}
@@ -77,21 +76,21 @@ public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable
public Long getCombinedDurationSeconds() {
if (trips == null || trips.isEmpty()) return 0L;
return trips.stream()
.mapToLong(Trip::getDurationSeconds)
.mapToLong(MemoryTrip::getDurationSeconds)
.sum();
}
public List<String> getCombinedStartPlaces() {
if (trips == null || trips.isEmpty()) return List.of();
return trips.stream()
.map(trip -> trip.getStartVisit() != null && trip.getStartVisit().getPlace() != null ? trip.getStartVisit().getPlace().getName() : null)
.map(trip -> trip.getStartVisit().getName())
.toList();
}
public List<String> getCombinedEndPlaces() {
if (trips == null || trips.isEmpty()) return List.of();
return trips.stream()
.map(trip -> trip.getEndVisit() != null && trip.getEndVisit().getPlace() != null ? trip.getEndVisit().getPlace().getName() : null)
.map(trip -> trip.getEndVisit().getName())
.toList();
}

View File

@@ -0,0 +1,73 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
public class MemoryVisit {
private final Long id;
private final boolean connected;
private final String name;
private final Instant startTime;
private final Instant endTime;
private final double latitudeCentroid;
private final double longitudeCentroid;
private final ZoneId timezone;
public static MemoryVisit create(ProcessedVisit visit) {
return new MemoryVisit(null, true, visit.getPlace().getName(), visit.getStartTime(), visit.getEndTime(), visit.getPlace().getLatitudeCentroid(), visit.getPlace().getLongitudeCentroid(), visit.getPlace().getTimezone());
}
public MemoryVisit(Long id, boolean connected, String name, Instant startTime, Instant endTime, double latitudeCentroid, double longitudeCentroid, ZoneId timezone) {
this.id = id;
this.connected = connected;
this.name = name;
this.startTime = startTime;
this.endTime = endTime;
this.latitudeCentroid = latitudeCentroid;
this.longitudeCentroid = longitudeCentroid;
this.timezone = timezone;
}
public Long getId() {
return this.id;
}
public boolean isConnected() {
return this.connected;
}
public Instant getStartTime() {
return this.startTime;
}
public Instant getEndTime() {
return this.endTime;
}
public long getDurationSeconds() {
return Duration.between(startTime, endTime).getSeconds();
}
public String getName() {
return this.name;
}
public double getLatitudeCentroid() {
return this.latitudeCentroid;
}
public double getLongitudeCentroid() {
return this.longitudeCentroid;
}
public ZoneId getTimezone() {
return timezone;
}
public MemoryVisit withId(Long generatedId) {
return new MemoryVisit(generatedId, this.connected, this.name, this.startTime, this.endTime, this.latitudeCentroid, this.longitudeCentroid, timezone);
}
}

View File

@@ -1,8 +1,5 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.Visit;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -12,13 +9,13 @@ import java.util.Objects;
public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable {
private final MemoryClusterBlock clusterBlock;
private final List<ProcessedVisit> visits;
private final List<MemoryVisit> visits;
private final String rawLocationPointsUrl;
private final LocalDateTime adjustedStartTime;
private final LocalDateTime adjustedEndTime;
private final Long completeDuration;
public MemoryVisitClusterBlockDTO(MemoryClusterBlock clusterBlock, List<ProcessedVisit> visits, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration) {
public MemoryVisitClusterBlockDTO(MemoryClusterBlock clusterBlock, List<MemoryVisit> visits, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration) {
this.clusterBlock = clusterBlock;
this.visits = visits != null ? List.copyOf(visits) : List.of();
this.rawLocationPointsUrl = rawLocationPointsUrl;
@@ -31,7 +28,7 @@ public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable
return clusterBlock;
}
public List<ProcessedVisit> getVisits() {
public List<MemoryVisit> getVisits() {
return visits;
}
@@ -56,7 +53,7 @@ public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable
public Instant getCombinedStartTime() {
if (visits == null || visits.isEmpty()) return null;
return visits.stream()
.map(ProcessedVisit::getStartTime)
.map(MemoryVisit::getStartTime)
.min(Instant::compareTo)
.orElse(null);
}
@@ -64,7 +61,7 @@ public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable
public Instant getCombinedEndTime() {
if (visits == null || visits.isEmpty()) return null;
return visits.stream()
.map(ProcessedVisit::getEndTime)
.map(MemoryVisit::getEndTime)
.max(Instant::compareTo)
.orElse(null);
}
@@ -72,7 +69,7 @@ public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable
public Long getCombinedDurationSeconds() {
if (visits == null || visits.isEmpty()) return 0L;
return visits.stream()
.mapToLong(ProcessedVisit::getDurationSeconds)
.mapToLong(MemoryVisit::getDurationSeconds)
.sum();
}

View File

@@ -26,7 +26,7 @@ public class MemoryClusterBlockRepository {
this.objectMapper = objectMapper;
}
public void save(User user, MemoryClusterBlock cluster) {
public MemoryClusterBlock save(User user, MemoryClusterBlock cluster) {
String sql = "INSERT INTO memory_block_cluster (block_id, part_ids, user_id, title, description, type) VALUES (?, ?::jsonb, ?, ?, ?, ?) " +
"ON CONFLICT (block_id) DO UPDATE SET part_ids = EXCLUDED.part_ids, title = EXCLUDED.title, description = EXCLUDED.description, type = EXCLUDED.type";
try {
@@ -35,6 +35,8 @@ public class MemoryClusterBlockRepository {
} catch (Exception e) {
throw new RuntimeException("Failed to save MemoryClusterBlock", e);
}
return cluster;
}
public Optional<MemoryClusterBlock> findByBlockId(User user, Long blockId) {

View File

@@ -0,0 +1,126 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.MemoryTrip;
import com.dedicatedcode.reitti.model.memory.MemoryVisit;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.ZoneId;
import java.util.List;
@Service
public class MemoryTripJdbcService {
private final JdbcTemplate jdbcTemplate;
public MemoryTripJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public MemoryTrip save(User user, MemoryTrip memoryTrip, Long memoryBlockId, Long originalId, Long startVisitId, Long endVisitId) {
String sql = """
INSERT INTO memory_trips (user_id, original_id, memory_block_id, start_visit_id, end_visit_id, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id
""";
Long generatedId = jdbcTemplate.queryForObject(sql, Long.class,
user.getId(),
originalId,
memoryBlockId,
startVisitId,
endVisitId,
Timestamp.from(memoryTrip.getStartTime()),
Timestamp.from(memoryTrip.getEndTime())
);
return memoryTrip.withId(generatedId);
}
public List<MemoryTrip> findByMemoryBlockId(Long memoryBlockId) {
String sql = """
SELECT mt.id, mt.start_time, mt.end_time, mt.original_id,
sv.id as start_visit_id, sv.name as start_visit_name, sv.start_time as start_visit_start_time, sv.original_id as start_visit_original_id,
sv.end_time as start_visit_end_time, sv.latitude_centroid as start_visit_lat, sv.longitude_centroid as start_visit_lon, sv.timezone as start_visit_timezone,
ev.id as end_visit_id, ev.name as end_visit_name, ev.start_time as end_visit_start_time, ev.original_id as end_visit_original_id,
ev.end_time as end_visit_end_time, ev.latitude_centroid as end_visit_lat, ev.longitude_centroid as end_visit_lon, ev.timezone as end_visit_timezone
FROM memory_trips mt
LEFT JOIN memory_visits sv ON mt.start_visit_id = sv.id
LEFT JOIN memory_visits ev ON mt.end_visit_id = ev.id
WHERE mt.memory_block_id = ?
ORDER BY mt.start_time
""";
return jdbcTemplate.query(sql, new MemoryTripRowMapper(), memoryBlockId);
}
public List<MemoryTrip> findByUserAndMemoryId(User user, Long memoryId) {
String sql = """
SELECT mt.id, mt.start_time, mt.end_time, mt.original_id,
sv.id as start_visit_id, sv.name as start_visit_name, sv.start_time as start_visit_start_time,
sv.original_id as start_visit_original_id,
sv.end_time as start_visit_end_time, sv.latitude_centroid as start_visit_lat, sv.longitude_centroid as start_visit_lon, sv.timezone as start_visit_timezone,
ev.id as end_visit_id, ev.name as end_visit_name, ev.start_time as end_visit_start_time, ev.original_id as end_visit_original_id,
ev.end_time as end_visit_end_time, ev.latitude_centroid as end_visit_lat, ev.longitude_centroid as end_visit_lon, ev.timezone as end_visit_timezone
FROM memory_trips mt
LEFT JOIN memory_visits sv ON mt.start_visit_id = sv.id
LEFT JOIN memory_visits ev ON mt.end_visit_id = ev.id
JOIN memory_block mb ON mt.memory_block_id = mb.id
WHERE mt.user_id = ? AND mb.memory_id = ?
ORDER BY mt.start_time
""";
return jdbcTemplate.query(sql, new MemoryTripRowMapper(), user.getId(), memoryId);
}
public void deleteByMemoryBlockId(Long memoryBlockId) {
String sql = "DELETE FROM memory_trips WHERE memory_block_id = ?";
jdbcTemplate.update(sql, memoryBlockId);
}
private static class MemoryTripRowMapper implements RowMapper<MemoryTrip> {
@Override
public MemoryTrip mapRow(ResultSet rs, int rowNum) throws SQLException {
MemoryVisit startVisit = null;
MemoryVisit endVisit = null;
if (rs.getLong("start_visit_id") != 0) {
startVisit = new MemoryVisit(
rs.getLong("start_visit_id"),
rs.getObject("start_visit_original_id") != null,
rs.getString("start_visit_name"),
rs.getTimestamp("start_visit_start_time").toInstant(),
rs.getTimestamp("start_visit_end_time").toInstant(),
rs.getDouble("start_visit_lat"),
rs.getDouble("start_visit_lon"),
ZoneId.of(rs.getString("start_visit_timezone")));
}
if (rs.getLong("end_visit_id") != 0) {
endVisit = new MemoryVisit(
rs.getLong("end_visit_id"),
rs.getObject("end_visit_original_id") != null,
rs.getString("end_visit_name"),
rs.getTimestamp("end_visit_start_time").toInstant(),
rs.getTimestamp("end_visit_end_time").toInstant(),
rs.getDouble("end_visit_lat"),
rs.getDouble("end_visit_lon"),
ZoneId.of(rs.getString("end_visit_timezone")));
}
return new MemoryTrip(
rs.getLong("id"),
rs.getObject("original_id") != null,
startVisit,
endVisit,
rs.getTimestamp("start_time").toInstant(),
rs.getTimestamp("end_time").toInstant()
);
}
}
}

View File

@@ -0,0 +1,78 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.MemoryVisit;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
@Service
public class MemoryVisitJdbcService {
private final JdbcTemplate jdbcTemplate;
public MemoryVisitJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public MemoryVisit save(User user, MemoryVisit memoryVisit, Long memoryBlockId, Long originalId) {
String sql = """
INSERT INTO memory_visits (user_id, original_id, memory_block_id, name, start_time, end_time, latitude_centroid, longitude_centroid, timezone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
""";
Long generatedId = jdbcTemplate.queryForObject(sql, Long.class,
user.getId(),
originalId,
memoryBlockId,
memoryVisit.getName(),
Timestamp.from(memoryVisit.getStartTime()),
Time.from(memoryVisit.getEndTime()),
memoryVisit.getLatitudeCentroid(),
memoryVisit.getLongitudeCentroid(),
memoryVisit.getTimezone().getId()
);
return memoryVisit.withId(generatedId);
}
public List<MemoryVisit> findByMemoryBlockId(Long memoryBlockId) {
String sql = """
SELECT id, name, start_time, end_time, latitude_centroid, longitude_centroid, timezone
FROM memory_visits
WHERE memory_block_id = ?
ORDER BY start_time
""";
return jdbcTemplate.query(sql, new MemoryVisitRowMapper(), memoryBlockId);
}
public void deleteByMemoryBlockId(Long memoryBlockId) {
String sql = "DELETE FROM memory_visits WHERE memory_block_id = ?";
jdbcTemplate.update(sql, memoryBlockId);
}
private static class MemoryVisitRowMapper implements RowMapper<MemoryVisit> {
@Override
public MemoryVisit mapRow(ResultSet rs, int rowNum) throws SQLException {
return new MemoryVisit(
rs.getLong("id"),
true, // connected - always true for persisted visits
rs.getString("name"),
rs.getTimestamp("start_time").toInstant(),
rs.getTimestamp("end_time").toInstant(),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
ZoneId.of(rs.getString("timezone")));
}
}
}

View File

@@ -2,9 +2,6 @@ package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
import com.dedicatedcode.reitti.model.TimeDisplayMode;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.geo.Trip;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSettings;
@@ -30,9 +27,11 @@ public class MemoryService {
private final MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService;
private final MemoryClusterBlockRepository memoryClusterBlockRepository;
private final MemoryBlockGenerationService blockGenerationService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final TripJdbcService tripJdbcService;
private final MemoryVisitJdbcService memoryVisitJdbcService;
private final MemoryTripJdbcService memoryTripJdbcService;
private final UserSettingsJdbcService userSettingsJdbcService;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
public MemoryService(
MemoryJdbcService memoryJdbcService,
@@ -41,18 +40,22 @@ public class MemoryService {
MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService,
MemoryClusterBlockRepository memoryClusterBlockRepository,
MemoryBlockGenerationService blockGenerationService,
ProcessedVisitJdbcService processedVisitJdbcService,
MemoryVisitJdbcService memoryVisitJdbcService,
MemoryTripJdbcService memoryTripJdbcService,
UserSettingsJdbcService userSettingsJdbcService,
TripJdbcService tripJdbcService,
UserSettingsJdbcService userSettingsJdbcService) {
ProcessedVisitJdbcService processedVisitJdbcService) {
this.memoryJdbcService = memoryJdbcService;
this.memoryBlockJdbcService = memoryBlockJdbcService;
this.memoryBlockTextJdbcService = memoryBlockTextJdbcService;
this.memoryBlockImageGalleryJdbcService = memoryBlockImageGalleryJdbcService;
this.memoryClusterBlockRepository = memoryClusterBlockRepository;
this.blockGenerationService = blockGenerationService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.tripJdbcService = tripJdbcService;
this.memoryVisitJdbcService = memoryVisitJdbcService;
this.memoryTripJdbcService = memoryTripJdbcService;
this.userSettingsJdbcService = userSettingsJdbcService;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
}
@Transactional
@@ -162,8 +165,35 @@ public class MemoryService {
}
@Transactional
public void createClusterBlock(User user, MemoryClusterBlock clusterBlock) {
memoryClusterBlockRepository.save(user, clusterBlock);
public MemoryClusterBlock createClusterBlock(User user, Memory memory, String title, int position, BlockType type, List<Long> selectedParts) {
MemoryBlock block = addBlock(user, memory.getId(), position, type);
List<Long> selectedPartIds = new ArrayList<>();
switch (type) {
case CLUSTER_TRIP:
for (Long partId : selectedParts) {
this.tripJdbcService.findById(partId)
.map(trip -> {
MemoryTrip memoryTrip = MemoryTrip.create(trip);
MemoryVisit startVisit = this.memoryVisitJdbcService.save(user, MemoryVisit.create(trip.getStartVisit()), block.getId(), trip.getStartVisit().getId());
MemoryVisit endVisit = this.memoryVisitJdbcService.save(user, MemoryVisit.create(trip.getEndVisit()), block.getId(), trip.getEndVisit().getId());
return this.memoryTripJdbcService.save(user, memoryTrip, block.getId(), trip.getId(), startVisit.getId(), endVisit.getId());
}).map(MemoryTrip::getId).ifPresent(selectedPartIds::add);
}
break;
case CLUSTER_VISIT:
for (Long partId : selectedParts) {
this.processedVisitJdbcService.findById(partId)
.map(visit -> {
MemoryVisit memoryVisit = MemoryVisit.create(visit);
return this.memoryVisitJdbcService.save(user, memoryVisit, block.getId(), visit.getId());
}).map(MemoryVisit::getId).ifPresent(selectedPartIds::add);
}
break;
default:
throw new IllegalArgumentException("Invalid block type");
}
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(block.getId(), selectedPartIds, title, null, type);
return memoryClusterBlockRepository.save(user, clusterBlock);
}
@Transactional
@@ -234,11 +264,7 @@ public class MemoryService {
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, BlockType.IMAGE_GALLERY);
memoryBlockImageGalleryJdbcService.create(new MemoryBlockImageGallery(memoryBlock.getId(), imageGalleryBlock.getImages()));
} else if (autoGeneratedBlock instanceof MemoryClusterBlock clusterBlock) {
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, clusterBlock.getType());
memoryClusterBlockRepository.save(user, new MemoryClusterBlock(memoryBlock.getId(),
clusterBlock.getPartIds(),
clusterBlock.getTitle(),
clusterBlock.getDescription(), clusterBlock.getType()));
createClusterBlock(user, memory, clusterBlock.getTitle(), -1, clusterBlock.getType(), clusterBlock.getPartIds());
}
}
@@ -247,39 +273,39 @@ public class MemoryService {
private Optional<? extends MemoryBlockPart> getClusterTripBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
Optional<? extends MemoryBlockPart> part;
Optional<MemoryClusterBlock> clusterBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
part = clusterBlockOpt.map(memoryClusterBlock -> {
List<Trip> trips = tripJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
Optional<Trip> first = trips.stream().findFirst();
Optional<Trip> lastTrip = trips.stream().max(Comparator.comparing(Trip::getEndTime));
long movingTime = trips.stream().mapToLong(Trip::getDurationSeconds).sum();
return clusterBlockOpt.map(memoryClusterBlock -> {
List<MemoryTrip> trips = memoryTripJdbcService.findByMemoryBlockId(memoryClusterBlock.getBlockId());
Optional<MemoryTrip> first = trips.stream().findFirst();
Optional<MemoryTrip> lastTrip = trips.stream().max(Comparator.comparing(MemoryTrip::getEndTime));
if (first.isEmpty()) {
return null;
}
long movingTime = trips.stream().mapToLong(MemoryTrip::getDurationSeconds).sum();
long completeTime = first.map(trip -> Duration.between(trip.getStartTime(), lastTrip.get().getEndTime()).toSeconds()).orElse(0L);
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getStartVisit().getPlace(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = lastTrip.map(t -> adjustTime(settings, t.getEndTime(), t.getEndVisit().getPlace(), timezone)).orElse(null);
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getStartVisit().getTimezone(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = lastTrip.map(t -> adjustTime(settings, t.getEndTime(), t.getEndVisit().getTimezone(), timezone)).orElse(null);
return new MemoryTripClusterBlockDTO(
memoryClusterBlock,
trips,
"/api/v1/raw-location-points/trips?trips=" + String.join(",", memoryClusterBlock.getPartIds().stream().map(Objects::toString).toList()),
"/api/v1/raw-location-points?startDate=" + adjustedStartTime + "&endDate=" + adjustedEndTime + "&timezone=" + timezone.getId(),
adjustedStartTime,
adjustedEndTime,
completeTime,
movingTime);
});
return part;
}
private Optional<? extends MemoryBlockPart> getClusterVisitBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
Optional<? extends MemoryBlockPart> part;
Optional<MemoryClusterBlock> clusterVisitBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
part = clusterVisitBlockOpt.map(memoryClusterBlock -> {
List<ProcessedVisit> visits = processedVisitJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
Optional<ProcessedVisit> first = visits.stream().findFirst();
Optional<ProcessedVisit> last = visits.stream().max(Comparator.comparing(ProcessedVisit::getEndTime));
List<MemoryVisit> visits = memoryVisitJdbcService.findByMemoryBlockId(block.getId());
Optional<MemoryVisit> first = visits.stream().findFirst();
Optional<MemoryVisit> last = visits.stream().max(Comparator.comparing(MemoryVisit::getEndTime));
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getPlace(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = last.map(t -> adjustTime(settings, t.getEndTime(), t.getPlace(), timezone)).orElse(null);
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getTimezone(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = last.map(t -> adjustTime(settings, t.getEndTime(), t.getTimezone(), timezone)).orElse(null);
Long completeDuration = 0L;
String rawLocationPointsUrl = first.map(processedVisit -> "/api/v1/raw-location-points?startDate=" + processedVisit.getStartTime().atZone(timezone).toLocalDateTime() + "&endDate=" + last.get().getEndTime().atZone(timezone).toLocalDateTime() + "&timezone=" + timezone).orElse(null);
return new MemoryVisitClusterBlockDTO(
@@ -293,11 +319,11 @@ public class MemoryService {
return part;
}
private LocalDateTime adjustTime(UserSettings settings, Instant startTime, SignificantPlace place, ZoneId timezone) {
private LocalDateTime adjustTime(UserSettings settings, Instant startTime, ZoneId placeTimezone, ZoneId timezone) {
if (settings.getTimeDisplayMode() == TimeDisplayMode.DEFAULT) {
return startTime.atZone(timezone).toLocalDateTime();
} else {
return startTime.atZone(place.getTimezone()).toLocalDateTime();
return startTime.atZone(placeTimezone).toLocalDateTime();
}
}

View File

@@ -0,0 +1,26 @@
CREATE TABLE memory_visits
(
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
original_id BIGINT NULL REFERENCES processed_visits (id) ON DELETE SET NULL,
memory_block_id BIGINT NOT NULL REFERENCES memory_block (id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
latitude_centroid double precision not null,
longitude_centroid double precision not null,
timezone text not null
);
CREATE TABLE memory_trips
(
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
original_id BIGINT NULL REFERENCES trips (id) ON DELETE SET NULL,
memory_block_id BIGINT NOT NULL REFERENCES memory_block (id) ON DELETE CASCADE,
start_visit_id BIGINT REFERENCES memory_visits (id) ON DELETE CASCADE,
end_visit_id BIGINT REFERENCES memory_visits (id) ON DELETE CASCADE,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL
);

View File

@@ -525,6 +525,7 @@ button {
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
a.btn:hover,
@@ -1971,17 +1972,20 @@ button:disabled {
.memories-page .memory-header .action-bar {
z-index: 500;
position: relative;
padding: 24px;
padding: 8px;
display: flex;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
transition: all 0.3s ease;
background: #00000075;
backdrop-filter: blur(4px);
}
.memories-page .memory-header:hover .action-bar {
opacity: 1;
pointer-events: all;
box-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
}
.memories-page .memory-header .action-bar div:first-child {
@@ -2196,7 +2200,6 @@ button:disabled {
/* Lazy loading styles */
.lazy-image {
transition: opacity 0.3s ease;
background-color: #f0f0f0;
}
.lazy-image.loaded {
@@ -2207,7 +2210,7 @@ button:disabled {
.gallery-image-item {
position: relative;
min-height: 200px;
background-color: #f8f9fa;
background-color: #2d2d2d;
border-radius: 4px;
overflow: hidden;
}

View File

@@ -24,47 +24,48 @@
<div class="settings-content-area">
<div class="memory-header" th:fragment="memory-header">
<div class="action-bar">
<div sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')"><a class="btn"
th:href="@{/memories}"><i class="lni lni-arrow-left"></i>
<span th:text="#{memory.view.back}"></span></a></a>
<div sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')">
<a class="btn" th:href="@{/memories}"><i class="lni lni-arrow-left"></i> <span th:text="#{memory.view.back}"></span></a>
</div>
<div>
<button type="button"
class="btn"
th:if="${canEdit}"
th:attr="hx-get=@{/memories/{id}/edit(id=${memory.id})}"
hx-target=".memory-header"
hx-swap="innerHTML"
hx-vals="js:{timezone: getUserTimezone()}"
th:text="#{memory.view.edit}">Edit
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-post=@{/memories/{id}/recalculate(id=${memory.id})}"
hx-target="body"
hx-swap="innerHTML"
hx-confirm="Recalculate this memory? This will update all location data."
hx-vals="js:{timezone: getUserTimezone()}"
hx-indicator="#memory-processing-overlay"
th:text="#{memory.view.recalculate}">Recalculate
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-share"></i> Share
</button>
<button type="button"
class="btn btn-danger"
th:if="${canEdit && isOwner}"
th:attr="hx-delete=@{/memories/{id}(id=${memory.id})}"
hx-confirm="Are you sure you want to delete this memory? This action cannot be undone."
hx-target="body"
hx-swap="innerHTML">
<i class="lni lni-trash-3"></i> Delete
</button>
</div>
<button type="button"
class="btn"
th:if="${canEdit}"
th:attr="hx-get=@{/memories/{id}/edit(id=${memory.id})}"
hx-target=".memory-header"
hx-swap="innerHTML"
hx-vals="js:{timezone: getUserTimezone()}"
th:text="#{memory.view.edit}">Edit
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-post=@{/memories/{id}/recalculate(id=${memory.id})}"
hx-target="body"
hx-swap="innerHTML"
hx-confirm="Recalculate this memory? This will update all location data."
hx-vals="js:{timezone: getUserTimezone()}"
hx-indicator="#memory-processing-overlay"
th:text="#{memory.view.recalculate}">Recalculate
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-share"></i> Share
</button>
<button type="button"
class="btn btn-danger"
th:if="${canEdit && isOwner}"
th:attr="hx-delete=@{/memories/{id}(id=${memory.id})}"
hx-confirm="Are you sure you want to delete this memory? This action cannot be undone."
hx-target="body"
hx-swap="innerHTML">
<i class="lni lni-trash-3"></i> Delete
</button>
</div>
<div class="memory-header-section map-header-section">
<div id="memory-map" class="memory-map"></div>
@@ -115,10 +116,8 @@
class="gallery-image-item"
th:attr="data-image-url=${image.imageUrl},data-caption=${image.caption}">
<div class="photo-loading-spinner"></div>
<img th:data-src="${image.imageUrl}"
th:alt="${image.caption != null ? image.caption : 'Gallery image'}"
<img th:data-src="${image.imageUrl}"
class="lazy-image"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELoading...%3C/text%3E%3C/svg%3E"
onload="this.classList.add('loaded'); this.previousElementSibling.style.display='none';"
onerror="this.style.display='none'; this.previousElementSibling.style.display='none'; this.parentElement.innerHTML='📷';">
</div>
@@ -331,10 +330,10 @@
if (trips.length > 0) {
// Start marker
const startTrip = trips[0];
if (startTrip.startVisit && startTrip.startVisit.place) {
const startLat = startTrip.startVisit.place.latitudeCentroid;
const startLon = startTrip.startVisit.place.longitudeCentroid;
const startName = startTrip.startVisit.place.name;
if (startTrip.startVisit) {
const startLat = startTrip.startVisit.latitudeCentroid;
const startLon = startTrip.startVisit.longitudeCentroid;
const startName = startTrip.startVisit.name;
const startTimeFormatted = combinedStartTime ? new Date(combinedStartTime).toLocaleTimeString() : 'unknown';
L.circleMarker([startLat, startLon], {
color: '#4a9fdc',
@@ -347,10 +346,10 @@
// Intermediate stops (end of each trip except last)
for (let i = 0; i < trips.length - 1; i++) {
const trip = trips[i];
if (trip.endVisit && trip.endVisit.place) {
const lat = trip.endVisit.place.latitudeCentroid;
const lon = trip.endVisit.place.longitudeCentroid;
const name = trip.endVisit.place.name;
if (trip.endVisit) {
const lat = trip.endVisit.latitudeCentroid;
const lon = trip.endVisit.longitudeCentroid;
const name = trip.endVisit.name;
const arrivedAfter = combinedStartTime && trip.endTime ? humanizeDuration((new Date(trip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
L.circleMarker([lat, lon], {
color: '#6a6a6a',
@@ -363,10 +362,10 @@
// End marker
const endTrip = trips[trips.length - 1];
if (endTrip.endVisit && endTrip.endVisit.place) {
const endLat = endTrip.endVisit.place.latitudeCentroid;
const endLon = endTrip.endVisit.place.longitudeCentroid;
const endName = endTrip.endVisit.place.name;
if (endTrip.endVisit) {
const endLat = endTrip.endVisit.latitudeCentroid;
const endLon = endTrip.endVisit.longitudeCentroid;
const endName = endTrip.endVisit.name;
const arrivedAfter = combinedStartTime && endTrip.endTime ? humanizeDuration((new Date(endTrip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
L.circleMarker([endLat, endLon], {
color: '#37bd57',
@@ -395,9 +394,9 @@
<h4>Trips in this Journey:</h4>
<ul>
<li th:each="trip : ${block.trips}" class="trip-item">
<span th:text="${trip.startVisit != null && trip.startVisit.place != null ? trip.startVisit.place.name : 'Start'}">Start</span>
<span th:text="${trip.startVisit.name ?: 'Start'}">Start</span>
<span></span>
<span th:text="${trip.endVisit != null && trip.endVisit.place != null ? trip.endVisit.place.name : 'End'}">End</span>
<span th:text="${trip.endVisit.name ?: 'End'}">End</span>
</li>
</ul>
</div>
@@ -461,18 +460,15 @@
let bounds = [];
if (visits.length > 0) {
for (let i = 0; i < visits.length; i++) {
const visit = visits[i];
if (visit.place) {
const lat = visit.place.latitudeCentroid;
const lon = visit.place.longitudeCentroid;
const name = visit.place.name;
L.circleMarker([lat, lon], {
color: '#6a6a6a',
fillColor: '#ff984f',
fillOpacity: 0.1
}).addTo(clusterMap).bindPopup('Name: ' + name);
bounds.push([lat, lon]);
}
const lat = visits[i].latitudeCentroid;
const lon = visits[i].longitudeCentroid;
const name = visits[i].name;
L.circleMarker([lat, lon], {
color: '#6a6a6a',
fillColor: '#ff984f',
fillOpacity: 0.1
}).addTo(clusterMap).bindPopup('Name: ' + name);
bounds.push([lat, lon]);
}
}
@@ -488,7 +484,7 @@
<h4>Visits in this Journey:</h4>
<ul>
<li th:each="visit : ${block.visits}" class="trip-item">
<span th:text="${visit.place != null ? visit.place.name : 'Start'}">Start</span>
<span th:text="${visit.name ?: 'Start'}">Start</span>
</li>
</ul>
</div>