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 ###
uploads/
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 LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String activityLocation;
// Map rendering data
private Map<String, Object> 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());

View file

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

View file

@ -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<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
*/

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,
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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<Activity, UUID> {
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,

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

View file

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

View file

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

View file

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

View file

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

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)}
</a>
${!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>

View file

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

View file

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