Skip to main content

ai_usagebar/
notify.rs

1//! Quota-threshold desktop notifications.
2//!
3//! After a *fresh* fetch (never a cached, stale, or failed one — see
4//! [`crate::outcome::Outcome::off_the_wire`]), the metric rows a refresh
5//! produced are checked against a configurable threshold. A window that
6//! crosses the threshold raises one notification per crossing, and a banked
7//! reset credit that expires soon raises one per credit.
8//!
9//! Layout:
10//! - [`decide`] is the pure decision function: rows + threshold + previous
11//!   state + now → notifications, with two-band urgency and hysteresis
12//!   re-arming.
13//! - [`NotifyState`] is the persisted dedupe map
14//!   (`~/.cache/ai-usagebar/notifications.json`), written atomically under
15//!   the same flock discipline as the vendor caches.
16//! - [`NotifySink`] delivers through `notify-send` on Linux and Notification
17//!   Center on macOS, without changing the decision or persisted state.
18//!
19//! Everything here is best-effort: a missing notifier, an unwritable state
20//! file, or a contended lock is a silent skip — a missed notification beats
21//! a double one, and nothing in this module may change a caller's return
22//! value or exit code.
23//!
24//! No burn-rate extrapolation anywhere: reset times in bodies are only the
25//! vendor-reported absolute instants.
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34use crate::countdown;
35use crate::display::sanitize_untrusted_line;
36use crate::format;
37
38/// Hysteresis band, in percentage points. A notified key re-arms only when
39/// usage drops `threshold - 7` or further, so a window hovering at the
40/// threshold (97 → 96 → 97) does not re-fire on every refresh.
41const HYSTERESIS_PCT: i32 = 7;
42
43/// How much later a reported reset instant has to be before it counts as a new
44/// window. Vendors report that instant with sub-second precision that jitters
45/// between fetches — Anthropic's five-hour window came back 0.7s apart on two
46/// fetches four minutes apart — and a rolling window slides its reset forward
47/// with the refresh interval, so a strict comparison re-armed the key and
48/// re-notified on refresh with nothing changed. Ninety minutes clears every
49/// refresh interval this ships with (the bar's default is five minutes and its
50/// maximum an hour; the tray refreshes every ten at most) and stays far below
51/// the shortest window, so a real new window still moves the instant by more
52/// than this.
53const RESET_MOVE_TOLERANCE_SECS: i64 = 90 * 60;
54
55/// How long before a banked reset credit's expiry the warning fires.
56const CREDIT_WARNING_SECS: i64 = 48 * 3600;
57
58/// How long to wait for the state lock before skipping the whole check.
59/// Deliberately short: notifications are never worth stalling a refresh for.
60const LOCK_TIMEOUT: Duration = Duration::from_secs(2);
61
62/// A notifier that hangs must not hang the bar with it.
63#[cfg(any(target_os = "linux", target_os = "macos"))]
64const SPAWN_KILL_AFTER: Duration = Duration::from_secs(5);
65
66/// Timeout a critical toast asks `notify-send` for, in milliseconds.
67///
68/// Omarchy's notification plugin treats critical urgency as immortal and
69/// ignores the sender's timeout, so a 100% toast stays until it is dismissed.
70/// [omacom/omarchy#12964](https://github.com/omacom/omarchy/pull/12964)
71/// honours a positive timeout instead, capped at 30s, while a critical toast
72/// sent without one still stays — hence asking for the cap.
73#[cfg(target_os = "linux")]
74const CRITICAL_TIMEOUT_MS: &str = "30000";
75
76/// Urgency band for one notification. `notify-send`'s `-u` maps directly.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Urgency {
79    /// `threshold <= pct < 100`.
80    Normal,
81    /// The window is exhausted (`pct >= 100`).
82    Critical,
83}
84
85#[cfg(target_os = "linux")]
86impl Urgency {
87    fn as_arg(self) -> &'static str {
88        match self {
89            Self::Normal => "normal",
90            Self::Critical => "critical",
91        }
92    }
93}
94
95/// One notification to deliver. `key` is the dedupe identity the state map
96/// is keyed by; title/body are already sanitized for a subprocess boundary.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Notification {
99    pub title: String,
100    pub body: String,
101    pub urgency: Urgency,
102    pub key: String,
103}
104
105/// One metric row of a refresh, as the shared panel projection shapes it.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct MetricRow {
108    /// Window label ("Session (5h)", "Weekly (7d)"). Stable per vendor, so it
109    /// is the window half of the dedupe key.
110    pub window: String,
111    pub percent: u16,
112    /// Vendor-reported absolute reset instant. `None` when the vendor does
113    /// not state one — then the body carries no ETA, by rule.
114    pub reset_at: Option<DateTime<Utc>>,
115}
116
117/// One banked reset credit with a known expiry (Codex/SuperGrok
118/// `reset_credits.credits[]`).
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CreditExpiry {
121    pub title: Option<String>,
122    pub expires_at: DateTime<Utc>,
123}
124
125/// The notification-relevant projection of one vendor entry's refresh.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct RefreshInput {
128    /// The report entry id (`vendor` or `vendor@account`) — the entry half of
129    /// the dedupe key.
130    pub entry_id: String,
131    /// Canonical vendor display name (`VendorId::display_name`, or the
132    /// configured name of a `[[custom]]` provider).
133    pub vendor: String,
134    pub account: Option<String>,
135    pub rows: Vec<MetricRow>,
136    pub credits: Vec<CreditExpiry>,
137}
138
139/// Decide which notifications one refresh raises.
140///
141/// `state` is mutated to the post-decision map; the caller persists it (see
142/// [`run_at`]) so re-arms survive restarts. Rules:
143///
144/// - Fire when `percent >= threshold` and the key is not marked notified.
145///   Band: `>= 100` is [`Urgency::Critical`], otherwise [`Urgency::Normal`].
146/// - A key re-arms when usage drops below `threshold - 7`, or when the
147///   window's `reset_at` moves to a later instant (a new window). The same
148///   crossing therefore never fires twice.
149/// - A credit expiring within 48h (and not yet expired) fires once per
150///   expiry instant, at [`Urgency::Normal`].
151pub fn decide(
152    input: &RefreshInput,
153    threshold: u8,
154    state: &mut NotifyState,
155    now: DateTime<Utc>,
156) -> Vec<Notification> {
157    migrate_anthropic_api_spend_key(input, state);
158    let mut fired = Vec::new();
159    let threshold = threshold as i32;
160    let rearm_below = threshold - HYSTERESIS_PCT;
161    for row in &input.rows {
162        let key = format!("{}::{}", input.entry_id, row.window);
163        let pct = row.percent as i32;
164        if pct >= threshold {
165            let armed = match state.entries.get(&key) {
166                None => true,
167                Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
168            };
169            if armed {
170                fired.push(threshold_notification(input, row, now));
171                state.entries.insert(
172                    key,
173                    KeyRecord {
174                        notified_at: now,
175                        reset_at_snapshot: row.reset_at,
176                    },
177                );
178            }
179        } else if pct < rearm_below {
180            // Drop below the hysteresis band: forget the crossing so the next
181            // one can fire. Persisted by the caller like any other change.
182            state.entries.remove(&key);
183        }
184    }
185    for credit in &input.credits {
186        let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
187        if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
188            continue;
189        }
190        let key = format!(
191            "{}::credit::{}",
192            input.entry_id,
193            credit
194                .expires_at
195                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
196        );
197        if state.entries.contains_key(&key) {
198            continue;
199        }
200        fired.push(credit_notification(input, credit, now));
201        state.entries.insert(
202            key,
203            KeyRecord {
204                notified_at: now,
205                reset_at_snapshot: None,
206            },
207        );
208    }
209    fired
210}
211
212/// Version 1.41.0 used the last covered day as Anthropic API's spend-window
213/// identity. Collapse those daily keys into the stable row before deciding so
214/// an upgrade neither repeats a notification nor leaves unbounded stale state.
215fn migrate_anthropic_api_spend_key(input: &RefreshInput, state: &mut NotifyState) {
216    if input.entry_id != "anthropic_api" && !input.entry_id.starts_with("anthropic_api@") {
217        return;
218    }
219    let prefix = format!("{}::", input.entry_id);
220    let stable = format!("{prefix}Spend (mo)");
221    let legacy: Vec<String> = state
222        .entries
223        .keys()
224        .filter(|key| {
225            let Some(label) = key.strip_prefix(&prefix) else {
226                return false;
227            };
228            label == "Completed daily buckets this month"
229                || (label.starts_with("Month to date (through ") && label.ends_with(')'))
230        })
231        .cloned()
232        .collect();
233    if legacy.is_empty() {
234        return;
235    }
236    // An already-stable record is authoritative. Otherwise keep the newest
237    // daily record, since it represents the most recent delivered crossing.
238    let mut chosen = state.entries.get(&stable).cloned();
239    if chosen.is_none() {
240        chosen = legacy
241            .iter()
242            .filter_map(|key| state.entries.get(key).cloned())
243            .max_by_key(|record| record.notified_at);
244    }
245    for key in legacy {
246        state.entries.remove(&key);
247    }
248    if let Some(record) = chosen {
249        state.entries.insert(stable, record);
250    }
251}
252
253/// `true` when `current` is a later reset than `snapshot` — a `None → Some`
254/// change counts (the vendor started reporting a reset), `Some → None` and
255/// backwards moves do not, and so do not moves within
256/// [`RESET_MOVE_TOLERANCE_SECS`], which are the same window reported again.
257fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
258    match (current, snapshot) {
259        (Some(current), Some(snapshot)) => {
260            current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
261        }
262        (Some(_), None) => true,
263        _ => false,
264    }
265}
266
267fn threshold_notification(
268    input: &RefreshInput,
269    row: &MetricRow,
270    now: DateTime<Utc>,
271) -> Notification {
272    let who = match &input.account {
273        Some(account) => format!("{} · {}", input.vendor, account),
274        None => input.vendor.clone(),
275    };
276    let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
277    let mut body = format!("{}% of the {} used", row.percent, row.window);
278    if let Some(reset_at) = row.reset_at {
279        body.push_str(&format!(
280            " · resets {} ({})",
281            countdown::format(Some(reset_at), now),
282            format::local_time_hm(reset_at)
283        ));
284    }
285    Notification {
286        title,
287        body: sanitize_untrusted_line(&body),
288        urgency: if row.percent >= 100 {
289            Urgency::Critical
290        } else {
291            Urgency::Normal
292        },
293        key: format!("{}::{}", input.entry_id, row.window),
294    }
295}
296
297fn credit_notification(
298    input: &RefreshInput,
299    credit: &CreditExpiry,
300    now: DateTime<Utc>,
301) -> Notification {
302    let label = credit.title.as_deref().unwrap_or("Reset credit");
303    let title = sanitize_untrusted_line(&format!(
304        "{} — reset credit expires {}",
305        input.vendor,
306        format::local_date_hm(credit.expires_at)
307    ));
308    let body = sanitize_untrusted_line(&format!(
309        "{} — redeem within {} ({})",
310        label,
311        countdown::format(Some(credit.expires_at), now),
312        format::local_time_hm(credit.expires_at)
313    ));
314    Notification {
315        title,
316        body,
317        urgency: Urgency::Normal,
318        key: format!(
319            "{}::credit::{}",
320            input.entry_id,
321            credit
322                .expires_at
323                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
324        ),
325    }
326}
327
328// ─── Persisted dedupe state ────────────────────────────────────────────────
329
330/// Map of dedupe key → when it was notified and the reset instant that was
331/// current at the time. `notified_at` is bookkeeping (inspectable state);
332/// re-arming reads `reset_at_snapshot`. On disk this is the bare map, not a
333/// wrapper object.
334#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(transparent)]
336pub struct NotifyState {
337    entries: BTreeMap<String, KeyRecord>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341struct KeyRecord {
342    notified_at: DateTime<Utc>,
343    reset_at_snapshot: Option<DateTime<Utc>>,
344}
345
346impl NotifyState {
347    #[cfg(test)]
348    fn is_notified(&self, key: &str) -> bool {
349        self.entries.contains_key(key)
350    }
351
352    /// Read the state file. Missing or corrupt counts as nothing notified —
353    /// a corrupt file must never pin a vendor silent or double-fire forever.
354    fn read_from(path: &Path) -> Self {
355        let Ok(raw) = std::fs::read_to_string(path) else {
356            return Self::default();
357        };
358        serde_json::from_str(&raw).unwrap_or_default()
359    }
360
361    /// Atomic write (tempfile + persist) with mode 0600, matching the cache
362    /// sidecar discipline.
363    fn write_to(&self, path: &Path) -> crate::error::Result<()> {
364        let bytes = serde_json::to_vec(&self.entries)?;
365        crate::cache::atomic_write(path, &bytes)?;
366        #[cfg(unix)]
367        {
368            use std::os::unix::fs::PermissionsExt;
369            if let Ok(meta) = std::fs::metadata(path) {
370                let mut perms = meta.permissions();
371                perms.set_mode(0o600);
372                let _ = std::fs::set_permissions(path, perms);
373            }
374        }
375        Ok(())
376    }
377}
378
379// ─── Delivery seam ─────────────────────────────────────────────────────────
380
381/// Delivery backend. Implementations own one platform each.
382///
383/// `deliver` returns nothing and must never panic: a failing or missing
384/// notifier is ALWAYS a silent no-op, and nothing in this module may change
385/// a caller's return value or exit code.
386pub trait NotifySink {
387    fn deliver(&mut self, notification: &Notification);
388}
389
390/// The do-nothing sink for platforms without a desktop delivery backend.
391#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
392#[derive(Debug, Default)]
393pub struct NoopSink;
394
395#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
396impl NotifySink for NoopSink {
397    fn deliver(&mut self, _notification: &Notification) {}
398}
399
400/// Linux `notify-send` sink. argv only — never a shell — with stderr nulled:
401/// the notification daemon's complaints are not the bar's problem. A missing
402/// binary or a failed spawn is swallowed; a hanging one is killed after
403/// [`SPAWN_KILL_AFTER`].
404#[cfg(target_os = "linux")]
405#[derive(Debug, Clone)]
406pub struct NotifySendSink {
407    program: String,
408}
409
410#[cfg(target_os = "linux")]
411impl Default for NotifySendSink {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417#[cfg(target_os = "linux")]
418impl NotifySendSink {
419    pub fn new() -> Self {
420        Self {
421            program: "notify-send".to_string(),
422        }
423    }
424
425    /// Point at a specific executable — the seam the failing-notifier guard
426    /// test uses, and the way a future desktop integration pins a path.
427    pub fn with_program(program: impl Into<String>) -> Self {
428        Self {
429            program: program.into(),
430        }
431    }
432
433    /// `notify-send` arguments for one notification, program name aside.
434    ///
435    /// The title and body come last: `notify-send` reads the trailing
436    /// positionals as those two, so every switch has to precede them.
437    fn argv(&self, notification: &Notification) -> Vec<String> {
438        let mut args = vec![
439            "-a".to_string(),
440            "ai-usagebar".to_string(),
441            "-c".to_string(),
442            "quota".to_string(),
443            "-u".to_string(),
444            notification.urgency.as_arg().to_string(),
445        ];
446        if notification.urgency == Urgency::Critical {
447            args.push("-t".to_string());
448            args.push(CRITICAL_TIMEOUT_MS.to_string());
449        }
450        args.push(notification.title.clone());
451        args.push(notification.body.clone());
452        args
453    }
454}
455
456#[cfg(target_os = "linux")]
457impl NotifySink for NotifySendSink {
458    fn deliver(&mut self, notification: &Notification) {
459        let spawned = std::process::Command::new(&self.program)
460            .args(self.argv(notification))
461            .stdin(std::process::Stdio::null())
462            .stdout(std::process::Stdio::null())
463            .stderr(std::process::Stdio::null())
464            .spawn();
465        if let Ok(child) = spawned {
466            reap(child);
467        }
468    }
469}
470
471/// Wait for the notifier, killing it at the cap. Desktop delivery normally
472/// returns in milliseconds; the cap only matters when the service is wedged.
473#[cfg(any(target_os = "linux", target_os = "macos"))]
474fn reap(mut child: std::process::Child) {
475    let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
476    loop {
477        match child.try_wait() {
478            Ok(Some(_)) => return,
479            Ok(None) => {}
480            Err(_) => return,
481        }
482        if std::time::Instant::now() >= deadline {
483            let _ = child.kill();
484            let _ = child.wait();
485            return;
486        }
487        std::thread::sleep(Duration::from_millis(25));
488    }
489}
490
491fn production_sink() -> Box<dyn NotifySink + Send> {
492    #[cfg(target_os = "linux")]
493    return Box::new(NotifySendSink::new());
494    #[cfg(target_os = "macos")]
495    return Box::new(MacNotificationSink);
496    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
497    return Box::new(NoopSink);
498}
499
500/// AppleScript's Standard Additions sends a native Notification Center banner.
501/// Passing title and body as argv keeps provider text out of the script source.
502#[cfg(target_os = "macos")]
503struct MacNotificationSink;
504
505#[cfg(target_os = "macos")]
506impl NotifySink for MacNotificationSink {
507    fn deliver(&mut self, notification: &Notification) {
508        const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
509        let spawned = std::process::Command::new("/usr/bin/osascript")
510            .arg("-e")
511            .arg(SCRIPT)
512            .arg("--")
513            .arg(&notification.title)
514            .arg(&notification.body)
515            .stdin(std::process::Stdio::null())
516            .stdout(std::process::Stdio::null())
517            .stderr(std::process::Stdio::null())
518            .spawn();
519        if let Ok(child) = spawned {
520            reap(child);
521        }
522    }
523}
524
525// ─── Orchestration ─────────────────────────────────────────────────────────
526
527/// Production entry point: the real state path and platform sink. Returns
528/// nothing, always — the caller wraps it in `let _ =`.
529pub async fn run(input: RefreshInput, threshold: u8) {
530    let Some(state_path) = default_state_path() else {
531        return;
532    };
533    // The lock wait and the bounded notifier spawn are blocking; the TUI runs
534    // a current-thread runtime, so neither belongs on the reactor thread.
535    let joined = tokio::task::spawn_blocking(move || {
536        let mut sink = production_sink();
537        run_at(
538            &input,
539            threshold,
540            &state_path,
541            LOCK_TIMEOUT,
542            sink.as_mut(),
543            Utc::now(),
544        );
545    });
546    let _ = joined.await;
547}
548
549/// `~/.cache/ai-usagebar/notifications.json` (or under `$XDG_CACHE_HOME`).
550/// Tests never call this — they pass their own path to [`run_at`].
551pub fn default_state_path() -> Option<PathBuf> {
552    crate::cache::xdg_cache_dir()
553        .ok()
554        .map(|base| base.join("ai-usagebar").join("notifications.json"))
555}
556
557/// The whole check, serialized against any concurrent writer by the same
558/// flock pattern the vendor caches use. On lock timeout the check is skipped
559/// entirely — a missed notification beats a double one. State is persisted
560/// BEFORE delivery: a write failure then costs a missed notification (the
561/// preferred failure) rather than a repeat.
562pub fn run_at(
563    input: &RefreshInput,
564    threshold: u8,
565    state_path: &Path,
566    lock_timeout: Duration,
567    sink: &mut dyn NotifySink,
568    now: DateTime<Utc>,
569) {
570    let lock_path = state_path.with_file_name(".notifications.lock");
571    let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
572        return;
573    };
574    let mut state = NotifyState::read_from(state_path);
575    let before = serde_json::to_string(&state.entries).unwrap_or_default();
576    let fired = decide(input, threshold, &mut state, now);
577    if serde_json::to_string(&state.entries).unwrap_or_default() != before
578        && state.write_to(state_path).is_err()
579    {
580        // Could not persist the marks: delivering anyway would repeat the
581        // notification on the next fresh fetch. Skip instead.
582        return;
583    }
584    for notification in &fired {
585        sink.deliver(notification);
586    }
587}
588
589// ─── Shared-assembly projection ────────────────────────────────────────────
590
591impl RefreshInput {
592    /// Project one tab's completed state into the notification input. Rows
593    /// come from the same panel projection every frontend reads; grouped
594    /// sub-rows (SuperGrok's product slices) are skipped — they share the
595    /// overall pool's window, and one notification per pool is the point.
596    /// Banked credits ride along only when the vendor reports them and the
597    /// report would surface them (`available > 0`).
598    pub(crate) fn from_tab(
599        tab: &crate::tui::app::TabId,
600        state: &crate::tui::app::TabState,
601        now: DateTime<Utc>,
602    ) -> Option<Self> {
603        use crate::tui::app::{TabSource, TabState};
604        use crate::tui::panels::sections_with_metadata_for;
605
606        let TabState::Ready(ready) = state else {
607            return None;
608        };
609        let (vendor, entry_id) = match &tab.source {
610            TabSource::Builtin(vendor) => (
611                vendor.display_name().to_string(),
612                crate::report::tab_id(tab),
613            ),
614            TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
615        };
616        // The tolerance only shapes pacing footnotes, which the notification
617        // body never reads; any value behaves identically here.
618        let rows = metric_rows(sections_with_metadata_for(state, now, 0));
619        let credits = ready
620            .snapshot
621            .reset_credits()
622            .filter(|credits| credits.available > 0)
623            .map(|credits| {
624                credits
625                    .credits
626                    .iter()
627                    .filter_map(|credit| {
628                        credit.expires_at.map(|expires_at| CreditExpiry {
629                            title: credit.title.clone(),
630                            expires_at,
631                        })
632                    })
633                    .collect()
634            })
635            .unwrap_or_default();
636        Some(Self {
637            entry_id,
638            vendor,
639            account: tab.account.clone(),
640            rows,
641            credits,
642        })
643    }
644}
645
646/// The metric rows one refresh checks, each named by the window it measures.
647///
648/// Grouped sub-rows are skipped (see [`RefreshInput::from_tab`]). A label is
649/// normally unique within an entry, but Antigravity and MiniMax lay a
650/// `Session` and a `Weekly` heading over the same pool labels, so "Gemini"
651/// named two windows. Sharing one dedupe key, the row under the re-arm band
652/// cleared the record its namesake over the threshold had just written, and
653/// the same notification fired again on every refresh. Metrics under a heading
654/// always include it in their name ("Weekly · Gemini"), even if no other row
655/// currently has the same label: a missing sibling must not change a window's
656/// identity. Metrics without a heading keep their bare names.
657fn metric_rows(
658    sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
659) -> Vec<MetricRow> {
660    use crate::tui::panels::Section;
661
662    // A heading is a text row with no value; it covers the metrics below it
663    // until any other row except a spacer.
664    let mut heading: Option<String> = None;
665    let mut rows = Vec::new();
666    for projected in sections {
667        if projected.group.is_some() {
668            continue;
669        }
670        match projected.section {
671            Section::Text { label, value } if value.is_empty() => heading = Some(label),
672            Section::Metric { label, pct, .. } => {
673                let window = match &heading {
674                    Some(heading) => format!("{heading} · {label}"),
675                    None => label,
676                };
677                rows.push(MetricRow {
678                    window,
679                    percent: pct,
680                    reset_at: projected.reset_at,
681                });
682            }
683            Section::Spacer => {}
684            _ => heading = None,
685        }
686    }
687    rows
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use chrono::TimeZone;
694    use tempfile::TempDir;
695
696    fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
697        Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
698    }
699
700    fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
701        MetricRow {
702            window: window.to_string(),
703            percent,
704            reset_at,
705        }
706    }
707
708    fn input(rows: Vec<MetricRow>) -> RefreshInput {
709        RefreshInput {
710            entry_id: "anthropic@gmail".into(),
711            vendor: "Claude".into(),
712            account: Some("gmail".into()),
713            rows,
714            credits: Vec::new(),
715        }
716    }
717
718    /// Sink that records deliveries instead of spawning anything.
719    #[derive(Default)]
720    struct RecorderSink {
721        delivered: Vec<Notification>,
722    }
723
724    impl NotifySink for RecorderSink {
725        fn deliver(&mut self, notification: &Notification) {
726            self.delivered.push(notification.clone());
727        }
728    }
729
730    fn state_path() -> (TempDir, PathBuf) {
731        crate::cache::closed_temp_file("notifications.json", None)
732    }
733
734    // — Decision: threshold crossing ————————————————
735
736    #[test]
737    fn crossing_at_exactly_the_threshold_fires_normal() {
738        let mut state = NotifyState::default();
739        let now = at(23, 12, 0);
740        let reset = at(24, 12, 0);
741        let fired = decide(
742            &input(vec![row("Session (5h)", 97, Some(reset))]),
743            97,
744            &mut state,
745            now,
746        );
747        assert_eq!(fired.len(), 1);
748        assert_eq!(fired[0].urgency, Urgency::Normal);
749        assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
750        assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
751        // Sep 23 12:00 → Sep 24 12:00 is exactly one day: "1d 0h", with the
752        // reset's local clock time beside it (rendered by the shared helper,
753        // so the assertion holds on any machine timezone).
754        assert_eq!(
755            fired[0].body,
756            format!(
757                "97% of the Session (5h) used · resets 1d 0h ({})",
758                format::local_time_hm(reset)
759            )
760        );
761    }
762
763    #[test]
764    fn below_the_threshold_fires_nothing() {
765        let mut state = NotifyState::default();
766        let fired = decide(
767            &input(vec![row("Session (5h)", 96, None)]),
768            97,
769            &mut state,
770            at(23, 12, 0),
771        );
772        assert!(fired.is_empty());
773        assert!(state.entries.is_empty());
774    }
775
776    #[test]
777    fn exhausted_windows_fire_critical() {
778        let mut state = NotifyState::default();
779        let fired = decide(
780            &input(vec![row("Weekly (7d)", 100, None)]),
781            97,
782            &mut state,
783            at(23, 12, 0),
784        );
785        assert_eq!(fired.len(), 1);
786        assert_eq!(fired[0].urgency, Urgency::Critical);
787        assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
788    }
789
790    #[test]
791    fn the_same_crossing_never_fires_twice() {
792        let mut state = NotifyState::default();
793        let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
794        let now = at(23, 12, 0);
795        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
796        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
797        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
798    }
799
800    // — Decision: hysteresis re-arm ——————————————————
801
802    #[test]
803    fn hysteresis_rearms_only_below_threshold_minus_seven() {
804        let mut state = NotifyState::default();
805        let now = at(23, 12, 0);
806        let fires_at = |pct: u16, state: &mut NotifyState| {
807            let case = input(vec![row("Weekly (7d)", pct, None)]);
808            decide(&case, 97, state, now).len()
809        };
810
811        assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
812        assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
813        // 90 == threshold - 7: still inside the band, still armed-out.
814        assert_eq!(fires_at(90, &mut state), 0);
815        assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
816        // Below the band: the mark is dropped…
817        assert_eq!(fires_at(89, &mut state), 0);
818        assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
819        // …so the next crossing is a new crossing.
820        assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
821    }
822
823    #[test]
824    fn a_jittered_reset_instant_is_the_same_window() {
825        // Observed live on Anthropic's five-hour window: two fetches four
826        // minutes apart reported the same window's reset 0.70s apart, and the
827        // strict comparison re-armed the key and re-notified on refresh with
828        // nothing changed.
829        let mut state = NotifyState::default();
830        let now = at(23, 12, 0);
831        let reset = at(23, 17, 0);
832        let first = input(vec![row("Session (5h)", 100, Some(reset))]);
833        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
834
835        for drift in [
836            chrono::Duration::milliseconds(488),
837            chrono::Duration::milliseconds(700),
838            chrono::Duration::seconds(59),
839        ] {
840            let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
841            assert_eq!(
842                decide(&jittered, 97, &mut state, now).len(),
843                0,
844                "{drift:?} later is the same window"
845            );
846        }
847
848        // A window that really rolled over moves the instant by its length.
849        let next_window = input(vec![row(
850            "Session (5h)",
851            100,
852            Some(reset + chrono::Duration::hours(5)),
853        )]);
854        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
855    }
856
857    #[test]
858    fn a_later_reset_rearms_the_key() {
859        let mut state = NotifyState::default();
860        let now = at(23, 12, 0);
861        let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
862        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
863
864        // Same reset, still above the threshold: no re-fire.
865        assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
866        // Earlier or absent resets are not new windows.
867        let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
868        assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
869        let absent = input(vec![row("Session (5h)", 98, None)]);
870        assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
871        // A later reset is a new window: fires again.
872        let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
873        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
874    }
875
876    #[test]
877    fn a_reset_appearing_where_none_was_reported_rearms() {
878        let mut state = NotifyState::default();
879        let now = at(23, 12, 0);
880        let without = input(vec![row("Weekly quota", 99, None)]);
881        assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
882        assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
883        let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
884        assert_eq!(
885            decide(&with, 97, &mut state, now).len(),
886            1,
887            "the vendor starting to report a reset is a new window"
888        );
889    }
890
891    // — Decision: copy ——————————————————————————————
892
893    #[test]
894    fn absent_reset_at_omits_the_eta_from_the_body() {
895        let mut state = NotifyState::default();
896        let fired = decide(
897            &input(vec![row("Weekly (7d)", 97, None)]),
898            97,
899            &mut state,
900            at(23, 12, 0),
901        );
902        assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
903        assert!(!fired[0].body.contains("resets"));
904    }
905
906    #[test]
907    fn account_label_is_omitted_from_the_title_when_absent() {
908        let mut state = NotifyState::default();
909        let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
910        plain.entry_id = "zai".into();
911        plain.vendor = "Z.AI".into();
912        plain.account = None;
913        let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
914        assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
915    }
916
917    #[test]
918    fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
919        let mut state = NotifyState::default();
920        let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
921        hostile.account = Some("gmail\n\u{202E}evil".into());
922        let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
923        assert!(
924            !fired[0].title.chars().any(|c| c.is_control()),
925            "{}",
926            fired[0].title
927        );
928        assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
929        assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
930    }
931
932    // — Decision: banked credits ————————————————————
933
934    #[test]
935    fn banked_credit_fires_only_inside_the_48h_window() {
936        let now = at(23, 12, 0);
937        for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
938        {
939            let mut state = NotifyState::default();
940            let case = RefreshInput {
941                credits: vec![CreditExpiry {
942                    title: Some("Full reset".into()),
943                    expires_at: now + chrono::Duration::seconds(offset_secs),
944                }],
945                ..input(Vec::new())
946            };
947            let fired = decide(&case, 97, &mut state, now);
948            assert_eq!(fired.len(), expected, "offset {offset_secs}s");
949        }
950    }
951
952    #[test]
953    fn banked_credit_title_and_body_name_the_expiry() {
954        let now = at(23, 12, 0);
955        let mut state = NotifyState::default();
956        let case = RefreshInput {
957            credits: vec![CreditExpiry {
958                title: Some("Full reset (Weekly + 5 hr)".into()),
959                expires_at: now + chrono::Duration::hours(3),
960            }],
961            ..input(Vec::new())
962        };
963        let fired = decide(&case, 97, &mut state, now);
964        assert_eq!(fired.len(), 1);
965        assert_eq!(fired[0].urgency, Urgency::Normal);
966        assert!(
967            fired[0].title.starts_with("Claude — reset credit expires "),
968            "{}",
969            fired[0].title
970        );
971        assert!(
972            fired[0]
973                .body
974                .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
975            "{}",
976            fired[0].body
977        );
978    }
979
980    #[test]
981    fn banked_credit_dedupes_on_the_expiry_instant() {
982        let now = at(23, 12, 0);
983        let mut state = NotifyState::default();
984        let expiry = now + chrono::Duration::hours(10);
985        let case = RefreshInput {
986            credits: vec![CreditExpiry {
987                title: None,
988                expires_at: expiry,
989            }],
990            ..input(Vec::new())
991        };
992        assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
993        assert_eq!(
994            decide(&case, 97, &mut state, now).len(),
995            0,
996            "same expiry is one notification"
997        );
998        assert!(
999            state
1000                .entries
1001                .keys()
1002                .all(|key| key.starts_with("anthropic@gmail::credit::")),
1003            "{:?}",
1004            state.entries.keys().collect::<Vec<_>>()
1005        );
1006        // A different expiry instant is a different credit.
1007        let later = RefreshInput {
1008            credits: vec![CreditExpiry {
1009                title: None,
1010                expires_at: now + chrono::Duration::hours(20),
1011            }],
1012            ..input(Vec::new())
1013        };
1014        assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
1015    }
1016
1017    // — State persistence ———————————————————————————
1018
1019    #[test]
1020    fn state_round_trips_through_disk_and_tolerates_corruption() {
1021        let (dir, path) = state_path();
1022        let mut state = NotifyState::default();
1023        state.entries.insert(
1024            "anthropic::Weekly (7d)".into(),
1025            KeyRecord {
1026                notified_at: at(23, 12, 0),
1027                reset_at_snapshot: Some(at(30, 0, 0)),
1028            },
1029        );
1030        state.write_to(&path).unwrap();
1031        assert_eq!(NotifyState::read_from(&path), state);
1032
1033        std::fs::write(&path, "{ not json").unwrap();
1034        assert_eq!(NotifyState::read_from(&path), NotifyState::default());
1035        drop(dir);
1036    }
1037
1038    #[test]
1039    #[cfg(unix)]
1040    fn state_file_is_mode_0600() {
1041        use std::os::unix::fs::PermissionsExt;
1042        let (dir, path) = state_path();
1043        NotifyState::default().write_to(&path).unwrap();
1044        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1045        assert_eq!(mode & 0o777, 0o600);
1046        drop(dir);
1047    }
1048
1049    #[test]
1050    fn rearm_survives_a_simulated_restart() {
1051        let (dir, path) = state_path();
1052        let now = at(23, 12, 0);
1053        let mut state = NotifyState::read_from(&path);
1054
1055        let high = input(vec![row("Weekly (7d)", 97, None)]);
1056        let low = input(vec![row("Weekly (7d)", 89, None)]);
1057        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1058        state.write_to(&path).unwrap();
1059
1060        // New process: same crossing persists.
1061        let mut state = NotifyState::read_from(&path);
1062        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
1063
1064        // New process: usage drops below the band, mark cleared and written.
1065        decide(&low, 97, &mut state, now);
1066        state.write_to(&path).unwrap();
1067
1068        // New process: the crossing fires again.
1069        let mut state = NotifyState::read_from(&path);
1070        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1071        drop(dir);
1072    }
1073
1074    #[test]
1075    fn anthropic_api_daily_notification_keys_migrate_without_refiring() {
1076        let (dir, path) = state_path();
1077        let old = at(17, 9, 0);
1078        let newest = at(18, 9, 0);
1079        let mut state = NotifyState::default();
1080        for (label, notified_at) in [
1081            ("Month to date (through Sep 17)", old),
1082            ("Month to date (through Sep 18)", newest),
1083            ("Completed daily buckets this month", old),
1084        ] {
1085            state.entries.insert(
1086                format!("anthropic_api::{label}"),
1087                KeyRecord {
1088                    notified_at,
1089                    reset_at_snapshot: None,
1090                },
1091            );
1092        }
1093        state.write_to(&path).unwrap();
1094        let input = RefreshInput {
1095            entry_id: "anthropic_api".into(),
1096            vendor: "Anthropic API".into(),
1097            account: None,
1098            rows: vec![row("Spend (mo)", 98, None)],
1099            credits: Vec::new(),
1100        };
1101        let mut sink = RecorderSink::default();
1102        run_at(
1103            &input,
1104            97,
1105            &path,
1106            Duration::from_secs(2),
1107            &mut sink,
1108            at(19, 9, 0),
1109        );
1110
1111        assert!(
1112            sink.delivered.is_empty(),
1113            "the existing crossing remains notified"
1114        );
1115        let migrated = NotifyState::read_from(&path);
1116        assert_eq!(migrated.entries.len(), 1);
1117        assert_eq!(
1118            migrated.entries["anthropic_api::Spend (mo)"].notified_at,
1119            newest
1120        );
1121        assert!(
1122            migrated
1123                .entries
1124                .keys()
1125                .all(|key| !key.contains("Month to date") && !key.contains("Completed daily"))
1126        );
1127        drop(dir);
1128    }
1129
1130    #[test]
1131    fn an_existing_stable_anthropic_api_notification_record_wins_migration() {
1132        let stable_at = at(16, 9, 0);
1133        let mut state = NotifyState::default();
1134        for (label, notified_at) in [
1135            ("Spend (mo)", stable_at),
1136            ("Month to date (through Sep 18)", at(18, 9, 0)),
1137        ] {
1138            state.entries.insert(
1139                format!("anthropic_api@work::{label}"),
1140                KeyRecord {
1141                    notified_at,
1142                    reset_at_snapshot: None,
1143                },
1144            );
1145        }
1146        let input = RefreshInput {
1147            entry_id: "anthropic_api@work".into(),
1148            vendor: "Anthropic API".into(),
1149            account: Some("work".into()),
1150            rows: Vec::new(),
1151            credits: Vec::new(),
1152        };
1153        assert!(decide(&input, 97, &mut state, at(19, 9, 0)).is_empty());
1154        assert_eq!(state.entries.len(), 1);
1155        assert_eq!(
1156            state.entries["anthropic_api@work::Spend (mo)"].notified_at,
1157            stable_at
1158        );
1159    }
1160
1161    // — Orchestration ————————————————————————————————
1162
1163    #[test]
1164    fn run_at_persists_marks_and_delivers_through_the_sink() {
1165        let (dir, path) = state_path();
1166        let mut sink = RecorderSink::default();
1167        let input = input(vec![row("Weekly (7d)", 97, None)]);
1168        run_at(
1169            &input,
1170            97,
1171            &path,
1172            Duration::from_secs(2),
1173            &mut sink,
1174            at(23, 12, 0),
1175        );
1176        assert_eq!(sink.delivered.len(), 1);
1177
1178        // Second run with a fresh sink: marked, nothing delivered.
1179        let mut second = RecorderSink::default();
1180        run_at(
1181            &input,
1182            97,
1183            &path,
1184            Duration::from_secs(2),
1185            &mut second,
1186            at(23, 12, 0),
1187        );
1188        assert!(second.delivered.is_empty());
1189        assert!(path.exists());
1190        drop(dir);
1191    }
1192
1193    #[test]
1194    fn lock_contention_skips_notifying_entirely() {
1195        let (dir, path) = state_path();
1196        let lock_path = path.with_file_name(".notifications.lock");
1197        let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1198
1199        let mut sink = RecorderSink::default();
1200        let input = input(vec![row("Weekly (7d)", 100, None)]);
1201        run_at(
1202            &input,
1203            97,
1204            &path,
1205            Duration::from_millis(100),
1206            &mut sink,
1207            at(23, 12, 0),
1208        );
1209        assert!(
1210            sink.delivered.is_empty(),
1211            "a missed notification beats a double one"
1212        );
1213        assert!(!path.exists(), "no state without the lock");
1214        drop(dir);
1215    }
1216
1217    #[test]
1218    fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1219        let dir = tempfile::TempDir::new().unwrap();
1220        // A directory where the state file should be makes the atomic write
1221        // fail: persisting the mark is a precondition of delivering.
1222        let state_path = dir.path().join("notifications.json");
1223        std::fs::create_dir(&state_path).unwrap();
1224        let mut sink = RecorderSink::default();
1225        let input = input(vec![row("Weekly (7d)", 97, None)]);
1226        run_at(
1227            &input,
1228            97,
1229            &state_path,
1230            Duration::from_secs(2),
1231            &mut sink,
1232            at(23, 12, 0),
1233        );
1234        assert!(sink.delivered.is_empty());
1235    }
1236
1237    /// A 100% toast asks for the 30s Omarchy honours once it stops treating
1238    /// critical urgency as immortal; a normal one leaves the timeout to the
1239    /// daemon, which is where Omarchy's own 8s default lives.
1240    #[test]
1241    #[cfg(target_os = "linux")]
1242    fn a_critical_toast_asks_for_a_timeout_and_a_normal_one_does_not() {
1243        let sink = NotifySendSink::new();
1244        let toast = |urgency| Notification {
1245            title: "Claude — Session (5h) at 100%".to_string(),
1246            body: "resets in 2h".to_string(),
1247            urgency,
1248            key: "anthropic::Session (5h)".to_string(),
1249        };
1250        let critical_toast = toast(Urgency::Critical);
1251
1252        let critical = sink.argv(&critical_toast);
1253        assert!(
1254            critical
1255                .windows(2)
1256                .any(|pair| pair[0] == "-t" && pair[1] == "30000"),
1257            "a critical toast asks for the cap: {critical:?}"
1258        );
1259
1260        let normal = sink.argv(&toast(Urgency::Normal));
1261        assert!(
1262            !normal.iter().any(|arg| arg == "-t"),
1263            "a normal toast leaves the timeout to the daemon: {normal:?}"
1264        );
1265
1266        // `notify-send` reads the trailing positionals as title and body, so
1267        // the switch this test is about must not land after them.
1268        let tail = &critical[critical.len() - 2..];
1269        assert_eq!(tail[0], critical_toast.title);
1270        assert_eq!(tail[1], critical_toast.body);
1271    }
1272
1273    /// The guard test for the whole feature: a notifier that cannot even be
1274    /// spawned must not make the check error, panic, or skip the state
1275    /// bookkeeping. `run_at` has no failure channel at all — this pins that.
1276    #[test]
1277    #[cfg(target_os = "linux")]
1278    fn a_missing_notifier_binary_is_a_silent_no_op() {
1279        let (dir, path) = state_path();
1280        let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1281        let input = input(vec![row("Weekly (7d)", 97, None)]);
1282        run_at(
1283            &input,
1284            97,
1285            &path,
1286            Duration::from_secs(2),
1287            &mut sink,
1288            at(23, 12, 0),
1289        );
1290        assert!(
1291            path.exists(),
1292            "state bookkeeping is independent of delivery"
1293        );
1294        assert!(
1295            NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1296            "the crossing is marked even though nothing was delivered"
1297        );
1298        drop(dir);
1299    }
1300
1301    #[test]
1302    fn noop_sink_records_nothing_and_never_fails() {
1303        let (dir, path) = state_path();
1304        let mut sink = NoopSink;
1305        let input = input(vec![row("Weekly (7d)", 97, None)]);
1306        run_at(
1307            &input,
1308            97,
1309            &path,
1310            Duration::from_secs(2),
1311            &mut sink,
1312            at(23, 12, 0),
1313        );
1314        assert!(path.exists());
1315        drop(dir);
1316    }
1317
1318    // — Projection from the shared assembly ——————————
1319
1320    #[test]
1321    fn from_tab_projects_rows_account_and_credits() {
1322        use crate::tui::app::{ReadyTab, TabId, TabState};
1323        use crate::usage::{
1324            AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1325        };
1326
1327        let reset = at(24, 2, 0);
1328        let state = TabState::Ready(Box::new(ReadyTab {
1329            snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1330                plan: "Claude Max 20x".into(),
1331                session: UsageWindow {
1332                    utilization_pct: 98,
1333                    resets_at: Some(reset),
1334                    window_duration: chrono::Duration::hours(5),
1335                },
1336                weekly: UsageWindow {
1337                    utilization_pct: 42,
1338                    resets_at: None,
1339                    window_duration: chrono::Duration::days(7),
1340                },
1341                sonnet: None,
1342                scoped: vec![],
1343                extra: None,
1344                // Claude banks resets too, and the expiry warning is the
1345                // whole point of carrying them: a grant the sidebar lists but
1346                // the notifier ignores is a half-delivered feature.
1347                reset_credits: ResetCredits {
1348                    available: 1,
1349                    credits: vec![ResetCredit {
1350                        title: Some("Opus 5.5 launch reset".into()),
1351                        expires_at: Some(at(25, 0, 0)),
1352                    }],
1353                },
1354            }),
1355            stale: false,
1356            last_error: None,
1357            fetched_at: None,
1358            display: Default::default(),
1359        }));
1360
1361        let tab = TabId::account("gmail");
1362        let projected =
1363            RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1364        assert_eq!(projected.entry_id, "anthropic@gmail");
1365        assert_eq!(projected.vendor, "Claude");
1366        assert_eq!(projected.account.as_deref(), Some("gmail"));
1367        assert_eq!(
1368            projected.rows,
1369            vec![
1370                row("Session (5h)", 98, Some(reset)),
1371                row("Weekly (7d)", 42, None),
1372            ]
1373        );
1374        assert_eq!(
1375            projected.credits,
1376            vec![CreditExpiry {
1377                title: Some("Opus 5.5 launch reset".into()),
1378                expires_at: at(25, 0, 0),
1379            }]
1380        );
1381
1382        // A Codex snapshot carries its banked credits through.
1383        let state = TabState::Ready(Box::new(ReadyTab {
1384            snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1385                plan: "ChatGPT Pro".into(),
1386                session: None,
1387                weekly: None,
1388                code_review: None,
1389                additional_limits: Vec::new(),
1390                unavailable_models: Vec::new(),
1391                credits: None,
1392                reset_credits: Some(ResetCredits {
1393                    available: 2,
1394                    credits: vec![ResetCredit {
1395                        title: Some("Full reset".into()),
1396                        expires_at: Some(at(25, 0, 0)),
1397                    }],
1398                }),
1399                source: crate::usage::OpenAiSource::CodexOauth,
1400            }),
1401            stale: false,
1402            last_error: None,
1403            fetched_at: None,
1404            display: Default::default(),
1405        }));
1406        let projected = RefreshInput::from_tab(
1407            &TabId::vendor(crate::vendor::VendorId::Openai),
1408            &state,
1409            at(23, 12, 0),
1410        )
1411        .expect("ready tab projects");
1412        assert_eq!(projected.entry_id, "openai");
1413        assert_eq!(
1414            projected.credits,
1415            vec![CreditExpiry {
1416                title: Some("Full reset".into()),
1417                expires_at: at(25, 0, 0),
1418            }]
1419        );
1420    }
1421
1422    #[test]
1423    fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1424        use crate::tui::app::{ReadyTab, TabId, TabState};
1425        use crate::usage::{
1426            ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1427            VendorSnapshot,
1428        };
1429
1430        let state = TabState::Ready(Box::new(ReadyTab {
1431            snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1432                plan: "SuperGrok Heavy".into(),
1433                account: "scope".into(),
1434                weekly_pct: 97,
1435                period: SuperGrokPeriod::Weekly,
1436                reset_at: Some(at(26, 0, 0)),
1437                prepaid_balance: None,
1438                // available == 0: the report would not surface these either.
1439                reset_credits: ResetCredits {
1440                    available: 0,
1441                    credits: vec![ResetCredit {
1442                        title: None,
1443                        expires_at: Some(at(25, 0, 0)),
1444                    }],
1445                },
1446                products: vec![SuperGrokProduct {
1447                    label: "Grok Build".into(),
1448                    percent: 94,
1449                }],
1450            }),
1451            stale: false,
1452            last_error: None,
1453            fetched_at: None,
1454            display: Default::default(),
1455        }));
1456        let projected = RefreshInput::from_tab(
1457            &TabId::vendor(crate::vendor::VendorId::Supergrok),
1458            &state,
1459            at(23, 12, 0),
1460        )
1461        .expect("ready tab projects");
1462        // Only the overall meter — the grouped Grok Build slice shares its
1463        // window and must not raise a second notification.
1464        assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1465        assert!(projected.credits.is_empty());
1466    }
1467
1468    fn antigravity_ready(
1469        session: i32,
1470        third_party_session: i32,
1471        weekly: i32,
1472        third_party_weekly: i32,
1473    ) -> crate::tui::app::TabState {
1474        use crate::tui::app::{ReadyTab, TabState};
1475        use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1476
1477        let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1478            Some(UsageWindow {
1479                utilization_pct: pct,
1480                resets_at: Some(resets_at),
1481                window_duration: chrono::Duration::hours(hours),
1482            })
1483        };
1484        TabState::Ready(Box::new(ReadyTab {
1485            snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1486                plan: "Google AI Pro".into(),
1487                account: "acct:test".into(),
1488                source: AntigravitySource::Local,
1489                session: window(session, at(24, 2, 21), 5),
1490                weekly: window(weekly, at(26, 1, 34), 168),
1491                third_party_session: window(third_party_session, at(24, 2, 19), 5),
1492                third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1493                model_pools: Vec::new(),
1494            }),
1495            stale: false,
1496            last_error: None,
1497            fetched_at: None,
1498            display: Default::default(),
1499        }))
1500    }
1501
1502    /// Antigravity lays "Session" and "Weekly" headings over the same two
1503    /// pool labels; each window needs its own name, or the four rows share
1504    /// two dedupe keys.
1505    #[test]
1506    fn from_tab_names_repeated_labels_by_their_heading() {
1507        use crate::tui::app::TabId;
1508
1509        let projected = RefreshInput::from_tab(
1510            &TabId::vendor(crate::vendor::VendorId::Antigravity),
1511            &antigravity_ready(3, 100, 98, 56),
1512            at(23, 22, 0),
1513        )
1514        .expect("ready tab projects");
1515        let windows: Vec<_> = projected
1516            .rows
1517            .iter()
1518            .map(|row| row.window.as_str())
1519            .collect();
1520        assert_eq!(
1521            windows,
1522            [
1523                "Session · Gemini",
1524                "Session · Claude & GPT OSS",
1525                "Weekly · Gemini",
1526                "Weekly · Claude & GPT OSS",
1527            ]
1528        );
1529    }
1530
1531    /// The reported bug: a weekly Gemini window at 98% beside a session one at
1532    /// 3%, and a session Claude window at 100% beside a weekly one at 56%. Under
1533    /// one key per label, the low twin cleared the record its high twin had
1534    /// just written, so both notifications fired again on every refresh.
1535    #[test]
1536    fn a_window_whose_label_repeats_notifies_once() {
1537        use crate::tui::app::TabId;
1538
1539        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1540        let state = antigravity_ready(3, 100, 98, 56);
1541        let mut notify_state = NotifyState::default();
1542        let fired: Vec<Vec<String>> = (0..4)
1543            .map(|refresh| {
1544                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1545                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1546                decide(&input, 97, &mut notify_state, now)
1547                    .into_iter()
1548                    .map(|notification| notification.title)
1549                    .collect()
1550            })
1551            .collect();
1552        assert_eq!(
1553            fired[0],
1554            [
1555                "Antigravity — Session · Claude & GPT OSS at 100%",
1556                "Antigravity — Weekly · Gemini at 98%",
1557            ]
1558        );
1559        assert!(
1560            fired[1..].iter().all(Vec::is_empty),
1561            "later refreshes must stay quiet: {fired:?}"
1562        );
1563    }
1564
1565    /// A window's identity must not depend on whether its sibling arrived.
1566    #[test]
1567    fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1568        use crate::tui::app::{TabId, TabState};
1569        use crate::usage::VendorSnapshot;
1570
1571        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1572        for starts_with_session in [false, true] {
1573            let mut notify_state = NotifyState::default();
1574            // The unchanged weekly quota fires only once, regardless of
1575            // which snapshot shape arrived first. A low reading while the
1576            // session is absent must also re-arm that same weekly key.
1577            let steps = [
1578                (starts_with_session, 98, 1),
1579                (!starts_with_session, 98, 0),
1580                (starts_with_session, 98, 0),
1581                (false, 20, 0),
1582                (true, 98, 1),
1583                (false, 98, 0),
1584            ];
1585            for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1586                let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1587                let TabState::Ready(ready) = &mut state else {
1588                    unreachable!();
1589                };
1590                let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1591                    unreachable!();
1592                };
1593                if !has_session {
1594                    snapshot.session = None;
1595                    snapshot.third_party_session = None;
1596                }
1597                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1598                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1599                let fired = decide(&input, 97, &mut notify_state, now);
1600                assert_eq!(
1601                    fired.len(),
1602                    expected,
1603                    "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1604                );
1605                if let Some(notification) = fired.first() {
1606                    assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1607                }
1608            }
1609        }
1610    }
1611
1612    /// A window's identity must not depend on the day of the fetch either. An
1613    /// Anthropic API spend row named after its covered day got a new key every
1614    /// morning, so one crossing of the threshold notified again each day (#470).
1615    #[test]
1616    fn a_spend_over_the_threshold_notifies_once_as_the_covered_day_moves() {
1617        use crate::tui::app::{ReadyTab, TabId, TabState};
1618        use crate::usage::{AnthropicApiSnapshot, VendorSnapshot};
1619
1620        let tab = TabId::vendor(crate::vendor::VendorId::AnthropicApi);
1621        let mut notify_state = NotifyState::default();
1622        let fired: Vec<usize> = (17..=19)
1623            .map(|day| {
1624                let state = TabState::Ready(Box::new(ReadyTab {
1625                    snapshot: VendorSnapshot::AnthropicApi(AnthropicApiSnapshot {
1626                        spent: 98.0,
1627                        limit: Some(100.0),
1628                        covered_through: chrono::NaiveDate::from_ymd_opt(2026, 9, day),
1629                    }),
1630                    stale: false,
1631                    last_error: None,
1632                    fetched_at: None,
1633                    display: Default::default(),
1634                }));
1635                let now = at(day + 1, 9, 0);
1636                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1637                decide(&input, 97, &mut notify_state, now).len()
1638            })
1639            .collect();
1640        assert_eq!(fired, [1, 0, 0]);
1641    }
1642
1643    /// Heading context applies to unique labels too, survives spacers, and
1644    /// ends at a non-heading row. Unscoped metrics keep their bare names.
1645    #[test]
1646    fn metric_names_follow_heading_scope_even_for_unique_labels() {
1647        use crate::balance::MetricHeadline;
1648        use crate::pacing::PaceSeverity;
1649        use crate::tui::panels::{Section, SectionProjection};
1650
1651        let projection = |section| SectionProjection {
1652            section,
1653            reset_at: None,
1654            window: None,
1655            group: None,
1656            model_pool: false,
1657            headline: MetricHeadline::Percent,
1658            used_cents: None,
1659            limit_cents: None,
1660        };
1661        let metric = |label: &str, pct| {
1662            projection(Section::Metric {
1663                label: label.into(),
1664                pct,
1665                severity: PaceSeverity::Low,
1666                value_label: format!("{pct}%"),
1667                footnote: String::new(),
1668            })
1669        };
1670        let heading = |label: &str| {
1671            projection(Section::Text {
1672                label: label.into(),
1673                value: String::new(),
1674            })
1675        };
1676        let rows = metric_rows([
1677            metric("Unscoped", 20),
1678            heading("Session"),
1679            projection(Section::Spacer),
1680            metric("General", 40),
1681            metric("Video", 10),
1682            projection(Section::Spacer),
1683            heading("Weekly"),
1684            metric("General", 60),
1685            projection(Section::Text {
1686                label: "Info".into(),
1687                value: "Details".into(),
1688            }),
1689            metric("Other", 30),
1690        ]);
1691        let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1692        assert_eq!(
1693            windows,
1694            [
1695                "Unscoped",
1696                "Session · General",
1697                "Session · Video",
1698                "Weekly · General",
1699                "Other",
1700            ]
1701        );
1702    }
1703
1704    #[test]
1705    fn from_tab_returns_none_for_unready_states() {
1706        use crate::tui::app::{TabId, TabState};
1707        assert!(
1708            RefreshInput::from_tab(
1709                &TabId::vendor(crate::vendor::VendorId::Zai),
1710                &TabState::Loading,
1711                at(23, 12, 0)
1712            )
1713            .is_none()
1714        );
1715        assert!(
1716            RefreshInput::from_tab(
1717                &TabId::vendor(crate::vendor::VendorId::Zai),
1718                &TabState::error("not signed in"),
1719                at(23, 12, 0)
1720            )
1721            .is_none()
1722        );
1723    }
1724}