use crate::alert::{self, Alert};
use crate::config::{Alerts, Site};
use crate::nightscout::{Client, Entry};
use crate::units::Units;
#[derive(Debug, Clone)]
pub struct SiteStatus {
pub site_id: String,
pub name: String,
pub latest: Option<Entry>,
pub delta: Option<f64>,
pub history: Vec<f64>,
pub alert: Alert,
pub error: Option<String>,
}
impl SiteStatus {
pub fn age_min(&self, now_ms: i64) -> Option<i64> {
self.latest
.as_ref()
.map(|e| ((now_ms - e.date) / 60_000).max(0))
}
pub fn value(&self, units: Units) -> String {
self.latest
.as_ref()
.map(|e| units.format(e.sgv))
.unwrap_or_else(|| "—".into())
}
pub fn arrow(&self) -> &'static str {
self.latest.as_ref().map_or("", |e| e.arrow())
}
pub fn delta_text(&self, units: Units) -> String {
self.delta
.map(|d| units.format_delta(d))
.unwrap_or_else(|| "--".into())
}
fn severity(&self) -> u8 {
self.alert.severity()
}
}
pub async fn poll(sites: &[(Site, Alerts)], now_ms: i64) -> Vec<SiteStatus> {
let mut tasks = tokio::task::JoinSet::new();
for (site, alerts) in sites {
let (site, alerts) = (site.clone(), alerts.clone());
tasks.spawn(async move { poll_one(&site, &alerts, now_ms).await });
}
let mut out = Vec::with_capacity(sites.len());
while let Some(res) = tasks.join_next().await {
if let Ok(status) = res {
out.push(status);
}
}
sort_worst_first(&mut out);
out
}
async fn poll_one(site: &Site, alerts: &Alerts, now_ms: i64) -> SiteStatus {
let site_id = site.stable_id();
let name = site.name.clone();
let client = match Client::for_site(site) {
Ok(c) => c,
Err(e) => return unreadable(site_id, name, e.to_string()),
};
match client.entries_range(now_ms - 3_600_000, now_ms, 24).await {
Ok(entries) => {
let latest = entries.first().cloned();
let delta = match (entries.first(), entries.get(1)) {
(Some(a), Some(b)) => Some(a.sgv - b.sgv),
_ => None,
};
let alert = match &latest {
Some(e) => alert::evaluate(e.sgv, now_ms - e.date, alerts),
None => Alert::Stale,
};
SiteStatus {
site_id,
name,
latest,
delta,
history: entries.iter().rev().map(|entry| entry.sgv).collect(),
alert,
error: None,
}
}
Err(e) => unreadable(site_id, name, e.to_string()),
}
}
fn unreadable(site_id: String, name: String, error: String) -> SiteStatus {
SiteStatus {
site_id,
name,
latest: None,
delta: None,
history: Vec::new(),
alert: Alert::Stale,
error: Some(error),
}
}
fn sort_worst_first(sites: &mut [SiteStatus]) {
sites.sort_by(|a, b| a.severity().cmp(&b.severity()).then(a.name.cmp(&b.name)));
}
pub fn worst(sites: &[SiteStatus]) -> Option<&SiteStatus> {
sites.iter().min_by_key(|s| s.severity())
}
pub fn demo(now_ms: i64, alerts: &[Alerts]) -> Vec<SiteStatus> {
let specs = [
("Alex", 58.0, "FortyFiveDown", 2),
("Sam", 112.0, "Flat", 1),
("River", 196.0, "SingleUp", 4),
];
let mut out: Vec<_> = specs
.into_iter()
.enumerate()
.map(|(idx, (name, value, direction, age))| {
let history: Vec<f64> = (0..12)
.map(|step| value + (step as f64 - 11.0) * (idx as f64 - 1.0) * 3.0)
.collect();
let latest = Entry {
sgv: value,
date: now_ms - age * 60_000,
direction: Some(direction.into()),
};
let profile = alerts.get(idx).unwrap_or(&alerts[0]);
SiteStatus {
site_id: name.into(),
name: name.into(),
latest: Some(latest),
delta: history
.last()
.zip(history.iter().rev().nth(1))
.map(|(a, b)| a - b),
history,
alert: alert::evaluate(value, age * 60_000, profile),
error: None,
}
})
.collect();
sort_worst_first(&mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(sgv: f64) -> Entry {
Entry {
sgv,
date: 0,
direction: Some("Flat".into()),
}
}
fn status(name: &str, alert: Alert) -> SiteStatus {
SiteStatus {
site_id: name.into(),
name: name.into(),
latest: Some(entry(100.0)),
delta: Some(2.0),
history: vec![90.0, 94.0, 100.0],
alert,
error: None,
}
}
#[test]
fn the_person_who_needs_attention_is_first() {
let mut sites = vec![
status("alice", Alert::InRange),
status("bob", Alert::High),
status("carol", Alert::UrgentLow),
];
sort_worst_first(&mut sites);
let order: Vec<&str> = sites.iter().map(|s| s.name.as_str()).collect();
assert_eq!(order, ["carol", "bob", "alice"]);
assert_eq!(worst(&sites).unwrap().name, "carol");
}
#[test]
fn a_site_that_cannot_be_read_ranks_with_the_urgent_ones() {
let mut sites = vec![
status("alice", Alert::InRange),
status("bob", Alert::Low),
unreadable(
"carol-id".into(),
"carol".into(),
"connection refused".into(),
),
];
sort_worst_first(&mut sites);
assert_eq!(sites[0].name, "carol");
assert_eq!(sites[0].alert, Alert::Stale);
assert!(sites[0].error.is_some());
assert_eq!(sites[0].value(Units::Mgdl), "—");
assert_eq!(sites[0].age_min(1_000).unwrap_or(-1), -1);
}
#[test]
fn equal_severity_keeps_a_stable_order() {
let mut sites = vec![
status("carol", Alert::InRange),
status("alice", Alert::InRange),
status("bob", Alert::InRange),
];
sort_worst_first(&mut sites);
let order: Vec<&str> = sites.iter().map(|s| s.name.as_str()).collect();
assert_eq!(order, ["alice", "bob", "carol"]);
}
#[test]
fn readings_render_in_display_units() {
let s = status("alice", Alert::InRange);
assert_eq!(s.value(Units::Mgdl), "100");
assert_eq!(s.value(Units::Mmol), "5.6");
assert_eq!(s.delta_text(Units::Mgdl), "+2");
assert_eq!(s.arrow(), "→");
assert_eq!(s.age_min(600_000), Some(10));
}
#[test]
fn nothing_to_follow_has_no_worst() {
assert!(worst(&[]).is_none());
}
}