sugarrush 2026.8.3

A terminal UI for viewing Nightscout CGM (blood glucose sensor) data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
//! One-shot status-bar output for whatever bar you run.
//!
//! Every bar wants the same three facts — the reading, how it's moving, and how
//! worried to look — and disagrees only about syntax. So this builds one
//! [`Status`] and renders it per format: Waybar's JSON, i3blocks' three-line
//! protocol, polybar's and tmux's inline colour tags, or plain text for
//! everything else (a shell prompt, a macOS menu-bar helper, `watch`).
//!
//! It always prints something. A bar showing a stale dash is a bar you can
//! still read; a bar showing nothing looks like it works.

use anyhow::Result;
use ratatui::style::Color;
use serde_json::json;

use crate::units::Units;

use crate::alert::{self, Alert};
use crate::config::Config;
use crate::nightscout::{Client, Entry};

const HOUR_MS: i64 = 3_600_000;
const BARS: [char; 8] = ['', '', '', '', '', '', '', ''];

/// Output syntax for a status bar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    /// One JSON object, in the shape Waybar's custom modules read. Every bar
    /// that takes JSON reads the same document, which is why the format is
    /// named for the syntax rather than for Waybar.
    Json,
    /// i3blocks: full text, short text, colour — one per line.
    I3blocks,
    /// polybar: inline `%{F#rrggbb}` colour tags.
    Polybar,
    /// tmux: inline `#[fg=#rrggbb]` style tags.
    Tmux,
    /// Plain text, no markup.
    Text,
}

impl Format {
    /// Parse a `--format` value. `None` for anything unrecognised, so the
    /// caller can say which formats exist rather than silently picking one.
    pub fn parse(name: &str) -> Option<Self> {
        match name.to_ascii_lowercase().as_str() {
            // `waybar` came first and is in every config that predates the
            // rename, so it keeps working; `bar` reads better next to the
            // other bar-shaped names.
            "json" | "waybar" | "bar" => Some(Format::Json),
            "i3blocks" => Some(Format::I3blocks),
            "polybar" => Some(Format::Polybar),
            "tmux" => Some(Format::Tmux),
            "text" | "plain" => Some(Format::Text),
            _ => None,
        }
    }

    pub const NAMES: &'static str = "text, json (waybar, bar), i3blocks, polybar, tmux";
}

/// Everything a status bar might want, resolved once.
#[derive(Debug, Clone)]
pub struct Status {
    /// The reading, in display units — or `—` when there's nothing to show.
    pub value: String,
    /// The display unit the value is in — the one fact that tells a bar what
    /// kind of measurement it is showing.
    pub units: &'static str,
    pub arrow: String,
    pub delta: String,
    pub state: Alert,
    pub tooltip: String,
    /// Position between the urgent bounds, 0–100.
    pub percentage: u8,
    pub color: Color,
    /// The last hour as `(epoch_ms, display_value)`, oldest first, so a bar
    /// can draw the shape of it rather than only the number.
    pub series: Vec<(i64, f64)>,
    /// Where the reading is heading, when predictive alerts are switched on.
    pub forecast: Option<crate::predict::Outlook>,
    /// Set when the forecast — not the reading — is what the colour is saying.
    predicted: Option<Alert>,
}

impl Status {
    /// The CSS-style class a bar styles from. A predicted crossing is named as
    /// one, never as the state itself: the reading has not crossed anything,
    /// and a bar that cannot tell them apart would cry wolf.
    pub fn class(&self) -> String {
        match self.predicted {
            Some(a) => format!("predicted-{}", a.class()),
            None => self.state.class().to_string(),
        }
    }

    /// The tooltip line a predicted crossing earns, if any. A bar that shows
    /// only a colour change would say something is wrong without saying what.
    pub fn forecast_line(&self) -> Option<String> {
        let ahead = self.predicted?;
        let forecast = self.forecast.as_ref()?;
        Some(format!(
            "{} in {} min · {}",
            forecast.value,
            forecast.in_min,
            ahead.label()
        ))
    }

