3 files changed,
957 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-05 14:36:00 +0300
Authored at:
2026-05-05 00:21:59 +0300
Change ID:
llyzpsyosxspykwwrkkmmsnnuosvvtwx
Parent:
d3bc404
jump to
| M | main.go |
| A | sources/weather/weather.go |
| A | sources/weather/weather_test.go |
M
main.go
··· 8 8 "olexsmir.xyz/rss-tools/app" 9 9 "olexsmir.xyz/rss-tools/sources/moviefeed" 10 10 "olexsmir.xyz/rss-tools/sources/telegram" 11 + "olexsmir.xyz/rss-tools/sources/weather" 11 12 "olexsmir.xyz/rss-tools/sources/ztoe" 12 13 ) 13 14 ··· 38 39 _ = ztoe.Register(app) 39 40 _ = telegram.Register(app) 40 41 _ = moviefeed.Register(app) 42 + _ = weather.Register(app) 41 43 42 44 return app.Start(ctx) 43 45 }
A
sources/weather/weather.go
··· 1 +package weather 2 + 3 +import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "html" 9 + "math" 10 + "net/http" 11 + "net/url" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "olexsmir.xyz/rss-tools/app" 17 +) 18 + 19 +const ( 20 + forecastAPIURL = "https://api.open-meteo.com/v1/forecast" 21 + airQualityAPIURL = "https://air-quality-api.open-meteo.com/v1/air-quality" 22 + geocodingAPIURL = "https://nominatim.openstreetmap.org/reverse" 23 + 24 + clockLayout = "15:04" 25 + localLayout = "2006-01-02T15:04" 26 +) 27 + 28 +var timelineHours = []int{8, 12, 16, 20} 29 + 30 +type weather struct { 31 + client *http.Client 32 + forecastURL string 33 + airQualityURL string 34 + geocodingURL string 35 +} 36 + 37 +func Register(a *app.App) error { 38 + w := &weather{ 39 + client: a.Client, 40 + forecastURL: forecastAPIURL, 41 + airQualityURL: airQualityAPIURL, 42 + geocodingURL: geocodingAPIURL, 43 + } 44 + a.Route("GET /weather", w.handler) 45 + a.Logger.Info("weather source registered") 46 + return nil 47 +} 48 + 49 +func (w *weather) handler(rw http.ResponseWriter, r *http.Request) { 50 + lat, lon, err := parseCoordinates(r) 51 + if err != nil { 52 + http.Error(rw, "invalid latitude/longitude", http.StatusBadRequest) 53 + return 54 + } 55 + 56 + forecast, err := w.fetchForecast(r.Context(), lat, lon) 57 + if err != nil { 58 + http.Error(rw, "failed to fetch weather forecast", http.StatusBadGateway) 59 + return 60 + } 61 + 62 + air, err := w.fetchAirQuality(r.Context(), lat, lon) 63 + if err != nil { 64 + http.Error(rw, "failed to fetch air quality", http.StatusBadGateway) 65 + return 66 + } 67 + 68 + content, updated, err := buildMorningBriefing(forecast, air) 69 + if err != nil { 70 + http.Error(rw, "failed to build weather briefing", http.StatusBadGateway) 71 + return 72 + } 73 + 74 + place := fmt.Sprintf("%.4f,%.4f", lat, lon) 75 + if town, err := w.fetchTownName(r.Context(), lat, lon); err == nil && town != "" { 76 + place = town 77 + } 78 + 79 + feedID := weatherFeedID(lat, lon) 80 + feed := app.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID). 81 + WithUpdated(updated) 82 + 83 + feed.Add(app.FeedEntry{ 84 + Title: "Morning weather briefing", 85 + ID: fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")), 86 + Content: formatBriefingHTML(content), 87 + ContentType: "html", 88 + Updated: updated, 89 + }) 90 + 91 + if err := feed.Render(rw); err != nil { 92 + http.Error(rw, "failed to render feed", http.StatusInternalServerError) 93 + } 94 +} 95 + 96 +func parseCoordinates(r *http.Request) (float64, float64, error) { 97 + latRaw := strings.TrimSpace(r.URL.Query().Get("latitude")) 98 + lonRaw := strings.TrimSpace(r.URL.Query().Get("longitude")) 99 + if latRaw == "" || lonRaw == "" { 100 + return 0, 0, errors.New("latitude and longitude are required") 101 + } 102 + 103 + lat, err := strconv.ParseFloat(latRaw, 64) 104 + if err != nil { 105 + return 0, 0, fmt.Errorf("invalid latitude: %w", err) 106 + } 107 + lon, err := strconv.ParseFloat(lonRaw, 64) 108 + if err != nil { 109 + return 0, 0, fmt.Errorf("invalid longitude: %w", err) 110 + } 111 + 112 + if lat < -90 || lat > 90 { 113 + return 0, 0, errors.New("latitude is out of range") 114 + } 115 + if lon < -180 || lon > 180 { 116 + return 0, 0, errors.New("longitude is out of range") 117 + } 118 + return lat, lon, nil 119 +} 120 + 121 +type forecastResponse struct { 122 + Timezone string `json:"timezone"` 123 + Current struct { 124 + Time string `json:"time"` 125 + Temperature2M *float64 `json:"temperature_2m"` 126 + ApparentTemperature *float64 `json:"apparent_temperature"` 127 + } `json:"current"` 128 + Daily struct { 129 + Time []string `json:"time"` 130 + TemperatureMin []float64 `json:"temperature_2m_min"` 131 + TemperatureMax []float64 `json:"temperature_2m_max"` 132 + WeatherCode []int `json:"weather_code"` 133 + PrecipitationProbability []float64 `json:"precipitation_probability_max"` 134 + PrecipitationSum []float64 `json:"precipitation_sum"` 135 + RainSum []float64 `json:"rain_sum"` 136 + ShowersSum []float64 `json:"showers_sum"` 137 + WindSpeedMax []float64 `json:"wind_speed_10m_max"` 138 + WindDirectionDominant []float64 `json:"wind_direction_10m_dominant"` 139 + } `json:"daily"` 140 + Hourly struct { 141 + Time []string `json:"time"` 142 + Temperature []float64 `json:"temperature_2m"` 143 + WeatherCode []int `json:"weather_code"` 144 + PrecipitationProbability []float64 `json:"precipitation_probability"` 145 + WindSpeed []float64 `json:"wind_speed_10m"` 146 + Humidity []float64 `json:"relative_humidity_2m"` 147 + } `json:"hourly"` 148 +} 149 + 150 +type airQualityResponse struct { 151 + Current struct { 152 + USAQI *float64 `json:"us_aqi"` 153 + PM2_5 *float64 `json:"pm2_5"` 154 + PM10 *float64 `json:"pm10"` 155 + Ozone *float64 `json:"ozone"` 156 + NitrogenDioxide *float64 `json:"nitrogen_dioxide"` 157 + } `json:"current"` 158 +} 159 + 160 +type geocodingResponse struct { 161 + Name string `json:"name"` 162 + DisplayName string `json:"display_name"` 163 + Address struct { 164 + City string `json:"city"` 165 + Town string `json:"town"` 166 + Village string `json:"village"` 167 + Municipality string `json:"municipality"` 168 + Hamlet string `json:"hamlet"` 169 + County string `json:"county"` 170 + } `json:"address"` 171 +} 172 + 173 +func (w *weather) fetchForecast(ctx context.Context, lat, lon float64) (forecastResponse, error) { 174 + var payload forecastResponse 175 + 176 + q := url.Values{} 177 + q.Set("latitude", strconv.FormatFloat(lat, 'f', 6, 64)) 178 + q.Set("longitude", strconv.FormatFloat(lon, 'f', 6, 64)) 179 + q.Set("timezone", "auto") 180 + q.Set("forecast_days", "1") 181 + q.Set("current", strings.Join([]string{ 182 + "temperature_2m", 183 + "apparent_temperature", 184 + }, ",")) 185 + q.Set("daily", strings.Join([]string{ 186 + "temperature_2m_min", 187 + "temperature_2m_max", 188 + "weather_code", 189 + "precipitation_probability_max", 190 + "precipitation_sum", 191 + "rain_sum", 192 + "showers_sum", 193 + "wind_speed_10m_max", 194 + "wind_direction_10m_dominant", 195 + }, ",")) 196 + q.Set("hourly", strings.Join([]string{ 197 + "temperature_2m", 198 + "weather_code", 199 + "precipitation_probability", 200 + "wind_speed_10m", 201 + "relative_humidity_2m", 202 + }, ",")) 203 + 204 + endpoint := w.forecastURL + "?" + q.Encode() 205 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 206 + if err != nil { 207 + return payload, err 208 + } 209 + 210 + res, err := w.client.Do(req) 211 + if err != nil { 212 + return payload, err 213 + } 214 + defer res.Body.Close() 215 + 216 + if res.StatusCode != http.StatusOK { 217 + return payload, fmt.Errorf("forecast API returned %s", res.Status) 218 + } 219 + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { 220 + return payload, err 221 + } 222 + if len(payload.Daily.Time) == 0 || len(payload.Daily.TemperatureMin) == 0 || len(payload.Daily.TemperatureMax) == 0 { 223 + return payload, errors.New("forecast payload missing daily data") 224 + } 225 + if len(payload.Hourly.Time) == 0 || len(payload.Hourly.Temperature) == 0 { 226 + return payload, errors.New("forecast payload missing hourly data") 227 + } 228 + return payload, nil 229 +} 230 + 231 +func (w *weather) fetchAirQuality(ctx context.Context, lat, lon float64) (airQualityResponse, error) { 232 + var payload airQualityResponse 233 + 234 + q := url.Values{} 235 + q.Set("latitude", strconv.FormatFloat(lat, 'f', 6, 64)) 236 + q.Set("longitude", strconv.FormatFloat(lon, 'f', 6, 64)) 237 + q.Set("timezone", "auto") 238 + q.Set("current", strings.Join([]string{ 239 + "us_aqi", 240 + "pm2_5", 241 + "pm10", 242 + "ozone", 243 + "nitrogen_dioxide", 244 + }, ",")) 245 + 246 + endpoint := w.airQualityURL + "?" + q.Encode() 247 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 248 + if err != nil { 249 + return payload, err 250 + } 251 + 252 + res, err := w.client.Do(req) 253 + if err != nil { 254 + return payload, err 255 + } 256 + defer res.Body.Close() 257 + 258 + if res.StatusCode != http.StatusOK { 259 + return payload, fmt.Errorf("air quality API returned %s", res.Status) 260 + } 261 + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { 262 + return payload, err 263 + } 264 + return payload, nil 265 +} 266 + 267 +func (w *weather) fetchTownName(ctx context.Context, lat, lon float64) (string, error) { 268 + var payload geocodingResponse 269 + 270 + geocodingURL := w.geocodingURL 271 + if geocodingURL == "" { 272 + geocodingURL = geocodingAPIURL 273 + } 274 + 275 + q := url.Values{} 276 + q.Set("lat", strconv.FormatFloat(lat, 'f', 6, 64)) 277 + q.Set("lon", strconv.FormatFloat(lon, 'f', 6, 64)) 278 + q.Set("format", "jsonv2") 279 + q.Set("accept-language", "en") 280 + q.Set("zoom", "12") 281 + q.Set("addressdetails", "1") 282 + 283 + endpoint := geocodingURL + "?" + q.Encode() 284 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 285 + if err != nil { 286 + return "", err 287 + } 288 + req.Header.Set("User-Agent", "rss-tools/1.0 (+https://github.com/olexsmir/rss-tools)") 289 + 290 + res, err := w.client.Do(req) 291 + if err != nil { 292 + return "", err 293 + } 294 + defer res.Body.Close() 295 + 296 + if res.StatusCode != http.StatusOK { 297 + return "", fmt.Errorf("geocoding API returned %s", res.Status) 298 + } 299 + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { 300 + return "", err 301 + } 302 + 303 + for _, candidate := range []string{ 304 + payload.Address.City, 305 + payload.Address.Town, 306 + payload.Address.Village, 307 + payload.Address.Municipality, 308 + payload.Address.Hamlet, 309 + payload.Address.County, 310 + payload.Name, 311 + firstDisplayNamePart(payload.DisplayName), 312 + } { 313 + if town := strings.TrimSpace(candidate); town != "" { 314 + return town, nil 315 + } 316 + } 317 + return "", errors.New("town name not found") 318 +} 319 + 320 +func firstDisplayNamePart(displayName string) string { 321 + part, _, _ := strings.Cut(displayName, ",") 322 + return strings.TrimSpace(part) 323 +} 324 + 325 +func formatBriefingHTML(content string) string { 326 + return "<pre>" + html.EscapeString(content) + "</pre>" 327 +} 328 + 329 +func buildMorningBriefing(forecast forecastResponse, air airQualityResponse) (string, time.Time, error) { 330 + loc := time.Local 331 + if tz := strings.TrimSpace(forecast.Timezone); tz != "" { 332 + zone, err := time.LoadLocation(tz) 333 + if err == nil { 334 + loc = zone 335 + } 336 + } 337 + 338 + day := firstEl(forecast.Daily.Time) 339 + if day == "" { 340 + return "", time.Time{}, errors.New("missing day") 341 + } 342 + 343 + updated := time.Now().In(loc) 344 + if parsed, err := time.ParseInLocation(localLayout, forecast.Current.Time, loc); err == nil { 345 + updated = parsed 346 + } 347 + 348 + minTemp := firstEl(forecast.Daily.TemperatureMin) 349 + maxTemp := firstEl(forecast.Daily.TemperatureMax) 350 + code := firstEl(forecast.Daily.WeatherCode) 351 + _, dayText := weatherCodeLabel(code) 352 + 353 + peakPrecipHour := peakPrecipitationHour(forecast, day, loc) 354 + if maxProbability := firstEl(forecast.Daily.PrecipitationProbability); maxProbability >= 50 { 355 + switch { 356 + case peakPrecipHour >= 12 && peakPrecipHour <= 18: 357 + dayText += " with showers in the afternoon" 358 + case peakPrecipHour >= 5 && peakPrecipHour < 12: 359 + dayText += " with rain in the morning" 360 + default: 361 + dayText += " with possible rain" 362 + } 363 + } 364 + 365 + rainLine := fmt.Sprintf( 366 + "☂ %d%% chance of rain (%s)", 367 + int(math.Round(firstEl(forecast.Daily.PrecipitationProbability))), 368 + rainAmount(forecast.Daily.PrecipitationSum, forecast.Daily.RainSum, forecast.Daily.ShowersSum), 369 + ) 370 + 371 + windMin, windMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.WindSpeed, day) 372 + if windMin == 0 && windMax == 0 { 373 + windMax = firstEl(forecast.Daily.WindSpeedMax) 374 + windMin = windMax 375 + } 376 + windDirection := windDirectionFromDegrees(firstEl(forecast.Daily.WindDirectionDominant)) 377 + windLine := formatWindLine(windMin, windMax, windDirection) 378 + 379 + lowTemp, lowTime, highTemp, highTime, ok := dayExtremes(forecast.Hourly.Time, forecast.Hourly.Temperature, day, loc) 380 + extremesLine := "" 381 + if ok { 382 + extremesLine = fmt.Sprintf( 383 + "📈 High: %s at %s • 📉 Low: %s at %s", 384 + formatSignedTemperature(highTemp), 385 + highTime.Format(clockLayout), 386 + formatSignedTemperature(lowTemp), 387 + lowTime.Format(clockLayout), 388 + ) 389 + } 390 + 391 + nowLine := "" 392 + if forecast.Current.Temperature2M != nil && forecast.Current.ApparentTemperature != nil { 393 + nowLine = fmt.Sprintf( 394 + "🌡 Now: %s (feels like %s)", 395 + formatSignedTemperature(*forecast.Current.Temperature2M), 396 + formatSignedTemperature(*forecast.Current.ApparentTemperature), 397 + ) 398 + } 399 + 400 + humidityMin, humidityMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.Humidity, day) 401 + humidityLine := "" 402 + if humidityMin != 0 || humidityMax != 0 { 403 + humidityLine = fmt.Sprintf("💧 Humidity: %d-%d%%", int(math.Round(humidityMin)), int(math.Round(humidityMax))) 404 + } 405 + 406 + airLine := formatAirQualityLine(air) 407 + timelineLines := timelineSummary(forecast, day, loc) 408 + if len(timelineLines) == 0 { 409 + return "", time.Time{}, errors.New("missing timeline data") 410 + } 411 + 412 + lines := []string{ 413 + fmt.Sprintf("%s / %s | %s", formatSignedTemperature(minTemp), formatSignedTemperature(maxTemp), dayText), 414 + rainLine, 415 + windLine, 416 + } 417 + if extremesLine != "" { 418 + lines = append(lines, extremesLine) 419 + } 420 + if nowLine != "" { 421 + lines = append(lines, nowLine) 422 + } 423 + if humidityLine != "" { 424 + lines = append(lines, humidityLine) 425 + } 426 + if airLine != "" { 427 + lines = append(lines, airLine) 428 + } 429 + lines = append(lines, "") 430 + lines = append(lines, timelineLines...) 431 + lines = append(lines, "", "Data: Open-Meteo") 432 + 433 + return strings.Join(lines, "\n"), updated, nil 434 +} 435 + 436 +func peakPrecipitationHour(forecast forecastResponse, day string, loc *time.Location) int { 437 + peakHour := -1 438 + maxProb := -1.0 439 + for i, raw := range forecast.Hourly.Time { 440 + if i >= len(forecast.Hourly.PrecipitationProbability) || !strings.HasPrefix(raw, day+"T") { 441 + continue 442 + } 443 + t, err := time.ParseInLocation(localLayout, raw, loc) 444 + if err != nil { 445 + continue 446 + } 447 + prob := forecast.Hourly.PrecipitationProbability[i] 448 + if prob > maxProb { 449 + maxProb = prob 450 + peakHour = t.Hour() 451 + } 452 + } 453 + return peakHour 454 +} 455 + 456 +func dayExtremes(times []string, values []float64, day string, loc *time.Location) (float64, time.Time, float64, time.Time, bool) { 457 + if len(times) == 0 || len(values) == 0 { 458 + return 0, time.Time{}, 0, time.Time{}, false 459 + } 460 + minVal, maxVal := 0.0, 0.0 461 + var minTime, maxTime time.Time 462 + found := false 463 + for i, raw := range times { 464 + if i >= len(values) || !strings.HasPrefix(raw, day+"T") { 465 + continue 466 + } 467 + t, err := time.ParseInLocation(localLayout, raw, loc) 468 + if err != nil { 469 + continue 470 + } 471 + v := values[i] 472 + if !found { 473 + minVal, maxVal = v, v 474 + minTime, maxTime = t, t 475 + found = true 476 + continue 477 + } 478 + if v < minVal { 479 + minVal = v 480 + minTime = t 481 + } 482 + if v > maxVal { 483 + maxVal = v 484 + maxTime = t 485 + } 486 + } 487 + return minVal, minTime, maxVal, maxTime, found 488 +} 489 + 490 +func minMaxForDay(times []string, values []float64, day string) (float64, float64) { 491 + if len(times) == 0 || len(values) == 0 { 492 + return 0, 0 493 + } 494 + minV, maxV := 0.0, 0.0 495 + found := false 496 + for i, raw := range times { 497 + if i >= len(values) || !strings.HasPrefix(raw, day+"T") { 498 + continue 499 + } 500 + v := values[i] 501 + if !found { 502 + minV, maxV = v, v 503 + found = true 504 + continue 505 + } 506 + if v < minV { 507 + minV = v 508 + } 509 + if v > maxV { 510 + maxV = v 511 + } 512 + } 513 + if !found { 514 + return 0, 0 515 + } 516 + return minV, maxV 517 +} 518 + 519 +func timelineSummary(forecast forecastResponse, day string, loc *time.Location) []string { 520 + out := make([]string, 0, len(timelineHours)) 521 + 522 + type point struct { 523 + time time.Time 524 + temp float64 525 + code int 526 + valid bool 527 + } 528 + 529 + points := make([]point, 0, len(forecast.Hourly.Time)) 530 + for i, raw := range forecast.Hourly.Time { 531 + if i >= len(forecast.Hourly.Temperature) || !strings.HasPrefix(raw, day+"T") { 532 + continue 533 + } 534 + t, err := time.ParseInLocation(localLayout, raw, loc) 535 + if err != nil { 536 + continue 537 + } 538 + code := 0 539 + if i < len(forecast.Hourly.WeatherCode) { 540 + code = forecast.Hourly.WeatherCode[i] 541 + } 542 + points = append(points, point{ 543 + time: t, 544 + temp: forecast.Hourly.Temperature[i], 545 + code: code, 546 + valid: true, 547 + }) 548 + } 549 + if len(points) == 0 { 550 + return nil 551 + } 552 + 553 + for _, targetHour := range timelineHours { 554 + bestIdx := -1 555 + bestDelta := 24 556 + for i, p := range points { 557 + delta := p.time.Hour() - targetHour 558 + if delta < 0 { 559 + delta = -delta 560 + } 561 + if delta < bestDelta { 562 + bestDelta = delta 563 + bestIdx = i 564 + } 565 + } 566 + if bestIdx < 0 { 567 + continue 568 + } 569 + icon, text := weatherCodeLabel(points[bestIdx].code) 570 + out = append(out, fmt.Sprintf("%02d:00 %s %s %s", targetHour, formatSignedTemperature(points[bestIdx].temp), icon, text)) 571 + } 572 + return out 573 +} 574 + 575 +func weatherCodeLabel(code int) (string, string) { 576 + switch code { 577 + case 0: 578 + return "☀", "Clear sky" 579 + case 1: 580 + return "⛅", "Mostly clear" 581 + case 2: 582 + return "🌥", "Partly cloudy" 583 + case 3: 584 + return "☁", "Cloudy" 585 + case 45, 48: 586 + return "🌫", "Fog" 587 + case 51, 53, 55, 56, 57: 588 + return "🌦", "Drizzle" 589 + case 61, 63, 65, 66, 67: 590 + return "🌧", "Rain" 591 + case 71, 73, 75, 77: 592 + return "🌨", "Snow" 593 + case 80, 81, 82: 594 + return "🌧", "Rain showers" 595 + case 85, 86: 596 + return "🌨", "Snow showers" 597 + case 95, 96, 99: 598 + return "⛈", "Thunderstorm" 599 + default: 600 + return "🌤", "Variable clouds" 601 + } 602 +} 603 + 604 +func windDirectionFromDegrees(degrees float64) string { 605 + directions := []string{ 606 + "N", "NNE", "NE", "ENE", 607 + "E", "ESE", "SE", "SSE", 608 + "S", "SSW", "SW", "WSW", 609 + "W", "WNW", "NW", "NNW", 610 + } 611 + normalized := math.Mod(degrees, 360) 612 + if normalized < 0 { 613 + normalized += 360 614 + } 615 + idx := int(math.Round(normalized/22.5)) % len(directions) 616 + return directions[idx] 617 +} 618 + 619 +func rainAmount(precipitationSum, rainSum, showersSum []float64) string { 620 + precip := firstEl(precipitationSum) 621 + rain := firstEl(rainSum) 622 + showers := firstEl(showersSum) 623 + 624 + low := rain 625 + if low <= 0 { 626 + low = precip 627 + } 628 + high := precip 629 + if showers > 0 && rain > 0 { 630 + high = math.Max(high, rain+showers) 631 + } 632 + if high < low { 633 + high = low 634 + } 635 + if high <= 0 { 636 + return "0 mm" 637 + } 638 + 639 + lowMM := int(math.Round(low)) 640 + highMM := int(math.Round(high)) 641 + if lowMM == highMM { 642 + return fmt.Sprintf("%d mm", highMM) 643 + } 644 + return fmt.Sprintf("%d-%d mm", lowMM, highMM) 645 +} 646 + 647 +func formatAirQualityLine(air airQualityResponse) string { 648 + if air.Current.USAQI == nil && air.Current.PM2_5 == nil && air.Current.PM10 == nil { 649 + return "" 650 + } 651 + 652 + parts := make([]string, 0, 4) 653 + if air.Current.USAQI != nil { 654 + aqi := int(math.Round(*air.Current.USAQI)) 655 + parts = append(parts, fmt.Sprintf("AQI %d (%s)", aqi, usAQILevel(aqi))) 656 + } 657 + if air.Current.PM2_5 != nil { 658 + parts = append(parts, fmt.Sprintf("PM2.5 %.1f", *air.Current.PM2_5)) 659 + } 660 + if air.Current.PM10 != nil { 661 + parts = append(parts, fmt.Sprintf("PM10 %.1f", *air.Current.PM10)) 662 + } 663 + if air.Current.Ozone != nil { 664 + parts = append(parts, fmt.Sprintf("O3 %.1f", *air.Current.Ozone)) 665 + } 666 + if air.Current.NitrogenDioxide != nil { 667 + parts = append(parts, fmt.Sprintf("NO2 %.1f", *air.Current.NitrogenDioxide)) 668 + } 669 + return "🌫 Air: " + strings.Join(parts, ", ") 670 +} 671 + 672 +func usAQILevel(v int) string { 673 + switch { 674 + case v <= 50: 675 + return "Good" 676 + case v <= 100: 677 + return "Moderate" 678 + case v <= 150: 679 + return "Unhealthy for sensitive groups" 680 + case v <= 200: 681 + return "Unhealthy" 682 + case v <= 300: 683 + return "Very unhealthy" 684 + default: 685 + return "Hazardous" 686 + } 687 +} 688 + 689 +func weatherFeedID(lat, lon float64) string { 690 + return fmt.Sprintf("weather-lat-%s-lon-%s", normalizeCoord(lat), normalizeCoord(lon)) 691 +} 692 + 693 +func normalizeCoord(v float64) string { 694 + s := strconv.FormatFloat(v, 'f', 4, 64) 695 + s = strings.ReplaceAll(s, "-", "m") 696 + return strings.ReplaceAll(s, ".", "_") 697 +} 698 + 699 +func formatSignedTemperature(v float64) string { 700 + n := int(math.Round(v)) 701 + if n > 0 { 702 + return fmt.Sprintf("+%d°", n) 703 + } 704 + return fmt.Sprintf("%d°", n) 705 +} 706 + 707 +func formatWindLine(minWind, maxWind float64, direction string) string { 708 + minV := int(math.Round(minWind)) 709 + maxV := int(math.Round(maxWind)) 710 + if maxV < minV { 711 + minV, maxV = maxV, minV 712 + } 713 + if minV == maxV { 714 + return fmt.Sprintf("🌬 Wind: %d km/h from %s", minV, direction) 715 + } 716 + return fmt.Sprintf("🌬 Wind: %d-%d km/h from %s", minV, maxV, direction) 717 +} 718 + 719 +func firstEl[T comparable](values []T) T { 720 + if len(values) == 0 { 721 + var zero T 722 + return zero 723 + } 724 + return values[0] 725 +}
A
sources/weather/weather_test.go
··· 1 +package weather 2 + 3 +import ( 4 + "encoding/xml" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "olexsmir.xyz/rss-tools/app" 12 + "olexsmir.xyz/x/is" 13 +) 14 + 15 +const forecastFixture = `{ 16 + "timezone": "Europe/Kyiv", 17 + "current": { 18 + "time": "2026-05-05T07:30", 19 + "temperature_2m": 17.1, 20 + "apparent_temperature": 16.3 21 + }, 22 + "daily": { 23 + "time": ["2026-05-05"], 24 + "temperature_2m_min": [13.2], 25 + "temperature_2m_max": [22.4], 26 + "weather_code": [3], 27 + "precipitation_probability_max": [70], 28 + "precipitation_sum": [9.2], 29 + "rain_sum": [5.1], 30 + "showers_sum": [4.1], 31 + "wind_speed_10m_max": [25.0], 32 + "wind_direction_10m_dominant": [315] 33 + }, 34 + "hourly": { 35 + "time": [ 36 + "2026-05-05T00:00", 37 + "2026-05-05T04:00", 38 + "2026-05-05T08:00", 39 + "2026-05-05T12:00", 40 + "2026-05-05T16:00", 41 + "2026-05-05T20:00" 42 + ], 43 + "temperature_2m": [15.1, 13.0, 14.1, 18.2, 21.4, 16.3], 44 + "weather_code": [2, 2, 3, 2, 80, 2], 45 + "precipitation_probability": [10, 20, 25, 35, 70, 45], 46 + "wind_speed_10m": [12.0, 14.0, 16.0, 20.0, 25.0, 18.0], 47 + "relative_humidity_2m": [85, 88, 82, 70, 60, 72] 48 + } 49 +}` 50 + 51 +const airFixture = `{ 52 + "current": { 53 + "us_aqi": 42, 54 + "pm2_5": 8.1, 55 + "pm10": 12.4, 56 + "ozone": 55.3, 57 + "nitrogen_dioxide": 7.0 58 + } 59 +}` 60 + 61 +const geocodeFixture = `{ 62 + "display_name": "Kyiv, Kyiv Oblast, Ukraine", 63 + "address": { 64 + "city": "Kyiv", 65 + "state": "Kyiv Oblast", 66 + "country": "Ukraine" 67 + } 68 +}` 69 + 70 +func TestWeatherHandlerRendersMorningBriefing(t *testing.T) { 71 + forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 + w.Header().Set("Content-Type", "application/json") 73 + _, _ = w.Write([]byte(forecastFixture)) 74 + })) 75 + defer forecastSrv.Close() 76 + 77 + airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 + w.Header().Set("Content-Type", "application/json") 79 + _, _ = w.Write([]byte(airFixture)) 80 + })) 81 + defer airSrv.Close() 82 + 83 + geoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 + w.Header().Set("Content-Type", "application/json") 85 + _, _ = w.Write([]byte(geocodeFixture)) 86 + })) 87 + defer geoSrv.Close() 88 + 89 + w := &weather{ 90 + client: forecastSrv.Client(), 91 + forecastURL: forecastSrv.URL, 92 + airQualityURL: airSrv.URL, 93 + geocodingURL: geoSrv.URL, 94 + } 95 + 96 + mux := http.NewServeMux() 97 + mux.HandleFunc("GET /weather", w.handler) 98 + 99 + req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.4501&longitude=30.5234", nil) 100 + rr := httptest.NewRecorder() 101 + mux.ServeHTTP(rr, req) 102 + 103 + is.Equal(t, http.StatusOK, rr.Code) 104 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { 105 + t.Fatalf("expected atom feed, got %q", got) 106 + } 107 + 108 + var feed app.AtomFeed 109 + is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 110 + is.Equal(t, len(feed.Entries), 1) 111 + is.Equal(t, feed.Title, "Weather forecast for Kyiv") 112 + is.Equal(t, feed.Entries[0].Content.Type, "html") 113 + is.Equal(t, strings.HasPrefix(feed.Entries[0].Content.Value, "<pre>"), true) 114 + 115 + content := feed.Entries[0].Content.Value 116 + if !strings.Contains(content, "+13° / +22° | Cloudy with showers in the afternoon") { 117 + t.Fatalf("missing summary line in content:\n%s", content) 118 + } 119 + if !strings.Contains(content, "☂ 70% chance of rain (5-9 mm)") { 120 + t.Fatalf("missing rain line in content:\n%s", content) 121 + } 122 + if !strings.Contains(content, "🌬 Wind: 12-25 km/h from NW") { 123 + t.Fatalf("missing wind line in content:\n%s", content) 124 + } 125 + if !strings.Contains(content, "🌡 Now: +17° (feels like +16°)") { 126 + t.Fatalf("missing feels-like line in content:\n%s", content) 127 + } 128 + if !strings.Contains(content, "💧 Humidity: 60-88%") { 129 + t.Fatalf("missing humidity line in content:\n%s", content) 130 + } 131 + if !strings.Contains(content, "🌫 Air: AQI 42 (Good), PM2.5 8.1, PM10 12.4, O3 55.3, NO2 7.0") { 132 + t.Fatalf("missing air quality line in content:\n%s", content) 133 + } 134 + for _, line := range []string{ 135 + "08:00 +14° ☁ Cloudy", 136 + "12:00 +18° 🌥 Partly cloudy", 137 + "16:00 +21° 🌧 Rain showers", 138 + "20:00 +16° 🌥 Partly cloudy", 139 + } { 140 + if !strings.Contains(content, line) { 141 + t.Fatalf("missing timeline line %q in content:\n%s", line, content) 142 + } 143 + } 144 +} 145 + 146 +func TestWeatherHandlerBadCoordinates(t *testing.T) { 147 + w := &weather{ 148 + client: http.DefaultClient, 149 + forecastURL: "http://example.test/forecast", 150 + airQualityURL: "http://example.test/air", 151 + } 152 + 153 + mux := http.NewServeMux() 154 + mux.HandleFunc("GET /weather", w.handler) 155 + 156 + for _, route := range []string{ 157 + "/weather", 158 + "/weather?latitude=abc&longitude=30.5", 159 + "/weather?latitude=95&longitude=30.5", 160 + "/weather?latitude=50.5&longitude=200", 161 + } { 162 + req := httptest.NewRequest(http.MethodGet, route, nil) 163 + rr := httptest.NewRecorder() 164 + mux.ServeHTTP(rr, req) 165 + is.Equal(t, http.StatusBadRequest, rr.Code) 166 + } 167 +} 168 + 169 +func TestWeatherHandlerUpstreamFailure(t *testing.T) { 170 + forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 171 + http.Error(w, "boom", http.StatusBadGateway) 172 + })) 173 + defer forecastSrv.Close() 174 + 175 + airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 176 + w.Header().Set("Content-Type", "application/json") 177 + _, _ = w.Write([]byte(airFixture)) 178 + })) 179 + defer airSrv.Close() 180 + 181 + w := &weather{ 182 + client: forecastSrv.Client(), 183 + forecastURL: forecastSrv.URL, 184 + airQualityURL: airSrv.URL, 185 + } 186 + 187 + mux := http.NewServeMux() 188 + mux.HandleFunc("GET /weather", w.handler) 189 + 190 + req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.45&longitude=30.52", nil) 191 + rr := httptest.NewRecorder() 192 + mux.ServeHTTP(rr, req) 193 + 194 + is.Equal(t, http.StatusBadGateway, rr.Code) 195 +} 196 + 197 +func TestWindDirectionFromDegrees(t *testing.T) { 198 + is.Equal(t, windDirectionFromDegrees(0), "N") 199 + is.Equal(t, windDirectionFromDegrees(45), "NE") 200 + is.Equal(t, windDirectionFromDegrees(180), "S") 201 + is.Equal(t, windDirectionFromDegrees(315), "NW") 202 +} 203 + 204 +func TestTimelineSummaryUsesRequestedHours(t *testing.T) { 205 + forecast := forecastResponse{ 206 + Timezone: "Europe/Kyiv", 207 + } 208 + forecast.Hourly.Time = []string{ 209 + "2026-05-05T07:00", 210 + "2026-05-05T11:00", 211 + "2026-05-05T15:00", 212 + "2026-05-05T19:00", 213 + } 214 + forecast.Hourly.Temperature = []float64{14.2, 18.4, 21.0, 16.7} 215 + forecast.Hourly.WeatherCode = []int{3, 2, 80, 2} 216 + 217 + loc, err := timeLoadLocation("Europe/Kyiv") 218 + is.Err(t, err, nil) 219 + lines := timelineSummary(forecast, "2026-05-05", loc) 220 + 221 + is.Equal(t, len(lines), 4) 222 + is.Equal(t, strings.HasPrefix(lines[0], "08:00"), true) 223 + is.Equal(t, strings.HasPrefix(lines[1], "12:00"), true) 224 + is.Equal(t, strings.HasPrefix(lines[2], "16:00"), true) 225 + is.Equal(t, strings.HasPrefix(lines[3], "20:00"), true) 226 +} 227 + 228 +func timeLoadLocation(name string) (*time.Location, error) { 229 + return time.LoadLocation(name) 230 +}