Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/activity.rs

//! **The Activity panel's words and its arithmetic** — the half of the usage
//! page that decides things, kept where `cargo test` can reach it.
//!
//! Same argument as [`crate::room`], and the same layout: `pages::usage` is
//! `#[cfg(target_arch = "wasm32")]`, so nothing written beside the view is
//! compiled — let alone run — by `cargo test` on the host. A rule that only
//! ever executes inside a browser nobody launches is a rule with no test. So
//! every sentence this panel puts on screen, and every number that decides how
//! tall a bar is, is decided here: no leptos, no `web-sys`, the clock arrives
//! as a parameter.
//!
//! # The three answers this panel can give, and why they must not look alike
//!
//! `Usage.Activity` answers a *series*, and there are three completely
//! different things it can come back with. Drawing any two of them the same way
//! is the defect this module exists to prevent, because all three are "an empty
//! chart" to a renderer that is not paying attention:
//!
//! * **Bars.** Rows came back. Draw them.
//! * **Nothing moved.** A real answer with an empty `days` and a non-zero
//!   `retained_days`: gunnar keeps four hundred days, holds a ledger for this
//!   namespace, and there is no row in the last thirty because nothing was
//!   pushed, fetched or refused. The tenant is quiet. ([`NoBars::Quiet`])
//! * **This box does not count activity.** Either gunnar answered
//!   `retained_days: 0` — it keeps no ledger at all — or it refused the call
//!   with `Unimplemented`, which is what a gunnar older than the FLOW ledger
//!   does. **That is not a quiet tenant**, and saying "no activity in the last
//!   30 days" over it would be this console asserting a measurement that was
//!   never taken, about a namespace that may well be the busiest on the box.
//!   ([`NoBars::NotCounted`])
//! * **It could not be read.** Any other refusal — `PermissionDenied` above
//!   all. Silence is not evidence of silence. ([`NoBars::Unreadable`])
//!
//! # A day with no row is a gap, and the chart draws it as one
//!
//! gunnar sends **no row at all** for a day nothing happened on, and the server
//! carries that absence through rather than zero-filling it (`api::usage`). The
//! chart still wants a fixed x axis — thirty slots, oldest first, one per UTC
//! day — so [`bars`] lays the rows into their slots and leaves the rest EMPTY.
//! That is a layout decision and not an invented measurement: an empty slot
//! carries the sentence *nothing moved*, and it is reachable in the DOM as that
//! sentence rather than as a bar of height zero that a reader would take for a
//! rounding artefact.

use std::collections::BTreeMap;

use serde::Deserialize;

use crate::fmt;

/// **How many UTC days the panel draws**, and it must be the number
/// `api::usage::ACTIVITY_DAYS` asks gunnar for.
///
/// The two cannot share a constant — this crate deliberately depends on nothing
/// of the server's — so it is pinned in words on both sides and asserted in
/// [`tests::the_window_is_thirty_days_on_both_sides_of_the_wire`]. A page that
/// said "the last 30 days" over a fortnight of bars would be a true sentence
/// above a false chart, which is worse than either alone.
pub const WINDOW_DAYS: i64 = 30;

const DAY_MS: i64 = 86_400_000;

