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] 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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 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);
|
||||
|
|
|
|||
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:
|
||||
enabled: ${OSM_TILES_ENABLED:true}
|
||||
|
||||
# Weather API settings
|
||||
weather:
|
||||
enabled: ${WEATHER_ENABLED:false}
|
||||
api-key: ${OPENWEATHERMAP_API_KEY:}
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
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>
|
||||
</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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue