Skip to main content

sioul_core/
weather.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The weather at a place you chose, for a small applet in the status line:
5//! now, the next four hours one by one, then mornings, afternoons, evenings
6//! and nights; and for the phone's card on the home screen (`card`, `days`):
7//! the hour under way, the next four, the next four parts of the day, then
8//! the next seven days. From Open-Meteo (no key; data CC BY 4.0, credited
9//! where shown: <https://open-meteo.com/en/licence>), in monochrome icons.
10
11use crate::i18n::Translator;
12use jiff::civil::Date;
13use jiff::{Timestamp, Zoned};
14use serde::{Deserialize, Serialize};
15
16/// One hour of forecast.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Hour {
19    /// Its start, Unix seconds.
20    pub at: i64,
21    pub temperature: f32,
22    /// The chance of rain, in percent; none when the model does not say.
23    pub rain: Option<u8>,
24    /// WMO weather code.
25    pub code: u8,
26    pub day: bool,
27}
28
29/// One day of forecast, at the place.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct Day {
32    /// The day, as the place counts its days.
33    pub date: Date,
34    /// WMO weather code, the day's most marked.
35    pub code: u8,
36    pub low: f32,
37    pub high: f32,
38    /// The highest chance of rain in the day, in percent; none when the model does not say.
39    pub rain: Option<u8>,
40}
41
42/// The forecast kept: the hours from now on, the days from the place's
43/// today on, and when it was fetched.
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
45pub struct Forecast {
46    pub fetched: i64,
47    pub hours: Vec<Hour>,
48    /// None in a forecast kept before the days were asked for.
49    #[serde(default)]
50    pub days: Vec<Day>,
51}
52
53#[derive(Deserialize)]
54struct Answer {
55    hourly: Hourly,
56    #[serde(default)]
57    daily: Option<Daily>,
58    /// The place's offset from UTC (its days start at its own midnight).
59    #[serde(default)]
60    utc_offset_seconds: i64,
61}
62
63#[derive(Deserialize)]
64struct Daily {
65    /// Each day's midnight at the place, Unix seconds.
66    time: Vec<i64>,
67    #[serde(default)]
68    weather_code: Vec<Option<u8>>,
69    #[serde(default)]
70    temperature_2m_max: Vec<Option<f32>>,
71    #[serde(default)]
72    temperature_2m_min: Vec<Option<f32>>,
73    #[serde(default)]
74    precipitation_probability_max: Vec<Option<u8>>,
75}
76
77#[derive(Deserialize)]
78struct Hourly {
79    time: Vec<i64>,
80    temperature_2m: Vec<Option<f32>>,
81    #[serde(default)]
82    precipitation_probability: Vec<Option<u8>>,
83    weather_code: Vec<Option<u8>>,
84    #[serde(default)]
85    is_day: Vec<Option<u8>>,
86}
87
88/// The query for a place, times as Unix seconds: hourly, three days ahead
89/// (the phone's card is written until the end of tomorrow, and each of its
90/// hours shows the parts of the day after it); daily, today and the eight
91/// days after (tomorrow's card still shows seven days to come).
92pub fn query(latitude: f64, longitude: f64) -> String {
93    format!(
94        "https://api.open-meteo.com/v1/forecast?latitude={latitude:.2}&longitude={longitude:.2}&hourly=temperature_2m,precipitation_probability,weather_code,is_day&forecast_hours=72&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=9&timeformat=unixtime&timezone=auto"
95    )
96}
97
98/// Reads Open-Meteo's answer.
99pub fn parse(json: &str, now: i64) -> Result<Forecast, String> {
100    let answer: Answer = serde_json::from_str(json).map_err(|e| e.to_string())?;
101    let days = answer.daily.map(|d| daily(&d, answer.utc_offset_seconds)).unwrap_or_default();
102    let h = answer.hourly;
103    let hours = h
104        .time
105        .iter()
106        .enumerate()
107        .filter_map(|(i, at)| {
108            Some(Hour {
109                at: *at,
110                temperature: (*h.temperature_2m.get(i)?)?,
111                rain: h.precipitation_probability.get(i).copied().flatten(),
112                code: (*h.weather_code.get(i)?)?,
113                day: h.is_day.get(i).copied().flatten().is_none_or(|d| d == 1),
114            })
115        })
116        .collect();
117    Ok(Forecast { fetched: now, hours, days })
118}
119
120/// The days of the answer, each dated as the place counts: its midnight
121/// read at the place's noon, so that a change of hour between now (the
122/// offset given) and that day does not move it to the day before.
123fn daily(d: &Daily, offset: i64) -> Vec<Day> {
124    d.time
125        .iter()
126        .enumerate()
127        .filter_map(|(i, at)| {
128            let noon = Timestamp::from_second(at.checked_add(offset)?.checked_add(12 * 3600)?).ok()?;
129            Some(Day {
130                date: noon.to_zoned(jiff::tz::TimeZone::UTC).date(),
131                code: (*d.weather_code.get(i)?)?,
132                low: (*d.temperature_2m_min.get(i)?)?,
133                high: (*d.temperature_2m_max.get(i)?)?,
134                rain: d.precipitation_probability_max.get(i).copied().flatten(),
135            })
136        })
137        .collect()
138}
139
140/// A WMO code as a word key and a Breeze icon, by day or night.
141pub fn describe(code: u8, day: bool) -> (&'static str, &'static str) {
142    let pick = |by_day: &'static str, by_night: &'static str| if day { by_day } else { by_night };
143    match code {
144        0 => ("clear", pick("weather-clear-symbolic", "weather-clear-night-symbolic")),
145        1 => ("mainly-clear", pick("weather-few-clouds-symbolic", "weather-few-clouds-night-symbolic")),
146        2 => ("partly-cloudy", pick("weather-clouds-symbolic", "weather-clouds-night-symbolic")),
147        3 => ("overcast", "weather-overcast-symbolic"),
148        45 | 48 => ("fog", "weather-fog-symbolic"),
149        51 | 53 | 55 => ("drizzle", pick("weather-showers-scattered-day-symbolic", "weather-showers-scattered-night-symbolic")),
150        56 | 57 => ("freezing-drizzle", "weather-freezing-scattered-rain-symbolic"),
151        61 | 63 => ("rain", pick("weather-showers-day-symbolic", "weather-showers-night-symbolic")),
152        65 => ("heavy-rain", "weather-showers-symbolic"),
153        66 | 67 => ("freezing-rain", "weather-freezing-rain-symbolic"),
154        71 | 73 | 75 | 77 => ("snow", pick("weather-snow-day-symbolic", "weather-snow-night-symbolic")),
155        80..=82 => ("showers", "weather-showers-scattered-symbolic"),
156        85 | 86 => ("snow-showers", "weather-snow-scattered-symbolic"),
157        95 => ("thunderstorm", pick("weather-storm-day-symbolic", "weather-storm-night-symbolic")),
158        96..=99 => ("hail", "weather-hail-symbolic"),
159        _ => ("unknown", "weather-none-available-symbolic"),
160    }
161}
162
163/// One line of the applet: an hour, or a part of a day.
164#[derive(Debug, Clone, PartialEq, Serialize)]
165pub struct Slot {
166    /// "15:00", "This evening", "Tomorrow morning".
167    pub label: String,
168    pub icon: String,
169    /// "14°", or "9° – 14°" over a part of a day.
170    pub temperature: String,
171    /// "40 %", or "" when rain is unlikely.
172    pub rain: String,
173    /// "Light rain", for screen readers and tips.
174    pub words: String,
175}
176
177/// What the applet shows.
178#[derive(Debug, Clone, PartialEq, Serialize)]
179pub struct WeatherView {
180    pub now: Option<Slot>,
181    pub hours: Vec<Slot>,
182    pub parts: Vec<Slot>,
183}
184
185/// Rain worth saying: a chance from 20 %.
186fn rain_words(chance: Option<u8>, tr: &Translator) -> String {
187    match chance {
188        Some(c) if c >= 20 => {
189            let mut args = crate::i18n::args();
190            args.set("chance", c);
191            tr.text("weather-rain-chance", Some(&args))
192        }
193        _ => String::new(),
194    }
195}
196
197fn degrees(t: f32) -> String {
198    format!("{}°", t.round() as i32)
199}
200
201/// An hour, as a line or a cell says it.
202fn hour_slot(label: String, h: &Hour, tr: &Translator) -> Slot {
203    let (word, icon) = describe(h.code, h.day);
204    Slot { label, icon: icon.into(), temperature: degrees(h.temperature), rain: rain_words(h.rain, tr), words: tr.text(&format!("weather-{word}"), None) }
205}
206
207/// A part of a day, from its hours: the most marked weather of it, by day
208/// if any hour of it is; its lowest and highest; its highest chance of rain.
209fn part_slot(label: String, hours: &[&Hour], tr: &Translator) -> Slot {
210    let worst = hours.iter().map(|h| h.code).max().unwrap_or(0);
211    let (word, icon) = describe(worst, hours.iter().any(|h| h.day));
212    let (low, high) = hours.iter().fold((f32::MAX, f32::MIN), |(lo, hi), h| (lo.min(h.temperature), hi.max(h.temperature)));
213    let temperature = if degrees(low) == degrees(high) { degrees(high) } else { format!("{} – {}", degrees(low), degrees(high)) };
214    Slot { label, icon: icon.into(), temperature, rain: rain_words(hours.iter().filter_map(|h| h.rain).max(), tr), words: tr.text(&format!("weather-{word}"), None) }
215}
216
217/// The applet's lines, from the forecast and the time now.
218pub fn view(forecast: &Forecast, now: &Zoned, tr: &Translator) -> WeatherView {
219    let zone = now.time_zone().clone();
220    let this_hour = now.timestamp().as_second() - now.timestamp().as_second().rem_euclid(3600);
221    let ahead: Vec<&Hour> = forecast.hours.iter().filter(|h| h.at >= this_hour).collect();
222    let slot = |label: String, h: &Hour| hour_slot(label, h, tr);
223    let local = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone())).ok();
224    let now_slot = ahead.first().map(|h| slot(tr.text("weather-now", None), h));
225    let hours: Vec<Slot> = ahead.iter().skip(1).take(4).filter_map(|h| Some(slot(local(h.at)?.strftime("%H:%M").to_string(), h))).collect();
226    // Then the parts of days: morning 6–12, afternoon 12–18, evening 18–24, night 0–6.
227    let mut parts: Vec<(String, Vec<&Hour>)> = Vec::new();
228    for h in ahead.iter().skip(5) {
229        let Some(at) = local(h.at) else { continue };
230        // Today, tomorrow, the day after: further is not shown.
231        let Ok(offset) = at.date().since(now.date()).map(|s| s.get_days()) else { continue };
232        let key = match (at.hour(), offset) {
233            (6..=11, 0) => "weather-this-morning",
234            (12..=17, 0) => "weather-this-afternoon",
235            (18..=23, 0) => "weather-this-evening",
236            (0..=5, 0 | 1) => "weather-tonight",
237            (6..=11, 1) => "weather-tomorrow-morning",
238            (12..=17, 1) => "weather-tomorrow-afternoon",
239            (18..=23, 1) => "weather-tomorrow-evening",
240            (0..=5, 2) => "weather-tomorrow-night",
241            _ => continue,
242        };
243        match parts.last_mut() {
244            Some((last, hours)) if last == key => hours.push(h),
245            _ => parts.push((key.to_string(), vec![h])),
246        }
247    }
248    let parts = parts.into_iter().filter(|(_, hours)| hours.len() >= 2).map(|(key, hours)| part_slot(tr.text(&key, None), &hours, tr)).collect();
249    WeatherView { now: now_slot, hours, parts }
250}
251
252/// The hours the card shows one by one after the hour under way, on the
253/// same row as it (the owner's choice, 8 October 2026: four, not two).
254pub const CARD_HOURS: usize = 4;
255/// The parts of the day the card shows after them.
256pub const CARD_PARTS: usize = 4;
257
258/// What the phone's card shows of the weather at `at` (docs/android.md,
259/// "The card on the home screen"): the hour under way ("Now"), the next four
260/// hours, then the next four parts of the day that hold two hours of the
261/// forecast at least, each the next of its kind, in order: morning (6–12),
262/// afternoon (12–18), evening (18–24), night (0–6), named shortly
263/// ("Afternoon", "Night"), since their order says which day they are. None
264/// when the forecast does not hold the hour under way.
265pub fn card(forecast: &Forecast, at: &Zoned, tr: &Translator) -> Option<WeatherView> {
266    let zone = at.time_zone().clone();
267    let stamp = at.timestamp().as_second();
268    // The forecast's hours start at the place's hours: a zone half an hour off keeps them.
269    let under_way = forecast.hours.iter().position(|h| h.at <= stamp && stamp < h.at + 3600)?;
270    let first = &forecast.hours[under_way];
271    let local = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone())).ok();
272    let rest: Vec<&Hour> = forecast.hours[under_way + 1..].iter().collect();
273    let hours = rest.iter().take(CARD_HOURS).filter_map(|h| Some(hour_slot(local(h.at)?.strftime("%H:%M").to_string(), h, tr))).collect();
274    let mut parts: Vec<((Date, &'static str), Vec<&Hour>)> = Vec::new();
275    for h in rest.iter().skip(CARD_HOURS) {
276        let Some(when) = local(h.at) else { continue };
277        let key = match when.hour() {
278            0..=5 => "weather-part-night",
279            6..=11 => "weather-part-morning",
280            12..=17 => "weather-part-afternoon",
281            _ => "weather-part-evening",
282        };
283        match parts.last_mut() {
284            Some(((day, last), hours)) if *day == when.date() && *last == key => hours.push(h),
285            _ => parts.push(((when.date(), key), vec![h])),
286        }
287    }
288    let parts = parts.into_iter().filter(|(_, hours)| hours.len() >= 2).take(CARD_PARTS).map(|((_, key), hours)| part_slot(tr.text(key, None), &hours, tr)).collect();
289    Some(WeatherView { now: Some(hour_slot(tr.text("weather-now", None), first, tr)), hours, parts })
290}
291
292/// A day of the forecast, as the card shows it among the days to come.
293#[derive(Debug, Clone, PartialEq, Serialize)]
294pub struct DaySlot {
295    pub date: Date,
296    /// "Fri", "ven.".
297    pub label: String,
298    /// The day's icon, by day.
299    pub icon: String,
300    /// "16°".
301    pub high: String,
302    /// "9°".
303    pub low: String,
304    /// "40 %", or "" when rain is unlikely.
305    pub rain: String,
306    /// "Light rain", for screen readers.
307    pub words: String,
308}
309
310/// The forecast's days, as the card shows them: each with its short name,
311/// its icon, its high and low, its chance of rain.
312pub fn days(forecast: &Forecast, tr: &Translator) -> Vec<DaySlot> {
313    forecast
314        .days
315        .iter()
316        .map(|d| {
317            let (word, icon) = describe(d.code, true);
318            DaySlot { date: d.date, label: tr.weekday_short(d.date), icon: icon.into(), high: degrees(d.high), low: degrees(d.low), rain: rain_words(d.rain, tr), words: tr.text(&format!("weather-{word}"), None) }
319        })
320        .collect()
321}
322
323/// A place found by name.
324#[derive(Debug, Clone, PartialEq, Serialize)]
325pub struct Place {
326    /// "Lyon, Auvergne-Rhône-Alpes, France".
327    pub name: String,
328    pub latitude: f64,
329    pub longitude: f64,
330}
331
332#[derive(Deserialize)]
333struct Found {
334    #[serde(default)]
335    results: Vec<FoundPlace>,
336}
337
338#[derive(Deserialize)]
339struct FoundPlace {
340    name: String,
341    latitude: f64,
342    longitude: f64,
343    #[serde(default)]
344    admin1: Option<String>,
345    #[serde(default)]
346    country: Option<String>,
347}
348
349/// The query to find a place by its name, in your language.
350pub fn place_query(name: &str, language: &str) -> String {
351    let encoded: String = name.trim().bytes().map(|b| if b.is_ascii_alphanumeric() || b == b'-' { (b as char).to_string() } else { format!("%{b:02X}") }).collect();
352    format!("https://geocoding-api.open-meteo.com/v1/search?name={encoded}&count=6&language={language}&format=json")
353}
354
355/// The places found, with their region and country so namesakes are told apart.
356pub fn parse_places(json: &str) -> Result<Vec<Place>, String> {
357    let found: Found = serde_json::from_str(json).map_err(|e| e.to_string())?;
358    Ok(found
359        .results
360        .into_iter()
361        .map(|p| Place { name: [Some(p.name), p.admin1, p.country].into_iter().flatten().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", "), latitude: p.latitude, longitude: p.longitude })
362        .collect())
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn hours_then_parts_of_days() {
371        // Saturday 3 October 2026, 13:20 in Paris; hourly from 13:00.
372        let now: Zoned = "2026-10-03T13:20[Europe/Paris]".parse().unwrap();
373        let start: Zoned = "2026-10-03T13:00[Europe/Paris]".parse().unwrap();
374        let first = start.timestamp().as_second();
375        let count = 36;
376        let time: Vec<i64> = (0..count).map(|i| first + i * 3600).collect();
377        let codes: Vec<u8> = (0..count).map(|i| if (5..10).contains(&i) { 61 } else { 2 }).collect();
378        let json = serde_json::json!({
379            "hourly": {
380                "time": time,
381                "temperature_2m": (0..count).map(|i| 10.0 + (i % 6) as f32).collect::<Vec<_>>(),
382                "precipitation_probability": (0..count).map(|i| if (5..10).contains(&i) { 70 } else { 5 }).collect::<Vec<_>>(),
383                "weather_code": codes,
384                "is_day": (0..count).map(|i| u8::from(i < 6)).collect::<Vec<_>>()
385            }
386        })
387        .to_string();
388        let forecast = parse(&json, now.timestamp().as_second()).unwrap();
389        let tr = Translator::new("en");
390        let shown = view(&forecast, &now, &tr);
391        assert_eq!(shown.now.as_ref().map(|s| s.icon.as_str()), Some("weather-clouds-symbolic"));
392        let hours: Vec<&str> = shown.hours.iter().map(|s| s.label.as_str()).collect();
393        assert_eq!(hours, vec!["14:00", "15:00", "16:00", "17:00"]);
394        let parts: Vec<(&str, &str)> = shown.parts.iter().map(|s| (s.label.as_str(), s.rain.as_str())).collect();
395        assert_eq!(parts[0], ("This evening", "70 %"), "rain from 18:00 makes the evening rainy");
396        assert_eq!(parts[1].0, "Tonight");
397        let labels: Vec<&str> = parts.iter().map(|p| p.0).collect();
398        assert_eq!(labels, vec!["This evening", "Tonight", "Tomorrow morning", "Tomorrow afternoon", "Tomorrow evening"], "a single hour of the next night is not a part");
399    }
400
401    /// Open-Meteo's answer for Geneva, asked on Thursday 8 October 2026 at
402    /// 09:29 with `query`'s parts: 72 hours from 09:00, nine days from the 8th.
403    const SAVED: &str = include_str!("../tests/fixtures/weather/open-meteo.json");
404
405    #[test]
406    fn the_card_reads_a_saved_answer() {
407        let at = |text: &str| text.parse::<Zoned>().unwrap();
408        let forecast = parse(SAVED, at("2026-10-08T09:29[Europe/Paris]").timestamp().as_second()).unwrap();
409        assert_eq!(forecast.hours.len(), 72);
410        // The days, as the place counts them, today first.
411        assert_eq!(forecast.days.len(), 9);
412        assert_eq!(forecast.days[0], Day { date: Date::constant(2026, 10, 8), code: 80, low: 13.3, high: 16.4, rain: Some(98) });
413        assert_eq!(forecast.days[8].date, Date::constant(2026, 10, 16));
414        let tr = Translator::new("en");
415        // 09:20: the hour under way, the next four, then the next afternoon, evening, night and morning.
416        let shown = card(&forecast, &at("2026-10-08T09:20[Europe/Paris]"), &tr).unwrap();
417        let now = shown.now.as_ref().unwrap();
418        assert_eq!((now.label.as_str(), now.icon.as_str(), now.temperature.as_str(), now.rain.as_str(), now.words.as_str()), ("Now", "weather-showers-day-symbolic", "16°", "98 %", "Rain"));
419        let hours: Vec<(&str, &str)> = shown.hours.iter().map(|s| (s.label.as_str(), s.temperature.as_str())).collect();
420        assert_eq!(hours, [("10:00", "16°"), ("11:00", "15°"), ("12:00", "15°"), ("13:00", "14°")]);
421        let parts: Vec<(&str, &str, &str, &str)> = shown.parts.iter().map(|s| (s.label.as_str(), s.icon.as_str(), s.temperature.as_str(), s.rain.as_str())).collect();
422        assert_eq!(
423            parts,
424            [
425                ("Afternoon", "weather-showers-day-symbolic", "14° – 15°", "98 %"),
426                ("Evening", "weather-overcast-symbolic", "13° – 14°", "65 %"),
427                ("Night", "weather-overcast-symbolic", "13° – 14°", ""),
428                ("Morning", "weather-clouds-symbolic", "13° – 14°", ""),
429            ]
430        );
431        // 16:10: the afternoon's last hour and the evening's first among the four; the evening's rest next, then Friday's afternoon.
432        let later = card(&forecast, &at("2026-10-08T16:10[Europe/Paris]"), &tr).unwrap();
433        assert_eq!(later.hours.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["17:00", "18:00", "19:00", "20:00"]);
434        assert_eq!(later.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Evening", "Night", "Morning", "Afternoon"]);
435        // At 11:00, two hours of the afternoon are left after the four shown (16:00, 17:00): a part of its own.
436        let edge = card(&forecast, &at("2026-10-08T11:00[Europe/Paris]"), &tr).unwrap();
437        assert_eq!(edge.parts[0].label, "Afternoon");
438        assert_eq!(edge.parts[0].temperature, "14° – 15°");
439        // Before the forecast's first hour, or past its last: nothing said.
440        assert!(card(&forecast, &at("2026-10-08T08:59[Europe/Paris]"), &tr).is_none());
441        assert!(card(&forecast, &at("2026-10-11T09:00[Europe/Paris]"), &tr).is_none());
442        // Near its end, fewer parts: never one of a single hour.
443        let end = card(&forecast, &at("2026-10-11T02:00[Europe/Paris]"), &tr).unwrap();
444        assert_eq!(end.hours.len(), 4);
445        assert_eq!(end.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Morning"]);
446        // The days: their short names, icons by day, high, low, rain from 20 %.
447        let days = days(&forecast, &tr);
448        let said: Vec<(&str, &str, &str, &str, &str)> = days.iter().take(3).map(|d| (d.label.as_str(), d.icon.as_str(), d.high.as_str(), d.low.as_str(), d.rain.as_str())).collect();
449        assert_eq!(said, [("Thu", "weather-showers-scattered-symbolic", "16°", "13°", "98 %"), ("Fri", "weather-showers-day-symbolic", "17°", "13°", ""), ("Sat", "weather-showers-day-symbolic", "16°", "12°", "40 %")]);
450        // In French.
451        let fr = Translator::new("fr");
452        let shown = card(&forecast, &at("2026-10-08T09:20[Europe/Paris]"), &fr).unwrap();
453        assert_eq!(shown.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Après-midi", "Soir", "Nuit", "Matin"]);
454        assert_eq!(super::days(&forecast, &fr)[1].label, "ven.");
455        // A forecast kept before the days were asked for still reads, without them.
456        let older: Forecast = serde_json::from_str(r#"{"fetched": 1, "hours": []}"#).unwrap();
457        assert!(older.days.is_empty());
458        // The query asks for both, enough of each.
459        let asked = query(46.2, 6.15);
460        assert!(asked.contains("&forecast_hours=72&") && asked.contains("&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=9&"));
461    }
462
463    #[test]
464    fn a_day_keeps_its_date_across_a_change_of_hour() {
465        // Asked in summer time (UTC+2): the day after the change to winter time
466        // starts at 23:00 UTC, the one after it at 00:00 UTC +1.
467        let d = Daily { time: vec![1_792_879_200, 1_792_969_200], weather_code: vec![Some(0), Some(3)], temperature_2m_max: vec![Some(10.0), Some(9.0)], temperature_2m_min: vec![Some(2.0), Some(1.0)], precipitation_probability_max: vec![None, Some(30)] };
468        let days = daily(&d, 7200);
469        assert_eq!(days.iter().map(|d| d.date).collect::<Vec<_>>(), [Date::constant(2026, 10, 25), Date::constant(2026, 10, 26)]);
470    }
471
472    #[test]
473    fn namesakes_told_apart() {
474        let json = r#"{"results":[{"name":"Paris","latitude":33.66,"longitude":-95.56,"country":"United States","admin1":"Texas"},{"name":"Paris","latitude":48.85,"longitude":2.35,"country":"France","admin1":"Île-de-France"}]}"#;
475        let places = parse_places(json).unwrap();
476        assert_eq!((places[0].name.as_str(), places[1].name.as_str()), ("Paris, Texas, United States", "Paris, Île-de-France, France"));
477        assert!(place_query("Saint-Dié", "fr").contains("name=Saint-Di%C3%A9&"));
478    }
479}