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;
#[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 {
Units,
Refresh,
Desktop,
Sound,
Snooze,
QuietHours,
QuietStart,
QuietEnd,
QuietUrgentLow,
Escalate,
PredictHorizon,
UrgentLow,
Low,
High,
UrgentHigh,
Stale,
GraphStyle,
AgpDays,
MinimapEnabled,
MinimapSpan,
ThemeLow,
ThemeInRange,
ThemeHigh,
ThemeUrgent,
ThemePrediction,
ThemeGraph,
Colorblind,
}
impl Field {
pub const ALL: [Field; 27] = [
Field::Units,
Field::Refresh,
Field::Desktop,
Field::Sound,
Field::Snooze,
Field::QuietHours,
Field::QuietStart,
Field::QuietEnd,
Field::QuietUrgentLow,
Field::Escalate,
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::Units => "Units",
Field::Refresh => "Refresh interval",
Field::Desktop => "Desktop notifications",
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::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::Units | Field::Refresh => "General",
Field::Desktop
| Field::Sound
| Field::Snooze
| Field::QuietHours
| Field::QuietStart
| Field::QuietEnd
| Field::QuietUrgentLow
| Field::Escalate => "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 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 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_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,
next_retry_at: Option<i64>,
pub last_error: Option<String>,
pub should_quit: 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,
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_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,
next_retry_at: None,
last_error: None,
should_quit: false,
}
}
pub fn mark_online(&mut self, now_ms: i64) {
self.online = true;
self.last_ok_ms = Some(now_ms);
self.fetch_fails = 0;
self.next_retry_at = None;
self.last_error = None;
}
pub fn mark_offline(&mut self, now_ms: i64, err: String) {
self.online = false;
self.fetch_fails = self.fetch_fails.saturating_add(1);
let secs = (5u64 << (self.fetch_fails.min(4) - 1)).min(60);
self.next_retry_at = Some(now_ms + secs as i64 * 1000);
self.last_error = Some(err);
}
pub fn should_retry(&self, now_ms: i64) -> bool {
!self.online && self.view.is_live() && self.next_retry_at.is_some_and(|t| now_ms >= t)
}
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_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();
}
pub fn evaluate_alert(&mut self, now_ms: i64) -> Alert {
self.alert = if self.view.is_live() {
match self.latest() {
Some(e) => alert::evaluate(e.sgv, now_ms - e.date, &self.alerts),
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.low <= self.alerts.low {
return Some((false, ((p.at_ms - now_ms) / 60_000).max(0)));
}
if p.high >= self.alerts.high {
return Some((true, ((p.at_ms - now_ms) / 60_000).max(0)));
}
}
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_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.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 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)
}
Field::Low => self.alerts.low = clamp_bg(self.alerts.low + d * step_mgdl),
Field::High => self.alerts.high = clamp_bg(self.alerts.high + d * step_mgdl),
Field::UrgentHigh => {
self.alerts.urgent_high = clamp_bg(self.alerts.urgent_high + d * step_mgdl)
}
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;
}
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")?;
std::fs::write(&p, body).with_context(|| format!("failed to write {}", p.display()))?;
Ok(p)
});
self.status = Some(match result {
Ok(p) => 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(),
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::PredictHorizon => {
if self.alerts.predict_horizon_minutes == 0 {
"off".to_string()
} else {
format!("{} min", self.alerts.predict_horizon_minutes)
}
}
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)
}