10.6 KiB · text History 4f1f7f4
//! A query said in a few words.
//!
//! The raw reading goes on the bus too, for a widget that wants to draw it
//! properly. This is for everything else: a wallpaper, a tooltip, a line in
//! a panel -- somewhere that has room for a sentence and no business knowing
//! what a GBFS dock is.
//!
//! Shapes come from apollos-types, the source of truth for the wire. A type
//! it does not describe, or a reading that does not parse, says nothing
//! rather than something wrong.

use apollos_types::{
    AqiCondensed, CalendarCondensed, EphemerisCondensed, GbfsCondensed, GtfsCondensed,
    TidalCondensed, WeatherCondensed,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Point {
    pub text: String,
    /// `#RRGGBB`, where the thing has a colour of its own -- a transit line.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,
}

impl Point {
    fn plain(text: String) -> Point {
        Point { text, color: None }
    }
}

/// Departures beyond this many routes are a timetable, not a glance.
const MAX_ROUTES: usize = 6;

pub fn summarise(kind: &str, data: &Value) -> Vec<Point> {
    match kind {
        "gtfs" => parse(data).map(gtfs).unwrap_or_default(),
        "gbfs" => parse(data).map(gbfs).unwrap_or_default(),
        "weather" => parse(data).map(weather).unwrap_or_default(),
        "tidal" => parse(data).map(tidal).unwrap_or_default(),
        "aqi" => parse(data).map(aqi).unwrap_or_default(),
        "ephem" => parse(data).map(ephem).unwrap_or_default(),
        "calendar" => parse(data).map(calendar).unwrap_or_default(),
        _ => vec![],
    }
}

fn parse<T: DeserializeOwned>(data: &Value) -> Option<Vec<T>> {
    serde_json::from_value(data.clone()).ok()
}

/// `08:05:00` as `08:05`; GTFS's after-midnight `25:10:00` as `01:10`.
fn clock(t: &str) -> String {
    let mut parts = t.split(':');
    match (
        parts.next().and_then(|h| h.parse::<u32>().ok()),
        parts.next(),
    ) {
        (Some(h), Some(m)) => format!("{:02}:{m}", h % 24),
        _ => t.to_string(),
    }
}

/// Realtime where the feed gave it, the timetable where it did not --
/// paired by position, so one missing estimate does not shift the rest.
fn departures(r: &GtfsCondensed) -> Vec<String> {
    r.times
        .iter()
        .enumerate()
        .map(|(i, scheduled)| {
            r.times_live
                .as_ref()
                .and_then(|live| live.get(i).cloned().flatten())
                .unwrap_or_else(|| scheduled.clone())
        })
        .collect()
}

fn gtfs(mut routes: Vec<GtfsCondensed>) -> Vec<Point> {
    // Soonest first: the feed's order says nothing about which bus is next.
    // Sorted on the clock as sent, where 25:10 rightly follows 23:50.
    routes.sort_by_key(|r| {
        departures(r)
            .first()
            .cloned()
            .unwrap_or_else(|| "99".into())
    });
    routes
        .iter()
        .filter(|r| !r.times.is_empty())
        .take(MAX_ROUTES)
        .map(|r| {
            let times: Vec<String> = departures(r).iter().take(2).map(|t| clock(t)).collect();
            let name = r.route_name.as_deref().unwrap_or(&r.route);
            Point {
                text: format!("{name} {} · {}", r.dest, times.join(" ")),
                color: r.color.clone(),
            }
        })
        .collect()
}

fn gbfs(entries: Vec<GbfsCondensed>) -> Vec<Point> {
    let (loose, docks): (Vec<_>, Vec<_>) = entries
        .into_iter()
        .partition(|e| e.kind.as_deref() == Some("free_bike"));

    let mut points: Vec<Point> = docks
        .iter()
        .map(|d| {
            let bikes = if d.avail_elec > 0 {
                format!("{} bikes ({} e)", d.avail, d.avail_elec)
            } else {
                format!("{} bikes", d.avail)
            };
            Point::plain(format!("{}: {bikes} · {} docks", d.name, d.docks_avail))
        })
        .collect();
    if !loose.is_empty() {
        points.push(Point::plain(format!("{} free nearby", loose.len())));
    }
    points
}

fn weather(entries: Vec<WeatherCondensed>) -> Vec<Point> {
    entries
        .iter()
        .map(|w| {
            let unit = match w.units.as_str() {
                "metric" => "°C",
                "imperial" => "°F",
                _ => "°",
            };
            Point::plain(format!(
                "{}: {}{unit} {}",
                w.name,
                w.temp.round(),
                w.weather
            ))
        })
        .collect()
}

fn tidal(entries: Vec<TidalCondensed>) -> Vec<Point> {
    entries
        .iter()
        .filter_map(|t| {
            let mut parts = vec![];
            if let Some(h) = &t.first_h {
                parts.push(format!("High {h}"));
            }
            if let Some(l) = &t.first_l {
                parts.push(format!("Low {l}"));
            }
            (!parts.is_empty()).then(|| Point::plain(parts.join(" · ")))
        })
        .collect()
}

fn aqi(entries: Vec<AqiCondensed>) -> Vec<Point> {
    entries
        .iter()
        .filter_map(|a| {
            // Whatever the monitor measured; nothing is assumed present. Only
            // numbers: `combined` is the publisher's own rendering of these,
            // and a monitor with nothing to report sends nulls.
            let mut readings: Vec<(String, String)> = a
                .measurements
                .iter()
                .filter_map(|(k, v)| match v {
                    Value::Number(n) => Some((pollutant(k), n.to_string())),
                    _ => None,
                })
                .collect();
            if readings.is_empty() {
                return None;
            }
            readings.sort();
            let text: Vec<String> = readings.iter().map(|(k, v)| format!("{k} {v}")).collect();
            let text = text.join(" · ");
            Some(Point::plain(match &a.name {
                Some(n) => format!("{n}: {text}"),
                None => text,
            }))
        })
        .collect()
}

/// AirNow's parameter names as people write them.
fn pollutant(key: &str) -> String {
    match key {
        "pm25" => "PM2.5".into(),
        "pm10" => "PM10".into(),
        "ozone" => "O₃".into(),
        "no2" => "NO₂".into(),
        "so2" => "SO₂".into(),
        "co" => "CO".into(),
        other => other.to_uppercase(),
    }
}

fn ephem(entries: Vec<EphemerisCondensed>) -> Vec<Point> {
    entries
        .iter()
        .map(|e| {
            // Times in the order the day happens, then whatever is not a
            // time -- the moon's phase -- said in words.
            let is_time = |v: &str| v.len() >= 5 && v.as_bytes()[2] == b':';
            let mut times: Vec<(&String, &String)> =
                e.periods.iter().filter(|(_, v)| is_time(v)).collect();
            times.sort_by(|a, b| a.1.cmp(b.1));
            let mut others: Vec<(&String, &String)> =
                e.periods.iter().filter(|(_, v)| !is_time(v)).collect();
            others.sort();
            let text: Vec<String> = times
                .iter()
                .map(|(k, v)| format!("{k} {}", clock(v)))
                .chain(others.iter().map(|(_, v)| v.replace('_', " ")))
                .collect();
            Point::plain(format!("{}: {}", e.name, text.join(" · ")))
        })
        .collect()
}

fn calendar(mut events: Vec<CalendarCondensed>) -> Vec<Point> {
    events.sort_by(|a, b| a.date_start.cmp(&b.date_start));
    events
        .iter()
        .map(|e| Point::plain(format!("{} · {}", e.description, e.date_start)))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn transit_is_soonest_first_and_prefers_the_live_time() {
        let data = json!([
            {"route": "87", "dest": "Arlington", "dir": "In", "mode": "Bus",
             "times": ["08:30:00"]},
            {"route": "14", "route_name": "14", "dest": "Heath St", "dir": "In", "mode": "Bus",
             "times": ["08:00:00", "08:20:00"], "times_live": ["08:03:00", null],
             "color": "#FFC72C"}
        ]);
        let p = summarise("gtfs", &data);
        assert_eq!(p[0].text, "14 Heath St · 08:03 08:20");
        assert_eq!(p[0].color.as_deref(), Some("#FFC72C"));
        assert_eq!(p[1].text, "87 Arlington · 08:30");
    }

    #[test]
    fn after_midnight_reads_as_a_clock() {
        assert_eq!(clock("25:10:00"), "01:10");
    }

    #[test]
    fn a_dock_and_the_loose_bikes_around_it() {
        let data = json!([
            {"name": "Union Sq", "id": "1", "avail": 5, "avail_elec": 2, "docks_avail": 7,
             "ebikes_info": []},
            {"name": "bike", "id": "b1", "kind": "free_bike", "ebikes_info": []},
            {"name": "bike", "id": "b2", "kind": "free_bike", "ebikes_info": []}
        ]);
        let p: Vec<String> = summarise("gbfs", &data)
            .into_iter()
            .map(|p| p.text)
            .collect();
        assert_eq!(
            p,
            vec!["Union Sq: 5 bikes (2 e) · 7 docks", "2 free nearby"]
        );
    }

    #[test]
    fn weather_carries_its_units() {
        let data = json!([{"name": "Home", "weather": "light rain", "temp": 11.6, "feel": 10.0,
            "hum": 80.0, "pressure": 1012.0, "wind": {"speed": 3.0, "deg": 90.0}, "units": "metric"}]);
        assert_eq!(summarise("weather", &data)[0].text, "Home: 12°C light rain");
    }

    #[test]
    fn air_quality_is_its_numbers_not_the_publishers_string() {
        // Verbatim shapes from a Boston Metro query: one monitor per entry,
        // `combined` already rendered, and a monitor that reported nothing.
        let data = json!([
            {"combined": "O2: 27, PM2.5: 12, NO2: 3, ", "name": "Boston Metro",
             "no2": 3, "ozone": 27, "pm25": 12},
            {"combined": "PM2.5: , ", "name": null, "pm25": null}
        ]);
        let p: Vec<String> = summarise("aqi", &data)
            .into_iter()
            .map(|p| p.text)
            .collect();
        assert_eq!(p, vec!["Boston Metro: NO₂ 3 · O₃ 27 · PM2.5 12"]);
    }

    #[test]
    fn the_sky_in_the_order_it_happens() {
        let data = json!([{"name": "Here", "sunrise": "06:46:09.140", "moonset": "15:50:42",
            "sunset": "18:19:09.288", "moonrise": "01:14:35", "phase": "waning_crescent"}]);
        assert_eq!(
            summarise("ephem", &data)[0].text,
            "Here: moonrise 01:14 · sunrise 06:46 · moonset 15:50 · sunset 18:19 · waning crescent"
        );
    }

    #[test]
    fn what_does_not_parse_says_nothing() {
        assert!(summarise("gtfs", &json!({"not": "a list"})).is_empty());
        assert!(summarise("bourse", &json!([1, 2])).is_empty());
    }
}