Nice things
This commit is contained in:
parent
67a8aad4f1
commit
7e4b1d50d7
9 changed files with 635 additions and 4 deletions
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -837,13 +837,15 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [x] Database migration V10__create_analytics_tables.sql
|
- [x] Database migration V10__create_analytics_tables.sql
|
||||||
- [x] Integration with FitFileService (auto-update analytics on activity save)
|
- [x] Integration with FitFileService (auto-update analytics on activity save)
|
||||||
- [x] Security configuration updated (analytics routes and API endpoints)
|
- [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
|
### Phase 4: Enhanced Federation
|
||||||
- [ ] Rich preview cards for activities
|
- [ ] Rich preview cards for activities
|
||||||
- [ ] Media attachments (photos from workout)
|
|
||||||
- [ ] Activity challenges (federated events)
|
|
||||||
- [ ] Group/club support
|
|
||||||
- [ ] Cross-platform activity sync
|
- [ ] Cross-platform activity sync
|
||||||
|
|
||||||
### Phase 5: Mobile & Integrations
|
### Phase 5: Mobile & Integrations
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ public class ActivityController {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
|
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -530,4 +531,46 @@ public class ActivityController {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
src/main/java/org/operaton/fitpub/model/entity/WeatherData.java
Normal file
118
src/main/java/org/operaton/fitpub/model/entity/WeatherData.java
Normal 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 -> "🌡️";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ public class FitFileService {
|
||||||
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 WeatherService weatherService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes an uploaded FIT file and creates an activity.
|
* Processes an uploaded FIT file and creates an activity.
|
||||||
|
|
@ -116,6 +117,14 @@ public class FitFileService {
|
||||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||||
activitySummaryService.updateSummariesForActivity(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;
|
return savedActivity;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new FitFileProcessingException("Failed to read FIT file", e);
|
throw new FitFileProcessingException("Failed to read FIT file", e);
|
||||||
|
|
|
||||||
257
src/main/java/org/operaton/fitpub/service/WeatherService.java
Normal file
257
src/main/java/org/operaton/fitpub/service/WeatherService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,11 @@ fitpub:
|
||||||
osm-tiles:
|
osm-tiles:
|
||||||
enabled: ${OSM_TILES_ENABLED:true}
|
enabled: ${OSM_TILES_ENABLED:true}
|
||||||
|
|
||||||
|
# Weather API settings
|
||||||
|
weather:
|
||||||
|
enabled: ${WEATHER_ENABLED:false}
|
||||||
|
api-key: ${OPENWEATHERMAP_API_KEY:}
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -77,6 +77,65 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 -->
|
<!-- Map -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|
@ -369,6 +428,9 @@
|
||||||
renderMap(activity.simplifiedTrack);
|
renderMap(activity.simplifiedTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load weather data
|
||||||
|
loadWeatherData(activity.id);
|
||||||
|
|
||||||
// Render elevation chart if data exists
|
// Render elevation chart if data exists
|
||||||
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
||||||
const hasElevation = activity.trackPoints.some(p => p.elevation != null);
|
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) {
|
function renderMap(simplifiedTrack) {
|
||||||
// Parse GeoJSON from simplifiedTrack
|
// Parse GeoJSON from simplifiedTrack
|
||||||
const geoJson = {
|
const geoJson = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue