1use crate::i18n::Translator;
12use jiff::civil::Date;
13use jiff::{Timestamp, Zoned};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Hour {
19 pub at: i64,
21 pub temperature: f32,
22 pub rain: Option<u8>,
24 pub code: u8,
26 pub day: bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct Day {
32 pub date: Date,
34 pub code: u8,
36 pub low: f32,
37 pub high: f32,
38 pub rain: Option<u8>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
45pub struct Forecast {
46 pub fetched: i64,
47 pub hours: Vec<Hour>,
48 #[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 #[serde(default)]
60 utc_offset_seconds: i64,
61}
62
63#[derive(Deserialize)]
64struct Daily {
65 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
88pub 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
98pub 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
120fn 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
140pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
165pub struct Slot {
166 pub label: String,
168 pub icon: String,
169 pub temperature: String,
171 pub rain: String,
173 pub words: String,
175}
176
177#[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
185fn 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
201fn 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
207fn 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
217pub 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 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 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
252pub const CARD_HOURS: usize = 4;
255pub const CARD_PARTS: usize = 4;
257
258pub 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
294pub struct DaySlot {
295 pub date: Date,
296 pub label: String,
298 pub icon: String,
300 pub high: String,
302 pub low: String,
304 pub rain: String,
306 pub words: String,
308}
309
310pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
325pub struct Place {
326 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
349pub 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
355pub 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 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 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 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 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 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 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 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 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 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 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 let older: Forecast = serde_json::from_str(r#"{"fetched": 1, "hours": []}"#).unwrap();
457 assert!(older.days.is_empty());
458 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 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}