/// One UTC day's flow, exactly as `GET /api/namespaces/{name}/activity` sends
/// it.
///
/// `#[serde(default)]` on every field that is not the day key, for one reason:
/// a console served from a build older than the server it is talking to must
/// draw the days it can rather than fail to parse the answer. The day key has
/// no default because a row with no day is not a row.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct Day {
    /// Midnight UTC of the day, in Unix milliseconds.
    pub day_unix_ms: i64,
    #[serde(default)]
    pub bytes_in: u64,
    #[serde(default)]
    pub bytes_out: u64,
    #[serde(default)]
    pub pushes: u64,
    #[serde(default)]
    pub fetches: u64,
    /// LFS object bytes as the LFS session counted them — **also inside
    /// `bytes_in`/`bytes_out`** as wire bytes. Two numbers about one transfer,
    /// so the panel never subtracts one from the other.
    #[serde(default)]
    pub lfs_bytes_in: u64,
    #[serde(default)]
    pub lfs_bytes_out: u64,
    #[serde(default)]
    pub refused: u64,
    /// Only the reasons that actually happened. A reason that is not here did
    /// not occur, and it is **absent rather than 0** all the way from gunnar.
    #[serde(default)]
    pub refused_by_reason: BTreeMap<String, u64>,
    #[serde(default)]
    pub auth_failures: u64,
    #[serde(default)]
    pub distinct_principals: u64,
    #[serde(default)]
    pub distinct_principals_capped: bool,
    /// The last push of that day, `None` when the day had none.
    #[serde(default)]
    pub last_push_unix_ms: Option<i64>,
    #[serde(default)]
    pub last_fetch_unix_ms: Option<i64>,
}

/// The series for one namespace.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Activity {
    pub namespace: String,
    /// How many days this gunnar keeps. **0 means it keeps none**, which is a
    /// box that does not count activity and not a quiet tenant — see
    /// [`NoBars`].
    #[serde(default)]
    pub retained_days: i64,
    /// Oldest first, days with no traffic absent.
    #[serde(default)]
    pub days: Vec<Day>,
}

// ── the bars ───────────────────────────────────────────────────────────────

/// **The tallest number any bar on one chart is drawn against.**
///
/// Both series on a chart share it — `bytes_in` with `bytes_out`, `pushes` with
/// `fetches` — because the whole reason to put them side by side is to compare
/// them, and two bars scaled independently would show a day that served a
/// gigabyte and accepted a kilobyte as two bars of the same height.
///
/// The two CHARTS do not share one: bytes and counts are different units, and
/// scaling twenty-seven fetches against four million bytes draws no chart at
/// all.
pub fn peak_bytes(days: &[Day]) -> u64 {
    days.iter()
        .map(|d| d.bytes_in.max(d.bytes_out))
        .max()
        .unwrap_or(0)
}

/// The same, for the transfer counts.
pub fn peak_count(days: &[Day]) -> u64 {
    days.iter()
        .map(|d| d.pushes.max(d.fetches))
        .max()
        .unwrap_or(0)
}

/// **A bar's height, 0–100, against the chart's peak.**
///
/// `0` when the peak is 0, which is the only division-by-zero this chart can
/// reach: a window in which every row is a row of zeroes. It is not
/// hypothetical — a day with only refusals moves no bytes at all — and a
/// panicking chart on a namespace that was turned away thirty days running
/// would be the worst possible moment for one.
///
/// The arithmetic goes through `u128` because `bytes_in` is a wire-byte count
/// and `value * 100` overflows `u64` above about 184 PB. A wrapped percentage
/// draws a nearly-empty bar for the busiest day in the window, which reads as
/// a data bug rather than the rendering bug it is. [`crate::room::fill_pct`]
/// takes the same precaution for the same reason.
pub fn bar_pct(value: u64, peak: u64) -> u32 {
    if peak == 0 {
        return 0;
    }
    ((value.min(peak) as u128 * 100) / peak as u128) as u32
}

/// One slot on the x axis: a UTC day, and what moved on it.
#[derive(Debug, Clone, PartialEq)]
pub struct Bar {
    /// Midnight UTC of the day this slot is.
    pub day_unix_ms: i64,
    /// `MM-DD`, the axis label. The year is in [`Bar::hint`]: thirty slots on a
    /// phone have no room for it, and every one of them is in the same year
    /// except across a new year, where the hint carries it.
    pub label: String,
    /// **False when gunnar sent no row for this day** — nothing moved. Not the
    /// same as a row of zeroes, and the panel says so rather than drawing a
    /// bar of height 0 that reads as a rounding artefact.
    pub moved: bool,
    pub bytes_in_pct: u32,
    pub bytes_out_pct: u32,
    pub pushes_pct: u32,
    pub fetches_pct: u32,
    /// Transfers turned away that day. `0` = none; the slot is marked only
    /// above 0.
    pub refused: u64,
    /// The whole day in one sentence, for the slot's `title` and its
    /// `aria-label`. A chart nobody can read with a screen reader is a chart
    /// half the operators of this console cannot read at all.
    pub hint: String,
}

