use chrono::{Local, TimeZone};
use chrono_tz::Tz;
use serde::Serialize;
use crate::config::{Alerts, Config, Site};
use crate::nightscout::{Client, Entry};
use crate::theme::Theme;
use crate::units::Units;
pub struct BuildInput {
pub now_ms: i64,
pub site: String,
pub can_write: bool,
pub units: Units,
pub alerts: Alerts,
pub theme: Theme,
pub timezone: Option<chrono_tz::Tz>,
pub recent: Vec<Entry>,
pub history: Vec<Entry>,
pub alert_records: Vec<crate::alertlog::Record>,
pub treatments: Vec<crate::nightscout::Treatment>,
pub stats_window_h: i64,
pub sensor_start_ms: Option<i64>,
pub sensor_days: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
pub schema: u8,
pub units: &'static str,
pub generated_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub site: String,
pub can_write: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub now: Option<Reading>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range: Option<Range>,
pub theme: ThemeDoc,
#[serde(skip_serializing_if = "Option::is_none")]
pub series: Option<Vec<(i64, f64)>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub treatments: Option<Vec<TreatmentDoc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<Stats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline: Option<Stats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub days: Option<Vec<DayDoc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insights: Option<Vec<InsightDoc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alerts: Option<Vec<AlertDoc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub night: Option<NightDoc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub band: Option<BandDoc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sensor: Option<SensorDoc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub forecast: Option<crate::predict::Outlook>,
#[serde(skip_serializing_if = "Option::is_none")]
pub overview: Option<OverviewDoc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agp: Option<AgpDoc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgpDoc {
pub days: usize,
pub step_min: i64,
pub points: Vec<(i64, f64, f64, f64, f64, f64)>,
pub samples: Vec<(i64, Vec<f64>)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Reading {
pub value: String,
pub arrow: String,
pub direction: String,
pub delta: String,
pub class: &'static str,
pub color: String,
pub age_min: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Range {
pub urgent_low: f64,
pub low: f64,
pub high: f64,
pub urgent_high: f64,
pub stale_minutes: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThemeDoc {
pub low: String,
pub in_range: String,
pub high: String,
pub urgent: String,
pub prediction: String,
pub graph: String,
}
impl ThemeDoc {
fn of(theme: &Theme) -> Self {
let hex = crate::theme::hex;
Self {
low: hex(theme.low),
in_range: hex(theme.in_range),
high: hex(theme.high),
urgent: hex(theme.urgent),
prediction: hex(theme.prediction),
graph: hex(theme.graph),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TreatmentDoc {
pub at_ms: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub carbs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insulin: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DayDoc {
pub date: String,
pub readings: usize,
pub tir: TirDoc,
}
#[derive(Debug, Clone, Serialize)]
pub struct AlertDoc {
pub at_ms: i64,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub minutes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<f64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub failed: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NightDoc {
pub from_ms: i64,
pub to_ms: i64,
pub in_progress: bool,
pub readings: usize,
pub tir: TirDoc,
#[serde(skip_serializing_if = "Option::is_none")]
pub lowest: Option<f64>,
pub series: Vec<(i64, f64)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Stats {
pub window_h: i64,
pub readings: usize,
pub mean: f64,
pub gmi: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cv: Option<f64>,
pub tir: TirDoc,
}
#[derive(Debug, Clone, Serialize)]
pub struct TirDoc {
pub very_low: f64,
pub low: f64,
pub in_range: f64,
pub high: f64,
pub very_high: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BandDoc {
pub days: usize,
pub points: Vec<(i64, f64, f64, f64)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OverviewDoc {
pub step_min: i64,
pub points: Vec<(i64, f64)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SensorDoc {
pub started_at: i64,
pub age_h: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in_h: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expired: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InsightDoc {
pub kind: &'static str,
pub window: String,
pub extreme: f64,
pub text: String,
}
fn scaled(units: Units, mgdl: f64) -> f64 {
let value = units.from_mgdl(mgdl);
match units {
Units::Mmol => (value * 10.0).round() / 10.0,
Units::Mgdl => value.round(),
}
}
fn round1(value: f64) -> f64 {
(value * 10.0).round() / 10.0
}
pub fn build(input: BuildInput) -> Snapshot {
let BuildInput {
now_ms,
site,
can_write,
units,
alerts,
theme,
timezone,
recent,
history,
alert_records,
treatments,
stats_window_h,
sensor_start_ms,
sensor_days,
} = input;
let now = recent.first().map(|latest| {
let state = crate::alert::evaluate(latest.sgv, now_ms - latest.date, &alerts);
let delta = recent
.get(1)
.map(|prev| units.format_delta(latest.sgv - prev.sgv))
.unwrap_or_else(|| "--".into());
Reading {
value: units.format(latest.sgv),
arrow: latest.arrow().to_string(),
direction: latest.direction.clone().unwrap_or_else(|| "?".into()),
delta,
class: state.class(),
color: crate::theme::hex(state.color(&theme)),
age_min: ((now_ms - latest.date) / 60_000).max(0),
}
});
let series: Vec<(i64, f64)> = recent
.iter()
.rev()
.map(|e| (e.date, scaled(units, e.sgv)))
.collect();
let stats_from = now_ms - stats_window_h * 3_600_000;
let source = if history.is_empty() {
&recent
} else {
&history
};
let stats_entries: Vec<Entry> = source
.iter()
.filter(|e| e.date >= stats_from)
.cloned()
.collect();
let stats = stats_for(&stats_entries, units, &alerts, stats_window_h);
let baseline = stats_for(&history, units, &alerts, 24 * 400);
let window_from = series.first().map(|(at, _)| *at).unwrap_or(i64::MIN);
let mut logged: Vec<TreatmentDoc> = treatments
.iter()
.filter(|t| t.at_ms >= window_from && t.at_ms <= now_ms)
.filter(|t| t.carbs.is_some_and(|c| c > 0.0) || t.insulin.is_some_and(|i| i > 0.0))
.map(|t| TreatmentDoc {
at_ms: t.at_ms,
carbs: t.carbs.filter(|c| *c > 0.0),
insulin: t.insulin.filter(|i| *i > 0.0),
})
.collect();
logged.sort_by_key(|t| t.at_ms);
let bands = crate::agp::profile_in(&history, timezone);
let insights = crate::agp::insights(&bands, alerts.low, alerts.high)
.iter()
.map(|insight| InsightDoc {
kind: match insight.kind {
crate::agp::Pattern::Lows => "lows",
crate::agp::Pattern::Highs => "highs",
},
window: insight.window(),
extreme: scaled(units, insight.extreme),
text: insight.text(units),
})
.collect();
let band = band_for(&history, &series, timezone, units);
let sensor = sensor_for(sensor_start_ms, sensor_days, now_ms);
let forecast = crate::predict::outlook(&recent, &alerts, units);
let overview = overview_for(&history, units);
let agp = agp_for(&bands, &crate::agp::samples_in(&history, timezone), units);
Snapshot {
schema: 1,
units: units.label(),
generated_at: now_ms,
error: None,
site,
can_write,
now,
range: Some(Range {
urgent_low: scaled(units, alerts.urgent_low),
low: scaled(units, alerts.low),
high: scaled(units, alerts.high),
urgent_high: scaled(units, alerts.urgent_high),
stale_minutes: alerts.stale_minutes,
}),
theme: ThemeDoc::of(&theme),
series: Some(series),
treatments: Some(logged),
stats,
baseline,
days: days_for(&history, timezone, &alerts),
insights: Some(insights),
alerts: Some(alerts_for(&alert_records, units, now_ms)),
night: night_for(&history, timezone, &alerts, units, now_ms),
band,
agp,
sensor,
forecast,
overview,
}
}
const OVERVIEW_STEP_MIN: i64 = 15;
fn overview_for(history: &[Entry], units: Units) -> Option<OverviewDoc> {
if history.is_empty() {
return None;
}
let step = OVERVIEW_STEP_MIN * 60_000;
let mut points: Vec<(i64, f64)> = Vec::new();
let mut last_bucket = i64::MIN;
for entry in history.iter().rev() {
let bucket = entry.date / step * step;
if bucket == last_bucket {
if let Some(point) = points.last_mut() {
point.1 = scaled(units, entry.sgv);
}
continue;
}
last_bucket = bucket;
points.push((bucket, scaled(units, entry.sgv)));
}
(!points.is_empty()).then_some(OverviewDoc {
step_min: OVERVIEW_STEP_MIN,
points,
})
}
fn sensor_for(started_at: Option<i64>, days: u32, now_ms: i64) -> Option<SensorDoc> {
let started_at = started_at?;
let age_h = ((now_ms - started_at) / 3_600_000).max(0);
if days == 0 {
return Some(SensorDoc {
started_at,
age_h,
expires_in_h: None,
expired: None,
});
}
let life_h = i64::from(days) * 24;
Some(SensorDoc {
started_at,
age_h,
expires_in_h: Some((life_h - age_h).max(0)),
expired: Some(age_h >= life_h),
})
}
const BAND_MIN_DAYS: usize = 3;
fn agp_for(
bands: &[crate::agp::Band],
samples: &[(i64, Vec<f64>)],
units: Units,
) -> Option<AgpDoc> {
let days = bands.iter().map(|b| b.days).max().unwrap_or(0);
if bands.is_empty() || days < BAND_MIN_DAYS {
return None;
}
Some(AgpDoc {
days,
step_min: crate::agp::BUCKET_MIN,
points: bands
.iter()
.map(|b| {
(
b.minute,
scaled(units, b.p05),
scaled(units, b.p25),
scaled(units, b.p50),
scaled(units, b.p75),
scaled(units, b.p95),
)
})
.collect(),
samples: samples
.iter()
.map(|(minute, values)| {
(
*minute,
values.iter().map(|value| scaled(units, *value)).collect(),
)
})
.collect(),
})
}
fn band_for(
history: &[Entry],
series: &[(i64, f64)],
timezone: Option<chrono_tz::Tz>,
units: Units,
) -> Option<BandDoc> {
let (first, last) = (series.first()?.0, series.last()?.0);
let bands = crate::agp::profile_in(history, timezone);
if bands.is_empty() {
return None;
}
let days = bands.iter().map(|b| b.days).max().unwrap_or(0);
if days < BAND_MIN_DAYS {
return None;
}
let step = crate::agp::BUCKET_MIN * 60_000;
let mut points = Vec::new();
let mut at = first - first.rem_euclid(step);
while at <= last {
if let Some(band) = bucket_at(&bands, at, timezone) {
points.push((
at,
scaled(units, band.p25),
scaled(units, band.p50),
scaled(units, band.p75),
));
}
at += step;
}
(!points.is_empty()).then_some(BandDoc { days, points })
}
fn bucket_at(
bands: &[crate::agp::Band],
at: i64,
timezone: Option<chrono_tz::Tz>,
) -> Option<&crate::agp::Band> {
use chrono::{TimeZone, Timelike};
let minute = match timezone {
Some(tz) => {
let local = tz.timestamp_millis_opt(at).single()?;
i64::from(local.hour() * 60 + local.minute())
}
None => {
let local = chrono::Local.timestamp_millis_opt(at).single()?;
i64::from(local.hour() * 60 + local.minute())
}
};
let index = minute / crate::agp::BUCKET_MIN;
bands
.iter()
.find(|b| b.minute / crate::agp::BUCKET_MIN == index)
}
fn night_for(
history: &[Entry],
timezone: Option<Tz>,
alerts: &Alerts,
units: Units,
now_ms: i64,
) -> Option<NightDoc> {
use chrono::{Duration, NaiveTime};
if history.is_empty() {
return None;
}
let local_now = match timezone {
Some(tz) => tz.timestamp_millis_opt(now_ms).single()?.naive_local(),
None => Local.timestamp_millis_opt(now_ms).single()?.naive_local(),
};
let night_start = NaiveTime::from_hms_opt(23, 0, 0)?;
let night_end = NaiveTime::from_hms_opt(7, 0, 0)?;
let start_date = if local_now.time() < night_end {
local_now.date() - Duration::days(1)
} else if local_now.time() >= night_start {
local_now.date()
} else {
local_now.date() - Duration::days(1)
};
let to_local = |naive: chrono::NaiveDateTime| -> Option<i64> {
match timezone {
Some(tz) => tz
.from_local_datetime(&naive)
.single()
.map(|dt| dt.timestamp_millis()),
None => Local
.from_local_datetime(&naive)
.single()
.map(|dt| dt.timestamp_millis()),
}
};
let from_ms = to_local(start_date.and_time(night_start))?;
let planned_end = to_local((start_date + Duration::days(1)).and_time(night_end))?;
let to_ms = planned_end.min(now_ms);
if to_ms <= from_ms {
return None;
}
let mut nightly: Vec<&Entry> = history
.iter()
.filter(|e| e.date >= from_ms && e.date <= to_ms)
.collect();
if nightly.is_empty() {
return None;
}
nightly.sort_by_key(|e| e.date);
let owned: Vec<Entry> = nightly.iter().map(|e| (*e).clone()).collect();
let tir = crate::stats::tir(
&owned,
alerts.urgent_low,
alerts.low,
alerts.high,
alerts.urgent_high,
)?;
Some(NightDoc {
from_ms,
to_ms,
in_progress: to_ms < planned_end,
readings: owned.len(),
tir: TirDoc {
very_low: round1(tir.very_low),
low: round1(tir.low),
in_range: round1(tir.in_range),
high: round1(tir.high),
very_high: round1(tir.very_high),
},
lowest: owned
.iter()
.map(|e| e.sgv)
.fold(None, |acc: Option<f64>, sgv| {
Some(acc.map_or(sgv, |low: f64| low.min(sgv)))
})
.map(|sgv| scaled(units, sgv)),
series: owned
.iter()
.map(|e| (e.date, scaled(units, e.sgv)))
.collect(),
})
}
fn alerts_for(records: &[crate::alertlog::Record], units: Units, now_ms: i64) -> Vec<AlertDoc> {
let mut out: Vec<AlertDoc> = crate::alertlog::episodes(records)
.into_iter()
.map(|episode| {
let ends = episode.end.unwrap_or(now_ms);
let failed: Vec<String> = records
.iter()
.filter(|r| r.event == "delivery" && r.ts >= episode.start && r.ts <= ends)
.filter(|r| r.outcome.as_deref().is_some_and(|o| o != "accepted"))
.filter_map(|r| r.channel.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
AlertDoc {
at_ms: episode.start,
state: episode.state.clone(),
minutes: episode.minutes(),
value: episode.sgv.map(|sgv| scaled(units, sgv)),
failed,
}
})
.collect();
out.sort_by_key(|episode| std::cmp::Reverse(episode.at_ms));
out
}
fn days_for(entries: &[Entry], timezone: Option<Tz>, alerts: &Alerts) -> Option<Vec<DayDoc>> {
if entries.is_empty() {
return None;
}
let mut by_date: std::collections::BTreeMap<String, Vec<Entry>> =
std::collections::BTreeMap::new();
for entry in entries {
let date = match timezone {
Some(tz) => tz
.timestamp_millis_opt(entry.date)
.single()
.map(|dt| dt.date_naive().to_string()),
None => Local
.timestamp_millis_opt(entry.date)
.single()
.map(|dt| dt.date_naive().to_string()),
};
if let Some(date) = date {
by_date.entry(date).or_default().push(entry.clone());
}
}
let days: Vec<DayDoc> = by_date
.into_iter()
.filter_map(|(date, entries)| {
let tir = crate::stats::tir(
&entries,
alerts.urgent_low,
alerts.low,
alerts.high,
alerts.urgent_high,
)?;
Some(DayDoc {
date,
readings: entries.len(),
tir: TirDoc {
very_low: round1(tir.very_low),
low: round1(tir.low),
in_range: round1(tir.in_range),
high: round1(tir.high),
very_high: round1(tir.very_high),
},
})
})
.collect();
(!days.is_empty()).then_some(days)
}
fn stats_for(entries: &[Entry], units: Units, alerts: &Alerts, window_h: i64) -> Option<Stats> {
if entries.len() < 2 {
return None;
}
let newest = entries.iter().map(|e| e.date).max()?;
let oldest = entries.iter().map(|e| e.date).min()?;
let covered_h = ((newest - oldest) as f64 / 3_600_000.0).ceil() as i64;
let window_h = covered_h.clamp(1, window_h);
let mean = crate::stats::mean_mgdl(entries)?;
let tir = crate::stats::tir(
entries,
alerts.urgent_low,
alerts.low,
alerts.high,
alerts.urgent_high,
)?;
Some(Stats {
window_h,
readings: entries.len(),
mean: scaled(units, mean),
gmi: round1(crate::stats::gmi(mean)),
cv: crate::stats::cv_pct(entries).map(round1),
tir: TirDoc {
very_low: round1(tir.very_low),
low: round1(tir.low),
in_range: round1(tir.in_range),
high: round1(tir.high),
very_high: round1(tir.very_high),
},
})
}
pub fn error_doc(now_ms: i64, theme: &Theme, message: &str) -> Snapshot {
Snapshot {
schema: 1,
units: Units::Mmol.label(),
generated_at: now_ms,
error: Some(message.to_string()),
site: String::new(),
can_write: false,
now: None,
range: None,
treatments: None,
theme: ThemeDoc::of(theme),
series: None,
stats: None,
baseline: None,
days: None,
insights: None,
alerts: None,
night: None,
band: None,
sensor: None,
forecast: None,
overview: None,
agp: None,
}
}
pub fn history_window(days: u32, now_ms: i64) -> Option<(i64, i64, usize)> {
if days == 0 {
return None;
}
let start = now_ms - i64::from(days) * 24 * 3_600_000;
let want = days as usize * 288 + 288;
Some((start, now_ms, want))
}
pub async fn fetch(cfg: &Config, site: &Site, hours: u32, days: u32) -> Snapshot {
let now_ms = chrono::Utc::now().timestamp_millis();
match collect(cfg, site, hours, days, now_ms).await {
Ok(snapshot) => snapshot,
Err(e) => error_doc(now_ms, &cfg.theme.resolve(), &e.to_string()),
}
}
async fn collect(
cfg: &Config,
site: &Site,
hours: u32,
days: u32,
now_ms: i64,
) -> anyhow::Result<Snapshot> {
let client = Client::for_site(site)?;
let (alerts, _warnings) = site.resolve_alerts(&cfg.alerts, cfg.units);
let hours = hours.max(1);
let recent_from = now_ms - i64::from(hours) * 3_600_000;
let recent = client
.entries_range(recent_from, now_ms, hours as usize * 12 + 12)
.await?;
let history = match history_window(days, now_ms) {
Some((start, end, want)) => client.entries_range(start, end, want).await?,
None => Vec::new(),
};
let sensor_start_ms = client.sensor_start().await.ok().flatten();
let treatments = client
.treatments(recent_from, now_ms)
.await
.unwrap_or_default();
let alert_records = crate::alertlog::read(now_ms - i64::from(days.max(1)) * 24 * 3_600_000);
Ok(build(BuildInput {
now_ms,
site: site.name.clone(),
can_write: site
.write_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty()),
units: cfg.units,
alerts,
theme: cfg.theme.resolve(),
timezone: site
.timezone
.as_deref()
.and_then(|name| name.parse::<chrono_tz::Tz>().ok()),
recent,
history,
alert_records,
treatments,
stats_window_h: 24,
sensor_start_ms,
sensor_days: cfg.sensor_days.min(30),
}))
}
pub fn demo(
units: Units,
alerts: Alerts,
theme: Theme,
hours: u32,
days: u32,
now_ms: i64,
) -> Snapshot {
let hours = hours.max(1);
let recent_from = now_ms - i64::from(hours) * 3_600_000;
let recent = crate::demo::entries(recent_from, now_ms);
let history = match history_window(days, now_ms) {
Some((start, end, _)) => crate::demo::entries(start, end),
None => Vec::new(),
};
build(BuildInput {
now_ms,
site: "demo".to_string(),
can_write: false,
units,
alerts,
theme,
timezone: None,
recent,
history,
alert_records: Vec::new(),
treatments: crate::demo::treatments(now_ms),
stats_window_h: 24,
sensor_start_ms: None,
sensor_days: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Alerts;
use crate::nightscout::Entry;
use crate::theme::Theme;
use crate::units::Units;
const MIN: i64 = 60_000;
fn entry(sgv: f64, date: i64, direction: &str) -> Entry {
Entry {
sgv,
date,
direction: Some(direction.into()),
}
}
fn alerts() -> Alerts {
Alerts {
urgent_low: 55.0,
low: 70.0,
high: 180.0,
urgent_high: 250.0,
..Alerts::default()
}
}
const NOW: i64 = 1_755_795_600_000;
fn input(recent: Vec<Entry>, history: Vec<Entry>) -> BuildInput {
BuildInput {
now_ms: NOW,
site: "Alex".to_string(),
can_write: true,
units: Units::Mmol,
alerts: alerts(),
theme: Theme::default(),
timezone: Some(chrono_tz::UTC),
recent,
history,
alert_records: Vec::new(),
treatments: Vec::new(),
stats_window_h: 24,
sensor_start_ms: None,
sensor_days: 0,
}
}
fn colorblind() -> Theme {
crate::theme::theme_from_names(&crate::theme::COLORBLIND_NAMES.map(|name| name.to_string()))
}
#[test]
fn the_document_carries_the_palette_it_was_built_with() {
let snap = build(BuildInput {
theme: colorblind(),
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let json = serde_json::to_value(&snap).unwrap();
let expect = colorblind();
assert_eq!(json["theme"]["low"], crate::theme::hex(expect.low));
assert_eq!(
json["theme"]["in_range"],
crate::theme::hex(expect.in_range)
);
assert_eq!(json["theme"]["high"], crate::theme::hex(expect.high));
assert_eq!(json["theme"]["urgent"], crate::theme::hex(expect.urgent));
assert_eq!(
json["theme"]["prediction"],
crate::theme::hex(expect.prediction)
);
assert_eq!(json["theme"]["graph"], crate::theme::hex(expect.graph));
assert_ne!(
json["theme"]["low"],
crate::theme::hex(Theme::default().low),
"the default palette reached the document instead of the configured one"
);
}
#[test]
fn even_a_failure_is_drawn_in_the_configured_palette() {
let json = serde_json::to_value(error_doc(NOW, &colorblind(), "site unreachable")).unwrap();
assert_eq!(json["error"], "site unreachable");
assert_eq!(
json["theme"]["urgent"],
crate::theme::hex(colorblind().urgent)
);
}
#[test]
fn the_document_names_the_site_it_describes() {
let snap = build(BuildInput {
site: "Alex's Libre".to_string(),
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["site"], "Alex's Libre");
}
#[test]
fn logged_carbs_and_insulin_reach_the_document_oldest_first() {
use crate::nightscout::Treatment;
let snap = build(BuildInput {
treatments: vec![
Treatment {
at_ms: NOW - 20 * MIN,
carbs: None,
insulin: Some(1.5),
},
Treatment {
at_ms: NOW - 50 * MIN,
carbs: Some(40.0),
insulin: Some(4.5),
},
],
..input(
vec![
entry(115.0, NOW - 4 * MIN, "Flat"),
entry(113.0, NOW - 60 * MIN, "Flat"),
],
vec![],
)
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["treatments"][0]["at_ms"], NOW - 50 * MIN);
assert_eq!(json["treatments"][0]["carbs"], 40.0);
assert_eq!(json["treatments"][0]["insulin"], 4.5);
assert_eq!(json["treatments"][1]["at_ms"], NOW - 20 * MIN);
assert_eq!(json["treatments"][1]["insulin"], 1.5);
assert!(json["treatments"][1].get("carbs").is_none());
}
#[test]
fn a_treatment_with_no_amount_is_not_a_marker() {
use crate::nightscout::Treatment;
let snap = build(BuildInput {
treatments: vec![
Treatment {
at_ms: NOW - 10 * MIN,
carbs: None,
insulin: None,
},
Treatment {
at_ms: NOW - 11 * MIN,
carbs: Some(0.0),
insulin: Some(0.0),
},
],
..input(
vec![
entry(115.0, NOW - 4 * MIN, "Flat"),
entry(113.0, NOW - 60 * MIN, "Flat"),
],
vec![],
)
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(
json["treatments"].as_array().map(Vec::len),
Some(0),
"an amountless treatment became a marker"
);
}
#[test]
fn a_treatment_from_before_the_window_is_left_out() {
use crate::nightscout::Treatment;
let snap = build(BuildInput {
treatments: vec![Treatment {
at_ms: NOW - 400 * MIN,
carbs: Some(30.0),
insulin: None,
}],
..input(
vec![
entry(115.0, NOW - 4 * MIN, "Flat"),
entry(113.0, NOW - 60 * MIN, "Flat"),
],
vec![],
)
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["treatments"].as_array().map(Vec::len), Some(0));
}
#[test]
fn the_document_says_whether_a_treatment_could_be_written() {
let yes = build(BuildInput {
can_write: true,
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let no = build(BuildInput {
can_write: false,
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
assert_eq!(serde_json::to_value(&yes).unwrap()["can_write"], true);
assert_eq!(serde_json::to_value(&no).unwrap()["can_write"], false);
let text = serde_json::to_string(&yes).unwrap();
assert!(!text.contains("token"), "the document mentions a token");
}
#[test]
fn the_baseline_covers_the_history_the_recent_window_is_a_slice_of() {
let mut history = Vec::new();
for step in 0..24 {
history.push(entry(200.0, NOW - (30 + step) * 60 * MIN, "Flat"));
}
for step in 0..12 {
history.push(entry(100.0, NOW - step * 60 * MIN, "Flat"));
}
let snap = build(input(
vec![history[24].clone(), history[25].clone()],
history,
));
let json = serde_json::to_value(&snap).unwrap();
let recent = json["stats"]["mean"].as_f64().unwrap();
let baseline = json["baseline"]["mean"].as_f64().unwrap();
assert!(
recent < baseline,
"today ({recent}) should read lower than the fortnight ({baseline})"
);
assert!(
json["baseline"]["window_h"].as_i64().unwrap()
> json["stats"]["window_h"].as_i64().unwrap(),
"the baseline covers no more ground than the recent window"
);
}
#[test]
fn each_local_day_gets_its_own_time_in_range() {
let day_ms = 24 * 60 * MIN;
let mut history = Vec::new();
for step in 0..30 {
history.push(entry(60.0, NOW - day_ms - step * 30 * MIN, "Flat"));
}
for step in 0..30 {
history.push(entry(110.0, NOW - step * 30 * MIN, "Flat"));
}
let snap = build(input(
vec![history[24].clone(), history[25].clone()],
history,
));
let json = serde_json::to_value(&snap).unwrap();
let days = json["days"].as_array().unwrap();
assert!(days.len() >= 2, "expected a day each, got {}", days.len());
let first = &days[0];
let last = &days[days.len() - 1];
assert!(
first["date"].as_str() < last["date"].as_str(),
"not oldest first"
);
let below =
first["tir"]["very_low"].as_f64().unwrap() + first["tir"]["low"].as_f64().unwrap();
assert!(below > 0.0, "the low day did not read as low, got {below}");
assert_eq!(last["tir"]["in_range"], 100.0);
assert!(last["readings"].as_u64().unwrap() >= 20);
}
#[test]
fn days_split_at_local_midnight_not_utc() {
let utc_evening = chrono::DateTime::parse_from_rfc3339("2026-08-21T22:30:00Z")
.unwrap()
.timestamp_millis();
let history = vec![
entry(110.0, utc_evening, "Flat"),
entry(112.0, utc_evening + 60 * MIN, "Flat"),
];
let snap = build(BuildInput {
timezone: Some("Pacific/Auckland".parse().unwrap()),
..input(history.clone(), history)
});
let json = serde_json::to_value(&snap).unwrap();
let days = json["days"].as_array().unwrap();
assert_eq!(days.len(), 1, "one local day, not two");
assert_eq!(days[0]["date"], "2026-08-22", "grouped in UTC, not locally");
}
#[test]
fn the_stats_say_how_many_readings_they_stand_on() {
let sparse: Vec<Entry> = (0..6)
.map(|step| entry(110.0, NOW - step * 60 * MIN, "Flat"))
.collect();
let snap = build(input(sparse.clone(), sparse));
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["stats"]["readings"], 6);
assert_eq!(json["stats"]["window_h"], 5);
}
fn alert_record(ts: i64, event: &str, state: &str) -> crate::alertlog::Record {
crate::alertlog::Record {
ts,
site: "Alex".into(),
site_id: None,
event: event.into(),
state: state.into(),
sgv: Some(58.0),
channel: None,
outcome: None,
}
}
#[test]
fn an_alarm_carries_the_delivery_that_failed_during_it() {
let start = NOW - 60 * MIN;
let mut push = alert_record(start + 10 * MIN, "delivery", "URGENT LOW");
push.channel = Some("push".into());
push.outcome = Some("failed".into());
let mut desktop = alert_record(start + 11 * MIN, "delivery", "URGENT LOW");
desktop.channel = Some("desktop".into());
desktop.outcome = Some("accepted".into());
let records = vec![
alert_record(start, "alert", "URGENT LOW"),
push,
desktop,
alert_record(start + 20 * MIN, "recovered", "URGENT LOW"),
];
let snap = build(BuildInput {
alert_records: records,
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["alerts"][0]["state"], "URGENT LOW");
assert_eq!(json["alerts"][0]["minutes"], 20);
assert_eq!(json["alerts"][0]["failed"][0], "push");
assert_eq!(
json["alerts"][0]["failed"].as_array().map(Vec::len),
Some(1)
);
}
#[test]
fn alarms_are_newest_first() {
let records = vec![
alert_record(NOW - 300 * MIN, "alert", "LOW"),
alert_record(NOW - 290 * MIN, "recovered", "LOW"),
alert_record(NOW - 60 * MIN, "alert", "HIGH"),
alert_record(NOW - 50 * MIN, "recovered", "HIGH"),
];
let snap = build(BuildInput {
alert_records: records,
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["alerts"][0]["state"], "HIGH");
assert_eq!(json["alerts"][1]["state"], "LOW");
}
#[test]
fn the_night_runs_from_last_evening_to_this_morning() {
let history: Vec<Entry> = (0..48)
.map(|step| entry(110.0, NOW - step * 30 * MIN, "Flat"))
.collect();
let snap = build(BuildInput {
timezone: Some(chrono_tz::UTC),
..input(vec![history[0].clone(), history[1].clone()], history)
});
let json = serde_json::to_value(&snap).unwrap();
let night = &json["night"];
let from = night["from_ms"].as_i64().unwrap();
let to = night["to_ms"].as_i64().unwrap();
let hour = |ms: i64| {
chrono::DateTime::from_timestamp_millis(ms)
.unwrap()
.format("%H:%M")
.to_string()
};
assert_eq!(hour(from), "23:00", "the night starts at 23:00 local");
assert_eq!(hour(to), "07:00", "and ends at 07:00 local");
assert_eq!(night["in_progress"], false, "that night has ended");
assert!(night["readings"].as_u64().unwrap() > 0);
}
#[test]
fn a_night_still_running_says_so_and_stops_at_now() {
let three_am = NOW - 14 * 60 * MIN; let history: Vec<Entry> = (0..24)
.map(|step| entry(110.0, three_am - step * 15 * MIN, "Flat"))
.collect();
let snap = build(BuildInput {
now_ms: three_am,
timezone: Some(chrono_tz::UTC),
..input(vec![history[0].clone(), history[1].clone()], history)
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["night"]["in_progress"], true);
assert_eq!(
json["night"]["to_ms"].as_i64().unwrap(),
three_am,
"an unfinished night ends now, not at 07:00"
);
}
#[test]
fn the_range_carries_the_threshold_a_reading_goes_stale_at() {
let snap = build(BuildInput {
alerts: Alerts {
stale_minutes: 9,
..alerts()
},
..input(vec![entry(115.0, NOW - 4 * MIN, "Flat")], vec![])
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["range"]["stale_minutes"], 9);
}
#[test]
fn a_normal_document_carries_schema_units_and_the_current_reading() {
let snap = build(input(
vec![
entry(115.0, NOW - 4 * MIN, "Flat"),
entry(113.0, NOW - 9 * MIN, "Flat"),
],
vec![],
));
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["schema"], 1);
assert_eq!(json["units"], "mmol/L");
assert_eq!(json["generated_at"], NOW);
assert_eq!(json["now"]["value"], "6.4");
assert_eq!(json["now"]["arrow"], "→");
assert_eq!(json["now"]["direction"], "Flat");
assert_eq!(json["now"]["class"], "in-range");
assert_eq!(json["now"]["age_min"], 4);
assert_eq!(json["now"]["color"].as_str().unwrap().len(), 7);
assert!(json["now"]["color"].as_str().unwrap().starts_with('#'));
assert!(json.get("error").is_none());
}
#[test]
fn every_value_is_in_the_display_unit() {
let mmol = build(input(vec![entry(115.0, NOW - MIN, "Flat")], vec![]));
let mmol_json = serde_json::to_value(&mmol).unwrap();
assert_eq!(mmol_json["series"][0][1], 6.4);
assert_eq!(mmol_json["range"]["low"], 3.9);
assert_eq!(mmol_json["range"]["high"], 10.0);
let mut mgdl_input = input(vec![entry(115.0, NOW - MIN, "Flat")], vec![]);
mgdl_input.units = Units::Mgdl;
let mgdl_json = serde_json::to_value(build(mgdl_input)).unwrap();
assert_eq!(mgdl_json["units"], "mg/dL");
assert_eq!(mgdl_json["series"][0][1], 115.0);
assert_eq!(mgdl_json["range"]["low"], 70.0);
assert_eq!(mgdl_json["range"]["high"], 180.0);
}
#[test]
fn no_readings_gives_empty_collections_rather_than_a_failure() {
let json = serde_json::to_value(build(input(vec![], vec![]))).unwrap();
assert_eq!(json["schema"], 1);
assert_eq!(json["series"].as_array().unwrap().len(), 0);
assert_eq!(json["insights"].as_array().unwrap().len(), 0);
assert!(json.get("stats").is_none());
assert!(json.get("now").is_none());
assert!(json.get("error").is_none());
}
#[test]
fn an_error_is_still_a_document() {
let json =
serde_json::to_value(error_doc(NOW, &Theme::default(), "no site configured")).unwrap();
assert_eq!(json["schema"], 1);
assert_eq!(json["generated_at"], NOW);
assert_eq!(json["error"], "no site configured");
assert!(json.get("now").is_none());
assert!(json.get("series").is_none());
assert!(json.get("stats").is_none());
assert!(json.get("insights").is_none());
}
fn nightly_lows() -> Vec<Entry> {
let day = 24 * 3_600_000i64;
let mut out = Vec::new();
for d in 1..=4i64 {
let start = NOW - d * day;
let midnight = start - start.rem_euclid(day);
for minute in (0..24 * 60).step_by(5) {
let at = midnight + minute as i64 * 60_000;
let sgv = if (120..=180).contains(&minute) {
60.0
} else {
110.0
};
out.push(entry(sgv, at, "Flat"));
}
}
out.reverse(); out
}
#[test]
fn a_pattern_becomes_an_insight_row() {
let mut with_history = input(vec![], nightly_lows());
with_history.timezone = Some(chrono_tz::UTC);
let json = serde_json::to_value(build(with_history)).unwrap();
let first = &json["insights"][0];
assert_eq!(first["kind"], "lows");
assert_eq!(first["window"], "02:00–03:15");
assert_eq!(first["extreme"], 3.3);
assert!(first["text"]
.as_str()
.unwrap()
.starts_with("lows 02:00–03:15"));
}
#[test]
fn asking_for_no_history_means_no_history_query() {
assert!(history_window(0, NOW).is_none());
let (start, end, want) = history_window(14, NOW).unwrap();
assert_eq!(end, NOW);
assert_eq!(start, NOW - 14 * 24 * 3_600_000);
assert_eq!(want, 14 * 288 + 288);
}
#[test]
fn stats_are_labelled_with_the_window_they_actually_cover() {
let three_hours: Vec<Entry> = (0..36)
.map(|i| entry(110.0, NOW - i * 5 * MIN, "Flat"))
.collect();
let json = serde_json::to_value(build(input(three_hours, vec![]))).unwrap();
assert_eq!(json["stats"]["window_h"], 3);
let full_day: Vec<Entry> = (0..288)
.map(|i| entry(110.0, NOW - i * 5 * MIN, "Flat"))
.collect();
let json = serde_json::to_value(build(input(vec![], full_day))).unwrap();
assert_eq!(json["stats"]["window_h"], 24);
}
#[test]
fn the_demo_document_has_the_same_shape_as_a_real_one() {
let json = serde_json::to_value(demo(Units::Mmol, alerts(), Theme::default(), 6, 14, NOW))
.unwrap();
assert_eq!(json["schema"], 1);
assert!(json.get("error").is_none());
assert!(json["now"]["value"].is_string());
assert!(json["series"].as_array().unwrap().len() > 50);
assert!(json["stats"]["tir"]["in_range"].is_number());
}
#[test]
fn the_band_describes_a_typical_day_across_the_chart_window() {
let mut with_history = input(
vec![
entry(115.0, NOW - 5 * MIN, "Flat"),
entry(113.0, NOW - 65 * MIN, "Flat"),
],
nightly_lows(),
);
with_history.timezone = Some(chrono_tz::UTC);
let json = serde_json::to_value(build(with_history)).unwrap();
assert_eq!(json["band"]["days"], 4);
let points = json["band"]["points"].as_array().unwrap();
assert!(points.len() >= 4, "got {} points", points.len());
let first = points[0].as_array().unwrap();
assert_eq!(first.len(), 4, "each point is [ts, p25, p50, p75]");
let p50 = first[2].as_f64().unwrap();
assert!((2.0..=12.0).contains(&p50), "p50 {p50} is not mmol/L");
assert!(first[1].as_f64().unwrap() <= p50);
assert!(p50 <= first[3].as_f64().unwrap());
assert!(
points[0].as_array().unwrap()[0].as_i64().unwrap()
< points[1].as_array().unwrap()[0].as_i64().unwrap()
);
}
#[test]
fn one_days_history_is_not_a_typical_day() {
let day = 24 * 3_600_000i64;
let mut one_day: Vec<Entry> = (0..288)
.map(|i| entry(110.0, NOW - day - i * 5 * MIN, "Flat"))
.collect();
one_day.reverse();
let mut short = input(vec![entry(115.0, NOW - 5 * MIN, "Flat")], one_day);
short.timezone = Some(chrono_tz::UTC);
let json = serde_json::to_value(build(short)).unwrap();
assert!(json.get("band").is_none());
}
#[test]
fn the_profile_is_a_whole_day_whatever_the_chart_shows() {
let mut with_history = input(
vec![
entry(115.0, NOW - 5 * MIN, "Flat"),
entry(113.0, NOW - 65 * MIN, "Flat"),
],
nightly_lows(),
);
with_history.timezone = Some(chrono_tz::UTC);
let json = serde_json::to_value(build(with_history)).unwrap();
assert_eq!(json["agp"]["days"], 4);
assert_eq!(json["agp"]["step_min"], crate::agp::BUCKET_MIN);
let points = json["agp"]["points"].as_array().unwrap();
assert_eq!(points.len(), (24 * 60 / crate::agp::BUCKET_MIN) as usize);
let first = points[0].as_array().unwrap();
assert_eq!(first.len(), 6, "[minute, p05, p25, p50, p75, p95]");
assert_eq!(first[0].as_i64().unwrap(), crate::agp::BUCKET_MIN / 2);
let last = points[points.len() - 1].as_array().unwrap();
assert!(last[0].as_i64().unwrap() < 1440);
let p50 = first[3].as_f64().unwrap();
assert!((2.0..=12.0).contains(&p50), "p50 {p50} is not mmol/L");
assert!(first[1].as_f64().unwrap() <= first[2].as_f64().unwrap());
assert!(first[2].as_f64().unwrap() <= p50);
assert!(p50 <= first[4].as_f64().unwrap());
assert!(first[4].as_f64().unwrap() <= first[5].as_f64().unwrap());
}
#[test]
fn one_days_history_is_not_a_profile() {
let day = 24 * 3_600_000i64;
let mut one_day: Vec<Entry> = (0..288)
.map(|i| entry(110.0, NOW - day - i * 5 * MIN, "Flat"))
.collect();
one_day.reverse();
let mut short = input(vec![entry(115.0, NOW - 5 * MIN, "Flat")], one_day);
short.timezone = Some(chrono_tz::UTC);
let json = serde_json::to_value(build(short)).unwrap();
assert!(json.get("agp").is_none());
}
#[test]
fn the_sensor_block_counts_down_from_its_expected_life() {
let day = 24 * 3_600_000i64;
let mut input = input(vec![entry(115.0, NOW - MIN, "Flat")], vec![]);
input.sensor_start_ms = Some(NOW - 6 * day - 4 * 3_600_000);
input.sensor_days = 10;
let json = serde_json::to_value(build(input)).unwrap();
assert_eq!(json["sensor"]["age_h"], 148);
assert_eq!(json["sensor"]["expires_in_h"], 92);
assert_eq!(json["sensor"]["expired"], false);
}
#[test]
fn a_sensor_past_its_life_says_so_rather_than_counting_backwards() {
let day = 24 * 3_600_000i64;
let mut input = input(vec![entry(115.0, NOW - MIN, "Flat")], vec![]);
input.sensor_start_ms = Some(NOW - 12 * day);
input.sensor_days = 10;
let json = serde_json::to_value(build(input)).unwrap();
assert_eq!(json["sensor"]["expired"], true);
assert_eq!(json["sensor"]["expires_in_h"], 0);
}
#[test]
fn no_expected_life_reports_the_age_and_claims_nothing_else() {
let day = 24 * 3_600_000i64;
let mut input = input(vec![entry(115.0, NOW - MIN, "Flat")], vec![]);
input.sensor_start_ms = Some(NOW - 3 * day);
input.sensor_days = 0;
let json = serde_json::to_value(build(input)).unwrap();
assert_eq!(json["sensor"]["age_h"], 72);
assert!(json["sensor"].get("expires_in_h").is_none());
assert!(json["sensor"].get("expired").is_none());
}
#[test]
fn no_sensor_event_means_no_sensor_block() {
let json = serde_json::to_value(build(input(vec![], vec![]))).unwrap();
assert!(json.get("sensor").is_none());
}
#[test]
fn the_forecast_projects_the_reading_forward() {
let json = serde_json::to_value(build(input(
vec![
entry(150.0, NOW - MIN, "SingleUp"),
entry(140.0, NOW - 6 * MIN, "SingleUp"),
],
vec![],
)))
.unwrap();
assert_eq!(json["forecast"]["in_min"], 30);
let value = json["forecast"]["value"].as_f64().unwrap();
assert!((9.2..14.0).contains(&value), "got {value}");
assert!(json["forecast"]["class"].is_string());
}
#[test]
fn a_gap_in_the_readings_forecasts_nothing() {
let json = serde_json::to_value(build(input(
vec![
entry(150.0, NOW - MIN, "Flat"),
entry(140.0, NOW - 31 * MIN, "Flat"),
],
vec![],
)))
.unwrap();
assert!(json.get("forecast").is_none());
}
#[test]
fn one_reading_forecasts_nothing() {
let json =
serde_json::to_value(build(input(vec![entry(150.0, NOW - MIN, "Flat")], vec![])))
.unwrap();
assert!(json.get("forecast").is_none());
}
#[test]
fn the_overview_covers_the_history_at_a_coarser_step() {
let day = 24 * 3_600_000i64;
let history: Vec<Entry> = (0..576)
.map(|i| entry(110.0 + (i % 40) as f64, NOW - i * 5 * MIN, "Flat"))
.collect();
let json =
serde_json::to_value(build(input(vec![entry(115.0, NOW - MIN, "Flat")], history)))
.unwrap();
assert_eq!(json["overview"]["step_min"], 15);
let points = json["overview"]["points"].as_array().unwrap();
assert!(
(180..=200).contains(&points.len()),
"two days at 15 minutes is about 192 points, got {}",
points.len()
);
let first = points[0].as_array().unwrap();
let second = points[1].as_array().unwrap();
assert!(first[0].as_i64().unwrap() < second[0].as_i64().unwrap());
let value = first[1].as_f64().unwrap();
assert!((5.0..9.0).contains(&value), "{value} is not mmol/L");
let span_h = (points[points.len() - 1].as_array().unwrap()[0]
.as_i64()
.unwrap()
- first[0].as_i64().unwrap())
/ 3_600_000;
assert!(span_h >= 47, "got {span_h}h");
let _ = day;
}
#[test]
fn no_history_means_no_overview() {
let json =
serde_json::to_value(build(input(vec![entry(115.0, NOW - MIN, "Flat")], vec![])))
.unwrap();
assert!(json.get("overview").is_none());
}
}