    /// Let a forecast colour a reading that is still in range.
    ///
    /// Only from in-range outwards: an alarm that is already sounding must
    /// never be repainted by a projection that says it is about to stop, and a
    /// projection is not evidence that anything has happened yet.
    pub fn apply_forecast(&mut self, theme: &crate::theme::Theme) {
        if self.state != Alert::InRange {
            return;
        }
        let Some(forecast) = &self.forecast else {
            return;
        };
        let ahead = forecast.alert;
        if ahead == Alert::InRange {
            return;
        }
        self.color = ahead.color(theme);
        self.predicted = Some(ahead);
    }
}

impl Status {
    /// Drop the parts the config switches off.
    ///
    /// At the source rather than per format: a part that is off is off on
    /// every bar, and a bar composing its own line from the JSON keys sees the
    /// same reading the plain text says. The value, the marker and the colour
    /// are never dropped — a pill with no reading in it, or an alarm with
    /// nothing to say it is one, is worse than a crowded one.
    pub fn apply_bar(&mut self, bar: &crate::config::BarConfig) {
        if !bar.arrow {
            self.arrow.clear();
        }
        if !bar.delta {
            self.delta.clear();
        }
        if !bar.units {
            self.units = "";
        }
        if !bar.sparkline {
            self.series.clear();
        }
    }

    /// The compact one-liner every format is built from.
    ///
    /// Non-in-range states carry a text marker, not just a colour: only the
    /// Waybar format emits a CSS class, so on polybar, tmux and i3blocks the
    /// colour *was* the entire signal — and `--format text`, the one a shell
    /// prompt or a screen reader consumes, had no state at all.
    pub fn text(&self) -> String {
        let mut line = format!("{}{}", self.marker(), self.value);
        // Joined rather than formatted with fixed gaps: a part switched off in
        // the config must leave no double space behind it, because a bar
        // renders the string it is handed.
        for part in [&self.arrow, &self.delta] {
            if !part.is_empty() {
                line.push(' ');
                line.push_str(part);
            }
        }
        line
    }

    /// A terse severity prefix: `!!` urgent, `!` out of range, `?` no data.
    pub fn marker(&self) -> &'static str {
        match self.state {
            Alert::UrgentLow | Alert::UrgentHigh => "!! ",
            Alert::Low | Alert::High => "! ",
            Alert::Stale => "? ",
            Alert::InRange => "",
        }
    }

    /// What fits when the bar is short on room: value and arrow only.
    pub fn short_text(&self) -> String {
        let mut line = format!("{}{}", self.marker(), self.value);
        if !self.arrow.is_empty() {
            line.push(' ');
            line.push_str(&self.arrow);
        }
        line
    }

    /// Render for a bar.
    pub fn render(&self, format: Format) -> String {
        let hex = crate::theme::hex(self.color);
        match format {
            // `color` is not part of Waybar's schema — Waybar ignores it, and
            // it styles the module from the class via CSS. It is here for bars
            // that have no stylesheet to write, notably the Quickshell widget,
            // so they can follow the configured sugarrush theme.
            Format::Json => json!({
                "text": self.text(),
                "tooltip": self.tooltip,
                "class": self.class(),
                "percentage": self.percentage,
                "color": hex,
                // The parts as well as the line, so a bar can compose its own
                // — the Quickshell widget puts the unit after the value —
                // without parsing `text` back apart.
                "value": self.value,
                "units": self.units,
                "arrow": self.arrow,
                "delta": self.delta,
                // The shape of the last hour, and where it is heading: a bar
                // that draws a sparkline should not have to fetch twice.
                "series": self.series,
                "forecast": self.forecast,
            })
            .to_string(),
            // i3blocks reads three lines: full text, short text, colour.
            Format::I3blocks => format!("{}\n{}\n{hex}", self.text(), self.short_text()),
            Format::Polybar => format!("%{{F{hex}}}{}%{{F-}}", self.text()),
            Format::Tmux => format!("#[fg={hex}]{}#[default]", self.text()),
            // No markup: whoever consumes this can colour it themselves, and a
            // prompt or a log is better off without escape codes in it.
            Format::Text => self.text(),
        }
    }
}

