Reverse Lookup for activities

This commit is contained in:
Tim Zöller 2026-01-15 10:59:34 +01:00
parent 612d67ccda
commit debc55d484
22 changed files with 347 additions and 94 deletions

2
.gitignore vendored
View file

@ -46,3 +46,5 @@ build/
### Application Files ### ### Application Files ###
uploads/ uploads/
logs/ logs/
/gadm_410.gpkg
/.postgresdata/

17
.idea/dataSources.xml generated Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

7
.idea/data_source_mapping.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
</component>
</project>

7
.idea/sqldialects.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
</component>
</project>

1
ogr.sh Executable file
View file

@ -0,0 +1 @@
ogr2ogr -nln public.gadm_410 -lco GEOMETRY_NAME=geom -lco FID=fid -f PostgreSQL PG:"host=localhost port=49812 dbname=testdb user=test password=test" -progress gadm_410.gpkg

View file

@ -46,6 +46,7 @@ public class ActivityDTO {
private ActivityMetricsDTO metrics; private ActivityMetricsDTO metrics;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private String activityLocation;
// Map rendering data // Map rendering data
private Map<String, Object> simplifiedTrack; // GeoJSON LineString private Map<String, Object> simplifiedTrack; // GeoJSON LineString
@ -124,7 +125,8 @@ public class ActivityDTO {
.elevationGain(activity.getElevationGain()) .elevationGain(activity.getElevationGain())
.elevationLoss(activity.getElevationLoss()) .elevationLoss(activity.getElevationLoss())
.createdAt(activity.getCreatedAt()) .createdAt(activity.getCreatedAt())
.updatedAt(activity.getUpdatedAt()); .updatedAt(activity.getUpdatedAt())
.activityLocation(activity.getActivityLocation());
if (activity.getTotalDurationSeconds() != null) { if (activity.getTotalDurationSeconds() != null) {
builder.totalDurationSeconds(activity.getTotalDurationSeconds()); builder.totalDurationSeconds(activity.getTotalDurationSeconds());
@ -237,6 +239,7 @@ public class ActivityDTO {
.subSport(activity.getSubSport()) .subSport(activity.getSubSport())
.indoorDetectionMethod(activity.getIndoorDetectionMethod()) .indoorDetectionMethod(activity.getIndoorDetectionMethod())
.race(activity.getRace() != null ? activity.getRace() : false) .race(activity.getRace() != null ? activity.getRace() : false)
.activityLocation(activity.getActivityLocation())
.build(); .build();
} }
@ -255,7 +258,8 @@ public class ActivityDTO {
.elevationGain(activity.getElevationGain()) .elevationGain(activity.getElevationGain())
.elevationLoss(activity.getElevationLoss()) .elevationLoss(activity.getElevationLoss())
.createdAt(activity.getCreatedAt()) .createdAt(activity.getCreatedAt())
.updatedAt(activity.getUpdatedAt()); .updatedAt(activity.getUpdatedAt())
.activityLocation(activity.getActivityLocation());
if (activity.getTotalDurationSeconds() != null) { if (activity.getTotalDurationSeconds() != null) {
builder.totalDurationSeconds(activity.getTotalDurationSeconds()); builder.totalDurationSeconds(activity.getTotalDurationSeconds());

View file

@ -38,6 +38,7 @@ public class TimelineActivityDTO {
private Double elevationLoss; private Double elevationLoss;
private String visibility; private String visibility;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private String activityLocation;
// User information // User information
private String username; private String username;
@ -99,6 +100,7 @@ public class TimelineActivityDTO {
.indoorDetectionMethod(activity.getIndoorDetectionMethod()) .indoorDetectionMethod(activity.getIndoorDetectionMethod())
.race(activity.getRace() != null ? activity.getRace() : false) .race(activity.getRace() != null ? activity.getRace() : false)
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null) .metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
.activityLocation(activity.getActivityLocation())
.build(); .build();
} }

View file

@ -1,15 +1,21 @@
package net.javahippie.fitpub.model.entity; package net.javahippie.fitpub.model.entity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type; import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LineString;
import javax.sound.midi.Track;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/** /**
@ -28,6 +34,7 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Slf4j
public class Activity { public class Activity {
@Id @Id
@ -93,6 +100,9 @@ public class Activity {
@Column(name = "elevation_loss", precision = 8, scale = 2) @Column(name = "elevation_loss", precision = 8, scale = 2)
private BigDecimal elevationLoss; private BigDecimal elevationLoss;
@Column(name = "activity_location")
private String activityLocation;
/** /**
* Original activity file (FIT or GPX) for re-processing if needed. * Original activity file (FIT or GPX) for re-processing if needed.
* Allows us to re-parse with updated algorithms. * Allows us to re-parse with updated algorithms.
@ -149,6 +159,42 @@ public class Activity {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public Optional<TrackPoint> findFirstTrackpoint() throws JsonProcessingException {
// Get first track point for location
JsonNode trackPoints = new ObjectMapper().readTree(this.getTrackPointsJson());
log.debug("Parsed track points, is array: {}, size: {}",
trackPoints.isArray(), trackPoints.isArray() ? trackPoints.size() : "N/A");
if (!trackPoints.isArray() || trackPoints.isEmpty()) {
log.warn("Track points is not an array or is empty for activity {}", this.getId());
return Optional.empty();
}
JsonNode firstPoint = trackPoints.get(0);
log.debug("First track point JSON: {}", firstPoint.toString());
// Check if lat/lon fields exist (support both "lat"/"lon" and "latitude"/"longitude")
boolean hasLat = firstPoint.has("lat") || firstPoint.has("latitude");
boolean hasLon = firstPoint.has("lon") || firstPoint.has("longitude");
if (!hasLat || !hasLon) {
// Collect field names from iterator
java.util.List<String> fieldNames = new java.util.ArrayList<>();
firstPoint.fieldNames().forEachRemaining(fieldNames::add);
log.error("First track point MISSING lat/lon fields for activity {}.", this.getId());
log.error("Available fields in track point: {}", fieldNames);
log.error("First track point content: {}", firstPoint);
return Optional.empty();
}
// Extract coordinates (try both short and long field names)
double lat = firstPoint.has("lat") ? firstPoint.get("lat").asDouble() : firstPoint.get("latitude").asDouble();
double lon = firstPoint.has("lon") ? firstPoint.get("lon").asDouble() : firstPoint.get("longitude").asDouble();
log.debug("Extracted location from first track point: lat={}, lon={}", lat, lon);
return Optional.of(new TrackPoint(lon, lat));
}
/** /**
* Helper method to set metrics for this activity * Helper method to set metrics for this activity
*/ */

View file

@ -0,0 +1,74 @@
package net.javahippie.fitpub.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "gadm_410", indexes = {
@Index(name = "gadm_410_pkey", columnList = "fid", unique = true),
@Index(name = "gadm_410_geom_geom_idx", columnList = "geom", unique = true)
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ReverseGeolocation {
@Id
@Column(name = "fid", nullable = false)
private Integer id;
/**
* Country
*/
@Column(name = "name_0")
private String name_0;
@Column(name = "name_1")
private String name_1;
@Column(name = "name_2")
private String name_2;
@Column(name = "name_3")
private String name_3;
@Column(name = "name_4")
private String name_4;
public String formatWithHighestResolution() {
String country = name_0;
if(containsText(name_4)) {
return "%s, %s".formatted(name_4, country);
}
if(containsText(name_3)) {
return "%s, %s".formatted(name_3, country);
}
if(containsText(name_2)) {
return "%s, %s".formatted(name_2, country);
}
if(containsText(name_1)) {
return "%s, %s".formatted(name_1, country);
}
return country;
}
private boolean containsText(String val) {
if(null == val) {
return false;
}
var trimmedValue = val.trim();
return !trimmedValue.isBlank();
}
}

View file

@ -0,0 +1,6 @@
package net.javahippie.fitpub.model.entity;
import java.math.BigDecimal;
public record TrackPoint(double lon, double lat) {
}

View file

@ -234,7 +234,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -268,7 +269,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -303,7 +305,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -350,7 +353,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -360,6 +364,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
AND (:searchText IS NULL OR ( AND (:searchText IS NULL OR (
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%')) LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%')) OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
)) ))
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at, GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss, a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
@ -390,7 +395,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -400,6 +406,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
AND (:searchText IS NULL OR ( AND (:searchText IS NULL OR (
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%')) LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%')) OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
)) ))
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at, GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss, a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
@ -431,7 +438,8 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
u.username, u.display_name, u.avatar_url, u.username, u.display_name, u.avatar_url,
COUNT(DISTINCT l.id) AS likes_count, COUNT(DISTINCT l.id) AS likes_count,
COUNT(DISTINCT c.id) AS comments_count, COUNT(DISTINCT c.id) AS comments_count,
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user,
a.activity_location
FROM activities a FROM activities a
INNER JOIN users u ON a.user_id = u.id INNER JOIN users u ON a.user_id = u.id
LEFT JOIN likes l ON a.id = l.activity_id LEFT JOIN likes l ON a.id = l.activity_id
@ -442,6 +450,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
AND (:searchText IS NULL OR ( AND (:searchText IS NULL OR (
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%')) LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%')) OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
)) ))
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at, GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss, a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,

