diff --git a/.gitignore b/.gitignore index 40bfe7f..cd24195 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ uploads/ logs/ /gadm_410.gpkg /.postgresdata/ +/peaks_worldwide.geojson diff --git a/scripts/import_peaks.py b/scripts/import_peaks.py new file mode 100644 index 0000000..8984d5a --- /dev/null +++ b/scripts/import_peaks.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Import peaks from GeoJSON into PostgreSQL/PostGIS. + +Usage: + python3 import_peaks.py [--db-url postgresql://user:pass@host:port/dbname] + +The script parses the GeoJSON, extracts elevation/wikipedia/wikidata from the +OSM hstore-style other_tags field, and bulk-inserts into the peaks table using +COPY for performance (~1M rows). +""" + +import argparse +import csv +import io +import json +import re +import sys + +try: + import psycopg2 +except ImportError: + print("psycopg2 is required: pip install psycopg2-binary", file=sys.stderr) + sys.exit(1) + + +def parse_other_tags(other_tags: str) -> dict: + """Parse OSM hstore-style other_tags string into a dict.""" + if not other_tags: + return {} + result = {} + # Pattern: "key"=>"value" + for match in re.finditer(r'"([^"]+)"=>"([^"]*)"', other_tags): + result[match.group(1)] = match.group(2) + return result + + +def wikipedia_to_url(wikipedia: str) -> str: + """Convert 'en:Article Name' to full Wikipedia URL.""" + if not wikipedia: + return None + parts = wikipedia.split(":", 1) + if len(parts) == 2: + lang, title = parts + return f"https://{lang}.wikipedia.org/wiki/{title.replace(' ', '_')}" + return None + + +def main(): + parser = argparse.ArgumentParser(description="Import peaks GeoJSON into PostgreSQL") + parser.add_argument("geojson_file", help="Path to the GeoJSON file") + parser.add_argument("--db-url", default="postgresql://test:test@localhost:5432/testdb", + help="PostgreSQL connection URL") + parser.add_argument("--batch-size", type=int, default=10000, + help="Batch size for COPY operations") + args = parser.parse_args() + + print(f"Loading GeoJSON from {args.geojson_file}...") + with open(args.geojson_file, "r") as f: + data = json.load(f) + + features = data["features"] + print(f"Loaded {len(features)} features") + + conn = psycopg2.connect(args.db_url) + cur = conn.cursor() + + # Clear existing data + cur.execute("TRUNCATE TABLE activity_peaks CASCADE") + cur.execute("TRUNCATE TABLE peaks CASCADE") + conn.commit() + + inserted = 0 + skipped = 0 + batch = [] + + for feat in features: + props = feat["properties"] + geom = feat["geometry"] + coords = geom["coordinates"] # [lon, lat] + + name = props.get("name") + if not name: + skipped += 1 + continue + + osm_id = int(props["osm_id"]) + other = parse_other_tags(props.get("other_tags", "")) + + wikipedia = wikipedia_to_url(other.get("wikipedia")) + wikidata = other.get("wikidata") + + lon, lat = coords[0], coords[1] + + batch.append((osm_id, name, wikipedia, wikidata, lon, lat)) + + if len(batch) >= args.batch_size: + _copy_batch(cur, batch) + inserted += len(batch) + batch = [] + print(f" Inserted {inserted}...", end="\r") + + if batch: + _copy_batch(cur, batch) + inserted += len(batch) + + conn.commit() + cur.close() + conn.close() + + print(f"\nDone. Inserted {inserted} peaks, skipped {skipped} (no name).") + + +def _copy_batch(cur, batch): + """Use COPY for fast bulk insert.""" + buf = io.StringIO() + writer = csv.writer(buf, delimiter="\t") + for osm_id, name, wikipedia, wikidata, lon, lat in batch: + writer.writerow([ + osm_id, + name, + wikipedia if wikipedia else r"\N", + wikidata if wikidata else r"\N", + f"SRID=4326;POINT({lon} {lat})", + ]) + buf.seek(0) + cur.copy_expert( + "COPY peaks (osm_id, name, wikipedia, wikidata, geom) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t', NULL '\\N')", + buf, + ) + + +if __name__ == "__main__": + main() diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index d2518e1..f2c442a 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -150,6 +150,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list .requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list .requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check + .requestMatchers(HttpMethod.GET, "/api/users/*/peaks").permitAll() // User's visited peaks .requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user .requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java index b9589d6..1b66991 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -55,10 +55,20 @@ public class ActivityController { private final WeatherService weatherService; private final PrivacyZoneService privacyZoneService; private final TrackPrivacyFilter trackPrivacyFilter; + private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; @Value("${fitpub.base-url}") private String baseUrl; + private void populatePeaks(net.javahippie.fitpub.model.dto.ActivityDTO dto, UUID activityId) { + var activityPeaks = activityPeakRepository.findByActivityId(activityId); + if (!activityPeaks.isEmpty()) { + dto.setPeaks(activityPeaks.stream() + .map(ap -> net.javahippie.fitpub.model.dto.PeakDTO.fromEntity(ap.getPeak())) + .toList()); + } + } + /** * Helper method to get user ID from authenticated UserDetails. * @@ -176,6 +186,7 @@ public class ActivityController { if (activity.getVisibility() == Activity.Visibility.PUBLIC) { // Public activities are always accessible, but apply privacy filtering ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); + populatePeaks(dto, id); log.debug("Activity {} - DTO privacy zones: {}", id, dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0); return ResponseEntity.ok(dto); @@ -196,6 +207,7 @@ public class ActivityController { // Apply privacy filtering (owner sees full track, others see filtered) ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter); + populatePeaks(dto, id); return ResponseEntity.ok(dto); } @@ -490,6 +502,15 @@ public class ActivityController { try { java.io.File imageFile = activityImageService.getActivityImageFile(id); + // Regenerate if missing (e.g. after container restart or temp dir cleanup) + if (!imageFile.exists()) { + Activity activity = fitFileService.getActivityById(id); + if (activity == null) { + return ResponseEntity.notFound().build(); + } + activityImageService.generateActivityImage(activity); + } + if (!imageFile.exists()) { return ResponseEntity.notFound().build(); } diff --git a/src/main/java/net/javahippie/fitpub/controller/DebugController.java b/src/main/java/net/javahippie/fitpub/controller/DebugController.java index b11d03c..3cb4a8b 100644 --- a/src/main/java/net/javahippie/fitpub/controller/DebugController.java +++ b/src/main/java/net/javahippie/fitpub/controller/DebugController.java @@ -27,6 +27,7 @@ import java.util.*; public class DebugController { private final UserRepository userRepository; + private final net.javahippie.fitpub.service.PeakDetectionService peakDetectionService; @GetMapping("/validate-keys") public Map validateKeys() { @@ -107,4 +108,13 @@ public class DebugController { signature.update(data); return signature.verify(signatureBytes); } + + @org.springframework.web.bind.annotation.PostMapping("/backfill-peaks") + public org.springframework.http.ResponseEntity> backfillPeaks() { + if (peakDetectionService.isBackfillRunning()) { + return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running")); + } + peakDetectionService.backfillAllActivities(); + return org.springframework.http.ResponseEntity.ok(Map.of("status", "started")); + } } diff --git a/src/main/java/net/javahippie/fitpub/controller/UserController.java b/src/main/java/net/javahippie/fitpub/controller/UserController.java index d0922fb..1c8658e 100644 --- a/src/main/java/net/javahippie/fitpub/controller/UserController.java +++ b/src/main/java/net/javahippie/fitpub/controller/UserController.java @@ -47,6 +47,7 @@ public class UserController { private final WebFingerClient webFingerClient; private final FederationService federationService; private final UserService userService; + private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; @Value("${fitpub.base-url}") private String baseUrl; @@ -616,4 +617,31 @@ public class UserController { return ResponseEntity.ok(Map.of("isFollowing", isFollowing)); } + + /** + * Get peaks visited by a user with visit counts and latest activity. + */ + @GetMapping("/{username}/peaks") + public ResponseEntity>> getUserPeaks( + @PathVariable String username + ) { + User user = userRepository.findByUsername(username).orElse(null); + if (user == null) { + return ResponseEntity.notFound().build(); + } + + var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId()); + var result = projections.stream() + .map(p -> { + Map map = new java.util.LinkedHashMap<>(); + map.put("name", p.getPeakName()); + map.put("wikipedia", p.getWikipedia()); + map.put("visitCount", p.getVisitCount()); + map.put("latestActivityId", p.getLatestActivityId()); + return map; + }) + .toList(); + + return ResponseEntity.ok(result); + } } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java index 0d24214..27a1de5 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java @@ -66,6 +66,9 @@ public class ActivityDTO { private Long commentsCount; private Boolean likedByCurrentUser; // True if current user has liked this activity + // Peaks visited on this activity + private List peaks; + // Privacy zones (only for activity owner, to show what's hidden for others) private List privacyZones; diff --git a/src/main/java/net/javahippie/fitpub/model/dto/PeakDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/PeakDTO.java new file mode 100644 index 0000000..5c77b1c --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/PeakDTO.java @@ -0,0 +1,30 @@ +package net.javahippie.fitpub.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import net.javahippie.fitpub.model.entity.Peak; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PeakDTO { + + private Integer id; + private String name; + private String wikipedia; + private Double latitude; + private Double longitude; + + public static PeakDTO fromEntity(Peak peak) { + return PeakDTO.builder() + .id(peak.getId()) + .name(peak.getName()) + .wikipedia(peak.getWikipedia()) + .latitude(peak.getGeom() != null ? peak.getGeom().getY() : null) + .longitude(peak.getGeom() != null ? peak.getGeom().getX() : null) + .build(); + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/ActivityPeak.java b/src/main/java/net/javahippie/fitpub/model/entity/ActivityPeak.java new file mode 100644 index 0000000..bf06697 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/ActivityPeak.java @@ -0,0 +1,29 @@ +package net.javahippie.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.UUID; + +@Entity +@Table(name = "activity_peaks", uniqueConstraints = { + @UniqueConstraint(columnNames = {"activity_id", "peak_id"}) +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ActivityPeak { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "activity_id", nullable = false) + private UUID activityId; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "peak_id", nullable = false) + private Peak peak; +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Peak.java b/src/main/java/net/javahippie/fitpub/model/entity/Peak.java new file mode 100644 index 0000000..4d42375 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/Peak.java @@ -0,0 +1,34 @@ +package net.javahippie.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.locationtech.jts.geom.Point; + +@Entity +@Table(name = "peaks") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Peak { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "osm_id", nullable = false, unique = true) + private Long osmId; + + @Column(nullable = false) + private String name; + + @Column(length = 500) + private String wikipedia; + + @Column(length = 50) + private String wikidata; + + @Column(columnDefinition = "geometry(Point, 4326)", nullable = false) + private Point geom; +} diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java new file mode 100644 index 0000000..978ec25 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java @@ -0,0 +1,47 @@ +package net.javahippie.fitpub.repository; + +import net.javahippie.fitpub.model.entity.ActivityPeak; +import net.javahippie.fitpub.model.entity.Peak; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface ActivityPeakRepository extends JpaRepository { + + List findByActivityId(UUID activityId); + + boolean existsByActivityIdAndPeakId(UUID activityId, Integer peakId); + + /** + * Find all unique peaks visited by a user with visit count and latest activity, + * ordered by name. + */ + @Query(value = """ + SELECT p.id AS peakId, p.name AS peakName, p.wikipedia AS wikipedia, + COUNT(ap.id) AS visitCount, + CAST(MAX(a.started_at) AS TIMESTAMP) AS latestVisit, + CAST((ARRAY_AGG(a.id ORDER BY a.started_at DESC))[1] AS UUID) AS latestActivityId + FROM activity_peaks ap + JOIN peaks p ON p.id = ap.peak_id + JOIN activities a ON a.id = ap.activity_id + WHERE a.user_id = :userId + GROUP BY p.id, p.name, p.wikipedia + ORDER BY p.name + """, + nativeQuery = true) + List findPeaksVisitedByUser(UUID userId); + + interface PeakVisitProjection { + Integer getPeakId(); + String getPeakName(); + String getWikipedia(); + Long getVisitCount(); + UUID getLatestActivityId(); + } + + void deleteByActivityId(UUID activityId); +} diff --git a/src/main/java/net/javahippie/fitpub/repository/PeakRepository.java b/src/main/java/net/javahippie/fitpub/repository/PeakRepository.java new file mode 100644 index 0000000..852b71a --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/PeakRepository.java @@ -0,0 +1,27 @@ +package net.javahippie.fitpub.repository; + +import net.javahippie.fitpub.model.entity.Peak; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PeakRepository extends JpaRepository { + + /** + * Find all peaks within a given distance (meters) of an activity's simplified track. + * Uses ST_DWithin on geography type for accurate meter-based distance. + */ + @Query(value = """ + SELECT p.* FROM peaks p + JOIN activities a ON a.id = :activityId + WHERE a.simplified_track IS NOT NULL + AND ST_DWithin(CAST(p.geom AS geography), CAST(a.simplified_track AS geography), :distanceMeters) + ORDER BY p.name + """, + nativeQuery = true) + List findPeaksNearActivity(UUID activityId, double distanceMeters); +} diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index c60b1b2..ff0bf9b 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -33,6 +33,7 @@ public class ActivityPostProcessingService { private final PersonalRecordService personalRecordService; private final WeatherService weatherService; private final HeatmapGridService heatmapGridService; + private final PeakDetectionService peakDetectionService; private final FederationService federationService; private final ActivityImageService activityImageService; private final ActivityRepository activityRepository; @@ -57,6 +58,7 @@ public class ActivityPostProcessingService { updatePersonalRecordsAsync(activityId); updateHeatmapAsync(activityId); fetchWeatherAsync(activityId); + detectPeaksAsync(activityId); log.info("Completed async post-processing for activity {}", activityId); } @@ -130,6 +132,28 @@ public class ActivityPostProcessingService { } } + /** + * Detect peaks along the activity's route. + * + * @param activityId the activity ID to process + */ + void detectPeaksAsync(UUID activityId) { + try { + log.debug("Async: Detecting peaks for activity {}", activityId); + + Activity activity = activityRepository.findById(activityId) + .orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId)); + + peakDetectionService.detectPeaksForActivity(activity); + + log.info("Async: Peak detection completed for activity {}", activityId); + + } catch (Exception e) { + log.error("Async: Failed to detect peaks for activity {}: {}", + activityId, e.getMessage(), e); + } + } + /** * Publish activity to the Fediverse (ActivityPub federation). * Generates activity image and sends Create activity to all follower inboxes. diff --git a/src/main/java/net/javahippie/fitpub/service/PeakDetectionService.java b/src/main/java/net/javahippie/fitpub/service/PeakDetectionService.java new file mode 100644 index 0000000..7763e50 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/PeakDetectionService.java @@ -0,0 +1,123 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivityPeak; +import net.javahippie.fitpub.model.entity.Peak; +import net.javahippie.fitpub.repository.ActivityPeakRepository; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.PeakRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PeakDetectionService { + + private static final double PEAK_PROXIMITY_METERS = 100.0; + + private final PeakRepository peakRepository; + private final ActivityPeakRepository activityPeakRepository; + private final ActivityRepository activityRepository; + + private final AtomicBoolean backfillRunning = new AtomicBoolean(false); + + /** + * Detect peaks along an activity's route and save the associations. + * + * @param activity the activity to detect peaks for + * @return the list of peaks found near the route + */ + @Transactional + public List detectPeaksForActivity(Activity activity) { + if (Boolean.TRUE.equals(activity.getIndoor())) { + log.debug("Activity {} is indoor/virtual, skipping peak detection", activity.getId()); + return List.of(); + } + + if (activity.getSimplifiedTrack() == null) { + log.debug("Activity {} has no GPS track, skipping peak detection", activity.getId()); + return List.of(); + } + + List nearbyPeaks = peakRepository.findPeaksNearActivity( + activity.getId(), PEAK_PROXIMITY_METERS + ); + + if (nearbyPeaks.isEmpty()) { + log.debug("No peaks found near activity {}", activity.getId()); + return List.of(); + } + + for (Peak peak : nearbyPeaks) { + if (!activityPeakRepository.existsByActivityIdAndPeakId(activity.getId(), peak.getId())) { + ActivityPeak activityPeak = ActivityPeak.builder() + .activityId(activity.getId()) + .peak(peak) + .build(); + activityPeakRepository.save(activityPeak); + } + } + + log.info("Detected {} peaks for activity {}", nearbyPeaks.size(), activity.getId()); + return nearbyPeaks; + } + + /** + * Run peak detection retroactively on all activities. + * Safe to call multiple times — skips activities that already have peaks detected. + * + * @return true if backfill was started, false if already running + */ + @Async("taskExecutor") + public void backfillAllActivities() { + if (!backfillRunning.compareAndSet(false, true)) { + log.warn("Peak backfill already running, skipping"); + return; + } + + try { + log.info("Starting retroactive peak detection for all activities"); + + int page = 0; + int pageSize = 100; + int totalProcessed = 0; + int totalPeaksFound = 0; + + Page activityPage; + do { + activityPage = activityRepository.findAll(PageRequest.of(page, pageSize)); + for (Activity activity : activityPage.getContent()) { + try { + List peaks = detectPeaksForActivity(activity); + totalPeaksFound += peaks.size(); + totalProcessed++; + } catch (Exception e) { + log.warn("Failed peak detection for activity {}: {}", activity.getId(), e.getMessage()); + } + } + page++; + log.info("Peak backfill progress: processed {} / {} activities, found {} peaks so far", + totalProcessed, activityPage.getTotalElements(), totalPeaksFound); + } while (activityPage.hasNext()); + + log.info("Peak backfill complete: processed {} activities, found {} total peak associations", + totalProcessed, totalPeaksFound); + } finally { + backfillRunning.set(false); + } + } + + public boolean isBackfillRunning() { + return backfillRunning.get(); + } +} diff --git a/src/main/resources/db/migration/V27__create_peaks_table.sql b/src/main/resources/db/migration/V27__create_peaks_table.sql new file mode 100644 index 0000000..e7a5b77 --- /dev/null +++ b/src/main/resources/db/migration/V27__create_peaks_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE peaks ( + id SERIAL PRIMARY KEY, + osm_id BIGINT NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + wikipedia VARCHAR(500), + wikidata VARCHAR(50), + geom geometry(Point, 4326) NOT NULL +); + +CREATE INDEX idx_peaks_geom ON peaks USING GIST (geom); +CREATE INDEX idx_peaks_name ON peaks (name); diff --git a/src/main/resources/db/migration/V28__create_activity_peaks_table.sql b/src/main/resources/db/migration/V28__create_activity_peaks_table.sql new file mode 100644 index 0000000..b29f78b --- /dev/null +++ b/src/main/resources/db/migration/V28__create_activity_peaks_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE activity_peaks ( + id SERIAL PRIMARY KEY, + activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + peak_id INTEGER NOT NULL REFERENCES peaks(id) ON DELETE CASCADE, + UNIQUE (activity_id, peak_id) +); + +CREATE INDEX idx_activity_peaks_activity ON activity_peaks (activity_id); +CREATE INDEX idx_activity_peaks_peak ON activity_peaks (peak_id); diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index f832fdb..cc98442 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -159,6 +159,23 @@ + + +