/// Fetch the last hour and build the status. Never fails: an error becomes a
/// stale-looking status carrying the message, so the bar keeps rendering.
pub async fn status(cfg: &Config) -> Status {
    let mut status = match build(cfg).await {
        Ok(s) => s,
        Err(e) => Status {
            value: "".into(),
            units: cfg.units.label(),
            arrow: String::new(),
            delta: String::new(),
            state: Alert::Stale,
            tooltip: format!("sugarrush: {e}"),
            percentage: 0,
            color: cfg.theme.resolve().urgent,
            series: Vec::new(),
            forecast: None,
            predicted: None,
        },
    };
    status.apply_bar(&cfg.bar);
    status
}

async fn build(cfg: &Config) -> Result<Status> {
    let sites = cfg.resolve_sites()?;
    let site = sites
        .first()
        .ok_or_else(|| anyhow::anyhow!("no site configured"))?;
    let client = Client::for_site(site)?;

    let now = chrono::Utc::now().timestamp_millis();
    let entries = client.entries_range(now - HOUR_MS, now, 100).await?;
    let theme = cfg.theme.resolve();
    let units = cfg.units;
    let alerts = site.resolve_alerts(&cfg.alerts, units).0;

    let Some(latest) = entries.first() else {
        return Ok(Status {
            value: "".into(),
            units: units.label(),
            arrow: String::new(),
            delta: String::new(),
            state: Alert::Stale,
            tooltip: "sugarrush: no recent readings".into(),
            percentage: 0,
            color: theme.urgent,
            series: Vec::new(),
            forecast: None,
            predicted: None,
        });
    };

    let state = alert::evaluate(latest.sgv, now - latest.date, &alerts);
    let delta = entries
        .get(1)
        .map(|prev| units.format_delta(latest.sgv - prev.sgv))
        .unwrap_or_else(|| "--".into());

    // Oldest → newest for the sparkline.
    let values: Vec<f64> = entries.iter().rev().map(|e: &Entry| e.sgv).collect();
    let age_min = ((now - latest.date) / 60_000).max(0);
    let tooltip = format!(
        "{} {}  {}\nΔ {} · {}m ago\n{}",
        units.format(latest.sgv),
        units.label(),
        latest.direction.as_deref().unwrap_or("?"),
        delta,
        age_min,
        sparkline(&values),
    );

    let span = (alerts.urgent_high - alerts.urgent_low).max(1.0);
    let percentage = (((latest.sgv - alerts.urgent_low) / span * 100.0).clamp(0.0, 100.0)) as u8;

    // Oldest first, in display units: a bar draws it left to right, and every
    // other number in this document is already in the unit someone reads in.
    let series = entries
        .iter()
        .rev()
        .map(|e: &Entry| (e.date, scaled(units, e.sgv)))
        .collect();
    // Gated on the same switch as the predictive notification: someone who
    // turned forecasts off should not have one colouring their bar.
    let forecast = (alerts.predict_horizon_minutes > 0)
        .then(|| crate::predict::outlook(&entries, &alerts, units))
        .flatten();

    let mut status = Status {
        value: units.format(latest.sgv),
        units: units.label(),
        arrow: latest.arrow().to_string(),
        delta,
        state,
        tooltip,
        percentage,
        color: state.color(&theme),
        series,
        forecast,
        predicted: None,
    };
    status.apply_forecast(&theme);
    if let Some(line) = status.forecast_line() {
        status.tooltip = format!("{}\n{line}", status.tooltip);
    }
    Ok(status)
}

/// A reading in display units, rounded the way that unit is written.
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(),
    }
}

