all repos

rss-tools @ 63e22ef

get rss feed from sources that(i need and) dont provide one
3 files changed, 957 insertions(+), 0 deletions(-)
add weather source
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
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
        +}