Peak Detection

This commit is contained in:
Tim Zöller 2026-04-06 23:41:33 +02:00
parent 6e70e1495e
commit a1416b232b
20 changed files with 653 additions and 0 deletions

1
.gitignore vendored
View file

@ -48,3 +48,4 @@ uploads/
logs/ logs/
/gadm_410.gpkg /gadm_410.gpkg
/.postgresdata/ /.postgresdata/
/peaks_worldwide.geojson

134
scripts/import_peaks.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Import peaks from GeoJSON into PostgreSQL/PostGIS.
Usage:
python3 import_peaks.py <geojson_file> [--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()

View file

@ -150,6 +150,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list .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/*/following").permitAll() // User following list
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check .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.POST, "/api/users/*/follow").authenticated() // Follow user
.requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user .requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user

View file

@ -55,10 +55,20 @@ public class ActivityController {
private final WeatherService weatherService; private final WeatherService weatherService;
private final PrivacyZoneService privacyZoneService; private final PrivacyZoneService privacyZoneService;
private final TrackPrivacyFilter trackPrivacyFilter; private final TrackPrivacyFilter trackPrivacyFilter;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; 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. * Helper method to get user ID from authenticated UserDetails.
* *
@ -176,6 +186,7 @@ public class ActivityController {
if (activity.getVisibility() == Activity.Visibility.PUBLIC) { if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
// Public activities are always accessible, but apply privacy filtering // Public activities are always accessible, but apply privacy filtering
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter);
populatePeaks(dto, id);
log.debug("Activity {} - DTO privacy zones: {}", id, log.debug("Activity {} - DTO privacy zones: {}", id,
dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0); dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
@ -196,6 +207,7 @@ public class ActivityController {
// Apply privacy filtering (owner sees full track, others see filtered) // Apply privacy filtering (owner sees full track, others see filtered)
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter); ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter);
populatePeaks(dto, id);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@ -490,6 +502,15 @@ public class ActivityController {
try { try {
java.io.File imageFile = activityImageService.getActivityImageFile(id); 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()) { if (!imageFile.exists()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }

View file

@ -27,6 +27,7 @@ import java.util.*;
public class DebugController { public class DebugController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final net.javahippie.fitpub.service.PeakDetectionService peakDetectionService;
@GetMapping("/validate-keys") @GetMapping("/validate-keys")
public Map<String, Object> validateKeys() { public Map<String, Object> validateKeys() {
@ -107,4 +108,13 @@ public class DebugController {
signature.update(data); signature.update(data);
return signature.verify(signatureBytes); return signature.verify(signatureBytes);
} }
@org.springframework.web.bind.annotation.PostMapping("/backfill-peaks")
public org.springframework.http.ResponseEntity<Map<String, String>> 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"));
}
} }

View file

@ -47,6 +47,7 @@ public class UserController {
private final WebFingerClient webFingerClient; private final WebFingerClient webFingerClient;
private final FederationService federationService; private final FederationService federationService;
private final UserService userService; private final UserService userService;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -616,4 +617,31 @@ public class UserController {
return ResponseEntity.ok(Map.of("isFollowing", isFollowing)); return ResponseEntity.ok(Map.of("isFollowing", isFollowing));
} }
/**
* Get peaks visited by a user with visit counts and latest activity.
*/
@GetMapping("/{username}/peaks")
public ResponseEntity<java.util.List<Map<String, Object>>> 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<String, Object> 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);
}
} }

View file

@ -66,6 +66,9 @@ public class ActivityDTO {
private Long commentsCount; private Long commentsCount;
private Boolean likedByCurrentUser; // True if current user has liked this activity private Boolean likedByCurrentUser; // True if current user has liked this activity
// Peaks visited on this activity
private List<PeakDTO> peaks;
// Privacy zones (only for activity owner, to show what's hidden for others) // Privacy zones (only for activity owner, to show what's hidden for others)
private List<PrivacyZonePreview> privacyZones; private List<PrivacyZonePreview> privacyZones;

View file

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

View file

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

View file

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

View file

@ -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<ActivityPeak, Integer> {
List<ActivityPeak> 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<PeakVisitProjection> findPeaksVisitedByUser(UUID userId);
interface PeakVisitProjection {
Integer getPeakId();
String getPeakName();
String getWikipedia();
Long getVisitCount();
UUID getLatestActivityId();
}
void deleteByActivityId(UUID activityId);
}

View file

@ -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<Peak, Integer> {
/**
* 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<Peak> findPeaksNearActivity(UUID activityId, double distanceMeters);
}

View file

@ -33,6 +33,7 @@ public class ActivityPostProcessingService {
private final PersonalRecordService personalRecordService; private final PersonalRecordService personalRecordService;
private final WeatherService weatherService; private final WeatherService weatherService;
private final HeatmapGridService heatmapGridService; private final HeatmapGridService heatmapGridService;
private final PeakDetectionService peakDetectionService;
private final FederationService federationService; private final FederationService federationService;
private final ActivityImageService activityImageService; private final ActivityImageService activityImageService;
private final ActivityRepository activityRepository; private final ActivityRepository activityRepository;
@ -57,6 +58,7 @@ public class ActivityPostProcessingService {
updatePersonalRecordsAsync(activityId); updatePersonalRecordsAsync(activityId);
updateHeatmapAsync(activityId); updateHeatmapAsync(activityId);
fetchWeatherAsync(activityId); fetchWeatherAsync(activityId);
detectPeaksAsync(activityId);
log.info("Completed async post-processing for activity {}", 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). * Publish activity to the Fediverse (ActivityPub federation).
* Generates activity image and sends Create activity to all follower inboxes. * Generates activity image and sends Create activity to all follower inboxes.

View file

@ -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<Peak> 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<Peak> 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<Activity> activityPage;
do {
activityPage = activityRepository.findAll(PageRequest.of(page, pageSize));
for (Activity activity : activityPage.getContent()) {
try {
List<Peak> 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();
}
}

View file

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

View file

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

View file

@ -159,6 +159,23 @@
</div> </div>
</div> </div>
<!-- Peaks Card -->
<div class="row mb-4" id="peaksSection" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-triangle"></i> Peaks
</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="peaksList">
</ul>
</div>
</div>
</div>
</div>
<!-- Weather Card --> <!-- Weather Card -->
<div class="row mb-4" id="weatherSection" style="display: none;"> <div class="row mb-4" id="weatherSection" style="display: none;">
<div class="col-12"> <div class="col-12">
@ -626,6 +643,18 @@
loadWeatherData(activity.id); loadWeatherData(activity.id);
} }
// Render peaks
if (activity.peaks && activity.peaks.length > 0) {
const peaksList = document.getElementById('peaksList');
peaksList.innerHTML = activity.peaks.map(peak => {
const content = peak.wikipedia
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
: peak.name;
return `<li class="list-group-item">${content}</li>`;
}).join('');
document.getElementById('peaksSection').style.display = 'block';
}
// Render elevation chart if data exists // Render elevation chart if data exists
if (activity.trackPoints && activity.trackPoints.length > 0) { if (activity.trackPoints && activity.trackPoints.length > 0) {
// Store track points globally for map marker updates // Store track points globally for map marker updates

View file

@ -92,6 +92,20 @@
</div> </div>
<!-- Public Activities --> <!-- Public Activities -->
<!-- Visited Peaks -->
<div class="card mb-4" id="peaksSection" style="display: none;">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-triangle"></i> Visited Peaks
<span class="badge bg-secondary ms-2" id="peaksCount">0</span>
</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="peaksList">
</ul>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
@ -149,6 +163,7 @@
.then(user => { .then(user => {
renderProfile(user); renderProfile(user);
loadPublicActivities(user.id); loadPublicActivities(user.id);
loadPeaks();
}) })
.catch(error => { .catch(error => {
console.error('Error loading profile:', error); console.error('Error loading profile:', error);
@ -298,6 +313,35 @@
let currentPage = 0; let currentPage = 0;
async function loadPeaks() {
try {
const response = await fetch(`/api/users/${targetUsername}/peaks`);
if (response.ok) {
const peaks = await response.json();
if (peaks.length > 0) {
document.getElementById('peaksCount').textContent = peaks.length;
const list = document.getElementById('peaksList');
list.innerHTML = peaks.map(peak => {
const nameHtml = peak.wikipedia
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
: peak.name;
const visits = peak.visitCount > 1 ? `${peak.visitCount} visits` : '1 visit';
const activityLink = peak.latestActivityId
? ` · <a href="/activities/${peak.latestActivityId}">latest activity</a>`
: '';
return `<li class="list-group-item d-flex justify-content-between align-items-center">
<span>${nameHtml}</span>
<span class="text-muted small">${visits}${activityLink}</span>
</li>`;
}).join('');
document.getElementById('peaksSection').style.display = 'block';
}
}
} catch (error) {
console.error('Error loading peaks:', error);
}
}
async function loadPublicActivities(userId) { async function loadPublicActivities(userId) {
try { try {
const response = await fetch(`/api/activities/user/${targetUsername}?page=${currentPage}&size=10`); const response = await fetch(`/api/activities/user/${targetUsername}?page=${currentPage}&size=10`);

View file

@ -89,6 +89,20 @@
</div> </div>
</div> </div>
<!-- Visited Peaks -->
<div class="card mb-4" id="peaksSection" style="display: none;">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-triangle"></i> Visited Peaks
<span class="badge bg-secondary ms-2" id="peaksCount">0</span>
</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="peaksList">
</ul>
</div>
</div>
<!-- Recent Activities --> <!-- Recent Activities -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@ -150,6 +164,7 @@
const user = await response.json(); const user = await response.json();
renderProfile(user); renderProfile(user);
loadRecentActivities(); loadRecentActivities();
loadPeaks(user.username);
} else { } else {
throw new Error('Failed to load profile'); throw new Error('Failed to load profile');
} }
@ -205,6 +220,35 @@
// Stats (activities count will be loaded separately) // Stats (activities count will be loaded separately)
} }
async function loadPeaks(username) {
try {
const response = await fetch(`/api/users/${username}/peaks`);
if (response.ok) {
const peaks = await response.json();
if (peaks.length > 0) {
document.getElementById('peaksCount').textContent = peaks.length;
const list = document.getElementById('peaksList');
list.innerHTML = peaks.map(peak => {
const nameHtml = peak.wikipedia
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
: peak.name;
const visits = peak.visitCount > 1 ? `${peak.visitCount} visits` : '1 visit';
const activityLink = peak.latestActivityId
? ` · <a href="/activities/${peak.latestActivityId}">latest activity</a>`
: '';
return `<li class="list-group-item d-flex justify-content-between align-items-center">
<span>${nameHtml}</span>
<span class="text-muted small">${visits}${activityLink}</span>
</li>`;
}).join('');
document.getElementById('peaksSection').style.display = 'block';
}
}
} catch (error) {
console.error('Error loading peaks:', error);
}
}
async function loadRecentActivities() { async function loadRecentActivities() {
try { try {
const response = await FitPubAuth.authenticatedFetch('/api/activities?page=0&size=5'); const response = await FitPubAuth.authenticatedFetch('/api/activities?page=0&size=5');

View file

@ -379,6 +379,7 @@ class FitFileServiceTest {
"New Title", "New Title",
"New Description", "New Description",
Activity.Visibility.PUBLIC, Activity.Visibility.PUBLIC,
null,
false false
); );
@ -423,6 +424,7 @@ class FitFileServiceTest {
"New Title", "New Title",
"New Description", "New Description",
Activity.Visibility.PUBLIC, Activity.Visibility.PUBLIC,
null,
false false
) )
); );
@ -453,6 +455,7 @@ class FitFileServiceTest {
"Hacked Title", "Hacked Title",
"Hacked Description", "Hacked Description",
Activity.Visibility.PUBLIC, Activity.Visibility.PUBLIC,
null,
false false
) )
); );
@ -496,6 +499,7 @@ class FitFileServiceTest {
"Updated Title", "Updated Title",
"Updated Description", "Updated Description",
Activity.Visibility.PUBLIC, Activity.Visibility.PUBLIC,
null,
false false
); );