/// An 8-level block sparkline over the values (min→max normalized).
fn sparkline(values: &[f64]) -> String {
    if values.is_empty() {
        return String::new();
    }
    let (min, max) = values
        .iter()
        .fold((f64::MAX, f64::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v)));
    let range = (max - min).max(1.0);
    values
        .iter()
        .map(|&v| {
            let level = ((v - min) / range * (BARS.len() - 1) as f64).round() as usize;
            BARS[level.min(BARS.len() - 1)]
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::theme::Theme;

    fn status() -> Status {
        Status {
            value: "5.6".into(),
            units: "mmol/L",
            arrow: "".into(),
            delta: "+0.2".into(),
            state: Alert::InRange,
            tooltip: "tip".into(),
            percentage: 42,
            color: Color::Rgb(0x12, 0x34, 0x56),
            series: Vec::new(),
            forecast: None,
            predicted: None,
        }
    }

    #[test]
    fn the_json_carries_the_shape_of_the_last_hour() {
        let mut s = status();
        s.series = vec![(1_000, 5.0), (1_300, 5.6)];
        let json: serde_json::Value = serde_json::from_str(&s.render(Format::Json)).unwrap();
        assert_eq!(json["series"][0][0], 1_000);
        assert_eq!(json["series"][0][1], 5.0);
        assert_eq!(json["series"][1][1], 5.6);
    }

    #[test]
    fn a_forecast_crossing_colours_a_reading_that_is_still_in_range() {
        let theme = Theme::default();
        let mut s = status();
        s.forecast = Some(crate::predict::Outlook {
            in_min: 30,
            value: 3.4,
            class: "urgent-low",
            alert: Alert::UrgentLow,
        });
        s.apply_forecast(&theme);
        // The reading has not crossed anything, so the value and the state are
        // untouched — but the pill says a low is coming.
        assert_eq!(s.value, "5.6");
        assert_eq!(s.state, Alert::InRange);
        assert_eq!(s.class(), "predicted-urgent-low");
        assert_eq!(s.color, Alert::UrgentLow.color(&theme));
        // And it says so in words, not only in colour.
        assert_eq!(
            s.forecast_line().as_deref(),
            Some("3.4 in 30 min · URGENT LOW")
        );
    }

    #[test]
    fn a_forecast_never_talks_over_an_alarm_that_is_already_sounding() {
        let theme = Theme::default();
        let mut s = status();
        s.state = Alert::UrgentLow;
        s.color = Alert::UrgentLow.color(&theme);
        // A rebound after a correction: the projection lands high while the
        // reading is still urgently low. What is happening beats what might.
        s.forecast = Some(crate::predict::Outlook {
            in_min: 30,
            value: 11.2,
            class: "high",
            alert: Alert::High,
        });
        s.apply_forecast(&theme);
        assert_eq!(s.class(), "urgent-low");
        assert_eq!(s.color, Alert::UrgentLow.color(&theme));
        assert_eq!(s.forecast_line(), None, "nothing to add to the tooltip");
    }

    #[test]
    fn format_names_are_forgiving_but_not_guessy() {
        // `json` is the name; `waybar` is what every config written before
        // the rename says, and must keep working.
        assert_eq!(Format::parse("json"), Some(Format::Json));
        assert_eq!(Format::parse("waybar"), Some(Format::Json));
        assert_eq!(Format::parse("bar"), Some(Format::Json));
        assert_eq!(Format::parse("JSON"), Some(Format::Json));
        assert_eq!(Format::parse("Tmux"), Some(Format::Tmux));
        assert_eq!(Format::parse("plain"), Some(Format::Text));
        // An unknown name is rejected rather than silently defaulted.
        assert_eq!(Format::parse("i3"), None);
        assert_eq!(Format::parse(""), None);
    }

    #[test]
    fn each_bar_gets_its_own_syntax() {
        let s = status();
        assert_eq!(s.render(Format::Text), "5.6 → +0.2");
        assert_eq!(s.render(Format::Polybar), "%{F#123456}5.6 → +0.2%{F-}");
        assert_eq!(s.render(Format::Tmux), "#[fg=#123456]5.6 → +0.2#[default]");
        // i3blocks: full text, short text, colour — in that order.
        assert_eq!(s.render(Format::I3blocks), "5.6 → +0.2\n5.6 →\n#123456");
    }

    #[test]
    fn waybar_output_is_json_with_the_state_class() {
        let json: serde_json::Value = serde_json::from_str(&status().render(Format::Json)).unwrap();
        assert_eq!(json["text"], "5.6 → +0.2");
        assert_eq!(json["class"], "in-range");
        assert_eq!(json["percentage"], 42);
        assert_eq!(json["tooltip"], "tip");
        // Waybar ignores the extra key; a QML bar widget uses it to follow the
        // configured sugarrush theme instead of hard-coding its own palette.
        assert_eq!(json["color"], "#123456");
        // The parts, so a bar can compose its own line — putting the unit
        // after the value, say — instead of parsing `text` back apart.
        assert_eq!(json["value"], "5.6");
        assert_eq!(json["units"], "mmol/L");
        assert_eq!(json["arrow"], "");
        assert_eq!(json["delta"], "+0.2");
    }

    #[test]
    fn urgent_states_use_the_urgent_colour() {
        let theme = Theme::default();
        for state in [Alert::UrgentLow, Alert::UrgentHigh, Alert::Stale] {
            assert_eq!(state.color(&theme), theme.urgent);
        }
        assert_eq!(Alert::InRange.color(&theme), theme.in_range);
        assert_eq!(Alert::High.color(&theme), theme.high);
        assert_eq!(Alert::Low.color(&theme), theme.low);
    }

    #[test]
    fn a_part_switched_off_leaves_no_gap_where_it_was() {
        let mut s = status();
        s.apply_bar(&crate::config::BarConfig {
            arrow: false,
            delta: true,
            units: true,
            sparkline: true,
        });
        // Not "5.6  +0.2": a bar renders the string it is handed, so the
        // spacing has to be right at the source.
        assert_eq!(s.render(Format::Text), "5.6 +0.2");
        assert_eq!(s.render(Format::I3blocks), "5.6 +0.2\n5.6\n#123456");

        let mut s = status();
        s.apply_bar(&crate::config::BarConfig {
            arrow: true,
            delta: false,
            units: true,
            sparkline: true,
        });
        assert_eq!(s.render(Format::Text), "5.6 \u{2192}");
    }

    #[test]
    fn a_part_switched_off_is_absent_from_the_json_too() {
        let mut s = status();
        s.series = vec![(1_000, 5.0), (1_300, 5.6)];
        s.apply_bar(&crate::config::BarConfig {
            arrow: false,
            delta: false,
            units: false,
            sparkline: false,
        });
        let json: serde_json::Value = serde_json::from_str(&s.render(Format::Json)).unwrap();
        // Empty, not missing: a bar composing its own line reads the keys it
        // expects, and a missing one would read as an older sugarrush.
        assert_eq!(json["arrow"], "");
        assert_eq!(json["delta"], "");
        assert_eq!(json["units"], "");
        assert_eq!(json["series"].as_array().map(Vec::len), Some(0));
        // The reading itself is never dropped.
        assert_eq!(json["value"], "5.6");
        assert_eq!(json["text"], "5.6");
    }

    #[test]
    fn an_out_of_range_marker_survives_every_part_being_off() {
        let mut s = status();
        s.state = Alert::UrgentLow;
        s.apply_bar(&crate::config::BarConfig {
            arrow: false,
            delta: false,
            units: false,
            sparkline: false,
        });
        assert_eq!(s.render(Format::Text), "!! 5.6");
        assert_eq!(s.short_text(), "!! 5.6");
    }

    /// The error branch, which needs no network: a config with no site fails
    /// in `build` before a client is made. It is the same tail of `status` the
    /// live path takes, so it pins the one thing the unit tests above cannot —
    /// that the config is actually consulted.
    #[tokio::test]
    async fn the_config_reaches_the_status_the_bar_is_handed() {
        let mut cfg = crate::config::Config::demo();
        cfg.url = None;
        cfg.token = None;
        cfg.bar.units = false;
        let s = super::status(&cfg).await;
        assert_eq!(s.state, Alert::Stale, "no site configured");
        assert_eq!(s.units, "", "the config was not applied");

        let mut cfg = crate::config::Config::demo();
        cfg.url = None;
        cfg.token = None;
        let s = super::status(&cfg).await;
        assert_eq!(s.units, "mmol/L");
    }

    #[test]
    fn every_part_is_on_by_default() {
        let mut s = status();
        s.series = vec![(1_000, 5.0), (1_300, 5.6)];
        s.apply_bar(&crate::config::BarConfig::default());
        assert_eq!(s.render(Format::Text), "5.6 \u{2192} +0.2");
        let json: serde_json::Value = serde_json::from_str(&s.render(Format::Json)).unwrap();
        assert_eq!(json["units"], "mmol/L");
        assert_eq!(json["series"].as_array().map(Vec::len), Some(2));
    }

    #[test]
    fn sparkline_maps_range_to_bars() {
        assert_eq!(sparkline(&[]), "");
        let s = sparkline(&[100.0, 150.0, 200.0]);
        assert_eq!(s.chars().count(), 3);
        assert_eq!(s.chars().next(), Some(''));
        assert_eq!(s.chars().last(), Some(''));
    }
}