use chrono::{Local, TimeZone};
use crate::agp;
use crate::config::Alerts;
use crate::nightscout::Entry;
use crate::stats;
use crate::units::Units;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Csv,
Report,
}
pub struct Context<'a> {
pub timezone: Option<&'a str>,
pub subject: Option<&'a str>,
}
impl Format {
pub fn extension(self) -> &'static str {
match self {
Format::Csv => "csv",
Format::Report => "txt",
}
}
}
pub fn write_pair(
dir: &std::path::Path,
entries: &[Entry],
alerts: &Alerts,
units: Units,
days: u32,
now_ms: i64,
context: Context<'_>,
) -> anyhow::Result<Vec<std::path::PathBuf>> {
use anyhow::Context;
let mut written = Vec::new();
for (format, body) in [
(Format::Csv, csv_in(entries, units, context.timezone)),
(
Format::Report,
report_in(entries, alerts, units, days, now_ms, context.timezone),
),
] {
let path = dir.join(filename(format, now_ms, context.subject));
crate::config::write_private(&path, &body)
.with_context(|| format!("failed to write {}", path.display()))?;
written.push(std::fs::canonicalize(&path).unwrap_or(path));
}
Ok(written)
}
pub fn filename(format: Format, now_ms: i64, subject: Option<&str>) -> String {
let stamp = Local
.timestamp_millis_opt(now_ms)
.single()
.map(|dt| dt.format("%Y%m%d-%H%M").to_string())
.unwrap_or_else(|| "export".into());
let subject = subject.map(|subject| {
let slug = subject
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"site".into()
} else {
slug
}
});
match subject {
Some(subject) => format!("sugarrush-{subject}-{stamp}.{}", format.extension()),
None => format!("sugarrush-{stamp}.{}", format.extension()),
}
}
fn field(value: &str) -> String {
let needs_guard = value.starts_with(['=', '+', '-', '@', '\t', '\r']);
let escaped = value.replace('"', "\"\"");
if needs_guard {
format!("\"'{escaped}\"")
} else if value.contains([',', '"', '\n', '\r']) {
format!("\"{escaped}\"")
} else {
escaped
}
}
fn timestamp(ms: i64, timezone: Option<&str>, format: &str) -> String {
if let Some(tz) = timezone.and_then(|name| name.parse::<chrono_tz::Tz>().ok()) {
return tz
.timestamp_millis_opt(ms)
.single()
.map(|dt| dt.format(format).to_string())
.unwrap_or_else(|| "—".into());
}
Local
.timestamp_millis_opt(ms)
.single()
.map(|dt| dt.format(format).to_string())
.unwrap_or_else(|| "—".into())
}
#[cfg(test)]
pub fn csv(entries: &[Entry], units: Units) -> String {
csv_in(entries, units, None)
}
pub fn csv_in(entries: &[Entry], units: Units, timezone: Option<&str>) -> String {
let mut out = String::from("timestamp,epoch_ms,mgdl,");
out.push_str(match units {
Units::Mmol => "mmol_l",
Units::Mgdl => "mgdl_display",
});
out.push_str(",direction\n");
for e in entries.iter().rev() {
let ts = timestamp(e.date, timezone, "%+");
out.push_str(&format!(
"{},{},{:.0},{},{}\n",
ts,
e.date,
e.sgv,
units.format(e.sgv),
field(e.direction.as_deref().unwrap_or(""))
));
}
out
}
#[cfg(test)]
pub fn report(entries: &[Entry], alerts: &Alerts, units: Units, days: u32, now_ms: i64) -> String {
report_in(entries, alerts, units, days, now_ms, None)
}
pub fn report_in(
entries: &[Entry],
alerts: &Alerts,
units: Units,
days: u32,
now_ms: i64,
timezone: Option<&str>,
) -> String {
let mut out = String::new();
let u = units.label();
let stamp = |ms: i64| timestamp(ms, timezone, "%Y-%m-%d %H:%M");
out.push_str("sugarrush glucose summary\n");
out.push_str("=========================\n\n");
out.push_str(&format!(
"Period {} days, to {}\n",
days,
stamp(now_ms)
));
out.push_str(&format!(
"Target {}–{} {u}\n",
units.format(alerts.low),
units.format(alerts.high)
));
out.push_str(&format!(
"Timezone {}\n",
timezone.unwrap_or("local (viewer)")
));
if entries.is_empty() {
out.push_str("\nNo readings in this period.\n");
return out;
}
let expected = (days as f64 * 24.0 * 12.0).max(1.0);
let coverage = (entries.len() as f64 / expected * 100.0).min(100.0);
let (oldest, newest) = (
entries.last().map(|e| e.date).unwrap_or(now_ms),
entries.first().map(|e| e.date).unwrap_or(now_ms),
);
out.push_str(&format!(
"Readings {} ({:.0}% sensor coverage), {} → {}\n",
entries.len(),
coverage,
stamp(oldest),
stamp(newest)
));
out.push_str("\nTime in range\n-------------\n");
if let Some(t) = stats::tir(
entries,
alerts.urgent_low,
alerts.low,
alerts.high,
alerts.urgent_high,
) {
let row = |label: &str, pct: f64, range: String| {
let cells = ((pct / 5.0).round() as usize).min(20);
format!(
"{label:<11}{pct:>5.1}% {:<21} {}\n",
"█".repeat(cells),
range
)
};
out.push_str(&row(
"Very high",
t.very_high,
format!("≥ {} {u}", units.format(alerts.urgent_high)),
));
out.push_str(&row(
"High",
t.high,
format!("> {} {u}", units.format(alerts.high)),
));
out.push_str(&row(
"In range",
t.in_range,
format!(
"{}–{} {u}",
units.format(alerts.low),
units.format(alerts.high)
),
));
out.push_str(&row(
"Low",
t.low,
format!("< {} {u}", units.format(alerts.low)),
));
out.push_str(&row(
"Very low",
t.very_low,
format!("≤ {} {u}", units.format(alerts.urgent_low)),
));
out.push_str(&format!(
"\nTime below range {:.1}% (consensus goal: < 4%, of which < 1% very low) [1]\n",
t.below()
));
}
out.push_str("\nAverages\n--------\n");
if let Some(mean) = stats::mean_mgdl(entries) {
out.push_str(&format!("Mean {} {u}\n", units.format(mean)));
out.push_str(&format!(
"GMI {:.1}% (estimated A1c) [2]\n",
stats::gmi(mean)
));
}
if let Some(cv) = stats::cv_pct(entries) {
out.push_str(&format!(
"CV {cv:.1}% (variability; consensus goal: ≤ 36%) [1]\n"
));
}
out.push_str("\nTime of day (median and 5–95% spread)\n");
out.push_str("-------------------------------------\n");
let tz = timezone.and_then(|name| name.parse::<chrono_tz::Tz>().ok());
let bands = agp::profile_in(entries, tz);
for hour in 0..24 {
let in_hour: Vec<&agp::Band> = bands
.iter()
.filter(|b| b.minute / 60 == hour as i64)
.collect();
if in_hour.is_empty() {
continue;
}
let avg = |f: fn(&agp::Band) -> f64| -> f64 {
in_hour.iter().map(|b| f(b)).sum::<f64>() / in_hour.len() as f64
};
let (p05, p50, p95) = (avg(|b| b.p05), avg(|b| b.p50), avg(|b| b.p95));
let lo = alerts.urgent_low.min(p05);
let hi = alerts.urgent_high.max(p95);
let pos = ((p50 - lo) / (hi - lo).max(1.0) * 39.0).round() as usize;
let mut track = [' '; 40];
track[pos.min(39)] = '●';
out.push_str(&format!(
"{hour:02}:00 {:>5} {:>5} {:>5} |{}|\n",
units.format(p05),
units.format(p50),
units.format(p95),
track.iter().collect::<String>()
));
}
let found = agp::insights(&bands, alerts.low, alerts.high);
if !found.is_empty() {
out.push_str("\nPatterns\n--------\n");
for i in &found {
out.push_str(&format!("{}\n", i.text(units)));
}
out.push_str(
"\n(A \"lows\" window is a time of day when a quarter of readings or\n\
more sit below target; \"highs\" is when the typical reading is above\n\
it. Runs shorter than 45 minutes aren't reported.)\n",
);
}
out.push_str("\nReferences\n----------\n");
out.push_str(
"[1] Battelino T, et al. Clinical targets for continuous glucose\n\
\x20 monitoring data interpretation: recommendations from the\n\
\x20 international consensus on time in range. Diabetes Care\n\
\x20 2019;42(8):1593-1603.\n",
);
out.push_str(
"[2] Bergenstal RM, et al. Glucose Management Indicator (GMI): a new\n\
\x20 term for estimating A1C from continuous glucose monitoring.\n\
\x20 Diabetes Care 2018;41(11):2275-2280.\n",
);
out.push_str(&format!(
"\nThe consensus targets are stated for a {}–{} {u} range; the percentages\n",
units.format(70.0),
units.format(180.0)
));
out.push_str("above are computed against the thresholds configured in sugarrush,\n");
out.push_str("shown under Target at the top. Compare like with like.\n");
out.push_str("\nsugarrush is not a medical device. These figures come from CGM\n");
out.push_str("data as published by Nightscout and are for discussion, not\n");
out.push_str("treatment decisions.\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(sgv: f64, date: i64) -> Entry {
Entry {
sgv,
date,
direction: Some("Flat".into()),
}
}
const NOW: i64 = 1_700_000_000_000;
fn window() -> Vec<Entry> {
(0..12)
.map(|i| entry(100.0 + i as f64 * 5.0, NOW - i * 5 * 60_000))
.collect()
}
#[test]
fn dump_refs() {
let alerts = Alerts::default();
let out = report(&window(), &alerts, Units::Mgdl, 14, NOW);
println!("{}", &out[out.len().saturating_sub(900)..]);
}
#[test]
fn the_summary_attributes_its_clinical_figures() {
let alerts = Alerts::default();
let out = report(&window(), &alerts, Units::Mgdl, 14, NOW);
assert!(
out.contains("Battelino"),
"the TIR/CV targets need a source"
);
assert!(out.contains("2019;42(8):1593-1603"));
assert!(out.contains("Bergenstal"), "GMI needs a source");
assert!(out.contains("2018;41(11):2275-2280"));
for (figure, marker) in [("Time below range", "[1]"), ("CV ", "[1]"), ("GMI ", "[2]")] {
let line = out
.lines()
.find(|l| l.contains(figure))
.unwrap_or_else(|| panic!("no {figure:?} line in the summary"));
assert!(line.contains(marker), "{figure:?} cites nothing: {line:?}");
}
assert!(out.contains("Compare like with like."));
}
#[test]
fn csv_has_a_header_and_is_oldest_first() {
let out = csv(&window(), Units::Mmol);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("timestamp,epoch_ms,mgdl,mmol_l,direction"));
assert_eq!(lines.len(), 13); let first_epoch: i64 = lines[1].split(',').nth(1).unwrap().parse().unwrap();
let last_epoch: i64 = lines[12].split(',').nth(1).unwrap().parse().unwrap();
assert!(first_epoch < last_epoch);
assert_eq!(last_epoch, NOW);
assert!(lines[12].contains(",100,5.6,Flat") || lines[12].contains(",100,5.5,Flat"));
}
#[test]
fn configured_timezone_is_declared_and_written_with_an_offset() {
let csv = csv_in(&window(), Units::Mgdl, Some("America/New_York"));
let timestamp = csv.lines().nth(1).unwrap().split(',').next().unwrap();
assert!(timestamp.ends_with("-05:00") || timestamp.ends_with("-04:00"));
let report = report_in(
&window(),
&Alerts::default(),
Units::Mgdl,
14,
NOW,
Some("America/New_York"),
);
assert!(report.contains("Timezone America/New_York"));
}
#[test]
fn csv_of_nothing_is_still_a_valid_file() {
let out = csv(&[], Units::Mgdl);
assert_eq!(out.lines().count(), 1); }
#[test]
fn report_covers_the_clinical_metrics() {
let out = report(&window(), &Alerts::default(), Units::Mgdl, 14, NOW);
for expected in [
"sugarrush glucose summary",
"Period 14 days",
"Time in range",
"Very low",
"Time below range",
"GMI",
"CV",
"Time of day",
"not a medical device",
] {
assert!(out.contains(expected), "report is missing {expected:?}");
}
assert!(out.contains("0% sensor coverage"), "{out}");
}
#[test]
fn report_says_so_when_there_is_nothing_to_report() {
let out = report(&[], &Alerts::default(), Units::Mmol, 14, NOW);
assert!(out.contains("No readings in this period."));
assert!(!out.contains("GMI"));
}
#[test]
fn filenames_are_sortable_and_typed() {
let csv_name = filename(Format::Csv, NOW, Some("Alice Smith"));
let txt_name = filename(Format::Report, NOW, Some("Alice Smith"));
assert!(csv_name.starts_with("sugarrush-"));
assert!(csv_name.contains("alice-smith"));
assert!(csv_name.ends_with(".csv"));
assert!(txt_name.ends_with(".txt"));
assert_eq!(
csv_name.trim_end_matches(".csv"),
txt_name.trim_end_matches(".txt")
);
}
#[test]
fn csv_fields_cannot_break_the_file_or_run_in_a_spreadsheet() {
let entries = vec![Entry {
sgv: 100.0,
date: NOW,
direction: Some("=cmd|'/c calc'!A1".into()),
}];
let out = csv(&entries, Units::Mgdl);
let row = out.lines().nth(1).unwrap();
assert!(row.ends_with("\"'=cmd|'/c calc'!A1\""), "{row}");
let entries = vec![Entry {
sgv: 100.0,
date: NOW,
direction: Some("Flat,Extra".into()),
}];
let out = csv(&entries, Units::Mgdl);
let row = out.lines().nth(1).unwrap();
assert_eq!(row.matches(',').count(), 5, "field count changed: {row}");
assert!(row.ends_with("\"Flat,Extra\""), "{row}");
let entries = vec![Entry {
sgv: 100.0,
date: NOW,
direction: Some("Flat".into()),
}];
assert!(csv(&entries, Units::Mgdl)
.lines()
.nth(1)
.unwrap()
.ends_with(",Flat"));
}
}