all repos

rss-tools @ 71f9578bfe2969b6b22984c4264c8bf6c067e608

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/weather/weather.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
refactor atom feed builder, 14 days ago
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
	"olexsmir.xyz/rss-tools/app/atom"
18
)
19
20
const (
21
	forecastAPIURL   = "https://api.open-meteo.com/v1/forecast"
22
	airQualityAPIURL = "https://air-quality-api.open-meteo.com/v1/air-quality"
23
	geocodingAPIURL  = "https://nominatim.openstreetmap.org/reverse"
24
25
	clockLayout = "15:04"
26
	localLayout = "2006-01-02T15:04"
27
)
28
29
var timelineHours = []int{8, 12, 16, 20}
30
31
type weather struct {
32
	client        *http.Client
33
	forecastURL   string
34
	airQualityURL string
35
	geocodingURL  string
36
}
37
38
func Register(a *app.App) error {
39
	w := &weather{
40
		client:        a.Client,
41
		forecastURL:   forecastAPIURL,
42
		airQualityURL: airQualityAPIURL,
43
		geocodingURL:  geocodingAPIURL,
44
	}
45
	a.Route("GET /weather", w.handler)
46
	a.Logger.Info("weather source registered")
47
	return nil
48
}
49
50
func (w *weather) handler(rw http.ResponseWriter, r *http.Request) {
51
	lat, lon, err := parseCoordinates(r)
52
	if err != nil {
53
		http.Error(rw, "invalid latitude/longitude", http.StatusBadRequest)
54
		return
55
	}
56
57
	forecast, err := w.fetchForecast(r.Context(), lat, lon)
58
	if err != nil {
59
		http.Error(rw, "failed to fetch weather forecast", http.StatusBadGateway)
60
		return
61
	}
62
63
	air, err := w.fetchAirQuality(r.Context(), lat, lon)
64
	if err != nil {
65
		http.Error(rw, "failed to fetch air quality", http.StatusBadGateway)
66
		return
67
	}
68
69
	content, updated, err := buildWeatherBriefing(forecast, air)
70
	if err != nil {
71
		http.Error(rw, "failed to build weather briefing", http.StatusBadGateway)
72
		return
73
	}
74
75
	place := fmt.Sprintf("%.4f,%.4f", lat, lon)
76
	if town, err := w.fetchTownName(r.Context(), lat, lon); err == nil && town != "" {
77
		place = town
78
	}
79
80
	feedID := weatherFeedID(lat, lon)
81
	feed := atom.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID).
82
		WithUpdated(updated)
