diff --git a/.gitignore b/.gitignore index f5c3dca..40bfe7f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ build/ ### Application Files ### uploads/ -logs/ \ No newline at end of file +logs/ +/gadm_410.gpkg +/.postgresdata/ diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..06a2c34 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:51826/testdb + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..9605f07 --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..69328be --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ogr.sh b/ogr.sh new file mode 100755 index 0000000..efd38f8 --- /dev/null +++ b/ogr.sh @@ -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 \ No newline at end of file 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 1b9f64d..0d24214 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityDTO.java @@ -46,6 +46,7 @@ public class ActivityDTO { private ActivityMetricsDTO metrics; private LocalDateTime createdAt; private LocalDateTime updatedAt; + private String activityLocation; // Map rendering data private Map simplifiedTrack; // GeoJSON LineString @@ -124,7 +125,8 @@ public class ActivityDTO { .elevationGain(activity.getElevationGain()) .elevationLoss(activity.getElevationLoss()) .createdAt(activity.getCreatedAt()) - .updatedAt(activity.getUpdatedAt()); + .updatedAt(activity.getUpdatedAt()) + .activityLocation(activity.getActivityLocation()); if (activity.getTotalDurationSeconds() != null) { builder.totalDurationSeconds(activity.getTotalDurationSeconds()); @@ -237,6 +239,7 @@ public class ActivityDTO { .subSport(activity.getSubSport()) .indoorDetectionMethod(activity.getIndoorDetectionMethod()) .race(activity.getRace() != null ? activity.getRace() : false) + .activityLocation(activity.getActivityLocation()) .build(); } @@ -255,7 +258,8 @@ public class ActivityDTO { .elevationGain(activity.getElevationGain()) .elevationLoss(activity.getElevationLoss()) .createdAt(activity.getCreatedAt()) - .updatedAt(activity.getUpdatedAt()); + .updatedAt(activity.getUpdatedAt()) + .activityLocation(activity.getActivityLocation()); if (activity.getTotalDurationSeconds() != null) { builder.totalDurationSeconds(activity.getTotalDurationSeconds()); diff --git a/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java index 89556a1..dcbb894 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/TimelineActivityDTO.java @@ -38,6 +38,7 @@ public class TimelineActivityDTO { private Double elevationLoss; private String visibility; private LocalDateTime createdAt; + private String activityLocation; // User information private String username; @@ -99,6 +100,7 @@ public class TimelineActivityDTO { .indoorDetectionMethod(activity.getIndoorDetectionMethod()) .race(activity.getRace() != null ? activity.getRace() : false) .metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null) + .activityLocation(activity.getActivityLocation()) .build(); } diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java index af302b9..477a7ec 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java @@ -1,15 +1,21 @@ 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 jakarta.persistence.*; import lombok.*; +import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.Type; import org.hibernate.annotations.UpdateTimestamp; import org.locationtech.jts.geom.LineString; +import javax.sound.midi.Track; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.Optional; import java.util.UUID; /** @@ -28,6 +34,7 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Builder +@Slf4j public class Activity { @Id @@ -93,6 +100,9 @@ public class Activity { @Column(name = "elevation_loss", precision = 8, scale = 2) private BigDecimal elevationLoss; + @Column(name = "activity_location") + private String activityLocation; + /** * Original activity file (FIT or GPX) for re-processing if needed. * Allows us to re-parse with updated algorithms. @@ -149,6 +159,42 @@ public class Activity { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + public Optional 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 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 */ diff --git a/src/main/java/net/javahippie/fitpub/model/entity/ReverseGeolocation.java b/src/main/java/net/javahippie/fitpub/model/entity/ReverseGeolocation.java new file mode 100644 index 0000000..6bdde78 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/ReverseGeolocation.java @@ -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(); + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/TrackPoint.java b/src/main/java/net/javahippie/fitpub/model/entity/TrackPoint.java new file mode 100644 index 0000000..99ef1ac --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/TrackPoint.java @@ -0,0 +1,6 @@ +package net.javahippie.fitpub.model.entity; + +import java.math.BigDecimal; + +public record TrackPoint(double lon, double lat) { +} diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 354cf0f..6b057d7 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -234,7 +234,8 @@ public interface ActivityRepository extends JpaRepository { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -268,7 +269,8 @@ public interface ActivityRepository extends JpaRepository { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -303,7 +305,8 @@ public interface ActivityRepository extends JpaRepository { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -350,7 +353,8 @@ public interface ActivityRepository extends JpaRepository { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -360,6 +364,7 @@ public interface ActivityRepository extends JpaRepository { AND (:searchText IS NULL OR ( LOWER(a.title) 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, 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 { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -400,6 +406,7 @@ public interface ActivityRepository extends JpaRepository { AND (:searchText IS NULL OR ( LOWER(a.title) 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, 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 { u.username, u.display_name, u.avatar_url, COUNT(DISTINCT l.id) AS likes_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 INNER JOIN users u ON a.user_id = u.id LEFT JOIN likes l ON a.id = l.activity_id @@ -442,6 +450,7 @@ public interface ActivityRepository extends JpaRepository { AND (:searchText IS NULL OR ( LOWER(a.title) 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, a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss, diff --git a/src/main/java/net/javahippie/fitpub/repository/ReverseGeolocationRepository.java b/src/main/java/net/javahippie/fitpub/repository/ReverseGeolocationRepository.java new file mode 100644 index 0000000..9d3bab2 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/ReverseGeolocationRepository.java @@ -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 { + + @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); +} diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java index 7b56536..8f965a7 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.repository.ReverseGeolocationRepository; import net.javahippie.fitpub.util.ActivityFormatter; import net.javahippie.fitpub.util.FitFileValidator; import net.javahippie.fitpub.util.FitParser; @@ -113,6 +114,7 @@ public class ActivityFileService { private final AchievementService achievementService; private final TrainingLoadService trainingLoadService; private final ActivitySummaryService activitySummaryService; + private final ReverseGeolocationRepository reverseGeolocationRepository; /** * Processes an uploaded activity file (FIT or GPX) and creates an activity. @@ -243,7 +245,7 @@ public class ActivityFileService { Activity.Visibility visibility, byte[] rawFile, ProcessingOptions options - ) { + ) throws JsonProcessingException { // Generate title if not provided String activityTitle = title != null && !title.isBlank() ? title @@ -299,6 +301,11 @@ public class ActivityFileService { 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!) Activity savedActivity = activityRepository.save(activity); diff --git a/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java b/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java index 7b373b0..5fb0d8b 100644 --- a/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java +++ b/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java @@ -74,6 +74,7 @@ public class TimelineResultMapper { Long likesCount = ((Number) result[idx++]).longValue(); Long commentsCount = ((Number) result[idx++]).longValue(); Boolean likedByCurrentUser = (Boolean) result[idx++]; + String activityLocation = (String) result[idx++]; // Build DTO return TimelineActivityDTO.builder() @@ -97,6 +98,7 @@ public class TimelineResultMapper { .commentsCount(commentsCount) .likedByCurrentUser(likedByCurrentUser) .hasGpsTrack(true) // Will be refined based on actual data + .activityLocation(activityLocation) .build(); } catch (Exception e) { diff --git a/src/main/java/net/javahippie/fitpub/service/WeatherService.java b/src/main/java/net/javahippie/fitpub/service/WeatherService.java index eea89c7..8aec90c 100644 --- a/src/main/java/net/javahippie/fitpub/service/WeatherService.java +++ b/src/main/java/net/javahippie/fitpub/service/WeatherService.java @@ -1,12 +1,15 @@ package net.javahippie.fitpub.service; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.repository.WeatherDataRepository; +import org.locationtech.jts.geom.Point; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,8 +56,8 @@ public class WeatherService { public Optional fetchWeatherForActivity(Activity activity) { log.info("=== Weather fetch requested for activity {} ===", activity.getId()); log.info("Weather configuration: enabled={}, API key configured={}, API key length={}", - weatherEnabled, (apiKey != null && !apiKey.isBlank()), - (apiKey != null ? apiKey.length() : 0)); + weatherEnabled, (apiKey != null && !apiKey.isBlank()), + (apiKey != null ? apiKey.length() : 0)); if (!weatherEnabled) { log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false). " + @@ -64,12 +67,12 @@ public class WeatherService { if (apiKey == null || apiKey.isBlank()) { log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty). " + - "Please set fitpub.weather.api-key in application properties."); + "Please set fitpub.weather.api-key in application properties."); return Optional.empty(); } - log.info("Weather API key present: length={} chars, first 4 chars={}...", - apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???"); + log.debug("Weather API key present: length={} chars, first 4 chars={}...", + apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???"); // Check if weather data already exists if (weatherDataRepository.existsByActivityId(activity.getId())) { @@ -86,73 +89,46 @@ public class WeatherService { log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length()); try { - // Get first track point for location - JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson()); - log.debug("Parsed track points, is array: {}, size: {}", - trackPoints.isArray(), trackPoints.isArray() ? trackPoints.size() : "N/A"); + Optional trackPoint = activity.findFirstTrackpoint(); - if (!trackPoints.isArray() || trackPoints.isEmpty()) { - log.warn("Track points is not an array or is empty for activity {}", activity.getId()); + if (trackPoint.isEmpty()) { return Optional.empty(); - } - - JsonNode firstPoint = trackPoints.get(0); - 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 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 currentTimestamp = Instant.now().getEpochSecond(); - long daysDifference = (currentTimestamp - activityTimestamp) / 86400; - - log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference); - - WeatherData weatherData; - if (daysDifference <= 5) { - log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference); - weatherData = fetchCurrentWeather(lat, lon, activity.getId()); } else { - 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(); - } + var resolvedTrackPoint = trackPoint.get(); + // Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive. + long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond(); + long currentTimestamp = Instant.now().getEpochSecond(); + long daysDifference = (currentTimestamp - activityTimestamp) / 86400; - if (weatherData != null) { - log.info("Successfully fetched and parsed weather data. Attempting to save to database..."); - try { - WeatherData saved = weatherDataRepository.save(weatherData); - log.info("Weather data SUCCESSFULLY SAVED to database with ID: {}", saved.getId()); - return Optional.of(saved); - } catch (Exception e) { - log.error("FAILED to save weather data to database: {}", e.getMessage(), e); + log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference); + + WeatherData weatherData; + if (daysDifference <= 5) { + log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference); + weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId()); + } else { + 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(); } - } else { - log.error("Weather data fetch returned NULL - check API errors above"); - } + if (weatherData != null) { + log.info("Successfully fetched and parsed weather data. Attempting to save to database..."); + try { + WeatherData saved = weatherDataRepository.save(weatherData); + log.info("Weather data SUCCESSFULLY SAVED to database with ID: {}", saved.getId()); + return Optional.of(saved); + } catch (Exception e) { + log.error("FAILED to save weather data to database: {}", e.getMessage(), e); + return Optional.empty(); + } + } else { + log.error("Weather data fetch returned NULL - check API errors above"); + } + + } } catch (Exception e) { log.error("EXCEPTION while fetching weather data for activity {}: {}", - activity.getId(), e.getMessage(), e); + activity.getId(), e.getMessage(), e); } return Optional.empty(); @@ -185,7 +161,7 @@ public class WeatherService { log.info("API response received: {} characters", response.length()); log.info("API response (first 300 chars): {}", - response.length() > 300 ? response.substring(0, 300) + "..." : response); + response.length() > 300 ? response.substring(0, 300) + "..." : response); log.info("Parsing weather response JSON..."); WeatherData weatherData = parseWeatherResponse(response, activityId); @@ -194,13 +170,13 @@ public class WeatherService { log.error("FAILED to parse weather response - see parsing errors above"); } else { log.info("Successfully parsed weather data: temp={}°C, feels_like={}°C, condition='{}', description='{}', humidity={}%, pressure={} hPa, wind={} m/s", - weatherData.getTemperatureCelsius(), - weatherData.getFeelsLikeCelsius(), - weatherData.getWeatherCondition(), - weatherData.getWeatherDescription(), - weatherData.getHumidity(), - weatherData.getPressure(), - weatherData.getWindSpeedMps()); + weatherData.getTemperatureCelsius(), + weatherData.getFeelsLikeCelsius(), + weatherData.getWeatherCondition(), + weatherData.getWeatherDescription(), + weatherData.getHumidity(), + weatherData.getPressure(), + weatherData.getWindSpeedMps()); } log.info("=== fetchCurrentWeather END === success={}", (weatherData != null)); @@ -274,8 +250,8 @@ public class WeatherService { weatherData.setHumidity(getInteger(main, "humidity")); weatherData.setPressure(getInteger(main, "pressure")); log.debug("Extracted main data: temp={}, feels_like={}, humidity={}, pressure={}", - weatherData.getTemperatureCelsius(), weatherData.getFeelsLikeCelsius(), - weatherData.getHumidity(), weatherData.getPressure()); + weatherData.getTemperatureCelsius(), weatherData.getFeelsLikeCelsius(), + weatherData.getHumidity(), weatherData.getPressure()); } else { log.warn("Response JSON does not contain 'main' section"); } @@ -287,7 +263,7 @@ public class WeatherService { weatherData.setWindSpeedMps(getBigDecimal(wind, "speed")); weatherData.setWindDirection(getInteger(wind, "deg")); log.debug("Extracted wind data: speed={} m/s, direction={} degrees", - weatherData.getWindSpeedMps(), weatherData.getWindDirection()); + weatherData.getWindSpeedMps(), weatherData.getWindDirection()); } else { log.debug("Response JSON does not contain 'wind' section"); } @@ -300,8 +276,8 @@ public class WeatherService { weatherData.setWeatherDescription(getString(weather, "description")); weatherData.setWeatherIcon(getString(weather, "icon")); log.debug("Extracted weather condition: main='{}', description='{}', icon='{}'", - weatherData.getWeatherCondition(), weatherData.getWeatherDescription(), - weatherData.getWeatherIcon()); + weatherData.getWeatherCondition(), weatherData.getWeatherDescription(), + weatherData.getWeatherIcon()); } else { log.warn("Response JSON does not contain valid 'weather' array"); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a2a2705..7da0d67 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -49,7 +49,6 @@ logging: level: root: INFO net.javahippie.fitpub: DEBUG - org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.springframework.security: DEBUG org.springframework.web: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index baf1721..06625e1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,7 +107,6 @@ logging: level: root: INFO net.javahippie.fitpub: DEBUG - org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.springframework.security: DEBUG diff --git a/src/main/resources/db/migration/V23__add_gadm_table.sql b/src/main/resources/db/migration/V23__add_gadm_table.sql new file mode 100644 index 0000000..eec250d --- /dev/null +++ b/src/main/resources/db/migration/V23__add_gadm_table.sql @@ -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); + diff --git a/src/main/resources/db/migration/V24__add_location_to_activity.sql b/src/main/resources/db/migration/V24__add_location_to_activity.sql new file mode 100644 index 0000000..a8b87dc --- /dev/null +++ b/src/main/resources/db/migration/V24__add_location_to_activity.sql @@ -0,0 +1,2 @@ +alter table activities +add column activity_location VARCHAR(255); \ No newline at end of file diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index e8b1b06..e0f27a6 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -141,7 +141,7 @@ const FitPubTimeline = { @${this.escapeHtml(activity.username)} ${!activity.isLocal ? ' Remote' : ''} - • ${this.formatTimeAgo(activity.startedAt)} + • ${this.formatTimeAgo(activity.startedAt)} • ${activity.activityLocation}
diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 896fad0..f832fdb 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -44,6 +44,10 @@ + + + + @@ -506,6 +510,7 @@ activity.startedAt, activity.timezone || 'UTC' ); + document.getElementById('activityLocation').textContent = activity.activityLocation; document.getElementById('activityVisibility').textContent = activity.visibility; // Visibility icon diff --git a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java index 74a67ac..836efcc 100644 --- a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java +++ b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java @@ -3,7 +3,11 @@ package net.javahippie.fitpub.config; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.BindMode; 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; /** @@ -16,17 +20,21 @@ public class TestcontainersConfiguration { /** * PostgreSQL container with PostGIS extension for tests. * PostGIS image is treated as a standard PostgreSQL container. + * * @ServiceConnection automatically configures the datasource from this container. */ @Bean @ServiceConnection public PostgreSQLContainer postgresContainer() { return new PostgreSQLContainer<>( - DockerImageName.parse("postgis/postgis:16-3.4") - .asCompatibleSubstituteFor("postgres") + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") ) - .withDatabaseName("testdb") - .withUsername("test") - .withPassword("test"); + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .waitingFor(new HostPortWaitStrategy()) + .withReuse(true) + .withFileSystemBind(".postgresdata", "/var/lib/postgresql/data", BindMode.READ_WRITE); } }