Nice things

This commit is contained in:
Tim Zöller 2025-12-04 13:04:08 +01:00
parent 67a8aad4f1
commit 7e4b1d50d7
9 changed files with 635 additions and 4 deletions

View file

@ -837,13 +837,15 @@ For ActivityPub federated posts and thumbnails:
- [x] Database migration V10__create_analytics_tables.sql
- [x] Integration with FitFileService (auto-update analytics on activity save)
- [x] Security configuration updated (analytics routes and API endpoints)
- [ ] Weather data integration
- [x] Weather data integration (WeatherData entity, WeatherDataRepository, WeatherService with OpenWeatherMap API)
- [x] Weather database migration V11__create_weather_data_table.sql
- [x] Weather API configuration (fitpub.weather.enabled, fitpub.weather.api-key)
- [x] Weather fetching on activity upload (automatic for activities within 5 days)
- [x] Weather API endpoint GET /api/activities/{id}/weather
- [x] Weather display on activity detail page (temperature, humidity, wind, pressure, precipitation)
### Phase 4: Enhanced Federation
- [ ] Rich preview cards for activities
- [ ] Media attachments (photos from workout)
- [ ] Activity challenges (federated events)
- [ ] Group/club support
- [ ] Cross-platform activity sync
### Phase 5: Mobile & Integrations

View file