/// **The thirty slots, oldest first, ending with the UTC day `now` is in.**
///
/// Rows are laid into the slot whose day they name and every other slot is
/// left empty. A row outside the window — which gunnar has no reason to send,
/// but which a clock skew of a few seconds across midnight could produce — is
/// dropped rather than drawn in the wrong place: a bar under the wrong date is
/// worse than a bar that is not there.
pub fn bars(activity: &Activity, now_unix_ms: i64) -> Vec<Bar> {
    let peak_b = peak_bytes(&activity.days);
    let peak_c = peak_count(&activity.days);
    let today = now_unix_ms.div_euclid(DAY_MS) * DAY_MS;
    let first = today - (WINDOW_DAYS - 1) * DAY_MS;

    (0..WINDOW_DAYS)
        .map(|i| {
            let day_unix_ms = first + i * DAY_MS;
            match activity.days.iter().find(|d| d.day_unix_ms == day_unix_ms) {
                Some(day) => Bar {
                    day_unix_ms,
                    label: axis_label(day_unix_ms),
                    moved: true,
                    bytes_in_pct: bar_pct(day.bytes_in, peak_b),
                    bytes_out_pct: bar_pct(day.bytes_out, peak_b),
                    pushes_pct: bar_pct(day.pushes, peak_c),
                    fetches_pct: bar_pct(day.fetches, peak_c),
                    refused: day.refused,
                    hint: day_hint(day),
                },
                None => Bar {
                    day_unix_ms,
                    label: axis_label(day_unix_ms),
                    moved: false,
                    bytes_in_pct: 0,
                    bytes_out_pct: 0,
                    pushes_pct: 0,
                    fetches_pct: 0,
                    refused: 0,
                    hint: format!("{} \u{b7} nothing moved", fmt::date(day_unix_ms)),
                },
            }
        })
        .collect()
}

/// `MM-DD` of a UTC midnight — the axis tick.
pub fn axis_label(day_unix_ms: i64) -> String {
    // `fmt::date` is `YYYY-MM-DD` and always ten characters wide, so this is
    // the tail of it rather than a second date arithmetic to keep in step.
    fmt::date(day_unix_ms)[5..].to_owned()
}

/// **One day in one sentence.**
///
/// Bytes in the units an operator reads, transfers as counts, and the refusals
/// last with their reasons — because a day with refusals on it is the only kind
/// of day somebody reads this panel to find.
pub fn day_hint(day: &Day) -> String {
    let mut line = format!(
        "{} \u{b7} {} in, {} out \u{b7} {} {}, {} {}",
        fmt::date(day.day_unix_ms),
        fmt::bytes(day.bytes_in),
        fmt::bytes(day.bytes_out),
        fmt::count(day.pushes),
        plural(day.pushes, "push", "pushes"),
        fmt::count(day.fetches),
        plural(day.fetches, "fetch", "fetches"),
    );
    let refusals = refusal_phrase(day);
    if !refusals.is_empty() {
        line.push_str(" \u{b7} ");
        line.push_str(&refusals);
    }
    line
}

