use std::cell::Cell;
use anyhow::Context;
use chrono::{Local, TimeZone, Timelike};
use ratatui::layout::Rect;
use crate::alert::{self, Alert};
use crate::config::{Alerts, AlertsConfig, Config, GraphStyle, MinimapConfig, Site};
use crate::nightscout::{DeviceStatus, Entry, Prediction, Treatment};
use crate::sound;
use crate::theme::{self, Theme, ThemeConfig};
use crate::units::Units;
use crate::view::{Span, View};
const MS_PER_HOUR: i64 = 3_600_000;
const MS_PER_DAY: i64 = 24 * MS_PER_HOUR;
const CONFIG_FAIL_LIMIT: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphView {
H3,
H24,
Agp,
}
impl GraphView {
pub const ALL: [GraphView; 3] = [GraphView::H3, GraphView::H24, GraphView::Agp];
pub fn label(self) -> &'static str {
match self {
GraphView::H3 => "3h",
GraphView::H24 => "24h",
GraphView::Agp => "AGP",
}
}
pub fn index(self) -> usize {
Self::ALL.iter().position(|&v| v == self).unwrap_or(0)
}
pub fn cycle(self, dir: i32) -> Self {
let n = Self::ALL.len() as i32;
Self::ALL[(self.index() as i32 + dir).rem_euclid(n) as usize]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
SiteUrl,
SiteToken,
Units,
Refresh,
Desktop,
NotifyContent,
Sound,
Snooze,
QuietHours,
QuietStart,
QuietEnd,
QuietUrgentLow,
Escalate,
PushAlerts,
PredictHorizon,
UrgentLow,
Low,
High,
UrgentHigh,
Stale,
GraphStyle,
AgpDays,
MinimapEnabled,
MinimapSpan,
ThemeLow,
ThemeInRange,
ThemeHigh,
ThemeUrgent,
ThemePrediction,
ThemeGraph,
Colorblind,
}
impl Field {
pub const ALL: [Field; 31] = [
Field::SiteUrl,
Field::SiteToken,
Field::Units,
Field::Refresh,
Field::Desktop,
Field::NotifyContent,
Field::Sound,
Field::Snooze,
Field::QuietHours,
Field::QuietStart,
Field::QuietEnd,
Field::QuietUrgentLow,
Field::Escalate,
Field::PushAlerts,
Field::PredictHorizon,
Field::UrgentLow,
Field::Low,
Field::High,
Field::UrgentHigh,
Field::Stale,
Field::GraphStyle,
Field::AgpDays,
Field::MinimapEnabled,
Field::MinimapSpan,
Field::ThemeLow,
Field::ThemeInRange,
Field::ThemeHigh,
Field::ThemeUrgent,
Field::ThemePrediction,
Field::ThemeGraph,
Field::Colorblind,
];
pub fn label(self) -> &'static str {
match self {
Field::SiteUrl => "Site URL",
Field::SiteToken => "Read-only token",
Field::Units => "Units",
Field::Refresh => "Refresh interval",
Field::Desktop => "Desktop notifications",
Field::NotifyContent => "Notification detail",
Field::Sound => "Audible alarm",
Field::Snooze => "Snooze",
Field::QuietHours => "Quiet hours",
Field::QuietStart => "Quiet start",
Field::QuietEnd => "Quiet end",
Field::QuietUrgentLow => "Quiet: urgent-low sounds",
Field::Escalate => "Escalate after",
Field::PushAlerts => "Push alerts",
Field::PredictHorizon => "Predict horizon",
Field::UrgentLow => "Urgent low",
Field::Low => "Low",
Field::High => "High",
Field::UrgentHigh => "Urgent high",
Field::Stale => "Stale after",
Field::GraphStyle => "Graph style",
Field::AgpDays => "AGP days",
Field::MinimapEnabled => "Minimap",
Field::MinimapSpan => "Minimap span",
Field::ThemeLow => "Color: low",
Field::ThemeInRange => "Color: in range",
Field::ThemeHigh => "Color: high",
Field::ThemeUrgent => "Color: urgent",
Field::ThemePrediction => "Color: forecast",
Field::ThemeGraph => "Color: graph",
Field::Colorblind => "Colorblind palette",
}
}
pub fn group(self) -> &'static str {
match self {
Field::SiteUrl | Field::SiteToken => "Site",
Field::Units | Field::Refresh => "General",
Field::Desktop
| Field::NotifyContent
| Field::Sound
| Field::Snooze
| Field::QuietHours
| Field::QuietStart
| Field::QuietEnd
| Field::QuietUrgentLow
| Field::Escalate
| Field::PushAlerts => "Alarm",
Field::PredictHorizon => "Predictions",
Field::UrgentLow | Field::Low | Field::High | Field::UrgentHigh | Field::Stale => {
"Thresholds"
}
Field::GraphStyle | Field::AgpDays | Field::MinimapEnabled | Field::MinimapSpan => {
"Graph"
}
Field::ThemeLow
| Field::ThemeInRange
| Field::ThemeHigh
| Field::ThemeUrgent
| Field::ThemePrediction
| Field::ThemeGraph
| Field::Colorblind => "Theme",
}
}
fn theme_index(self) -> Option<usize> {
match self {
Field::ThemeLow => Some(0),
Field::ThemeInRange => Some(1),
Field::ThemeHigh => Some(2),
Field::ThemeUrgent => Some(3),
Field::ThemePrediction => Some(4),
Field::ThemeGraph => Some(5),
_ => None,
}
}
}
pub struct FieldEdit {
pub field: Field,
pub buffer: String,
pub masked: bool,
}
pub struct App {
pub units: Units,
pub entries: Vec<Entry>,
pub view: View,
pub view_start: i64,
pub view_end: i64,
pub date_input: Option<String>,
pub field_edit: Option<FieldEdit>,
pub settings_dirty: bool,
pub predictions: Vec<Prediction>,
pub device: DeviceStatus,
pub treatments: Vec<Treatment>,
pub sensor_start_ms: Option<i64>,
pub alerts: Alerts,
pub alert: Alert,
last_notified: Option<Alert>,
snooze_until: Option<i64>,
urgent_since: Option<i64>,
pushed_episode: bool,
escalated: bool,
predicted_notified: bool,
pub screen: Screen,
pub settings_sel: usize,
pub refresh_secs: u64,
pub refresh_dirty: bool,
pub status: Option<String>,
pub theme: Theme,
theme_names: [String; 6],
pub sites: Vec<Site>,
pub site_idx: usize,
pub site_dirty: bool,
pub graph_style: GraphStyle,
pub graph_view: GraphView,
pub agp_entries: Vec<Entry>,
pub agp_fetched_ms: i64,
pub agp_days: u32,
pub minimap_enabled: bool,
pub minimap_span_ms: i64,
pub minimap_entries: Vec<Entry>,
pub minimap_rect: Cell<Option<Rect>>,
pub demo: bool,
pub perm_warning: bool,
pub online: bool,
pub last_ok_ms: Option<i64>,
fetch_fails: u32,
config_fails: u32,
pub fetch_paused: bool,
next_retry_at: Option<i64>,
pub last_error: Option<String>,
pub partial: Option<String>,
pub should_quit: bool,
pub show_help: bool,
}
impl App {
pub fn new(cfg: &Config, alerts: Alerts, sites: Vec<Site>) -> Self {
Self {
units: cfg.units,
entries: Vec::new(),
view: View::default(),
view_start: 0,
view_end: 0,
date_input: None,
field_edit: None,
settings_dirty: false,
predictions: Vec::new(),
device: DeviceStatus::default(),
treatments: Vec::new(),
sensor_start_ms: None,
alerts,
alert: Alert::InRange,
last_notified: None,
snooze_until: None,
urgent_since: None,
pushed_episode: false,
escalated: false,
predicted_notified: false,
screen: Screen::Dashboard,
settings_sel: 0,
refresh_secs: cfg.refresh_secs,
refresh_dirty: false,
status: None,
theme: cfg.theme.resolve(),
theme_names: names_from_config(&cfg.theme),
sites,
site_idx: 0,
site_dirty: false,
graph_style: cfg.graph_style,
graph_view: GraphView::H3,
agp_entries: Vec::new(),
agp_fetched_ms: 0,
agp_days: cfg.agp_days.clamp(1, 90),
minimap_enabled: cfg.minimap.enabled,
minimap_span_ms: cfg.minimap.span_hours.max(1) as i64 * MS_PER_HOUR,
minimap_entries: Vec::new(),
minimap_rect: Cell::new(None),
demo: false,
perm_warning: false,
online: true,
last_ok_ms: None,
fetch_fails: 0,
config_fails: 0,
fetch_paused: false,
next_retry_at: None,
last_error: None,
partial: None,
should_quit: false,
show_help: false,
}
}
pub fn set_partial(&mut self, missing: &[&str]) {
self.partial = (!missing.is_empty()).then(|| missing.join(", "));
}
pub fn mark_online(&mut self, now_ms: i64) {
self.online = true;
self.last_ok_ms = Some(now_ms);
self.fetch_fails = 0;
self.config_fails = 0;
self.fetch_paused = false;
self.next_retry_at = None;
self.last_error = None;
}
pub fn mark_offline(&mut self, now_ms: i64, err: String, permanent: bool) {
self.online = false;
self.partial = None;
self.fetch_fails = self.fetch_fails.saturating_add(1);
if permanent {
self.config_fails = self.config_fails.saturating_add(1);
} else {
self.config_fails = 0;
}
if self.config_fails >= CONFIG_FAIL_LIMIT {
self.fetch_paused = true;
self.next_retry_at = None;
self.last_error = Some(format!("{err} · retries paused, press r to retry"));
return;
}
let secs = (5u64 << (self.fetch_fails.min(5) - 1)).min(60);
self.next_retry_at = Some(now_ms + secs as i64 * 1000);
self.last_error = Some(err);
}
pub fn resume_fetching(&mut self) {
self.fetch_paused = false;
self.config_fails = 0;
self.fetch_fails = 0;
self.next_retry_at = None;
}
pub fn should_retry(&self, now_ms: i64) -> bool {
!self.online && !self.fetch_paused && self.next_retry_at.is_some_and(|t| now_ms >= t)
}
pub fn should_auto_refresh(&self) -> bool {
self.view.is_live() && !self.fetch_paused
}
pub fn is_agp(&self) -> bool {
self.graph_view == GraphView::Agp
}
pub fn set_graph_view(&mut self, v: GraphView) {
self.graph_view = v;
match v {
GraphView::H3 => {
self.view.span = Span::H3;
self.view.follow();
}
GraphView::H24 => {
self.view.span = Span::H24;
self.view.follow();
}
GraphView::Agp => {}
}
}
pub fn cycle_graph_view(&mut self, dir: i32) {
self.set_graph_view(self.graph_view.cycle(dir));
}
pub fn agp_span_ms(&self) -> i64 {
self.agp_days as i64 * MS_PER_DAY
}
pub fn agp_fetch_count(&self) -> usize {
self.agp_days as usize * 24 * 12 + 200
}
pub fn minimap_seek(&mut self, col: u16, row: u16, now_ms: i64) -> bool {
let Some(r) = self.minimap_rect.get() else {
return false;
};
if r.width == 0 || row < r.y || row >= r.y + r.height {
return false;
}
let col = col.clamp(r.x, r.x + r.width - 1);
let frac = (col - r.x) as f64 / r.width as f64;
let start = now_ms - self.minimap_span_ms;
let target = start + (frac * self.minimap_span_ms as f64) as i64;
let half = self.view.span.minutes() * 60_000 / 2;
let end = (target + half).min(now_ms);
self.view.end = if end >= now_ms { None } else { Some(end) };
true
}
pub fn active_site(&self) -> &Site {
&self.sites[self.site_idx.min(self.sites.len().saturating_sub(1))]
}
pub fn next_site(&mut self) {
if self.sites.len() > 1 {
self.site_idx = (self.site_idx + 1) % self.sites.len();
self.site_dirty = true;
self.view.follow();
}
}
pub fn begin_field_edit(&mut self) -> bool {
let field = Field::ALL[self.settings_sel.min(Field::ALL.len() - 1)];
let edit = match field {
Field::SiteUrl => FieldEdit {
field,
buffer: self.active_site().url.clone(),
masked: false,
},
Field::SiteToken => FieldEdit {
field,
buffer: String::new(),
masked: true,
},
_ => return false,
};
self.field_edit = Some(edit);
true
}
pub fn field_edit_push(&mut self, c: char) {
if let Some(e) = self.field_edit.as_mut() {
e.buffer.push(c);
}
}
pub fn field_edit_backspace(&mut self) {
if let Some(e) = self.field_edit.as_mut() {
e.buffer.pop();
}
}
pub fn cancel_field_edit(&mut self) {
self.field_edit = None;
}
pub fn commit_field_edit(&mut self) {
let Some(edit) = self.field_edit.take() else {
return;
};
let idx = self.site_idx.min(self.sites.len().saturating_sub(1));
match edit.field {
Field::SiteUrl => match crate::config::normalize_site_url(&edit.buffer) {
Ok(url) => {
self.sites[idx].url = url;
self.site_dirty = true;
self.settings_dirty = true;
self.status = Some(if self.sites[idx].is_insecure() {
"site updated · ⚠unencrypted http, the token is sent in clear".to_string()
} else {
format!("site set to {}", self.sites[idx].base_url())
});
}
Err(e) => {
self.status = Some(e.to_string());
self.field_edit = Some(edit);
}
},
Field::SiteToken => {
if edit.buffer.trim().is_empty() {
self.status = Some("token unchanged".to_string());
} else {
self.sites[idx].token = edit.buffer.trim().to_string();
self.site_dirty = true;
self.settings_dirty = true;
self.status = Some("token updated · press w to save".to_string());
}
}
_ => {}
}
}
pub fn begin_date_input(&mut self) {
self.date_input = Some(String::new());
}
pub fn cancel_date_input(&mut self) {
self.date_input = None;
}
pub fn latest(&self) -> Option<&Entry> {
self.entries.first()
}
pub fn delta_mgdl(&self) -> Option<f64> {
match (self.entries.first(), self.entries.get(1)) {
(Some(a), Some(b)) => Some(a.sgv - b.sgv),
_ => None,
}
}
pub fn toggle_units(&mut self) {
self.units = self.units.toggle();
self.settings_dirty = true;
}
pub fn evaluate_alert(&mut self, now_ms: i64) -> Alert {
self.alert = if self.view.is_live() {
match self.latest() {
Some(e) => alert::evaluate_from(e.sgv, now_ms - e.date, &self.alerts, self.alert),
None if self.last_ok_ms.is_some() => Alert::Stale,
None => Alert::InRange,
}
} else {
Alert::InRange
};
if !self.alert.is_urgent() {
self.snooze_until = None;
}
self.alert
}
pub fn alarm_active(&self, now_ms: i64) -> bool {
if !(self.alerts.sound && self.alert.is_urgent()) {
return false;
}
if self.snooze_until.is_some_and(|t| now_ms < t) {
return false;
}
if let Some(dt) = Local.timestamp_millis_opt(now_ms).single() {
let min_of_day = dt.hour() as i32 * 60 + dt.minute() as i32;
if self.alerts.in_quiet_hours(min_of_day) {
return self.alert == Alert::UrgentLow && self.alerts.quiet_urgent_low;
}
}
true
}
pub fn prediction_eta(&self, now_ms: i64) -> Option<(bool, i64)> {
if self.alert != Alert::InRange {
return None;
}
for p in &self.predictions {
if p.at_ms <= now_ms {
continue;
}
let centre = (p.low + p.high) / 2.0;
if centre <= self.alerts.low {
return Some((false, (p.at_ms - now_ms) / 60_000));
}
if centre >= self.alerts.high {
return Some((true, (p.at_ms - now_ms) / 60_000));
}
}
None
}
pub fn take_predictive(&mut self, now_ms: i64) -> Option<String> {
let horizon = self.alerts.predict_horizon_minutes;
match self.prediction_eta(now_ms) {
Some((rising, mins)) if horizon > 0 && mins <= horizon => {
if self.predicted_notified {
return None;
}
self.predicted_notified = true;
let dir = if rising { "high" } else { "low" };
Some(format!("heading {dir} in ~{mins} min"))
}
_ => {
self.predicted_notified = false;
None
}
}
}
pub fn alarm_tone(&self) -> sound::Tone {
match self.alert {
Alert::UrgentLow => sound::Tone::Low,
Alert::UrgentHigh => sound::Tone::High,
_ => sound::Tone::Stale,
}
}
pub fn snooze_remaining_min(&self, now_ms: i64) -> Option<i64> {
self.snooze_until
.filter(|t| *t > now_ms)
.map(|t| (t - now_ms) / 60_000 + 1)
}
pub fn snooze_alarm(&mut self, now_ms: i64) {
if self.alert.is_urgent() {
let mins = self.alerts.snooze_minutes.max(1);
self.snooze_until = Some(now_ms + mins * 60_000);
self.status = Some(format!("alarm snoozed {mins}m"));
}
}
pub fn update_urgent(&mut self, now_ms: i64) {
if self.alert.is_urgent() {
if self.urgent_since.is_none() {
self.urgent_since = Some(now_ms);
self.pushed_episode = false;
self.escalated = false;
}
} else {
self.urgent_since = None;
}
}
pub fn take_push(&mut self, now_ms: i64) -> Option<String> {
self.alerts.push_url.as_ref()?;
if !self.alerts.push_enabled {
return None;
}
if !self.alert.is_urgent() {
return None;
}
let value = self
.latest()
.map(|e| format!(" · {} {}", self.units.format(e.sgv), self.units.label()))
.unwrap_or_default();
if !self.pushed_episode {
self.pushed_episode = true;
return Some(format!("sugarrush: {}{}", self.alert.label(), value));
}
if self.alerts.escalate_minutes > 0 && !self.escalated {
if let Some(s) = self.urgent_since {
if now_ms - s >= self.alerts.escalate_minutes * 60_000 {
self.escalated = true;
return Some(format!(
"sugarrush: STILL {} after {} min{}",
self.alert.label(),
self.alerts.escalate_minutes,
value
));
}
}
}
None
}
pub fn take_notification(&mut self) -> Option<Alert> {
if self.last_notified == Some(self.alert) {
return None;
}
self.last_notified = Some(self.alert);
self.alert.is_alerting().then_some(self.alert)
}
pub fn toggle_settings(&mut self) {
self.screen = match self.screen {
Screen::Dashboard => Screen::Settings,
Screen::Settings => Screen::Dashboard,
};
self.status = None;
}
pub fn selected_field(&self) -> Field {
Field::ALL[self.settings_sel.min(Field::ALL.len() - 1)]
}
pub fn settings_move(&mut self, delta: isize) {
let n = Field::ALL.len() as isize;
let cur = self.settings_sel as isize;
self.settings_sel = ((cur + delta).rem_euclid(n)) as usize;
self.status = None;
}
fn is_colorblind(&self) -> bool {
self.theme_names
.iter()
.zip(theme::COLORBLIND_NAMES)
.all(|(a, b)| a == b)
}
fn ensure_quiet_hours(&mut self) {
if self.alerts.quiet_start.is_none() {
self.alerts.quiet_start = Some(23 * 60);
self.alerts.quiet_end = Some(7 * 60);
}
}
pub fn settings_adjust(&mut self, dir: i32) {
let was_dirty = self.settings_dirty;
self.settings_dirty = true;
let step_mgdl = self.units.to_mgdl(match self.units {
Units::Mmol => 0.1,
Units::Mgdl => 1.0,
});
let d = dir as f64;
match self.selected_field() {
Field::Units => self.toggle_units(),
Field::Desktop => self.alerts.desktop = !self.alerts.desktop,
Field::Sound => self.alerts.sound = !self.alerts.sound,
Field::Snooze => {
let next = self.alerts.snooze_minutes + dir as i64 * 5;
self.alerts.snooze_minutes = next.clamp(1, 120);
}
Field::QuietHours => {
if self.alerts.quiet_start.is_some() {
self.alerts.quiet_start = None;
self.alerts.quiet_end = None;
} else {
self.alerts.quiet_start = Some(23 * 60); self.alerts.quiet_end = Some(7 * 60); }
}
Field::QuietStart => {
self.ensure_quiet_hours();
if let Some(s) = self.alerts.quiet_start.as_mut() {
*s = (*s + dir * 30).rem_euclid(1440);
}
}
Field::QuietEnd => {
self.ensure_quiet_hours();
if let Some(e) = self.alerts.quiet_end.as_mut() {
*e = (*e + dir * 30).rem_euclid(1440);
}
}
Field::QuietUrgentLow => self.alerts.quiet_urgent_low = !self.alerts.quiet_urgent_low,
Field::Escalate => {
let next = self.alerts.escalate_minutes + dir as i64 * 5;
self.alerts.escalate_minutes = next.clamp(0, 120);
}
Field::PredictHorizon => {
let next = self.alerts.predict_horizon_minutes + dir as i64 * 5;
self.alerts.predict_horizon_minutes = next.clamp(0, 60);
}
Field::Refresh => {
let next = self.refresh_secs as i64 + dir as i64 * 5;
self.refresh_secs = next.max(5) as u64;
self.refresh_dirty = true;
}
Field::Stale => {
let next = self.alerts.stale_minutes + dir as i64;
self.alerts.stale_minutes = next.max(1);
}
Field::UrgentLow => {
self.alerts.urgent_low =
clamp_bg(self.alerts.urgent_low + d * step_mgdl).min(self.alerts.low)
}
Field::Low => {
self.alerts.low = clamp_bg(self.alerts.low + d * step_mgdl)
.clamp(self.alerts.urgent_low, self.alerts.high)
}
Field::High => {
self.alerts.high = clamp_bg(self.alerts.high + d * step_mgdl)
.clamp(self.alerts.low, self.alerts.urgent_high)
}
Field::UrgentHigh => {
self.alerts.urgent_high =
clamp_bg(self.alerts.urgent_high + d * step_mgdl).max(self.alerts.high)
}
Field::SiteUrl | Field::SiteToken => {
self.status = Some("press enter to edit".to_string());
self.settings_dirty = was_dirty;
}
Field::NotifyContent => self.alerts.notify_content = !self.alerts.notify_content,
Field::PushAlerts => {
if self.alerts.push_url.is_some() {
self.alerts.push_enabled = !self.alerts.push_enabled;
} else {
self.status =
Some("set push_url in config.toml to enable push alerts".to_string());
self.settings_dirty = was_dirty;
}
}
Field::GraphStyle => self.graph_style = self.graph_style.cycle(dir),
Field::AgpDays => {
let next = self.agp_days as i64 + dir as i64;
self.agp_days = next.clamp(1, 90) as u32;
self.agp_fetched_ms = 0;
}
Field::MinimapEnabled => self.minimap_enabled = !self.minimap_enabled,
Field::MinimapSpan => {
let next = self.minimap_span_ms / MS_PER_HOUR + dir as i64 * 6;
self.minimap_span_ms = next.clamp(6, 72) * MS_PER_HOUR;
}
Field::Colorblind => {
let names = if self.is_colorblind() {
theme::DEFAULT_NAMES
} else {
theme::COLORBLIND_NAMES
};
self.theme_names = names.map(String::from);
self.theme = theme::theme_from_names(&self.theme_names);
}
f => {
if let Some(i) = f.theme_index() {
self.theme_names[i] = theme::cycle_color(&self.theme_names[i], dir).to_string();
self.theme = theme::theme_from_names(&self.theme_names);
}
}
}
self.status = None;
}
pub fn save_config(&mut self) {
let result = Config::path().and_then(|p| {
let body = toml::to_string_pretty(&self.build_config())
.context("failed to serialize config")?;
Config::write_atomic(&p, &body)?;
Ok(p)
});
self.status = Some(match result {
Ok(p) => {
self.settings_dirty = false;
format!("saved to {}", p.display())
}
Err(e) => format!("save failed: {e}"),
});
}
fn build_config(&self) -> Config {
let single_default = self.sites.len() == 1 && self.sites[0].name == "default";
let (url, token, sites) = if single_default {
(
Some(self.sites[0].url.clone()),
Some(self.sites[0].token.clone()),
Vec::new(),
)
} else {
(None, None, self.sites.clone())
};
let u = self.units;
Config {
url,
token,
sites,
units: u,
refresh_secs: self.refresh_secs,
alerts: AlertsConfig {
urgent_low: Some(u.from_mgdl(self.alerts.urgent_low)),
low: Some(u.from_mgdl(self.alerts.low)),
high: Some(u.from_mgdl(self.alerts.high)),
urgent_high: Some(u.from_mgdl(self.alerts.urgent_high)),
stale_minutes: Some(self.alerts.stale_minutes),
desktop: Some(self.alerts.desktop),
sound: Some(self.alerts.sound),
snooze_minutes: Some(self.alerts.snooze_minutes),
quiet_start: self.alerts.quiet_start.map(crate::config::fmt_hhmm),
quiet_end: self.alerts.quiet_end.map(crate::config::fmt_hhmm),
quiet_urgent_low: Some(self.alerts.quiet_urgent_low),
escalate_minutes: Some(self.alerts.escalate_minutes),
push_url: self.alerts.push_url.clone(),
push_enabled: Some(self.alerts.push_enabled),
notify_content: Some(self.alerts.notify_content),
predict_horizon_minutes: Some(self.alerts.predict_horizon_minutes),
},
theme: ThemeConfig {
low: Some(self.theme_names[0].clone()),
in_range: Some(self.theme_names[1].clone()),
high: Some(self.theme_names[2].clone()),
urgent: Some(self.theme_names[3].clone()),
prediction: Some(self.theme_names[4].clone()),
graph: Some(self.theme_names[5].clone()),
},
graph_style: self.graph_style,
agp_days: self.agp_days,
minimap: MinimapConfig {
enabled: self.minimap_enabled,
span_hours: (self.minimap_span_ms / MS_PER_HOUR) as u32,
},
}
}
pub fn field_value(&self, field: Field) -> String {
match field {
Field::Units => self.units.label().to_string(),
Field::Refresh => format!("{}s", self.refresh_secs),
Field::Desktop => if self.alerts.desktop { "on" } else { "off" }.to_string(),
Field::Sound => if self.alerts.sound { "on" } else { "off" }.to_string(),
Field::Snooze => format!("{} min", self.alerts.snooze_minutes),
Field::QuietHours => if self.alerts.quiet_start.is_some() {
"on"
} else {
"off"
}
.to_string(),
Field::QuietStart => self
.alerts
.quiet_start
.map(crate::config::fmt_hhmm)
.unwrap_or_else(|| "—".into()),
Field::QuietEnd => self
.alerts
.quiet_end
.map(crate::config::fmt_hhmm)
.unwrap_or_else(|| "—".into()),
Field::QuietUrgentLow => if self.alerts.quiet_urgent_low {
"on"
} else {
"off"
}
.to_string(),
Field::Escalate => {
if self.alerts.escalate_minutes == 0 {
"off".to_string()
} else {
format!("{} min", self.alerts.escalate_minutes)
}
}
Field::SiteUrl => {
let site = self.active_site();
if site.is_insecure() && !self.demo {
format!("{} âš unencrypted", site.base_url())
} else {
site.base_url().to_string()
}
}
Field::SiteToken => if self.active_site().token.is_empty() {
"not set"
} else {
"set · ••••••"
}
.to_string(),
Field::PredictHorizon => {
let h = self.alerts.predict_horizon_minutes;
if h == 0 {
"off".to_string()
} else if h > crate::predict::HORIZON_MINUTES {
format!(
"{} min · local forecast {} min",
h,
crate::predict::HORIZON_MINUTES
)
} else {
format!("{h} min")
}
}
Field::NotifyContent => if self.alerts.notify_content {
"value + state"
} else {
"generic (no data)"
}
.to_string(),
Field::PushAlerts => match (&self.alerts.push_url, self.alerts.push_enabled) {
(None, _) => "not configured".to_string(),
(Some(url), true) => format!("on · {}", push_host(url)),
(Some(url), false) => format!("off · {}", push_host(url)),
},
Field::Stale => format!("{} min", self.alerts.stale_minutes),
Field::UrgentLow => self.threshold(self.alerts.urgent_low),
Field::Low => self.threshold(self.alerts.low),
Field::High => self.threshold(self.alerts.high),
Field::UrgentHigh => self.threshold(self.alerts.urgent_high),
Field::GraphStyle => self.graph_style.label().to_string(),
Field::AgpDays => format!("{} days", self.agp_days),
Field::MinimapEnabled => if self.minimap_enabled { "on" } else { "off" }.to_string(),
Field::MinimapSpan => format!("{}h", self.minimap_span_ms / MS_PER_HOUR),
Field::Colorblind => if self.is_colorblind() { "on" } else { "off" }.to_string(),
f => f
.theme_index()
.map(|i| self.theme_names[i].clone())
.unwrap_or_default(),
}
}
fn threshold(&self, mgdl: f64) -> String {
format!("{} {}", self.units.format(mgdl), self.units.label())
}
}
fn names_from_config(tc: &ThemeConfig) -> [String; 6] {
let d = theme::DEFAULT_NAMES;
[
tc.low.clone().unwrap_or_else(|| d[0].to_string()),
tc.in_range.clone().unwrap_or_else(|| d[1].to_string()),
tc.high.clone().unwrap_or_else(|| d[2].to_string()),
tc.urgent.clone().unwrap_or_else(|| d[3].to_string()),
tc.prediction.clone().unwrap_or_else(|| d[4].to_string()),
tc.graph.clone().unwrap_or_else(|| d[5].to_string()),
]
}
fn clamp_bg(mgdl: f64) -> f64 {
mgdl.clamp(20.0, 500.0)
}
fn push_host(url: &str) -> &str {
url.split_once("://")
.map_or(url, |(_, rest)| rest)
.split('/')
.next()
.unwrap_or(url)
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_700_000_000_000;
fn app() -> App {
let cfg = Config::demo();
let alerts = cfg.alerts.resolve(cfg.units);
let sites = cfg.resolve_sites().unwrap();
App::new(&cfg, alerts, sites)
}
fn entry(sgv: f64, date: i64) -> Entry {
Entry {
sgv,
date,
direction: None,
}
}
#[test]
fn dropout_without_history_does_not_alarm() {
let mut a = app();
assert_eq!(a.evaluate_alert(NOW), Alert::InRange);
}
#[test]
fn dropout_after_data_is_stale() {
let mut a = app();
a.last_ok_ms = Some(NOW);
a.entries.clear();
assert_eq!(a.evaluate_alert(NOW), Alert::Stale);
}
#[test]
fn fresh_in_range_reading() {
let mut a = app();
a.entries = vec![entry(100.0, NOW)];
assert_eq!(a.evaluate_alert(NOW), Alert::InRange);
}
#[test]
fn old_reading_is_stale() {
let mut a = app();
a.entries = vec![entry(100.0, NOW - 20 * 60_000)]; assert_eq!(a.evaluate_alert(NOW), Alert::Stale);
}
#[test]
fn urgent_low_reading_alarms() {
let mut a = app();
a.entries = vec![entry(50.0, NOW)]; assert_eq!(a.evaluate_alert(NOW), Alert::UrgentLow);
assert!(a.alarm_active(NOW));
}
#[test]
fn history_view_never_alarms() {
let mut a = app();
a.entries = vec![entry(40.0, NOW)];
a.view.end = Some(NOW - 3_600_000); assert_eq!(a.evaluate_alert(NOW), Alert::InRange);
}
#[test]
fn snooze_silences_then_re_arms() {
let mut a = app();
a.entries = vec![entry(40.0, NOW)];
a.evaluate_alert(NOW);
assert!(a.alarm_active(NOW));
a.snooze_alarm(NOW);
assert!(!a.alarm_active(NOW));
assert!(a.snooze_remaining_min(NOW).is_some());
a.entries = vec![entry(100.0, NOW)];
a.evaluate_alert(NOW);
assert!(a.snooze_remaining_min(NOW).is_none());
}
#[test]
fn alert_does_not_flap_on_a_threshold() {
let mut a = app();
a.entries = vec![entry(69.0, NOW)];
assert_eq!(a.evaluate_alert(NOW), Alert::Low);
a.entries = vec![entry(71.0, NOW)]; assert_eq!(a.evaluate_alert(NOW), Alert::Low);
a.entries = vec![entry(80.0, NOW)];
assert_eq!(a.evaluate_alert(NOW), Alert::InRange);
}
#[test]
fn flat_glucose_does_not_predict_a_low() {
let mut a = app();
a.entries = vec![entry(75.0, NOW)];
a.evaluate_alert(NOW);
a.predictions = (1..=6)
.map(|i| Prediction {
at_ms: NOW + i * 5 * 60_000,
low: 75.0 - 4.0 * i as f64,
high: 75.0 + 4.0 * i as f64,
})
.collect();
assert_eq!(a.prediction_eta(NOW), None);
a.predictions = (1..=6)
.map(|i| Prediction {
at_ms: NOW + i * 5 * 60_000,
low: 75.0 - 3.0 * i as f64,
high: 75.0 - i as f64,
})
.collect();
assert_eq!(a.prediction_eta(NOW), Some((false, 15)));
}
#[test]
fn permanent_failures_pause_fetching() {
let mut a = app();
for _ in 0..CONFIG_FAIL_LIMIT {
a.mark_offline(NOW, "authentication failed".into(), true);
}
assert!(a.fetch_paused);
assert!(!a.should_retry(NOW + 60_000));
assert!(!a.should_auto_refresh());
a.resume_fetching();
assert!(a.should_auto_refresh());
a.mark_offline(NOW, "authentication failed".into(), true);
a.mark_online(NOW);
assert!(!a.fetch_paused);
}
#[test]
fn thresholds_cannot_cross() {
let mut a = app();
a.settings_sel = Field::ALL.iter().position(|&f| f == Field::Low).unwrap();
for _ in 0..200 {
a.settings_adjust(-1);
}
assert!(a.alerts.low >= a.alerts.urgent_low);
for _ in 0..400 {
a.settings_adjust(1);
}
assert!(a.alerts.low <= a.alerts.high);
assert!(a.alerts.urgent_low <= a.alerts.low);
assert!(a.alerts.high <= a.alerts.urgent_high);
}
#[test]
fn push_toggle_needs_a_url_and_round_trips() {
let mut a = app();
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::PushAlerts)
.unwrap();
assert_eq!(a.field_value(Field::PushAlerts), "not configured");
a.settings_adjust(1);
assert!(a.alerts.push_enabled);
a.alerts.push_url = Some("https://ntfy.sh/secret-topic".into());
assert_eq!(a.field_value(Field::PushAlerts), "on · ntfy.sh"); a.settings_adjust(1);
assert!(!a.alerts.push_enabled);
assert!(a.alerts.push_url.is_some()); assert_eq!(a.build_config().alerts.push_enabled, Some(false));
a.entries = vec![entry(40.0, NOW)];
a.evaluate_alert(NOW);
a.update_urgent(NOW);
assert_eq!(a.take_push(NOW), None);
}
#[test]
fn backoff_reaches_the_documented_ceiling() {
let mut a = app();
let expected = [5, 10, 20, 40, 60, 60];
for (i, secs) in expected.iter().enumerate() {
a.mark_offline(NOW, "offline".into(), false);
assert!(
a.should_retry(NOW + secs * 1000),
"failure {} should retry after {secs}s",
i + 1
);
assert!(!a.should_retry(NOW + secs * 1000 - 1));
}
}
#[test]
fn retries_continue_while_browsing_history() {
let mut a = app();
a.view.end = Some(NOW - 3_600_000); a.mark_offline(NOW, "connection refused".into(), false);
assert!(!a.should_auto_refresh());
assert!(a.should_retry(NOW + 5_000));
}
#[test]
fn stale_uploader_forecast_does_not_fire() {
let mut a = app();
a.entries = vec![entry(100.0, NOW)];
a.evaluate_alert(NOW);
a.predictions = (1..=6)
.map(|i| Prediction {
at_ms: NOW - 40 * 60_000 + i * 5 * 60_000,
low: 60.0,
high: 60.0,
})
.collect();
assert_eq!(a.prediction_eta(NOW), None);
assert_eq!(a.take_predictive(NOW), None);
}
#[test]
fn horizon_beyond_the_local_forecast_says_so() {
let mut a = app();
a.alerts.predict_horizon_minutes = crate::predict::HORIZON_MINUTES;
assert_eq!(a.field_value(Field::PredictHorizon), "30 min");
a.alerts.predict_horizon_minutes = 45;
assert_eq!(
a.field_value(Field::PredictHorizon),
"45 min · local forecast 30 min"
);
}
#[test]
fn editing_the_site_url_normalizes_and_reloads() {
let mut a = app();
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::SiteUrl)
.unwrap();
assert!(a.begin_field_edit());
assert_eq!(a.field_edit.as_ref().unwrap().buffer, a.active_site().url);
a.field_edit.as_mut().unwrap().buffer = "ns.example.com/api/v1/entries.json".into();
a.commit_field_edit();
assert_eq!(a.active_site().url, "https://ns.example.com");
assert!(a.site_dirty); assert!(a.field_edit.is_none());
}
#[test]
fn a_bad_url_keeps_the_editor_open() {
let mut a = app();
let before = a.active_site().url.clone();
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::SiteUrl)
.unwrap();
a.begin_field_edit();
a.field_edit.as_mut().unwrap().buffer = "ftp://nope".into();
a.commit_field_edit();
assert_eq!(a.active_site().url, before);
assert!(a.field_edit.is_some()); assert!(a.status.is_some());
}
#[test]
fn token_edit_is_masked_and_never_rendered() {
let mut a = app();
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::SiteToken)
.unwrap();
a.begin_field_edit();
let edit = a.field_edit.as_ref().unwrap();
assert!(edit.masked);
assert!(edit.buffer.is_empty()); a.field_edit.as_mut().unwrap().buffer = "s3cret-token".into();
a.commit_field_edit();
assert_eq!(a.active_site().token, "s3cret-token");
assert!(a.site_dirty);
let shown = a.field_value(Field::SiteToken);
assert!(
!shown.contains("s3cret"),
"token leaked into the row: {shown}"
);
a.begin_field_edit();
a.commit_field_edit();
assert_eq!(a.active_site().token, "s3cret-token");
}
#[test]
fn edits_mark_settings_unsaved() {
let mut a = app();
assert!(!a.settings_dirty);
a.settings_sel = Field::ALL.iter().position(|&f| f == Field::Low).unwrap();
a.settings_adjust(1);
assert!(a.settings_dirty);
a.settings_dirty = false;
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::SiteUrl)
.unwrap();
a.settings_adjust(1);
assert!(!a.settings_dirty);
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::PushAlerts)
.unwrap();
a.settings_adjust(1);
assert!(!a.settings_dirty);
a.begin_field_edit();
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::SiteUrl)
.unwrap();
a.begin_field_edit();
a.field_edit.as_mut().unwrap().buffer = "https://ns.example.com".into();
a.commit_field_edit();
assert!(a.settings_dirty);
}
#[test]
fn notify_content_toggles_and_round_trips() {
let mut a = app();
assert!(a.alerts.notify_content); assert_eq!(a.field_value(Field::NotifyContent), "value + state");
a.settings_sel = Field::ALL
.iter()
.position(|&f| f == Field::NotifyContent)
.unwrap();
a.settings_adjust(1);
assert!(!a.alerts.notify_content);
assert_eq!(a.field_value(Field::NotifyContent), "generic (no data)");
assert_eq!(a.build_config().alerts.notify_content, Some(false));
}
#[test]
fn transient_failures_keep_retrying() {
let mut a = app();
for _ in 0..10 {
a.mark_offline(NOW, "connection refused".into(), false);
}
assert!(!a.fetch_paused);
assert!(a.should_retry(NOW + 120_000));
}
}