@ -43,6 +43,7 @@ public class ActivityController {
private final UserRepository userRepository;
private final FederationService federationService;
private final ActivityImageService activityImageService;
private final org.operaton.fitpub.service.WeatherService weatherService;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -530,4 +531,46 @@ public class ActivityController {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get weather data for an activity.
*
* @param id the activity ID
* @return weather data or 404 if not found
*/
@GetMapping("/{id}/weather")
public ResponseEntity<?> getActivityWeather(@PathVariable UUID id) {
try {
return weatherService.getWeatherForActivity(id)
.map(weatherData -> {
Map<String, Object> response = new HashMap<>();
response.put("id", weatherData.getId());
response.put("activityId", weatherData.getActivityId());
response.put("temperatureCelsius", weatherData.getTemperatureCelsius());
response.put("feelsLikeCelsius", weatherData.getFeelsLikeCelsius());
response.put("humidity", weatherData.getHumidity());
response.put("pressure", weatherData.getPressure());
response.put("windSpeedMps", weatherData.getWindSpeedMps());
response.put("windSpeedKmh", weatherData.getWindSpeedKmh());
response.put("windDirection", weatherData.getWindDirection());
response.put("windDirectionCardinal", weatherData.getWindDirectionCardinal());
response.put("weatherCondition", weatherData.getWeatherCondition());
response.put("weatherDescription", weatherData.getWeatherDescription());
response.put("weatherIcon", weatherData.getWeatherIcon());
response.put("weatherEmoji", weatherData.getWeatherEmoji());
response.put("cloudiness", weatherData.getCloudiness());
response.put("visibilityMeters", weatherData.getVisibilityMeters());
response.put("precipitationMm", weatherData.getPrecipitationMm());
response.put("snowMm", weatherData.getSnowMm());
response.put("sunrise", weatherData.getSunrise());
response.put("sunset", weatherData.getSunset());
response.put("dataSource", weatherData.getDataSource());
return ResponseEntity.ok(response);
})
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
log.error("Error retrieving weather data for activity {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View file

@ -0,0 +1,118 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entity representing weather data associated with an activity.
* Weather data is fetched from external APIs (e.g., OpenWeatherMap) based on
* activity location and timestamp.
*/
@Entity
@Table(name = "weather_data")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WeatherData {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "activity_id", nullable = false, unique = true)
private UUID activityId;
@Column(name = "temperature_celsius", precision = 5, scale = 2)
private BigDecimal temperatureCelsius;
@Column(name = "feels_like_celsius", precision = 5, scale = 2)
private BigDecimal feelsLikeCelsius;
@Column(name = "humidity")
private Integer humidity; // percentage 0-100
@Column(name = "pressure")
private Integer pressure; // hPa
@Column(name = "wind_speed_mps", precision = 5, scale = 2)
private BigDecimal windSpeedMps; // meters per second
@Column(name = "wind_direction")
private Integer windDirection; // degrees 0-360
@Column(name = "weather_condition", length = 50)
private String weatherCondition; // e.g., "Clear", "Clouds", "Rain"
@Column(name = "weather_description", length = 100)
private String weatherDescription; // e.g., "light rain", "overcast clouds"
@Column(name = "weather_icon", length = 10)
private String weatherIcon; // OpenWeatherMap icon code (e.g., "10d")
@Column(name = "cloudiness")
private Integer cloudiness; // percentage 0-100
@Column(name = "visibility_meters")
private Integer visibilityMeters;
@Column(name = "precipitation_mm", precision = 6, scale = 2)
private BigDecimal precipitationMm; // rainfall/snowfall in mm
@Column(name = "snow_mm", precision = 6, scale = 2)
private BigDecimal snowMm; // snowfall in mm (if applicable)
@Column(name = "sunrise")
private LocalDateTime sunrise;
@Column(name = "sunset")
private LocalDateTime sunset;
@Column(name = "fetched_at", nullable = false)
private LocalDateTime fetchedAt = LocalDateTime.now();
@Column(name = "data_source", length = 50)
private String dataSource = "openweathermap";
/**
* Get wind direction as cardinal direction (N, NE, E, SE, S, SW, W, NW)
*/
public String getWindDirectionCardinal() {
if (windDirection == null) return null;
String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
int index = (int) Math.round(((windDirection % 360) / 45.0)) % 8;
return directions[index];
}
/**
* Get wind speed in km/h
*/
public BigDecimal getWindSpeedKmh() {
if (windSpeedMps == null) return null;
return windSpeedMps.multiply(BigDecimal.valueOf(3.6));
}
/**
* Get emoji representation of weather condition
*/
public String getWeatherEmoji() {
if (weatherCondition == null) return "🌡️";
return switch (weatherCondition.toLowerCase()) {
case "clear" -> "☀️";
case "clouds" -> "☁️";
case "rain" -> "🌧️";
case "drizzle" -> "🌦️";
case "thunderstorm" -> "⛈️";
case "snow" -> "❄️";
case "mist", "fog" -> "🌫️";
default -> "🌡️";
};
}
}

View file

@ -0,0 +1,38 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.WeatherData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for accessing weather data.
*/
@Repository
public interface WeatherDataRepository extends JpaRepository<WeatherData, UUID> {
/**
* Find weather data for a specific activity.
*
* @param activityId the activity ID
* @return optional weather data
*/
Optional<WeatherData> findByActivityId(UUID activityId);
/**
* Check if weather data exists for an activity.
*
* @param activityId the activity ID
* @return true if weather data exists
*/
boolean existsByActivityId(UUID activityId);
/**
* Delete weather data for an activity.
*
* @param activityId the activity ID
*/
void deleteByActivityId(UUID activityId);
}

View file

@ -49,6 +49,7 @@ public class FitFileService {
private final AchievementService achievementService;
private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService;
private final WeatherService weatherService;
/**
* Processes an uploaded FIT file and creates an activity.
@ -116,6 +117,14 @@ public class FitFileService {
trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity);
// Fetch weather data (async, non-blocking)
try {
weatherService.fetchWeatherForActivity(savedActivity);
} catch (Exception e) {
log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage());
// Don't fail the activity creation if weather fetching fails
}
return savedActivity;
} catch (IOException e) {
throw new FitFileProcessingException("Failed to read FIT file", e);

View file

@ -0,0 +1,257 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.WeatherData;
import org.operaton.fitpub.repository.WeatherDataRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional;
import java.util.UUID;
/**
* Service for fetching and managing weather data for activities.
* Uses OpenWeatherMap API to retrieve historical weather data.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class WeatherService {
private final WeatherDataRepository weatherDataRepository;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${fitpub.weather.api-key:}")
private String apiKey;
@Value("${fitpub.weather.enabled:false}")
private boolean weatherEnabled;
private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather";
private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
/**
* Fetch and store weather data for an activity.
* Uses the activity's start location and timestamp to get weather conditions.
*
* @param activity the activity
* @return the weather data, or empty if fetching failed
*/
@Transactional
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
if (!weatherEnabled || apiKey == null || apiKey.isBlank()) {
log.debug("Weather fetching is disabled or API key is not configured");
return Optional.empty();
}
// Check if weather data already exists
if (weatherDataRepository.existsByActivityId(activity.getId())) {
log.debug("Weather data already exists for activity {}", activity.getId());
return weatherDataRepository.findByActivityId(activity.getId());
}
// Extract start location from track
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
log.debug("No track points available for activity {}", activity.getId());
return Optional.empty();
}
try {
// Get first track point for location
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson());
if (!trackPoints.isArray() || trackPoints.isEmpty()) {
return Optional.empty();
}
JsonNode firstPoint = trackPoints.get(0);
double lat = firstPoint.get("lat").asDouble();
double lon = firstPoint.get("lon").asDouble();
// 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;
WeatherData weatherData;
if (daysDifference <= 5) {
weatherData = fetchCurrentWeather(lat, lon, activity.getId());
} else {
log.debug("Activity is older than 5 days, historical weather data requires paid API plan");
// For historical data, we would use the Time Machine API
// weatherData = fetchHistoricalWeather(lat, lon, activityTimestamp, activity.getId());
return Optional.empty();
}
if (weatherData != null) {
return Optional.of(weatherDataRepository.save(weatherData));
}
} catch (Exception e) {
log.error("Error fetching weather data for activity {}: {}", activity.getId(), e.getMessage());
}
return Optional.empty();
}
/**
* Fetch current weather data from OpenWeatherMap.
*/
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId) {
try {
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
log.debug("Fetching current weather from: {}", url.replace(apiKey, "***"));
String response = restTemplate.getForObject(URI.create(url), String.class);
if (response == null) {
return null;
}
return parseWeatherResponse(response, activityId);
} catch (Exception e) {
log.error("Error fetching current weather: {}", e.getMessage());
return null;
}
}
/**
* Parse OpenWeatherMap API response and create WeatherData entity.
*/
private WeatherData parseWeatherResponse(String response, UUID activityId) {
try {
JsonNode root = objectMapper.readTree(response);
WeatherData weatherData = new WeatherData();
weatherData.setActivityId(activityId);
// Main temperature data
if (root.has("main")) {
JsonNode main = root.get("main");
weatherData.setTemperatureCelsius(getBigDecimal(main, "temp"));
weatherData.setFeelsLikeCelsius(getBigDecimal(main, "feels_like"));
weatherData.setHumidity(getInteger(main, "humidity"));
weatherData.setPressure(getInteger(main, "pressure"));
}
// Wind data
if (root.has("wind")) {
JsonNode wind = root.get("wind");
weatherData.setWindSpeedMps(getBigDecimal(wind, "speed"));
weatherData.setWindDirection(getInteger(wind, "deg"));
}
// Weather condition
if (root.has("weather") && root.get("weather").isArray() && !root.get("weather").isEmpty()) {
JsonNode weather = root.get("weather").get(0);
weatherData.setWeatherCondition(getString(weather, "main"));
weatherData.setWeatherDescription(getString(weather, "description"));
weatherData.setWeatherIcon(getString(weather, "icon"));
}
// Clouds
if (root.has("clouds")) {
weatherData.setCloudiness(getInteger(root.get("clouds"), "all"));
}
// Visibility
if (root.has("visibility")) {
weatherData.setVisibilityMeters(root.get("visibility").asInt());
}
// Rain
if (root.has("rain")) {
JsonNode rain = root.get("rain");
if (rain.has("1h")) {
weatherData.setPrecipitationMm(BigDecimal.valueOf(rain.get("1h").asDouble()));
}
}
// Snow
if (root.has("snow")) {
JsonNode snow = root.get("snow");
if (snow.has("1h")) {
weatherData.setSnowMm(BigDecimal.valueOf(snow.get("1h").asDouble()));
}
}
// Sun times
if (root.has("sys")) {
JsonNode sys = root.get("sys");
if (sys.has("sunrise")) {
weatherData.setSunrise(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunrise").asLong()), ZoneId.systemDefault()));
}
if (sys.has("sunset")) {
weatherData.setSunset(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunset").asLong()), ZoneId.systemDefault()));
}
}
weatherData.setFetchedAt(LocalDateTime.now());
weatherData.setDataSource("openweathermap");
return weatherData;
} catch (Exception e) {
log.error("Error parsing weather response: {}", e.getMessage());
return null;
}
}
/**
* Get weather data for an activity.
*
* @param activityId the activity ID
* @return optional weather data
*/
public Optional<WeatherData> getWeatherForActivity(UUID activityId) {
return weatherDataRepository.findByActivityId(activityId);
}
/**
* Delete weather data for an activity.
*
* @param activityId the activity ID
*/
@Transactional
public void deleteWeatherForActivity(UUID activityId) {
weatherDataRepository.deleteByActivityId(activityId);
}
// Helper methods to safely extract values from JSON
private BigDecimal getBigDecimal(JsonNode node, String field) {
if (node.has(field)) {
return BigDecimal.valueOf(node.get(field).asDouble());
}
return null;
}
private Integer getInteger(JsonNode node, String field) {
if (node.has(field)) {
return node.get(field).asInt();
}
return null;
}
private String getString(JsonNode node, String field) {
if (node.has(field)) {
return node.get(field).asText();
}
return null;
}
}

View file

@ -81,6 +81,11 @@ fitpub:
osm-tiles:
enabled: ${OSM_TILES_ENABLED:true}
# Weather API settings
weather:
enabled: ${WEATHER_ENABLED:false}
api-key: ${OPENWEATHERMAP_API_KEY:}
# Logging configuration
logging:
level:

View file

@ -0,0 +1,28 @@
-- Create weather_data table for storing weather conditions during activities
CREATE TABLE weather_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id UUID NOT NULL UNIQUE,
temperature_celsius DECIMAL(5, 2),
feels_like_celsius DECIMAL(5, 2),
humidity INTEGER, -- percentage 0-100
pressure INTEGER, -- hPa
wind_speed_mps DECIMAL(5, 2), -- meters per second
wind_direction INTEGER, -- degrees 0-360
weather_condition VARCHAR(50), -- e.g., "Clear", "Clouds", "Rain"
weather_description VARCHAR(100), -- e.g., "light rain", "overcast clouds"
weather_icon VARCHAR(10), -- OpenWeatherMap icon code
cloudiness INTEGER, -- percentage 0-100
visibility_meters INTEGER,
precipitation_mm DECIMAL(6, 2), -- rainfall/snowfall in mm
snow_mm DECIMAL(6, 2), -- snowfall in mm (if applicable)
sunrise TIMESTAMP,
sunset TIMESTAMP,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
data_source VARCHAR(50) DEFAULT 'openweathermap',
CONSTRAINT fk_weather_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE
);
-- Indexes for efficient queries
CREATE INDEX idx_weather_activity_id ON weather_data(activity_id);
CREATE INDEX idx_weather_temperature ON weather_data(temperature_celsius);
CREATE INDEX idx_weather_condition ON weather_data(weather_condition);

View file

@ -77,6 +77,65 @@
</table>
</div>
<!-- Weather Card -->
<div class="row mb-4" id="weatherSection" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-cloud-sun"></i> Weather Conditions
</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-2 text-center">
<div id="weatherEmoji" style="font-size: 4rem;">🌡️</div>
<div id="weatherCondition" class="text-muted">--</div>
</div>
<div class="col-md-10">
<div class="row">
<div class="col-md-3 mb-2">
<div class="text-muted small">Temperature</div>
<div class="fw-bold fs-4" id="weatherTemp">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Feels Like</div>
<div class="fw-bold" id="weatherFeelsLike">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Humidity</div>
<div class="fw-bold" id="weatherHumidity">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Wind</div>
<div class="fw-bold" id="weatherWind">--</div>
</div>
</div>
<div class="row mt-2">
<div class="col-md-3 mb-2">
<div class="text-muted small">Pressure</div>
<div class="fw-bold" id="weatherPressure">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Visibility</div>
<div class="fw-bold" id="weatherVisibility">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Cloudiness</div>
<div class="fw-bold" id="weatherCloudiness">--</div>
</div>
<div class="col-md-3 mb-2" id="weatherPrecipSection" style="display: none;">
<div class="text-muted small">Precipitation</div>
<div class="fw-bold" id="weatherPrecip">--</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map -->
<div class="row mb-4">
<div class="col-12">
@ -369,6 +428,9 @@
renderMap(activity.simplifiedTrack);
}
// Load weather data
loadWeatherData(activity.id);
// Render elevation chart if data exists
if (activity.trackPoints && activity.trackPoints.length > 0) {
const hasElevation = activity.trackPoints.some(p => p.elevation != null);
@ -420,6 +482,75 @@
}
}
async function loadWeatherData(activityId) {
try {
const response = await fetch(`/api/activities/${activityId}/weather`);
if (response.ok) {
const weather = await response.json();
displayWeather(weather);
} else if (response.status === 404) {
// No weather data available, hide section
console.debug('No weather data available for activity');
}
} catch (error) {
console.error('Error loading weather data:', error);
// Silently fail - weather is optional
}
}
function displayWeather(weather) {
// Show weather section
document.getElementById('weatherSection').style.display = 'block';
// Display emoji and condition
document.getElementById('weatherEmoji').textContent = weather.weatherEmoji || '🌡️';
document.getElementById('weatherCondition').textContent = weather.weatherDescription || weather.weatherCondition || '--';
// Temperature
if (weather.temperatureCelsius != null) {
document.getElementById('weatherTemp').textContent = Math.round(weather.temperatureCelsius) + '°C';
}
// Feels like
if (weather.feelsLikeCelsius != null) {
document.getElementById('weatherFeelsLike').textContent = Math.round(weather.feelsLikeCelsius) + '°C';
}
// Humidity
if (weather.humidity != null) {
document.getElementById('weatherHumidity').textContent = weather.humidity + '%';
}
// Wind
if (weather.windSpeedKmh != null) {
const windText = Math.round(weather.windSpeedKmh) + ' km/h';
const direction = weather.windDirectionCardinal ? ' ' + weather.windDirectionCardinal : '';
document.getElementById('weatherWind').textContent = windText + direction;
}
// Pressure
if (weather.pressure != null) {
document.getElementById('weatherPressure').textContent = weather.pressure + ' hPa';
}
// Visibility
if (weather.visibilityMeters != null) {
const visibilityKm = (weather.visibilityMeters / 1000).toFixed(1);
document.getElementById('weatherVisibility').textContent = visibilityKm + ' km';
}
// Cloudiness
if (weather.cloudiness != null) {
document.getElementById('weatherCloudiness').textContent = weather.cloudiness + '%';
}
// Precipitation
if (weather.precipitationMm != null && weather.precipitationMm > 0) {
document.getElementById('weatherPrecipSection').style.display = 'block';
document.getElementById('weatherPrecip').textContent = weather.precipitationMm.toFixed(1) + ' mm';
}
}
function renderMap(simplifiedTrack) {
// Parse GeoJSON from simplifiedTrack
const geoJson = {