/// The refusals of one day, with their reasons; empty when nothing was refused.
///
/// **A reason gunnar did not send is not named**, and the count is gunnar's own
/// total rather than the sum of the breakdown. The two can differ — a refusal
/// gunnar has not learned to attribute yet is in `refused` and in no reason —
/// and when they do, the honest sentence is *"5 refused: admission 1, cap 4"*
/// with the arithmetic left visible, not a total quietly rewritten to 5.
pub fn refusal_phrase(day: &Day) -> String {
    if day.refused == 0 {
        return String::new();
    }
    let mut line = format!("{} refused", fmt::count(day.refused));
    if !day.refused_by_reason.is_empty() {
        // `BTreeMap`, so the reasons are in the same order on every render:
        // `admission`, `cap`, `policy`.
        let reasons: Vec<String> = day
            .refused_by_reason
            .iter()
            .map(|(why, n)| format!("{why} {}", fmt::count(*n)))
            .collect();
        line.push_str(": ");
        line.push_str(&reasons.join(", "));
    }
    line
}

fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
    if n == 1 {
        one
    } else {
        many
    }
}

// ── last seen ──────────────────────────────────────────────────────────────

/// **When this namespace was last pushed to and last fetched from.**
///
/// `None` is **"never"**, and that word is the whole reason this function is
/// tested. gunnar sends `0` for a namespace nothing has touched inside the kept
/// window, the server turns that 0 into `None`
/// (`api::usage::Usage::last_push_unix_ms`), and if either half of that chain
/// broke the line would read *"last push 689 months ago"* — 1 Jan 1970 rendered
/// as an age — beside a namespace created this morning. "Never" and "a very
/// long time ago" are opposite answers to the question an operator is asking.
///
/// Both halves are on one line because the pair is the point: a namespace being
/// fetched from daily and pushed to never is a mirror, and one pushed to and
/// never fetched is a backup target. Two separate lines invite reading one.
pub fn last_seen_line(
    last_push_unix_ms: Option<i64>,
    last_fetch_unix_ms: Option<i64>,
    now_unix_ms: i64,
) -> String {
    format!(
        "Last push {} \u{b7} last fetch {}",
        seen(last_push_unix_ms, now_unix_ms),
        seen(last_fetch_unix_ms, now_unix_ms)
    )
}

fn seen(at: Option<i64>, now: i64) -> String {
    match at {
        Some(at) => fmt::since(at, now),
        None => "never".to_owned(),
    }
}

// ── an empty chart, and the three things it can mean ───────────────────────

/// **Why there are no bars.** See the module doc: three different facts, and a
/// renderer that drew them alike would be the defect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoBars {
    /// gunnar keeps a ledger for this namespace and has no row in the window.
    /// Nothing was pushed, fetched or refused. The tenant is quiet.
    Quiet,
    /// **This box does not count activity at all** — it keeps no days, or it is
    /// older than the FLOW ledger and refuses the call. Not a quiet tenant, and
    /// this namespace may be the busiest on the box.
    NotCounted,
    /// The answer could not be read: refused, unreachable, or a fault. Silence
    /// is not evidence of silence.
    Unreadable,
}

/// What an ANSWER with no rows in it means.
///
/// `retained_days` is the discriminator and it is gunnar's own field: it says
/// how far back the box remembers. Zero is a box that remembers nothing, which
/// is the one case where an empty `days` says nothing about the tenant.
pub fn no_bars(retained_days: i64) -> NoBars {
    if retained_days > 0 {
        NoBars::Quiet
    } else {
        NoBars::NotCounted
    }
}

/// What a REFUSAL means.
///
/// `Unimplemented` — HTTP 501 on this console, or gunnar's own code — is a
/// gunnar older than the FLOW ledger, and it is the one refusal that is a fact
/// about the BOX rather than about the caller or the namespace. Everything
/// else, `PermissionDenied` first among them, is [`NoBars::Unreadable`]:
/// treating "you may not look" as "nothing happened" is how a console tells a
/// tenant their service is idle when what it means is that it could not ask.
pub fn no_bars_after_refusal(status: u16, code: &str) -> NoBars {
    if status == 501 || code.eq_ignore_ascii_case("unimplemented") {
        NoBars::NotCounted
    } else {
        NoBars::Unreadable
    }
}