83
84
	feed.Add(&atom.Entry{
85
		Title:   "Weather briefing",
86
		ID:      fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")),
87
		Content: atom.NewText(formatBriefingXHTML(content), "xhtml"),
88
		Updated: atom.Time(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 formatBriefingXHTML(content string) string {
326
	blocks := strings.Split(content, "\n\n")
327
	overview := []string{}
328
	timeline := []string{}
329
	meta := []string{}
330
331
	if len(blocks) > 0 {
332
		overview = nonEmptyLines(blocks[0])
333
	}
334
	if len(blocks) > 1 {
335
		timeline = nonEmptyLines(blocks[1])
336
	}
337
	if len(blocks) > 2 {
338
		meta = nonEmptyLines(strings.Join(blocks[2:], "\n\n"))
339
	}
340
341
	var b strings.Builder
342
	b.WriteString("<article>")
343
344
	if len(overview) > 0 {
345
		b.WriteString("<section><h2>Overview</h2>")
346
		b.WriteString("<p>" + html.EscapeString(overview[0]) + "</p>")
347
		if len(overview) > 1 {
348
			b.WriteString("<ul>")
349
			for _, line := range overview[1:] {
350
				b.WriteString("<li>" + html.EscapeString(line) + "</li>")
351
			}
352
			b.WriteString("</ul>")
353
		}
354
		b.WriteString("</section>")
355
	}
356
357
	if len(timeline) > 0 {
358
		b.WriteString("<section><h2>Timeline</h2><ul>")
359
		for _, line := range timeline {
360
			timestamp, rest, ok := strings.Cut(strings.TrimSpace(line), "  ")
361
			if ok {
362
				b.WriteString("<li><strong>" + html.EscapeString(strings.TrimSpace(timestamp)) + "</strong> " + html.EscapeString(strings.TrimSpace(rest)) + "</li>")
363
				continue
364
			}
365
			b.WriteString("<li>" + html.EscapeString(line) + "</li>")
366
		}
367
		b.WriteString("</ul></section>")
368
	}
369
370
	if len(meta) > 0 {
371
		b.WriteString("<section><h2>Source</h2>")
372
		for _, line := range meta {
373
			b.WriteString("<p>" + html.EscapeString(line) + "</p>")
374
		}
375
		b.WriteString("</section>")
376
	}
377
378
	b.WriteString("</article>")
379
	return b.String()
380
}
381
382
func nonEmptyLines(block string) []string {
383
	lines := strings.Split(block, "\n")
384
	out := make([]string, 0, len(lines))
385
	for _, line := range lines {
386
		line = strings.TrimSpace(line)
387
		if line == "" {
388
			continue
389
		}
390
		out = append(out, line)
391
	}
392
	return out
393
}
394
395
func buildWeatherBriefing(forecast forecastResponse, air airQualityResponse) (string, time.Time, error) {
396
	loc := time.Local
397
	if tz := strings.TrimSpace(forecast.Timezone); tz != "" {
398
		zone, err := time.LoadLocation(tz)
399
		if err == nil {
400
			loc = zone
401
		}
402
	}
403
404
	day := firstEl(forecast.Daily.Time)
405
	if day == "" {
406
		return "", time.Time{}, errors.New("missing day")
407
	}
408
409
	updated := time.Now().In(loc)
410
	if parsed, err := time.ParseInLocation(localLayout, forecast.Current.Time, loc); err == nil {
411
		updated = parsed
412
	}
413
414
	minTemp := firstEl(forecast.Daily.TemperatureMin)
415
	maxTemp := firstEl(forecast.Daily.TemperatureMax)
416
	code := firstEl(forecast.Daily.WeatherCode)
417
	_, dayText := weatherCodeLabel(code)
418
419
	peakPrecipHour := peakPrecipitationHour(forecast, day, loc)
420
	if maxProbability := firstEl(forecast.Daily.PrecipitationProbability); maxProbability >= 50 {
421
		switch {
422
		case peakPrecipHour >= 12 && peakPrecipHour <= 18:
423
			dayText += " with showers in the afternoon"
424
		case peakPrecipHour >= 5 && peakPrecipHour < 12:
425
			dayText += " with rain in the morning"
426
		default:
427
			dayText += " with possible rain"
428
		}
429
	}
430
431
	rainLine := fmt.Sprintf(
432
		"ā˜‚ %d%% chance of rain (%s)",
433
		int(math.Round(firstEl(forecast.Daily.PrecipitationProbability))),
434
		rainAmount(forecast.Daily.PrecipitationSum, forecast.Daily.RainSum, forecast.Daily.ShowersSum),
435
	)
436
437
	windMin, windMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.WindSpeed, day)
438
	if windMin == 0 && windMax == 0 {
439
		windMax = firstEl(forecast.Daily.WindSpeedMax)
440
		windMin = windMax
441
	}
442
	windDirection := windDirectionFromDegrees(firstEl(forecast.Daily.WindDirectionDominant))
443
	windLine := formatWindLine(windMin, windMax, windDirection)
444
445
	lowTemp, lowTime, highTemp, highTime, ok := dayExtremes(forecast.Hourly.Time, forecast.Hourly.Temperature, day, loc)
446
	extremesLine := ""
447
	if ok {
448
		extremesLine = fmt.Sprintf(
449
			"šŸ“ˆ High: %s at %s  •  šŸ“‰ Low: %s at %s",
450
			formatSignedTemperature(highTemp),
451
			highTime.Format(clockLayout),
452
			formatSignedTemperature(lowTemp),
453
			lowTime.Format(clockLayout),
454
		)
455
	}
456
457
	nowLine := ""
458
	if forecast.Current.Temperature2M != nil && forecast.Current.ApparentTemperature != nil {
459
		nowLine = fmt.Sprintf(
460
			"🌔 Now: %s (feels like %s)",
461
			formatSignedTemperature(*forecast.Current.Temperature2M),
462
			formatSignedTemperature(*forecast.Current.ApparentTemperature),
463
		)
464
	}
465
466
	humidityMin, humidityMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.Humidity, day)
467
	humidityLine := ""
468
	if humidityMin != 0 || humidityMax != 0 {
469
		humidityLine = fmt.Sprintf("šŸ’§ Humidity: %d-%d%%", int(math.Round(humidityMin)), int(math.Round(humidityMax)))
470
	}
471
472
	airLine := formatAirQualityLine(air)
473
	timelineLines := timelineSummary(forecast, day, loc)
474
	if len(timelineLines) == 0 {
475
		return "", time.Time{}, errors.New("missing timeline data")
476
	}
477
478
	lines := []string{
479
		fmt.Sprintf("%s / %s  |  %s", formatSignedTemperature(minTemp), formatSignedTemperature(maxTemp), dayText),
480
		rainLine,
481
		windLine,
482
	}
483
	if extremesLine != "" {
484
		lines = append(lines, extremesLine)
485
	}
486
	if nowLine != "" {
487
		lines = append(lines, nowLine)
488
	}
489
	if humidityLine != "" {
490
		lines = append(lines, humidityLine)
491
	}
492
	if airLine != "" {
493
		lines = append(lines, airLine)
494
	}
495
	lines = append(lines, "")
496
	lines = append(lines, timelineLines...)
497
	lines = append(lines, "", "Data: Open-Meteo")
498
499
	return strings.Join(lines, "\n"), updated, nil
500
}
501
502
func peakPrecipitationHour(forecast forecastResponse, day string, loc *time.Location) int {
503
	peakHour := -1
504
	maxProb := -1.0
505
	for i, raw := range forecast.Hourly.Time {
506
		if i >= len(forecast.Hourly.PrecipitationProbability) || !strings.HasPrefix(raw, day+"T") {
507
			continue
508
		}
509
		t, err := time.ParseInLocation(localLayout, raw, loc)
510
		if err != nil {
511
			continue
512
		}
513
		prob := forecast.Hourly.PrecipitationProbability[i]
514
		if prob > maxProb {
515
			maxProb = prob
516
			peakHour = t.Hour()
517
		}
518
	}
519
	return peakHour
520
}
521
522
func dayExtremes(times []string, values []float64, day string, loc *time.Location) (float64, time.Time, float64, time.Time, bool) {
523
	if len(times) == 0 || len(values) == 0 {
524
		return 0, time.Time{}, 0, time.Time{}, false
525
	}
526
	minVal, maxVal := 0.0, 0.0
527
	var minTime, maxTime time.Time
528
	found := false
529
	for i, raw := range times {
530
		if i >= len(values) || !strings.HasPrefix(raw, day+"T") {
531
			continue
532
		}
533
		t, err := time.ParseInLocation(localLayout, raw, loc)
534
		if err != nil {
535
			continue
536
		}
537
		v := values[i]
538
		if !found {
539
			minVal, maxVal = v, v
540
			minTime, maxTime = t, t
541
			found = true
542
			continue
543
		}
544
		if v < minVal {
545
			minVal = v
546
			minTime = t
547
		}
548
		if v > maxVal {
549
			maxVal = v
550
			maxTime = t
551
		}
552
	}
553
	return minVal, minTime, maxVal, maxTime, found
554
}
555
556
func minMaxForDay(times []string, values []float64, day string) (float64, float64) {
557
	if len(times) == 0 || len(values) == 0 {
558
		return 0, 0
559
	}
560
	minV, maxV := 0.0, 0.0
561
	found := false
562
	for i, raw := range times {
563
		if i >= len(values) || !strings.HasPrefix(raw, day+"T") {
564
			continue
565
		}
566
		v := values[i]
567
		if !found {
568
			minV, maxV = v, v
569
			found = true
570
			continue
571
		}
572
		if v < minV {
573
			minV = v
574
		}
575
		if v > maxV {
576
			maxV = v
577
		}
578
	}
579
	if !found {
580
		return 0, 0
581
	}
582
	return minV, maxV
583
}
584
585
func timelineSummary(forecast forecastResponse, day string, loc *time.Location) []string {
586
	out := make([]string, 0, len(timelineHours))
587
588
	type point struct {
589
		time  time.Time
590
		temp  float64
591
		code  int
592
		valid bool
593
	}
594
595
	points := make([]point, 0, len(forecast.Hourly.Time))
596
	for i, raw := range forecast.Hourly.Time {
597
		if i >= len(forecast.Hourly.Temperature) || !strings.HasPrefix(raw, day+"T") {
598
			continue
599
		}
600
		t, err := time.ParseInLocation(localLayout, raw, loc)
601
		if err != nil {
602
			continue
603
		}
604
		code := 0
605
		if i < len(forecast.Hourly.WeatherCode) {
606
			code = forecast.Hourly.WeatherCode[i]
607
		}
608
		points = append(points, point{
609
			time:  t,
610
			temp:  forecast.Hourly.Temperature[i],
611
			code:  code,
612
			valid: true,
613
		})
614
	}
615
	if len(points) == 0 {
616
		return nil
617
	}
618
619
	for _, targetHour := range timelineHours {
620
		bestIdx := -1
621
		bestDelta := 24
622
		for i, p := range points {
623
			delta := p.time.Hour() - targetHour
624
			if delta < 0 {
625
				delta = -delta
626
			}
627
			if delta < bestDelta {
628
				bestDelta = delta
629
				bestIdx = i
630
			}
631
		}
632
		if bestIdx < 0 {
633
			continue
634
		}
635
		icon, text := weatherCodeLabel(points[bestIdx].code)
636
		out = append(out, fmt.Sprintf("%02d:00  %s  %s %s", targetHour, formatSignedTemperature(points[bestIdx].temp), icon, text))
637
	}
638
	return out
639
}
640
641
func weatherCodeLabel(code int) (string, string) {
642
	switch code {
643
	case 0:
644
		return "ā˜€", "Clear sky"
645
	case 1:
646
		return "ā›…", "Mostly clear"
647
	case 2:
648
		return "🌄", "Partly cloudy"
649
	case 3:
650
		return "☁", "Cloudy"
651
	case 45, 48:
652
		return "🌫", "Fog"
653
	case 51, 53, 55, 56, 57:
654
		return "🌦", "Drizzle"
655
	case 61, 63, 65, 66, 67:
656
		return "🌧", "Rain"
657
	case 71, 73, 75, 77:
658
		return "🌨", "Snow"
659
	case 80, 81, 82:
660
		return "🌧", "Rain showers"
661
	case 85, 86:
662
		return "🌨", "Snow showers"
663
	case 95, 96, 99:
664
		return "ā›ˆ", "Thunderstorm"
665
	default:
666
		return "🌤", "Variable clouds"
667
	}
668
}
669
670
func windDirectionFromDegrees(degrees float64) string {
671
	directions := []string{
672
		"N", "NNE", "NE", "ENE",
673
		"E", "ESE", "SE", "SSE",
674
		"S", "SSW", "SW", "WSW",
675
		"W", "WNW", "NW", "NNW",
676
	}
677
	normalized := math.Mod(degrees, 360)
678
	if normalized < 0 {
679
		normalized += 360
680
	}
681
	idx := int(math.Round(normalized/22.5)) % len(directions)
682
	return directions[idx]
683
}
684
685
func rainAmount(precipitationSum, rainSum, showersSum []float64) string {
686
	precip := firstEl(precipitationSum)
687
	rain := firstEl(rainSum)
688
	showers := firstEl(showersSum)
689
690
	low := rain
691
	if low <= 0 {
692
		low = precip
693
	}
694
	high := precip
695
	if showers > 0 && rain > 0 {
696
		high = math.Max(high, rain+showers)
697
	}
698
	if high < low {
699
		high = low
700
	}
701
	if high <= 0 {
702
		return "0 mm"
703
	}
704
705
	lowMM := int(math.Round(low))
706
	highMM := int(math.Round(high))
707
	if lowMM == highMM {
708
		return fmt.Sprintf("%d mm", highMM)
709
	}
710
	return fmt.Sprintf("%d-%d mm", lowMM, highMM)
711
}
712
713
func formatAirQualityLine(air airQualityResponse) string {
714
	if air.Current.USAQI == nil && air.Current.PM2_5 == nil && air.Current.PM10 == nil {
715
		return ""
716
	}
717
718
	parts := make([]string, 0, 4)
719
	if air.Current.USAQI != nil {
720
		aqi := int(math.Round(*air.Current.USAQI))
721
		parts = append(parts, fmt.Sprintf("AQI %d (%s)", aqi, usAQILevel(aqi)))
722
	}
723
	if air.Current.PM2_5 != nil {
724
		parts = append(parts, fmt.Sprintf("PM2.5 %.1f", *air.Current.PM2_5))
725
	}
726
	if air.Current.PM10 != nil {
727
		parts = append(parts, fmt.Sprintf("PM10 %.1f", *air.Current.PM10))
728
	}
729
	if air.Current.Ozone != nil {
730
		parts = append(parts, fmt.Sprintf("O3 %.1f", *air.Current.Ozone))
731
	}
732
	if air.Current.NitrogenDioxide != nil {
733
		parts = append(parts, fmt.Sprintf("NO2 %.1f", *air.Current.NitrogenDioxide))
734
	}
735
	return "🌫 Air: " + strings.Join(parts, ", ")
736
}
737
738
func usAQILevel(v int) string {
739
	switch {
740
	case v <= 50:
741
		return "Good"
742
	case v <= 100:
743
		return "Moderate"
744
	case v <= 150:
745
		return "Unhealthy for sensitive groups"
746
	case v <= 200:
747
		return "Unhealthy"
748
	case v <= 300:
749
		return "Very unhealthy"
750
	default:
751
		return "Hazardous"
752
	}
753
}
754
755
func weatherFeedID(lat, lon float64) string {
756
	return fmt.Sprintf("weather-lat-%s-lon-%s", normalizeCoord(lat), normalizeCoord(lon))
757
}
758
759
func normalizeCoord(v float64) string {
760
	s := strconv.FormatFloat(v, 'f', 4, 64)
761
	s = strings.ReplaceAll(s, "-", "m")
762
	return strings.ReplaceAll(s, ".", "_")
763
}
764
765
func formatSignedTemperature(v float64) string {
766
	n := int(math.Round(v))
767
	if n > 0 {
768
		return fmt.Sprintf("+%d°", n)
769
	}
770
	return fmt.Sprintf("%d°", n)
771
}
772
773
func formatWindLine(minWind, maxWind float64, direction string) string {
774
	minV := int(math.Round(minWind))
775
	maxV := int(math.Round(maxWind))
776
	if maxV < minV {
777
		minV, maxV = maxV, minV
778
	}
779
	if minV == maxV {
780
		return fmt.Sprintf("🌬 Wind: %d km/h from %s", minV, direction)
781
	}
782
	return fmt.Sprintf("🌬 Wind: %d-%d km/h from %s", minV, maxV, direction)
783
}
784
785
func firstEl[T comparable](values []T) T {
786
	if len(values) == 0 {
787
		var zero T
788
		return zero
789
	}
790
	return values[0]
791
}