Files
MyClub/internal/services/weather_service.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

411 lines
13 KiB
Go

package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"fotbal-club/internal/models"
"gorm.io/gorm"
)
type WeatherService struct {
db *gorm.DB
apiKey string
baseURL string
client *http.Client
}
type WeatherResponse struct {
Location struct {
Name string `json:"name"`
Region string `json:"region"`
Country string `json:"country"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
TzID string `json:"tz_id"`
LocalTime string `json:"localtime"`
} `json:"location"`
Current struct {
LastUpdated string `json:"last_updated"`
TempC float64 `json:"temp_c"`
TempF float64 `json:"temp_f"`
IsDay int `json:"is_day"`
Condition struct {
Text string `json:"text"`
Icon string `json:"icon"`
Code int `json:"code"`
} `json:"condition"`
WindMph float64 `json:"wind_mph"`
WindKph float64 `json:"wind_kph"`
WindDegree int `json:"wind_degree"`
WindDir string `json:"wind_dir"`
PressureMb float64 `json:"pressure_mb"`
PressureIn float64 `json:"pressure_in"`
PrecipMm float64 `json:"precip_mm"`
PrecipIn float64 `json:"precip_in"`
Humidity int `json:"humidity"`
Cloud int `json:"cloud"`
FeelsLikeC float64 `json:"feelslike_c"`
FeelsLikeF float64 `json:"feelslike_f"`
VisKm float64 `json:"vis_km"`
VisMiles float64 `json:"vis_miles"`
UV float64 `json:"uv"`
GustMph float64 `json:"gust_mph"`
GustKph float64 `json:"gust_kph"`
} `json:"current"`
Forecast struct {
ForecastDay []struct {
Date string `json:"date"`
DateEpoch int64 `json:"date_epoch"`
Day struct {
MaxTempC float64 `json:"maxtemp_c"`
MaxTempF float64 `json:"maxtemp_f"`
MinTempC float64 `json:"mintemp_c"`
MinTempF float64 `json:"mintemp_f"`
AvgTempC float64 `json:"avgtemp_c"`
AvgTempF float64 `json:"avgtemp_f"`
MaxWindMph float64 `json:"maxwind_mph"`
MaxWindKph float64 `json:"maxwind_kph"`
TotalPrecipMm float64 `json:"totalprecip_mm"`
TotalPrecipIn float64 `json:"totalprecip_in"`
TotalSnowCm float64 `json:"totalsnow_cm"`
AvgVisKm float64 `json:"avgvis_km"`
AvgVisMiles float64 `json:"avgvis_miles"`
AvgHumidity float64 `json:"avghumidity"`
DailyWillItRain int `json:"daily_will_it_rain"`
DailyChanceOfRain int `json:"daily_chance_of_rain"`
DailyWillItSnow int `json:"daily_will_it_snow"`
DailyChanceOfSnow int `json:"daily_chance_of_snow"`
Condition struct {
Text string `json:"text"`
Icon string `json:"icon"`
Code int `json:"code"`
} `json:"condition"`
UV float64 `json:"uv"`
} `json:"day"`
Astro struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
Moonrise string `json:"moonrise"`
Moonset string `json:"moonset"`
MoonPhase string `json:"moon_phase"`
MoonIllumination float64 `json:"moon_illumination"`
IsMoonUp int `json:"is_moon_up"`
IsSunUp int `json:"is_sun_up"`
} `json:"astro"`
Hour []struct {
TimeEpoch int64 `json:"time_epoch"`
Time string `json:"time"`
TempC float64 `json:"temp_c"`
TempF float64 `json:"temp_f"`
IsDay int `json:"is_day"`
Condition struct {
Text string `json:"text"`
Icon string `json:"icon"`
Code int `json:"code"`
} `json:"condition"`
WindMph float64 `json:"wind_mph"`
WindKph float64 `json:"wind_kph"`
WindDegree int `json:"wind_degree"`
WindDir string `json:"wind_dir"`
PressureMb float64 `json:"pressure_mb"`
PressureIn float64 `json:"pressure_in"`
PrecipMm float64 `json:"precip_mm"`
PrecipIn float64 `json:"precip_in"`
Humidity int `json:"humidity"`
Cloud int `json:"cloud"`
FeelsLikeC float64 `json:"feelslike_c"`
FeelsLikeF float64 `json:"feelslike_f"`
WindChillC float64 `json:"windchill_c"`
WindChillF float64 `json:"windchill_f"`
HeatIndexC float64 `json:"heatindex_c"`
HeatIndexF float64 `json:"heatindex_f"`
DewPointC float64 `json:"dewpoint_c"`
DewPointF float64 `json:"dewpoint_f"`
WillItRain int `json:"will_it_rain"`
ChanceOfRain int `json:"chance_of_rain"`
WillItSnow int `json:"will_it_snow"`
ChanceOfSnow int `json:"chance_of_snow"`
VisKm float64 `json:"vis_km"`
VisMiles float64 `json:"vis_miles"`
GustMph float64 `json:"gust_mph"`
GustKph float64 `json:"gust_kph"`
UV float64 `json:"uv"`
} `json:"hour"`
} `json:"forecastday"`
} `json:"forecast"`
Alerts []struct {
Headline string `json:"headline"`
MsgType string `json:"msgtype"`
Severity string `json:"severity"`
Urgency string `json:"urgency"`
Areas string `json:"areas"`
Category string `json:"category"`
Certainty string `json:"certainty"`
Event string `json:"event"`
Note string `json:"note"`
Effective string `json:"effective"`
Expires string `json:"expires"`
Desc string `json:"desc"`
Instruction string `json:"instruction"`
} `json:"alerts"`
}
func NewWeatherService(db *gorm.DB) *WeatherService {
apiKey := os.Getenv("WEATHER_API_KEY")
baseURL := os.Getenv("WEATHER_API_BASE_URL")
if apiKey == "" {
apiKey = "20dfd9a556ec43888dc103523250904" // fallback
}
if baseURL == "" {
baseURL = "https://api.weatherapi.com/v1"
}
return &WeatherService{
db: db,
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (ws *WeatherService) GetWeatherByLocation(location string) (*WeatherResponse, error) {
if location == "" {
// Try to get club location from settings
location = ws.getClubLocation()
if location == "" {
return nil, fmt.Errorf("no location specified and no club location found")
}
}
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=3&aqi=no&alerts=no",
ws.baseURL, ws.apiKey, location)
resp, err := ws.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var weatherResp WeatherResponse
if err := json.Unmarshal(body, &weatherResp); err != nil {
return nil, fmt.Errorf("failed to parse weather response: %w", err)
}
return &weatherResp, nil
}
func (ws *WeatherService) getClubLocation() string {
var settings models.Settings
if err := ws.db.First(&settings).Error; err != nil {
return ""
}
// If coordinates are available, use them for most accurate weather
if settings.LocationLatitude != 0 && settings.LocationLongitude != 0 {
return fmt.Sprintf("%.6f,%.6f", settings.LocationLatitude, settings.LocationLongitude)
}
// Try different location fields in order of preference
if settings.ContactCity != "" {
location := settings.ContactCity
if settings.ContactCountry != "" &&
settings.ContactCountry != "Czech Republic" &&
settings.ContactCountry != "Česká republika" &&
settings.ContactCountry != "Česko" {
location += "," + settings.ContactCountry
}
return location
}
// Fallback to a default Czech city if club is in Czech Republic
if settings.ContactCountry == "Czech Republic" ||
settings.ContactCountry == "Česká republika" ||
settings.ContactCountry == "Česko" {
return "Prague"
}
return ""
}
func (ws *WeatherService) GetWeatherForClub() (*WeatherResponse, error) {
return ws.GetWeatherByLocation("")
}
func (ws *WeatherService) GetWeatherForMatch(matchDateTime string, location string) (*WeatherResponse, error) {
if location == "" {
location = ws.getClubLocation()
if location == "" {
return nil, fmt.Errorf("no location specified and no club location found")
}
}
// Parse match date to determine if we need hourly forecast
matchTime, err := time.Parse("2006-01-02T15:04:05", matchDateTime)
if err != nil {
// Try alternative format
matchTime, err = time.Parse("2006-01-02 15:04:05", matchDateTime)
if err != nil {
return nil, fmt.Errorf("invalid match date time format: %v", err)
}
}
// If match is more than 7 days away, regular forecast won't be available
now := time.Now()
if matchTime.Sub(now) > 7*24*time.Hour {
return nil, fmt.Errorf("match is too far in the future for weather forecast")
}
// If match is in the past, no forecast needed
if matchTime.Before(now) {
return nil, fmt.Errorf("match is in the past")
}
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=7&aqi=no&alerts=no",
ws.baseURL, ws.apiKey, location)
resp, err := ws.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var weatherResp WeatherResponse
if err := json.Unmarshal(body, &weatherResp); err != nil {
return nil, fmt.Errorf("failed to parse weather response: %w", err)
}
return &weatherResp, nil
}
func (ws *WeatherService) FindClosestHourlyForecast(weather *WeatherResponse, matchTime time.Time) *struct {
TimeEpoch int64 `json:"time_epoch"`
Time string `json:"time"`
TempC float64 `json:"temp_c"`
TempF float64 `json:"temp_f"`
IsDay int `json:"is_day"`
Condition struct {
Text string `json:"text"`
Icon string `json:"icon"`
Code int `json:"code"`
} `json:"condition"`
WindMph float64 `json:"wind_mph"`
WindKph float64 `json:"wind_kph"`
WindDegree int `json:"wind_degree"`
WindDir string `json:"wind_dir"`
PressureMb float64 `json:"pressure_mb"`
PressureIn float64 `json:"pressure_in"`
PrecipMm float64 `json:"precip_mm"`
PrecipIn float64 `json:"precip_in"`
Humidity int `json:"humidity"`
Cloud int `json:"cloud"`
FeelsLikeC float64 `json:"feelslike_c"`
FeelsLikeF float64 `json:"feelslike_f"`
WindChillC float64 `json:"windchill_c"`
WindChillF float64 `json:"windchill_f"`
HeatIndexC float64 `json:"heatindex_c"`
HeatIndexF float64 `json:"heatindex_f"`
DewPointC float64 `json:"dewpoint_c"`
DewPointF float64 `json:"dewpoint_f"`
WillItRain int `json:"will_it_rain"`
ChanceOfRain int `json:"chance_of_rain"`
WillItSnow int `json:"will_it_snow"`
ChanceOfSnow int `json:"chance_of_snow"`
VisKm float64 `json:"vis_km"`
VisMiles float64 `json:"vis_miles"`
GustMph float64 `json:"gust_mph"`
GustKph float64 `json:"gust_kph"`
UV float64 `json:"uv"`
} {
var closestHour *struct {
TimeEpoch int64 `json:"time_epoch"`
Time string `json:"time"`
TempC float64 `json:"temp_c"`
TempF float64 `json:"temp_f"`
IsDay int `json:"is_day"`
Condition struct {
Text string `json:"text"`
Icon string `json:"icon"`
Code int `json:"code"`
} `json:"condition"`
WindMph float64 `json:"wind_mph"`
WindKph float64 `json:"wind_kph"`
WindDegree int `json:"wind_degree"`
WindDir string `json:"wind_dir"`
PressureMb float64 `json:"pressure_mb"`
PressureIn float64 `json:"pressure_in"`
PrecipMm float64 `json:"precip_mm"`
PrecipIn float64 `json:"precip_in"`
Humidity int `json:"humidity"`
Cloud int `json:"cloud"`
FeelsLikeC float64 `json:"feelslike_c"`
FeelsLikeF float64 `json:"feelslike_f"`
WindChillC float64 `json:"windchill_c"`
WindChillF float64 `json:"windchill_f"`
HeatIndexC float64 `json:"heatindex_c"`
HeatIndexF float64 `json:"heatindex_f"`
DewPointC float64 `json:"dewpoint_c"`
DewPointF float64 `json:"dewpoint_f"`
WillItRain int `json:"will_it_rain"`
ChanceOfRain int `json:"chance_of_rain"`
WillItSnow int `json:"will_it_snow"`
ChanceOfSnow int `json:"chance_of_snow"`
VisKm float64 `json:"vis_km"`
VisMiles float64 `json:"vis_miles"`
GustMph float64 `json:"gust_mph"`
GustKph float64 `json:"gust_kph"`
UV float64 `json:"uv"`
}
minDiff := 24 * time.Hour // Start with a large difference
for _, day := range weather.Forecast.ForecastDay {
for _, hour := range day.Hour {
hourTime := time.Unix(hour.TimeEpoch, 0)
diff := hourTime.Sub(matchTime)
if diff >= 0 && diff < minDiff {
minDiff = diff
closestHour = &hour
}
}
}
return closestHour
}
func (ws *WeatherService) GetWeatherIconURL(icon string) string {
if icon == "" {
return ""
}
// WeatherAPI provides relative URLs, make them absolute
return "https:" + icon
}