View file

@ -0,0 +1,18 @@
package net.javahippie.fitpub.repository;
import net.javahippie.fitpub.model.entity.ReverseGeolocation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface ReverseGeolocationRepository extends JpaRepository<ReverseGeolocation, Integer> {
@Query(value = """
SELECT fid, name_0, name_1, name_2, name_3, name_4, name_5
FROM gadm_410
WHERE ST_Intersects(ST_SetSRID(ST_Point(:lon, :lat), 4326), geom)
""",
nativeQuery = true)
ReverseGeolocation findForLocation(double lon, double lat);
}

View file

@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.repository.ReverseGeolocationRepository;
import net.javahippie.fitpub.util.ActivityFormatter; import net.javahippie.fitpub.util.ActivityFormatter;
import net.javahippie.fitpub.util.FitFileValidator; import net.javahippie.fitpub.util.FitFileValidator;
import net.javahippie.fitpub.util.FitParser; import net.javahippie.fitpub.util.FitParser;
@ -113,6 +114,7 @@ public class ActivityFileService {
private final AchievementService achievementService; private final AchievementService achievementService;
private final TrainingLoadService trainingLoadService; private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService; private final ActivitySummaryService activitySummaryService;
private final ReverseGeolocationRepository reverseGeolocationRepository;
/** /**
* Processes an uploaded activity file (FIT or GPX) and creates an activity. * Processes an uploaded activity file (FIT or GPX) and creates an activity.
@ -243,7 +245,7 @@ public class ActivityFileService {
Activity.Visibility visibility, Activity.Visibility visibility,
byte[] rawFile, byte[] rawFile,
ProcessingOptions options ProcessingOptions options
) { ) throws JsonProcessingException {
// Generate title if not provided // Generate title if not provided
String activityTitle = title != null && !title.isBlank() String activityTitle = title != null && !title.isBlank()
? title ? title
@ -299,6 +301,11 @@ public class ActivityFileService {
activity.setMetrics(metrics); activity.setMetrics(metrics);
} }
var res = activity.findFirstTrackpoint()
.map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()));
res.ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
// Save activity (single INSERT instead of 855!) // Save activity (single INSERT instead of 855!)
Activity savedActivity = activityRepository.save(activity); Activity savedActivity = activityRepository.save(activity);

View file

@ -74,6 +74,7 @@ public class TimelineResultMapper {
Long likesCount = ((Number) result[idx++]).longValue(); Long likesCount = ((Number) result[idx++]).longValue();
Long commentsCount = ((Number) result[idx++]).longValue(); Long commentsCount = ((Number) result[idx++]).longValue();
Boolean likedByCurrentUser = (Boolean) result[idx++]; Boolean likedByCurrentUser = (Boolean) result[idx++];
String activityLocation = (String) result[idx++];
// Build DTO // Build DTO
return TimelineActivityDTO.builder() return TimelineActivityDTO.builder()
@ -97,6 +98,7 @@ public class TimelineResultMapper {
.commentsCount(commentsCount) .commentsCount(commentsCount)
.likedByCurrentUser(likedByCurrentUser) .likedByCurrentUser(likedByCurrentUser)
.hasGpsTrack(true) // Will be refined based on actual data .hasGpsTrack(true) // Will be refined based on actual data
.activityLocation(activityLocation)
.build(); .build();
} catch (Exception e) { } catch (Exception e) {

View file

@ -1,12 +1,15 @@
package net.javahippie.fitpub.service; package net.javahippie.fitpub.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.TrackPoint;
import net.javahippie.fitpub.model.entity.WeatherData; import net.javahippie.fitpub.model.entity.WeatherData;
import net.javahippie.fitpub.repository.WeatherDataRepository; import net.javahippie.fitpub.repository.WeatherDataRepository;
import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -68,7 +71,7 @@ public class WeatherService {
return Optional.empty(); return Optional.empty();
} }
log.info("Weather API key present: length={} chars, first 4 chars={}...", log.debug("Weather API key present: length={} chars, first 4 chars={}...",
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???"); apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
// Check if weather data already exists // Check if weather data already exists
@ -86,41 +89,13 @@ public class WeatherService {
log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length()); log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length());
try { try {
// Get first track point for location Optional<TrackPoint> trackPoint = activity.findFirstTrackpoint();
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson());
log.debug("Parsed track points, is array: {}, size: {}",
trackPoints.isArray(), trackPoints.isArray() ? trackPoints.size() : "N/A");
if (!trackPoints.isArray() || trackPoints.isEmpty()) { if (trackPoint.isEmpty()) {
log.warn("Track points is not an array or is empty for activity {}", activity.getId());
return Optional.empty(); return Optional.empty();
} } else {
var resolvedTrackPoint = trackPoint.get();
JsonNode firstPoint = trackPoints.get(0); // Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive.
log.info("First track point JSON: {}", firstPoint.toString());
// Check if lat/lon fields exist (support both "lat"/"lon" and "latitude"/"longitude")
boolean hasLat = firstPoint.has("lat") || firstPoint.has("latitude");
boolean hasLon = firstPoint.has("lon") || firstPoint.has("longitude");
if (!hasLat || !hasLon) {
// Collect field names from iterator
java.util.List<String> fieldNames = new java.util.ArrayList<>();
firstPoint.fieldNames().forEachRemaining(fieldNames::add);
log.error("First track point MISSING lat/lon fields for activity {}.", activity.getId());
log.error("Available fields in track point: {}", fieldNames);
log.error("First track point content: {}", firstPoint.toString());
return Optional.empty();
}
// Extract coordinates (try both short and long field names)
double lat = firstPoint.has("lat") ? firstPoint.get("lat").asDouble() : firstPoint.get("latitude").asDouble();
double lon = firstPoint.has("lon") ? firstPoint.get("lon").asDouble() : firstPoint.get("longitude").asDouble();
log.info("Extracted location from first track point: lat={}, lon={}", lat, lon);
// Check if activity is recent (within 5 days) - use current weather API
// Otherwise use historical data API (requires paid plan)
long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond(); long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond();
long currentTimestamp = Instant.now().getEpochSecond(); long currentTimestamp = Instant.now().getEpochSecond();
long daysDifference = (currentTimestamp - activityTimestamp) / 86400; long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
@ -130,7 +105,7 @@ public class WeatherService {
WeatherData weatherData; WeatherData weatherData;
if (daysDifference <= 5) { if (daysDifference <= 5) {
log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference); log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference);
weatherData = fetchCurrentWeather(lat, lon, activity.getId()); weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId());
} else { } else {
log.warn("Activity is {} days old (exceeds 5 day threshold). Historical weather data requires OpenWeatherMap paid API plan. Skipping weather fetch.", daysDifference); log.warn("Activity is {} days old (exceeds 5 day threshold). Historical weather data requires OpenWeatherMap paid API plan. Skipping weather fetch.", daysDifference);
return Optional.empty(); return Optional.empty();
@ -150,6 +125,7 @@ public class WeatherService {
log.error("Weather data fetch returned NULL - check API errors above"); log.error("Weather data fetch returned NULL - check API errors above");
} }
}
} catch (Exception e) { } catch (Exception e) {
log.error("EXCEPTION while fetching weather data for activity {}: {}", log.error("EXCEPTION while fetching weather data for activity {}: {}",
activity.getId(), e.getMessage(), e); activity.getId(), e.getMessage(), e);

View file

@ -49,7 +49,6 @@ logging:
level: level:
root: INFO root: INFO
net.javahippie.fitpub: DEBUG net.javahippie.fitpub: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.security: DEBUG org.springframework.security: DEBUG
org.springframework.web: DEBUG org.springframework.web: DEBUG

View file

@ -107,7 +107,6 @@ logging:
level: level:
root: INFO root: INFO
net.javahippie.fitpub: DEBUG net.javahippie.fitpub: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.security: DEBUG org.springframework.security: DEBUG

View file

@ -0,0 +1,62 @@
create table gadm_410
(
fid serial
primary key,
uid bigint,
gid_0 varchar(10),
name_0 varchar(32),
varname_0 varchar(29),
gid_1 varchar(10),
name_1 varchar(34),
varname_1 varchar(82),
nl_name_1 varchar(87),
iso_1 varchar(10),
hasc_1 varchar(10),
cc_1 varchar(10),
type_1 varchar(32),
engtype_1 varchar(32),
validfr_1 varchar(15),
gid_2 varchar(12),
name_2 varchar(34),
varname_2 varchar(39),
nl_name_2 varchar(75),
hasc_2 varchar(15),
cc_2 varchar(12),
type_2 varchar(34),
engtype_2 varchar(32),
validfr_2 varchar(15),
gid_3 varchar(15),
name_3 varchar(38),
varname_3 varchar(44),
nl_name_3 varchar(56),
hasc_3 varchar(27),
cc_3 varchar(10),
type_3 varchar(32),
engtype_3 varchar(32),
validfr_3 varchar(10),
gid_4 varchar(18),
name_4 varchar(34),
varname_4 varchar(35),
cc_4 varchar(12),
type_4 varchar(29),
engtype_4 varchar(29),
validfr_4 varchar(10),
gid_5 varchar(19),
name_5 varchar(34),
cc_5 varchar(10),
type_5 varchar(22),
engtype_5 varchar(10),
governedby varchar(17),
sovereign varchar(32),
disputedby varchar(32),
region varchar(32),
varregion varchar(48),
country varchar(32),
continent varchar(13),
subcont varchar(10),
geom geometry(MultiPolygon, 4326)
);
create index geodata_pkey_geom_geom_idx
on gadm_410 using gist (geom);

View file

@ -0,0 +1,2 @@
alter table activities
add column activity_location VARCHAR(255);

View file

@ -141,7 +141,7 @@ const FitPubTimeline = {
@${this.escapeHtml(activity.username)} @${this.escapeHtml(activity.username)}
</a> </a>
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''} ${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
${this.formatTimeAgo(activity.startedAt)} ${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation}
</div> </div>
</div> </div>
<div> <div>

View file

@ -44,6 +44,10 @@
<i class="bi bi-calendar"></i> <i class="bi bi-calendar"></i>
<span id="activityDate"></span> <span id="activityDate"></span>
</span> </span>
<span class="ms-2">
<i class="bi bi-geo-alt"></i>
<span id="activityLocation"></span>
</span>
<span class="ms-2" id="visibilityBadge"> <span class="ms-2" id="visibilityBadge">
<i class="bi bi-globe"></i> <i class="bi bi-globe"></i>
<span id="activityVisibility"></span> <span id="activityVisibility"></span>
@ -506,6 +510,7 @@
activity.startedAt, activity.startedAt,
activity.timezone || 'UTC' activity.timezone || 'UTC'
); );
document.getElementById('activityLocation').textContent = activity.activityLocation;
document.getElementById('activityVisibility').textContent = activity.visibility; document.getElementById('activityVisibility').textContent = activity.visibility;
// Visibility icon // Visibility icon

View file

@ -3,7 +3,11 @@ package net.javahippie.fitpub.config;
import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.startupcheck.IsRunningStartupCheckStrategy;
import org.testcontainers.containers.startupcheck.StartupCheckStrategy;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerImageName;
/** /**
@ -16,6 +20,7 @@ public class TestcontainersConfiguration {
/** /**
* PostgreSQL container with PostGIS extension for tests. * PostgreSQL container with PostGIS extension for tests.
* PostGIS image is treated as a standard PostgreSQL container. * PostGIS image is treated as a standard PostgreSQL container.
*
* @ServiceConnection automatically configures the datasource from this container. * @ServiceConnection automatically configures the datasource from this container.
*/ */
@Bean @Bean
@ -27,6 +32,9 @@ public class TestcontainersConfiguration {
) )
.withDatabaseName("testdb") .withDatabaseName("testdb")
.withUsername("test") .withUsername("test")
.withPassword("test"); .withPassword("test")
.waitingFor(new HostPortWaitStrategy())
.withReuse(true)
.withFileSystemBind(".postgresdata", "/var/lib/postgresql/data", BindMode.READ_WRITE);
} }
} }