/// The heading for an empty chart.
pub fn no_bars_title(state: NoBars) -> &'static str {
    match state {
        NoBars::Quiet => "No activity in the last 30 days",
        NoBars::NotCounted => "This box does not count activity",
        NoBars::Unreadable => "This namespace's activity could not be read",
    }
}

/// The sentence under it, which is where the three states stop being
/// interchangeable.
pub fn no_bars_detail(state: NoBars, retained_days: i64) -> String {
    match state {
        NoBars::Quiet => format!(
            "Gunnar keeps {retained_days} days of this namespace's traffic and holds no row \
             for any of the last 30: nothing was pushed, fetched or refused. A day with no \
             traffic has no row, so this is silence and not a gap in the reading."
        ),
        NoBars::NotCounted => "This Gunnar does not keep a per-day ledger, so there is nothing \
             to draw. That is a fact about the server and not about this namespace \u{2014} it \
             may be the busiest on the box. The meters above are counted either way."
            .to_owned(),
        NoBars::Unreadable => "Gunnar refused or could not answer, so nothing is known about \
             this namespace's traffic. An empty chart here would say it was idle, which is \
             not what happened."
            .to_owned(),
    }
}

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

    const DAY: i64 = 86_400_000;
    /// Midnight UTC of an ordinary day (2026-09-03), and the anchor for every
    /// fixture here. A whole multiple of [`DAY`] on purpose: a "day" that is
    /// not a midnight would make the slot assertions pass for the wrong reason.
    const D0: i64 = 20_699 * DAY;

    /// Three days out of six, with the quiet days MISSING — the shape gunnar
    /// sends and the server carries through. Every number distinct, so a field
    /// read out of its neighbour is visible.
    fn ledger() -> Vec<Day> {
        vec![
            Day {
                day_unix_ms: D0,
                bytes_in: 1_048_576,
                bytes_out: 5_242_880,
                pushes: 4,
                fetches: 11,
                ..Day::default()
            },
            // `D0 + 1` has no row: nothing moved that day.
            Day {
                day_unix_ms: D0 + 2 * DAY,
                bytes_in: 3_145_728,
                bytes_out: 786_432,
                pushes: 2,
                fetches: 7,
                refused: 5,
                // Two reasons of the three; `policy` never happened.
                refused_by_reason: BTreeMap::from([
                    ("admission".to_owned(), 1),
                    ("cap".to_owned(), 4),
                ]),
                ..Day::default()
            },
            Day {
                day_unix_ms: D0 + 5 * DAY,
                bytes_in: 0,
                bytes_out: 9_437_184,
                pushes: 0,
                fetches: 19,
                last_push_unix_ms: None,
                ..Day::default()
            },
        ]
    }

    fn series() -> Activity {
        Activity {
            namespace: "alpha".to_owned(),
            retained_days: 400,
            days: ledger(),
        }
    }

    /// "Today" for the fixtures: the last row's day, so the whole ledger sits
    /// inside a thirty-day window ending on it.
    const NOW: i64 = D0 + 5 * DAY + 50_000_000;

    /// ★ **A day gunnar sent no row for is a GAP, not a bar of height zero.**
    ///
    /// gunnar omits a day nothing happened on and the server carries the
    /// absence through rather than zero-filling it. The chart still needs a
    /// fixed x axis, so the slots are laid out here — and the empty ones say
    /// *nothing moved* instead of drawing a zero-height bar that a reader takes
    /// for a rounding artefact or for a measurement that was made.
    ///
    /// Seen red against a `bars` that dropped the empty slots and rendered the
    /// three rows side by side: *"the chart has 3 slots; thirty days of x axis
    /// became three adjacent bars and 2026-09-07 is drawn where 2026-09-04
    /// belongs"*.
    #[test]
    fn every_day_in_the_window_gets_a_slot_and_a_day_with_no_row_is_marked_as_one() {
        let bars = bars(&series(), NOW);

        assert_eq!(
            bars.len(),
            WINDOW_DAYS as usize,
            "the chart has {} slots; the panel says thirty days in words",
            bars.len()
        );
        // Oldest first, one UTC day apart, ending with the day NOW is in.
        assert_eq!(bars[0].day_unix_ms, D0 + 5 * DAY - 29 * DAY);
        assert_eq!(bars[WINDOW_DAYS as usize - 1].day_unix_ms, D0 + 5 * DAY);
        for pair in bars.windows(2) {
            assert_eq!(
                pair[1].day_unix_ms - pair[0].day_unix_ms,
                DAY,
                "the x axis must be one whole UTC day per slot"
            );
        }

        let at = |ms: i64| {
            bars.iter()
                .find(|b| b.day_unix_ms == ms)
                .unwrap_or_else(|| panic!("a slot for {ms}"))
        };
        assert!(at(D0).moved, "the day with a row must be drawn as one");
        assert!(
            !at(D0 + DAY).moved,
            "a day gunnar sent no row for was drawn as traffic"
        );
        assert_eq!(
            at(D0 + DAY).hint,
            "2026-09-04 \u{b7} nothing moved",
            "an empty slot must say what it is, not sit there mute"
        );
        // …and it carries no numbers at all, so nothing about it can be read
        // as a measurement.
        assert_eq!(at(D0 + DAY).bytes_in_pct, 0);
        assert_eq!(at(D0 + DAY).refused, 0);
    }

    /// ★ **In and out share one scale; pushes and fetches share another.**
    ///
    /// The bytes chart's peak is 9 437 184 (the third day's `bytes_out`), so
    /// the first day's 1 MiB in is 11 % and its 5 MiB out is 55 %. The counts
    /// chart's peak is 19 fetches, so 4 pushes is 21 %.
    ///
    /// Seen red with one scale for both charts: nineteen fetches against nine
    /// million bytes is *"fetches_pct: left 21 right 0"* — every count bar
    /// invisible.
    #[test]
    fn the_two_series_on_a_chart_are_drawn_against_one_peak() {
        assert_eq!(peak_bytes(&ledger()), 9_437_184);
        assert_eq!(peak_count(&ledger()), 19);

        let bars = bars(&series(), NOW);
        let first = bars.iter().find(|b| b.day_unix_ms == D0).expect("a slot");
        assert_eq!(first.bytes_in_pct, 11, "1 MiB of a 9 MiB peak");
        assert_eq!(first.bytes_out_pct, 55, "5 MiB of a 9 MiB peak");
        assert_eq!(first.pushes_pct, 21, "4 of a peak of 19");
        assert_eq!(first.fetches_pct, 57, "11 of a peak of 19");

        let tallest = bars
            .iter()
            .find(|b| b.day_unix_ms == D0 + 5 * DAY)
            .expect("a slot");
        assert_eq!(tallest.bytes_out_pct, 100, "the peak day is a full bar");
        assert_eq!(tallest.fetches_pct, 100);
    }

    /// The scaling arithmetic, away from the chart.
    ///
    /// The zero peak is not hypothetical: a window in which every row is
    /// refusals moves no bytes at all, and a panicking chart on a namespace
    /// that was turned away thirty days running would be the worst possible
    /// moment for one.
    #[test]
    fn a_bar_against_a_peak_of_nothing_is_empty_rather_than_a_panic() {
        assert_eq!(bar_pct(0, 0), 0);
        assert_eq!(bar_pct(7, 0), 0);
        assert_eq!(bar_pct(0, 100), 0);
        assert_eq!(bar_pct(50, 100), 50);
        assert_eq!(bar_pct(100, 100), 100);
        // A value above the peak cannot happen — the peak is the maximum — but
        // the bar saturates rather than running off the end if it ever does.
        assert_eq!(bar_pct(140, 100), 100);
        // Wire bytes overflow `u64` when multiplied by 100 above about 184 PB,
        // and a wrapped percentage draws the busiest day as nearly empty.
        assert_eq!(bar_pct(u64::MAX / 2, u64::MAX), 49);
        assert_eq!(
            peak_bytes(&[]),
            0,
            "an empty series has no peak and no panic"
        );
        assert_eq!(peak_count(&[]), 0);
    }

    /// ★ **"never", not 1970.**
    ///
    /// gunnar sends 0 for a namespace nothing has touched inside the kept
    /// window and the server turns it into `None`. Rendering the 0 would put
    /// *"last push 689 months ago"* — an age computed from 1 Jan 1970 — beside
    /// a namespace created this morning, and "never" and "a very long time ago"
    /// are opposite answers to the question being asked.
    ///
    /// Seen red with `fmt::since(0, now)` in place of the `None` arm:
    /// *"Last push 689 mo ago \u{b7} last fetch 689 mo ago"*.
    #[test]
    fn a_namespace_nothing_ever_touched_reads_never_and_not_an_age() {
        let now = D0 + 5 * DAY;

        assert_eq!(
            last_seen_line(None, None, now),
            "Last push never \u{b7} last fetch never"
        );
        // A real stamp still reads as an age, or the rule above would be
        // "always never".
        assert_eq!(
            last_seen_line(Some(now - 2 * DAY), Some(now - 300_000), now),
            "Last push 2 d ago \u{b7} last fetch 5 min ago"
        );
        // The mixed case is the one the pair exists for: fetched from daily,
        // never pushed to, which is a mirror and not an idle namespace.
        assert_eq!(
            last_seen_line(None, Some(now - 3_600_000 * 3), now),
            "Last push never \u{b7} last fetch 3 h ago"
        );
    }

    /// ★ **Nothing moved, this box does not count, and could not be read are
    /// three different sentences.**
    ///
    /// All three are "an empty chart" to a renderer that is not paying
    /// attention, and each is a different thing to tell an operator. The one
    /// that matters most is the middle: a gunnar older than the FLOW ledger
    /// refuses `Usage.Activity` with `Unimplemented`, and answering that with
    /// "no activity in the last 30 days" would be this console asserting a
    /// measurement nobody took about a namespace that may be the busiest on the
    /// box.
    ///
    /// Seen red against a page that had one empty state: *"a box that does not
    /// count activity was reported as a quiet tenant"*.
    #[test]
    fn an_empty_chart_says_which_of_the_three_things_it_means() {
        // A real answer, a real ledger, no rows: the tenant is quiet.
        assert_eq!(no_bars(400), NoBars::Quiet);
        assert_eq!(
            no_bars_title(NoBars::Quiet),
            "No activity in the last 30 days"
        );
        assert!(
            no_bars_detail(NoBars::Quiet, 400).contains("400 days"),
            "the quiet sentence must say how far back the box actually looked: {}",
            no_bars_detail(NoBars::Quiet, 400)
        );

        // A box that keeps no ledger at all.
        assert_eq!(no_bars(0), NoBars::NotCounted);
        assert_eq!(
            no_bars_title(NoBars::NotCounted),
            "This box does not count activity"
        );

        // …and the same box seen through the refusal an older gunnar gives.
        assert_eq!(
            no_bars_after_refusal(501, "unimplemented"),
            NoBars::NotCounted
        );
        assert_eq!(no_bars_after_refusal(501, ""), NoBars::NotCounted);
        assert_eq!(
            no_bars_after_refusal(502, "Unimplemented"),
            NoBars::NotCounted
        );

        // Every other refusal is unreadable, and `PermissionDenied` above all:
        // "you may not look" must never be drawn as "nothing happened".
        assert_eq!(
            no_bars_after_refusal(403, "permissiondenied"),
            NoBars::Unreadable
        );
        assert_eq!(no_bars_after_refusal(404, "notfound"), NoBars::Unreadable);
        assert_eq!(no_bars_after_refusal(0, ""), NoBars::Unreadable);
        assert_eq!(
            no_bars_after_refusal(503, "unavailable"),
            NoBars::Unreadable
        );

        // The three sentences are three sentences.
        let quiet = no_bars_title(NoBars::Quiet);
        let uncounted = no_bars_title(NoBars::NotCounted);
        let unread = no_bars_title(NoBars::Unreadable);
        assert_ne!(quiet, uncounted);
        assert_ne!(quiet, unread);
        assert_ne!(uncounted, unread);
        assert_ne!(
            no_bars_detail(NoBars::Quiet, 400),
            no_bars_detail(NoBars::Unreadable, 400)
        );
    }

    /// ★ **A refusal reason gunnar did not send is not named, and the total is
    /// gunnar's own.**
    ///
    /// The walled day was refused by `admission` and by `cap` and never by
    /// `policy`. Naming `policy 0` would tell a tenant their pushes are not
    /// being turned away by the push gate — a fact this console did not measure
    /// and gunnar did not claim — and it is the tenant deciding whether their
    /// problem is money or a protected branch who needs the difference.
    #[test]
    fn a_day_names_only_the_refusal_reasons_that_happened() {
        let walled = &ledger()[1];
        assert_eq!(refusal_phrase(walled), "5 refused: admission 1, cap 4");
        assert!(
            !refusal_phrase(walled).contains("policy"),
            "a reason gunnar did not send was named: {}",
            refusal_phrase(walled)
        );
        // A day with no refusals says nothing at all rather than "0 refused".
        assert_eq!(refusal_phrase(&ledger()[0]), "");
        // Refusals with NO breakdown: gunnar counted them and attributed none,
        // so the sentence stops after the total rather than inventing a reason
        // or dropping the refusals it was told about.
        assert_eq!(
            refusal_phrase(&Day {
                refused: 3,
                refused_by_reason: BTreeMap::new(),
                ..Day::default()
            }),
            "3 refused"
        );
        // The total is gunnar's, not the sum: when the two disagree the
        // arithmetic is left visible rather than the total rewritten.
        assert_eq!(
            refusal_phrase(&Day {
                refused: 9,
                refused_by_reason: BTreeMap::from([("cap".to_owned(), 4)]),
                ..Day::default()
            }),
            "9 refused: cap 4"
        );
    }

    /// The day sentence, whole — the words a screen reader reads out, in the
    /// units an operator reads, with the singular that stops "1 pushes".
    #[test]
    fn one_day_reads_as_one_sentence() {
        assert_eq!(
            day_hint(&ledger()[0]),
            "2026-09-03 \u{b7} 1.0 MiB in, 5.0 MiB out \u{b7} 4 pushes, 11 fetches"
        );
        assert_eq!(
            day_hint(&ledger()[1]),
            "2026-09-05 \u{b7} 3.0 MiB in, 768.0 KiB out \u{b7} 2 pushes, 7 fetches \u{b7} \
             5 refused: admission 1, cap 4"
        );
        assert_eq!(
            day_hint(&Day {
                day_unix_ms: D0,
                pushes: 1,
                fetches: 1,
                ..Day::default()
            }),
            "2026-09-03 \u{b7} 0 B in, 0 B out \u{b7} 1 push, 1 fetch",
            "\"1 pushes\" is the kind of thing a reader stops trusting a page over"
        );
        assert_eq!(axis_label(D0), "09-03");
    }

    /// **Thirty days here and thirty days on the wire.**
    ///
    /// This crate depends on nothing of the server's, so the window is pinned
    /// in two places — `api::usage::ACTIVITY_DAYS` and [`WINDOW_DAYS`] — and
    /// the words on screen say thirty. A page that said "the last 30 days" over
    /// a fortnight of bars is a true sentence above a false chart.
    #[test]
    fn the_window_is_thirty_days_on_both_sides_of_the_wire() {
        assert_eq!(WINDOW_DAYS, 30);
        assert!(
            no_bars_title(NoBars::Quiet).contains("30 days"),
            "the empty state must name the same window the chart draws"
        );
        assert_eq!(bars(&series(), NOW).len(), WINDOW_DAYS as usize);
    }
}