Skip to main content

tix/ui/
analytics.rs

1//! The `F7` Stats page's **Burndown** tab (mission `burndown-scope-analytics-01M3C9J1`,
2//! WP06 T029–T033).
3//!
4//! # A tab on a page that already ships
5//!
6//! The `F7` **Stats** page, its `TopMode::Stats`, its [`crate::keybindings::InputContext::Stats`]
7//! and its `F7` binding all ship from `project-issue-health-status-01M3C8HX`, and its tab
8//! strip from `per-issue-time-tracking-01M3C9A4`. This module is the page's **third** tab,
9//! joining that strip — the rule [`crate::ui::roadmap`] documents for the `F10` Portfolio
10//! page. Nothing here introduces a mode: there is no `TopMode`, no `InputContext`, no
11//! `[shell]` action and no nav-tree limb, so
12//! `every_mode_maps_to_a_distinct_shell_action` is untouched (analysis-report H3).
13//!
14//! # What the tab renders
15//!
16//! Every figure on the tab is a field of the shared DTOs — [`ChartExport`] and its
17//! [`ChartSeries`](crate::analytics::chart::ChartSeries),
18//! [`ChartProjection`](crate::analytics::chart::ChartProjection) and findings nodes — read
19//! out of the **one** document the CLI's verbs read
20//! ([`crate::commands::export::chart_document`]). The tab computes no series, converts no
21//! unit, projects no date and groups nothing: it resolves a scope through the crate's own
22//! resolver, asks the shared adapter for the document, and draws it (C-008, NFR-003). An
23//! unanswerable value is `-` and never a `0` (C-005), a day's coverage marker travels beside
24//! the values it qualifies, and a closed snapshot's binding is named on the scope line and
25//! in the `closed_snapshot` finding (C-006).
26//!
27//! # The chart
28//!
29//! The `CHART` column is a hand-drawn terminal bar, not a dependency (C-009): one `█` per
30//! unit of the day's **remaining** value, truncated at [`BAR_CELLS`] with a trailing `…`,
31//! `·` for a known zero and a `-` for a day whose remaining work the record cannot answer —
32//! the same value the `REMAINING` column spells, drawn. It assembles no scale, computes no
33//! maximum and normalises nothing, so it can disagree with no other surface about a figure.
34//!
35//! # Read-only
36//!
37//! The tab's controls select a scope, a period source, a mode, a unit and a forecast
38//! origin in **session state** and re-fold through the shared seam; none of them writes.
39//! There is no key here that reaches a store's write path, and the tab's own tests assert
40//! the project's bytes are untouched after every control (T033).
41//!
42//! [`ChartSeries`]: crate::analytics::chart::ChartSeries
43
44use ratatui::style::{Color, Modifier, Style};
45use ratatui::text::{Line, Span};
46
47use crate::analytics::chart::{ChartExport, ChartProjection};
48use crate::analytics::events::{AnalyticsScope, Period};
49use crate::analytics::series::{Coverage, CoverageSummary, DailyPoint, Mode};
50use crate::keybindings::TuiAction;
51use crate::ui::detail::dash_number;
52
53/// How many cells the `CHART` column draws for one value before truncating it.
54///
55/// A cap, not a scale: past it the bar ends in `…`, and the value it stands for is
56/// printed beside it in `REMAINING`. A bar that re-scaled every day against the series'
57/// largest value would be a second arithmetic of the same figure.
58const BAR_CELLS: usize = 20;
59
60/// The glyph one unit of the chart's bar is drawn with.
61const BAR: &str = "█";
62
63/// The `CHART` column's own spelling for a value the record cannot answer.
64const NO_BAR: &str = "-";
65
66/// The `CHART` column's own spelling for a **known** zero: no work remains, and the record
67/// says so.
68///
69/// Distinct from [`NO_BAR`] on purpose — a zero-length bar and an unanswerable value must not
70/// render identically, or the chart would re-introduce the assumption C-005 forbids.
71const ZERO_BAR: &str = "·";
72
73/// The daily table's columns, in the data model's field order (T031).
74const DAY_COLUMNS: [&str; 7] = [
75    "DATE",
76    "COVERAGE",
77    "REMAINING",
78    "SCOPE",
79    "DONE",
80    "+ADD",
81    "-DONE",
82];
83
84/// The daily table's column widths, one per [`DAY_COLUMNS`] entry.
85///
86/// The two text columns (`DATE`, `COVERAGE`) are left-aligned and every numeric column
87/// right-aligned, so a column of `-`s and a column of counts read as the same column — the
88/// shape the page's other tables use.
89const DAY_WIDTHS: [usize; 7] = [10, 16, 9, 9, 9, 9, 9];
90
91/// Whether the column at `index` is text (left-aligned) rather than numeric.
92const fn day_text_column(index: usize) -> bool {
93    index < 2
94}
95
96/// The keys the Burndown tab answers to through the binding table (T029/T033).
97///
98/// The tab's cursor and the page's strip are the page's own actions — `j`/`k` (and the
99/// arrows), `Tab`/`Shift+Tab`, `?` and `q` — exactly as the Health and Time tabs use them.
100/// `Submit` is deliberately absent: `Enter` opens nothing on this tab, and an overlay that
101/// advertised it would name a key that does nothing.
102pub const HELP_ACTIONS: &[TuiAction] = &[
103    TuiAction::MoveUp,
104    TuiAction::MoveDown,
105    TuiAction::Next,
106    TuiAction::Previous,
107    TuiAction::Help,
108    TuiAction::Close,
109];
110
111/// The keys the Burndown tab answers to **raw**, with what each one does.
112///
113/// These are the controls T030 asks for, and they are raw for the reason the `F8` Schedule
114/// page's tab keys are: none of them is bound in
115/// [`crate::keybindings::InputContext::Stats`], so the binding layer would rewrite them to
116/// the inert `F(12)` sentinel, and none of them is named by a [`TuiAction`] — so the tab
117/// adds no action, no key-map row and no `docs/key-bindings.md` row (analysis-report H4).
118pub const HELP_RAW: &[(&str, &str)] = &[
119    ("[ / ]", "previous / next scope"),
120    ("p", "period source (planning / observed)"),
121    ("t", "mode (count / estimate)"),
122    ("u", "next estimate unit"),
123    ("v", "ask for the forecast"),
124    ("d / D", "forecast origin (earlier / later)"),
125];
126
127/// The footer rows the `F7` Stats page draws for its **Burndown** tab.
128///
129/// The page's own cursor, strip and exit rows, and nothing from the Time tab: the footer
130/// truncates, and a key that exists only on another tab is exactly the leak the tab scoping
131/// exists to prevent. They are the overlay's own rows — one tab, one key set, so the two
132/// surfaces cannot drift — and the controls themselves are spelled in [`HELP_RAW`] and in
133/// the tab's own hint row.
134pub const FOOTER_ACTIONS: &[TuiAction] = HELP_ACTIONS;
135
136/// Which period the series covers (T030).
137///
138/// Two callers' choices, both of them the record's own: the scope's **planning** period
139/// (what the CLI passes when no `--from`/`--to` is named) and the period the recorded
140/// **events** themselves span (the fold resolves an absent bound to the observed extent —
141/// its own documented answer, and the remedy the shared `missing_history` finding names).
142/// Neither is arithmetic: no date is shifted, only a bound released.
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
144pub enum BurndownPeriod {
145    /// The scope's own planning period — the cycle's declared dates, the milestone's target.
146    #[default]
147    Planning,
148    /// Both bounds released, so the fold covers exactly the days the record's events span.
149    Observed,
150}
151
152impl BurndownPeriod {
153    /// Both sources, in cycle order.
154    pub const ALL: [Self; 2] = [Self::Planning, Self::Observed];
155
156    /// The source's name, as the tab spells it.
157    pub const fn label(self) -> &'static str {
158        match self {
159            Self::Planning => "planning",
160            Self::Observed => "observed",
161        }
162    }
163
164    /// The source's one-line explanation.
165    pub const fn description(self) -> &'static str {
166        match self {
167            Self::Planning => "the scope's own planning period",
168            Self::Observed => "the days the record's own events span",
169        }
170    }
171
172    /// The next source, wrapping.
173    pub fn next(self) -> Self {
174        match self {
175            Self::Planning => Self::Observed,
176            Self::Observed => Self::Planning,
177        }
178    }
179}
180
181/// The `F7` Stats page's **Burndown** tab session state (T029–T032).
182///
183/// Every selection lives here and nowhere else: the scope, the period source, the mode, the
184/// unit, whether the forecast is asked for, and the forecast's origin. Nothing is persisted
185/// (a session default is not a preference), nothing is a total, and the tab's one **read** —
186/// the shared [`ChartExport`] — is rebuilt when the tab is opened and after every control,
187/// never during a render.
188///
189/// The origin is held as a count of days **back from the series' last covered day**, so a
190/// refresh cannot leave it pointing at a day the fold no longer holds: the projection's own
191/// refusal for an uncovered day is a statement the tab should never have to make.
192#[derive(Debug, Clone, PartialEq)]
193pub struct BurndownState {
194    /// The scopes the record offers, resolved through the crate's own scope resolver.
195    pub choices: Vec<AnalyticsScope>,
196    /// Which choice is selected, as an index into [`Self::choices`].
197    pub choice: usize,
198    /// Which period source the series is folded over.
199    pub period: BurndownPeriod,
200    /// How the series counts.
201    pub mode: Mode,
202    /// The units the project declares a conversion for ([`crate::analytics::units::declared_units`]).
203    pub units: Vec<String>,
204    /// Which unit is selected, as an index into [`Self::units`]. Remembered in count mode,
205    /// where the fold ignores it, so switching back does not lose the reader's choice.
206    pub unit: usize,
207    /// Whether the forecast is asked for.
208    pub forecast: bool,
209    /// The forecast origin: how many covered days back from the series' last covered day.
210    pub origin_back: usize,
211    /// Which daily row the cursor is on, as an index into the series' points.
212    pub cursor: usize,
213    /// The document the tab is showing, `None` when no fold has produced one.
214    pub document: Option<ChartExport>,
215    /// Why the last fold produced no document — a read that failed, or a request the
216    /// record cannot carry. An explicit state, never a blank pane.
217    pub problem: Option<String>,
218}
219
220impl BurndownState {
221    /// A fresh tab over `choices`, with the scope's own period, count mode, unit `0`, no
222    /// forecast, the origin on the series' last covered day and the cursor on the first row.
223    pub fn new(choices: Vec<AnalyticsScope>, units: Vec<String>) -> Self {
224        Self {
225            choices,
226            choice: 0,
227            period: BurndownPeriod::Planning,
228            mode: Mode::Count,
229            units,
230            unit: 0,
231            forecast: false,
232            origin_back: 0,
233            cursor: 0,
234            document: None,
235            problem: None,
236        }
237    }
238
239    /// The selected choice, when the record offers one.
240    pub fn selected(&self) -> Option<&AnalyticsScope> {
241        self.choices.get(self.choice)
242    }
243
244    /// The scope the fold is asked for: the selection with [`Self::period`] applied.
245    ///
246    /// The closed-snapshot binding travels with the choice (the record's close evidence), so
247    /// it survives the period change — a snapshot-bound scope stays bound whatever period the
248    /// reader looks through, which is what C-006 requires of a historical view.
249    pub fn scope(&self) -> Option<AnalyticsScope> {
250        let mut scope = self.selected()?.clone();
251        if self.period == BurndownPeriod::Observed {
252            scope.period = Period::unbounded();
253        }
254        Some(scope)
255    }
256
257    /// The unit the fold is asked for: the selection, and only in estimate mode — a count is
258    /// unitless, which is what the CLI's `--unit` does too.
259    pub fn unit(&self) -> Option<&str> {
260        if self.mode != Mode::Estimate {
261            return None;
262        }
263        self.units.get(self.unit).map(String::as_str)
264    }
265
266    /// The covered days the current document holds.
267    pub fn days(&self) -> usize {
268        self.document
269            .as_ref()
270            .map_or(0, |document| document.series.points.len())
271    }
272
273    /// The forecast origin: the covered day [`Self::origin_back`] days back from the last one.
274    pub fn origin(&self) -> Option<&DailyPoint> {
275        let points = &self.document.as_ref()?.series.points;
276        points.get(points.len().checked_sub(self.origin_back + 1)?)
277    }
278
279    /// The forecast to ask the document for, as `(as_of, end_date)` — the same pair the CLI
280    /// passes as `--as-of` and the scope's planning end.
281    ///
282    /// `None` when the forecast is off, when there is no covered day to stand on, or when the
283    /// effective period declares no end: the caller renders each of those states explicitly
284    /// rather than asking for a forecast the record cannot bound.
285    pub fn projection(&self) -> Option<(String, String)> {
286        if !self.forecast {
287            return None;
288        }
289        let origin = self.origin()?;
290        let end = self.scope()?.period_end().map(str::to_string)?;
291        Some((origin.date.clone(), end))
292    }
293
294    /// Switch to the scope `delta` steps away, wrapping — so one key reaches every scope from
295    /// either side, exactly as the page's tab strip does.
296    pub fn shift_scope(&mut self, delta: isize) {
297        if self.choices.is_empty() {
298            self.choice = 0;
299            return;
300        }
301        let len = self.choices.len() as isize;
302        self.choice = ((self.choice as isize + delta).rem_euclid(len)) as usize;
303        // A different scope holds a different series: the other selections are the reader's
304        // and stay, but the origin is a *day* of the series and starts from the scope's own
305        // end again. The cursor does too — row 12 of one scope is not row 12 of another.
306        self.origin_back = 0;
307        self.cursor = 0;
308    }
309
310    /// The other period source (T030).
311    pub fn cycle_period(&mut self) {
312        self.period = self.period.next();
313    }
314
315    /// The other mode: count ↔ estimate (T030).
316    pub fn cycle_mode(&mut self) {
317        self.mode = match self.mode {
318            Mode::Count => Mode::Estimate,
319            Mode::Estimate => Mode::Count,
320        };
321    }
322
323    /// The next unit the project declares, wrapping; inert when it declares none.
324    pub fn cycle_unit(&mut self) {
325        if !self.units.is_empty() {
326            self.unit = (self.unit + 1) % self.units.len();
327        }
328    }
329
330    /// Ask for the forecast, or stop asking for it.
331    pub fn toggle_forecast(&mut self) {
332        self.forecast = !self.forecast;
333        if !self.forecast {
334            self.origin_back = 0;
335        }
336    }
337
338    /// Move the forecast origin one covered day earlier, to the series' first day at most.
339    pub fn origin_earlier(&mut self) {
340        let last = self.days().saturating_sub(1);
341        self.origin_back = (self.origin_back + 1).min(last);
342    }
343
344    /// Move the forecast origin one covered day later, to the series' last day at most.
345    pub fn origin_later(&mut self) {
346        self.origin_back = self.origin_back.saturating_sub(1);
347    }
348
349    /// Move the daily cursor `delta` rows, clamped into the days the document holds.
350    pub fn move_cursor(&mut self, delta: isize) {
351        let last = self.days().saturating_sub(1);
352        let next = if delta.is_negative() {
353            self.cursor.saturating_sub(delta.unsigned_abs())
354        } else {
355            self.cursor.saturating_add(delta as usize)
356        };
357        self.cursor = next.min(last);
358    }
359
360    /// Keep every index inside the list it indexes (called after every read, so a control
361    /// cannot leave a cursor past the end of a document the fold just shrank).
362    pub fn clamp(&mut self) {
363        if self.choice >= self.choices.len() {
364            self.choice = 0;
365        }
366        if self.unit >= self.units.len() {
367            self.unit = 0;
368        }
369        let last = self.days().saturating_sub(1);
370        self.origin_back = self.origin_back.min(last);
371        self.cursor = self.cursor.min(last);
372    }
373}
374
375/// The tab's rendered body: the lines to draw and the line its cursor is on.
376///
377/// The cursor travels with the lines so the frame can scroll the body to keep the selected
378/// day visible without the tab re-deriving where that day landed.
379#[derive(Debug, Clone, PartialEq)]
380pub struct BurndownFrame {
381    /// The body, top to bottom.
382    pub lines: Vec<Line<'static>>,
383    /// The index in [`Self::lines`] of the selected day's row, or `0` when none is drawn.
384    pub cursor_line: usize,
385}
386
387/// The Burndown tab's body (T029/T031/T032).
388///
389/// The scope header, then the fold's own metadata, the daily table with its chart column,
390/// the forecast when one was asked for, and every finding with its reason and its remedy —
391/// in the order the CLI's report prints them. An empty answer is a sentence that says so,
392/// never a blank pane.
393pub fn frame(state: &BurndownState) -> BurndownFrame {
394    let mut lines: Vec<Line<'static>> = Vec::new();
395    let mut cursor_line = 0usize;
396
397    if state.choices.is_empty() {
398        lines.extend(no_scope_lines());
399        return BurndownFrame { lines, cursor_line };
400    }
401
402    lines.extend(scope_lines(state, state.document.as_ref()));
403    lines.push(Line::from(""));
404
405    let Some(document) = state.document.as_ref() else {
406        lines.extend(problem_lines(state));
407        return BurndownFrame { lines, cursor_line };
408    };
409
410    lines.extend(fold_lines(document));
411    lines.push(Line::from(""));
412
413    if document.is_empty() {
414        lines.extend(no_day_lines(document));
415    } else {
416        lines.push(head_line());
417        for (index, point) in document.series.points.iter().enumerate() {
418            let selected = index == state.cursor;
419            if selected {
420                cursor_line = lines.len();
421            }
422            lines.push(day_line(point, selected));
423        }
424    }
425
426    lines.push(Line::from(""));
427    lines.extend(projection_lines(state, document));
428    lines.push(Line::from(""));
429    lines.extend(diagnostic_lines(document));
430    BurndownFrame { lines, cursor_line }
431}
432
433fn dim() -> Style {
434    Style::default().fg(Color::DarkGray)
435}
436
437fn warning() -> Style {
438    Style::default()
439        .fg(Color::Yellow)
440        .add_modifier(Modifier::BOLD)
441}
442
443/// One `label: value` line, in the page's own field style.
444fn field(label: &str, value: &str) -> Line<'static> {
445    Line::from(vec![
446        Span::styled(format!("{label}: "), dim()),
447        Span::raw(value.to_string()),
448    ])
449}
450
451/// The scope lines, in the CLI's own order and spelling (`scope`, `id`, `period`,
452/// `snapshot`, `mode`, `unit`), plus the two facts only a *tabbed* surface needs to state:
453/// which period source is in force, and where the selection sits in the list.
454///
455/// The values are the resolved scope's own fields — the same value `tix analytics burndown`
456/// prints for the same scope — so the two surfaces cannot disagree about the period or the
457/// snapshot binding (NFR-003).
458fn scope_lines(state: &BurndownState, document: Option<&ChartExport>) -> Vec<Line<'static>> {
459    let mut lines = Vec::new();
460    let Some(scope) = state.scope() else {
461        return lines;
462    };
463    let period = format!(
464        "{}..{}",
465        bound_text(scope.period_start()),
466        bound_text(scope.period_end())
467    );
468    lines.push(field("scope", scope.kind.as_str()));
469    lines.push(field("id", &scope.id));
470    lines.push(field("period", &period));
471    lines.push(field(
472        "period source",
473        &format!("{} ({})", state.period.label(), state.period.description()),
474    ));
475    lines.push(field(
476        "snapshot",
477        scope.snapshot_id.as_deref().unwrap_or("-"),
478    ));
479    lines.push(field("mode", state.mode.as_str()));
480    // The document's own unit when there is one — the CLI prints the same field — and
481    // the selection otherwise, so the line reads the same before and after a fold.
482    let unit = document
483        .and_then(|document| document.unit.as_deref())
484        .or_else(|| state.unit())
485        .unwrap_or("-");
486    lines.push(field("unit", unit));
487    lines.push(field(
488        "selected",
489        &format!("{} of {}", state.choice + 1, state.choices.len()),
490    ));
491    lines
492}
493
494/// A period bound as the CLI prints it: the date, or `open` for a bound the record does not
495/// declare (which is coverage, not a guess).
496fn bound_text(bound: Option<&str>) -> &str {
497    bound.unwrap_or("open")
498}
499
500/// The fold's own metadata: the formula version, the day count, the covered span and the
501/// coverage tally — every value a field or an accessor of the shared document. The finding
502/// count is drawn once, above the findings themselves.
503fn fold_lines(document: &ChartExport) -> Vec<Line<'static>> {
504    let coverage: CoverageSummary = document.coverage;
505    let covered = document
506        .series
507        .points
508        .first()
509        .zip(document.series.points.last())
510        .map_or_else(
511            || "-".to_string(),
512            |(first, last)| format!("{}..{}", first.date, last.date),
513        );
514    vec![
515        field("formula", &document.formula_version),
516        Line::from(vec![
517            Span::styled("days: ", dim()),
518            Span::raw(document.series.points.len().to_string()),
519            Span::styled("   covered: ", dim()),
520            Span::raw(covered),
521        ]),
522        Line::from(vec![
523            Span::styled("coverage: ", dim()),
524            Span::raw(format!(
525                "known={} unknown={} coverage_limited={}",
526                coverage.known, coverage.unknown, coverage.coverage_limited
527            )),
528        ]),
529    ]
530}
531
532/// How many findings the tab renders: the series' own, the forecast's when one is in the
533/// document, then the document's — the same sum, in the same order, as the CLI's.
534fn finding_count(document: &ChartExport) -> usize {
535    document.series.diagnostics.len()
536        + document
537            .projection
538            .as_ref()
539            .map_or(0, |projection| projection.diagnostics.len())
540        + document.diagnostics.len()
541}
542
543/// The daily table's heading row.
544fn head_line() -> Line<'static> {
545    let mut text = String::from("  ");
546    for (index, column) in DAY_COLUMNS.iter().enumerate() {
547        if index > 0 {
548            text.push_str("  ");
549        }
550        let width = DAY_WIDTHS[index];
551        if day_text_column(index) {
552            text.push_str(&format!("{column:<width$}"));
553        } else {
554            text.push_str(&format!("{column:>width$}"));
555        }
556    }
557    text.push_str("  CHART");
558    Line::from(Span::styled(text, dim()))
559}
560
561/// One day as the table's row: the point's own fields, the shared coverage marker beside
562/// them, and the chart bar (T031).
563///
564/// A `None` is `-` through [`dash_number`], the crate's one rendering of "no value", so an
565/// unobserved day's additions read as absent rather than as a recorded zero — the `0` an
566/// *observed* quiet day carries is right beside it in the same column.
567fn day_line(point: &DailyPoint, selected: bool) -> Line<'static> {
568    let cells = [
569        point.date.clone(),
570        point.coverage.as_str().to_string(),
571        dash_number(point.remaining),
572        dash_number(point.scope_total),
573        dash_number(point.completed),
574        dash_number(point.scope_added),
575        dash_number(point.completions),
576    ];
577    let mut text = if selected {
578        String::from("> ")
579    } else {
580        String::from("  ")
581    };
582    for (index, cell) in cells.iter().enumerate() {
583        if index > 0 {
584            text.push_str("  ");
585        }
586        let width = DAY_WIDTHS[index];
587        if day_text_column(index) {
588            text.push_str(&format!("{cell:<width$}"));
589        } else {
590            text.push_str(&format!("{cell:>width$}"));
591        }
592    }
593    text.push_str("  ");
594    text.push_str(&bar_text(point.remaining));
595    Line::from(Span::styled(text, day_style(point.coverage, selected)))
596}
597
598/// The style one day's row is drawn in: the coverage marker is the day's own warning, and
599/// the cursor is the reader's.
600fn day_style(coverage: Coverage, selected: bool) -> Style {
601    let base = match coverage {
602        Coverage::Known => Style::default(),
603        Coverage::CoverageLimited => Style::default().fg(Color::Yellow),
604        Coverage::Unknown => dim(),
605    };
606    if selected {
607        base.add_modifier(Modifier::BOLD)
608    } else {
609        base
610    }
611}
612
613/// The chart column's text for one day: one cell per unit of remaining work, truncated with
614/// `…` past [`BAR_CELLS`], `·` for a known zero, and `-` for work the day cannot answer.
615fn bar_text(remaining: Option<i64>) -> String {
616    let Some(remaining) = remaining else {
617        return NO_BAR.to_string();
618    };
619    let cells = usize::try_from(remaining).unwrap_or(0);
620    if cells == 0 {
621        return ZERO_BAR.to_string();
622    }
623    let mut bar = BAR.repeat(cells.min(BAR_CELLS));
624    if cells > BAR_CELLS {
625        bar.push('…');
626    }
627    bar
628}
629
630/// The forecast block: the projection's own fields when one was drawn, and an explicit
631/// statement of *which* state the tab is in when one was not (T032).
632fn projection_lines(state: &BurndownState, document: &ChartExport) -> Vec<Line<'static>> {
633    let mut lines = Vec::new();
634    match document.projection.as_ref() {
635        Some(projection) => lines.extend(forecast_lines(projection)),
636        None if !state.forecast => {
637            lines.push(field("projection", "off (press `v` to ask for it)"));
638        }
639        None => {
640            lines.push(field("projection", "unavailable"));
641            match state.scope().filter(|scope| scope.period_end().is_none()) {
642                Some(scope) => {
643                    // The shared service names this state: the scope declares no planning end,
644                    // so there is no boundary to draw a forecast against. Pressing `p` cannot
645                    // bound a scope that declares none, so the remedy is the record's own.
646                    lines.push(Line::from(format!(
647                        "  the {} scope `{}` declares no planning end date, so a forecast has \
648                         no boundary to be drawn against",
649                        scope.kind, scope.id
650                    )));
651                    lines.push(Line::from(
652                        "  remedy: give the scope an end — the cycle's `due_date` or the \
653                         milestone's target date",
654                    ));
655                }
656                None => {
657                    lines.push(Line::from(
658                        "  the record holds no covered day to stand the forecast on",
659                    ));
660                    lines.push(Line::from(
661                        "  remedy: widen the series' period (press `p`) so it covers a \
662                         recorded day",
663                    ));
664                }
665            }
666        }
667    }
668    lines
669}
670
671/// The forecast's own fields, in the CLI's `projection_block` order.
672///
673/// The formula version is the **projection's**, not the series' — two different documents of
674/// two different formulas — and every unanswerable value is `-`: a forecast that could not be
675/// drawn says its `status` and its findings rather than printing a date it did not derive.
676fn forecast_lines(projection: &ChartProjection) -> Vec<Line<'static>> {
677    vec![
678        field("projection", projection.status.as_str()),
679        field("  as_of", &projection.as_of),
680        field("  end_date", &projection.end_date),
681        field("  remaining", &dash_number(projection.remaining)),
682        field("  observed_rate", &dash_number(projection.observed_rate)),
683        field(
684            "  projected_completion",
685            projection.projected_completion.as_deref().unwrap_or("-"),
686        ),
687        field("  meets_end_date", &dash_number(projection.meets_end_date)),
688        field("  formula", &projection.formula_version),
689    ]
690}
691
692/// Every finding the document holds, in the count's own order, each with its reason and its
693/// remedy (T031/T032).
694///
695/// The series' findings lead (they are the fold's), then the forecast's, then the document's
696/// own. Each is drawn from its fields, not from a pre-rendered string, so the tab lays out
697/// what the CLI prints to stderr — the same kind, scope, day, message and remedy.
698fn diagnostic_lines(document: &ChartExport) -> Vec<Line<'static>> {
699    let mut lines = vec![field("diagnostics", &finding_count(document).to_string())];
700    for diagnostic in &document.series.diagnostics {
701        lines.extend(finding_lines(diagnostic));
702    }
703    if let Some(projection) = document.projection.as_ref() {
704        for diagnostic in &projection.diagnostics {
705            lines.extend(finding_lines(diagnostic));
706        }
707    }
708    for diagnostic in &document.diagnostics {
709        lines.push(Line::from(format!(
710            "  {}  {}  {}",
711            diagnostic.kind,
712            diagnostic
713                .scope
714                .as_deref()
715                .or(diagnostic.target.as_deref())
716                .unwrap_or("-"),
717            diagnostic.reason
718        )));
719        if let Some(remedy) = diagnostic.remedy.as_deref() {
720            lines.push(Line::from(Span::styled(
721                format!("    remedy: {remedy}"),
722                dim(),
723            )));
724        }
725    }
726    if document.series.diagnostics.is_empty()
727        && document.diagnostics.is_empty()
728        && document
729            .projection
730            .as_ref()
731            .is_none_or(|projection| projection.diagnostics.is_empty())
732    {
733        lines.push(Line::from(Span::styled(
734            "  none: the fold answered every day it covers",
735            dim(),
736        )));
737    }
738    lines
739}
740
741/// One shared [`AnalyticsDiagnostic`](crate::analytics::diagnostics::AnalyticsDiagnostic) as
742/// the tab draws it: the kind and the scope it is about, its day and event when it has them,
743/// the message, and the remedy.
744fn finding_lines(
745    diagnostic: &crate::analytics::diagnostics::AnalyticsDiagnostic,
746) -> Vec<Line<'static>> {
747    let mut place = diagnostic.scope.clone();
748    if let Some(date) = diagnostic.date.as_deref() {
749        place.push_str(&format!("  {date}"));
750    }
751    if let Some(event_id) = diagnostic.event_id.as_deref() {
752        place.push_str(&format!("  {event_id}"));
753    }
754    let mut lines = vec![Line::from(format!(
755        "  {}  {place}  {}",
756        diagnostic.kind.as_str(),
757        diagnostic.message
758    ))];
759    if let Some(remedy) = diagnostic.remedy.as_deref() {
760        lines.push(Line::from(Span::styled(
761            format!("    remedy: {remedy}"),
762            dim(),
763        )));
764    }
765    lines
766}
767
768/// The tab with no scope to report: an explicit sentence naming how to give it one.
769fn no_scope_lines() -> Vec<Line<'static>> {
770    vec![
771        Line::from(Span::styled(
772            "No cycle or milestone is recorded.",
773            warning(),
774        )),
775        Line::from("The fold reports one scope at a time, and the record names none to report."),
776        Line::from("Define one with `tix cycle add <name>` or `tix milestone create <name>`,"),
777        Line::from("then open this tab again."),
778    ]
779}
780
781/// The fold's own answer when it covers no day: the sentence, and the findings that say why.
782fn no_day_lines(document: &ChartExport) -> Vec<Line<'static>> {
783    let findings = document.series.diagnostics.len();
784    let mut lines = vec![
785        Line::from(Span::styled(
786            "The fold answered no day for this scope.",
787            warning(),
788        )),
789        Line::from(format!(
790            "It reported {findings} finding(s) — below — rather than an empty series."
791        )),
792    ];
793    if document.series.diagnostics.is_empty() {
794        lines.push(Line::from(
795            "The period could not be enumerated: press `p` for the period the record's own",
796        ));
797        lines.push(Line::from(
798            "events span, or choose a scope whose record declares its dates.",
799        ));
800    }
801    lines
802}
803
804/// The tab when the last fold produced no document: the problem verbatim, then what the
805/// reader can do about it.
806fn problem_lines(state: &BurndownState) -> Vec<Line<'static>> {
807    let mut lines = vec![Line::from(Span::styled(
808        "The fold produced no document.",
809        warning(),
810    ))];
811    match state.problem.as_deref() {
812        Some(problem) => lines.push(Line::from(problem.to_string())),
813        None => lines.push(Line::from(
814            "The scope, period, mode or unit selects no series this build can fold.",
815        )),
816    }
817    lines.push(Line::from(""));
818    lines.push(Line::from(
819        "press `t` for count mode, `p` for the period the record's own events span,",
820    ));
821    lines.push(Line::from("or `[`/`]` for another scope"));
822    lines
823}
824
825#[cfg(test)]
826mod tests {
827    #[allow(clippy::wildcard_imports)]
828    use super::*;
829
830    /// A block of lines as text, one row per line — [`text`] renders a single [`Line`].
831    fn render(lines: &[Line<'static>]) -> String {
832        lines
833            .iter()
834            .map(|line| {
835                line.spans
836                    .iter()
837                    .map(|span| span.content.as_ref())
838                    .collect::<String>()
839            })
840            .collect::<Vec<_>>()
841            .join("\n")
842    }
843
844    fn unknown_day() -> DailyPoint {
845        DailyPoint {
846            date: "2026-01-05".to_string(),
847            scope_total: None,
848            remaining: None,
849            completed: None,
850            scope_added: None,
851            completions: None,
852            coverage: Coverage::Unknown,
853            event_ids: Vec::new(),
854        }
855    }
856
857    fn quiet_day() -> DailyPoint {
858        DailyPoint {
859            date: "2026-01-06".to_string(),
860            scope_total: Some(0),
861            remaining: Some(0),
862            completed: Some(0),
863            scope_added: Some(0),
864            completions: Some(0),
865            coverage: Coverage::Known,
866            event_ids: Vec::new(),
867        }
868    }
869
870    fn limited_day() -> DailyPoint {
871        DailyPoint {
872            date: "2026-01-07".to_string(),
873            scope_total: Some(2),
874            remaining: Some(1),
875            completed: None,
876            scope_added: None,
877            completions: None,
878            coverage: Coverage::CoverageLimited,
879            event_ids: Vec::new(),
880        }
881    }
882
883    fn text(line: &Line<'_>) -> String {
884        line.spans
885            .iter()
886            .map(|span| span.content.as_ref())
887            .collect::<String>()
888    }
889
890    /// A day the record cannot answer prints `-` in every numeric column, never a `0` — and
891    /// the day beside it, which *was* read, prints the zero the record holds.
892    #[test]
893    fn an_unanswerable_day_is_a_dash_and_a_read_quiet_day_is_a_zero() {
894        let unobserved = text(&day_line(&unknown_day(), false));
895        assert!(unobserved.contains("unknown"), "{unobserved}");
896        assert!(
897            unobserved.contains("2026-01-05  unknown"),
898            "the coverage marker travels beside the values it qualifies: {unobserved}"
899        );
900        let cells: Vec<&str> = unobserved.split_whitespace().collect();
901        assert_eq!(cells[0], "2026-01-05", "{cells:?}");
902        assert_eq!(cells[1], "unknown", "{cells:?}");
903        assert_eq!(
904            cells[2..7].to_vec(),
905            vec!["-"; 5],
906            "no numeric cell of an unanswerable day reads as a recorded zero: {cells:?}"
907        );
908        assert_eq!(
909            cells[7], "-",
910            "and the chart column draws the absent marker, not a zero-length bar: {cells:?}"
911        );
912
913        let observed = text(&day_line(&quiet_day(), false));
914        assert!(observed.contains("known"), "{observed}");
915        assert!(
916            observed.contains(" 0"),
917            "an observed quiet day's zero is a value: {observed}"
918        );
919        assert!(
920            observed.trim_end().ends_with(ZERO_BAR),
921            "and its chart column says a known zero, not an unknown one: {observed}"
922        );
923    }
924
925    /// A scope that declares no planning end, asked for a forecast, must say *that*: the
926    /// shared service names the state (`ProjectionUnknown`), and "press `p`" cannot bound a
927    /// scope that declares none. A review found the tab printing "no covered day" while it
928    /// was drawing covered days, with that unfollowable remedy.
929    #[test]
930    fn an_unbounded_scope_under_a_forecast_names_the_missing_planning_end() {
931        let unbounded = AnalyticsScope {
932            kind: crate::analytics::events::ScopeKind::Cycle,
933            id: "Open Cycle".to_string(),
934            period: crate::analytics::events::Period {
935                start: Some("2026-01-05".to_string()),
936                end: None,
937            },
938            snapshot_id: None,
939        };
940        let series = crate::analytics::series::BurndownSeries {
941            scope: unbounded.clone(),
942            mode: Mode::Count,
943            unit: None,
944            points: vec![quiet_day()],
945            diagnostics: Vec::new(),
946            formula_version: "burndown.count.v1".to_string(),
947        };
948        let document = ChartExport::build(&series, None, &[]);
949        let mut state = BurndownState::new(vec![unbounded.clone()], Vec::new());
950        state.forecast = true;
951
952        let rendered = render(&projection_lines(&state, &document));
953        assert!(
954            rendered.contains("declares no planning end date"),
955            "the missing planning end is named: {rendered}"
956        );
957        assert!(
958            !rendered.contains("no covered day"),
959            "the scope has a covered day, so that sentence is false here: {rendered}"
960        );
961        assert!(
962            rendered.contains("due_date"),
963            "and the remedy names the record's own field: {rendered}"
964        );
965
966        // The other cause keeps its own sentence: a scope that has an end but no covered day.
967        let bounded = AnalyticsScope {
968            period: crate::analytics::events::Period {
969                start: Some("2026-01-05".to_string()),
970                end: Some("2026-01-09".to_string()),
971            },
972            ..unbounded.clone()
973        };
974        let mut state = BurndownState::new(vec![bounded], Vec::new());
975        state.forecast = true;
976        let empty = ChartExport::build(
977            &crate::analytics::series::BurndownSeries {
978                points: Vec::new(),
979                ..series
980            },
981            None,
982            &[],
983        );
984        let rendered = render(&projection_lines(&state, &empty));
985        assert!(rendered.contains("no covered day"), "{rendered}");
986        assert!(!rendered.contains("planning end date"), "{rendered}");
987    }
988
989    /// A coverage-limited day whose level **is** known still draws the level it has, with the
990    /// marker beside it: the bar never stands in for a value the day does not hold.
991    #[test]
992    fn a_coverage_limited_day_draws_the_level_it_holds() {
993        let row = text(&day_line(&limited_day(), true));
994        assert!(row.starts_with("> "), "the cursor marks the row: {row}");
995        assert!(row.contains("coverage_limited"), "{row}");
996        assert!(row.contains('█'), "{row}");
997    }
998
999    /// The chart column is the value drawn, not a second arithmetic: one cell per unit, a
1000    /// truncation marker past the cap, and a dash for a value the record does not hold.
1001    #[test]
1002    fn the_chart_bar_draws_the_value_it_stands_for() {
1003        assert_eq!(bar_text(None), "-", "an unanswerable value draws no bar");
1004        assert_eq!(
1005            bar_text(Some(0)),
1006            "·",
1007            "a known zero is drawn as a zero, not as the absent marker"
1008        );
1009        assert_eq!(bar_text(Some(3)), "███");
1010        assert_eq!(bar_text(Some(20)), "█".repeat(20));
1011        assert_eq!(bar_text(Some(21)), format!("{}…", "█".repeat(20)));
1012    }
1013
1014    /// The Burndown tab lists the actions it really has, and none of the Time tab's.
1015    #[test]
1016    fn the_burndown_rows_name_only_this_tabs_keys() {
1017        let bindings =
1018            crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
1019                .expect("the built-in bindings resolve");
1020        let context = crate::keybindings::InputContext::Stats;
1021        for action in HELP_ACTIONS {
1022            assert!(
1023                bindings.bound_actions(context).contains(action),
1024                "`{}` is not bound in the Stats context",
1025                action.as_str()
1026            );
1027        }
1028        for absent in [
1029            TuiAction::TimeAdd,
1030            TuiAction::TimeEdit,
1031            TuiAction::TimeDelete,
1032            TuiAction::TimeFilter,
1033            TuiAction::TimeBreakdown,
1034            TuiAction::TimeHistory,
1035            // `Enter` opens nothing here, so the overlay must not name it.
1036            TuiAction::Submit,
1037        ] {
1038            assert!(
1039                !HELP_ACTIONS.contains(&absent),
1040                "`{}` acts on nothing on the Burndown tab",
1041                absent.as_str()
1042            );
1043            assert!(
1044                !FOOTER_ACTIONS.contains(&absent),
1045                "`{}` acts on nothing on the Burndown tab",
1046                absent.as_str()
1047            );
1048        }
1049        let advertised: Vec<&str> = HELP_RAW.iter().map(|(key, _)| *key).collect();
1050        for key in ["[ / ]", "p", "t", "u", "v", "d / D"] {
1051            assert!(
1052                advertised.contains(&key),
1053                "the tab names `{key}`: {advertised:?}"
1054            );
1055        }
1056    }
1057
1058    /// A tab with no scope says so rather than drawing nothing.
1059    #[test]
1060    fn a_tab_with_no_scope_says_so() {
1061        let state = BurndownState::new(Vec::new(), Vec::new());
1062        let frame = frame(&state);
1063        let text: Vec<String> = frame.lines.iter().map(text).collect();
1064        assert!(
1065            text.iter()
1066                .any(|line| line.contains("No cycle or milestone is recorded")),
1067            "{text:?}"
1068        );
1069        assert_eq!(frame.cursor_line, 0);
1070    }
1071
1072    /// The state's own controls are pure session choices: the mode toggle, the unit cycle,
1073    /// the period source and the scope ring.
1074    #[test]
1075    fn the_controls_move_between_the_records_own_choices() {
1076        let mut state = BurndownState::new(
1077            vec![
1078                AnalyticsScope::cycle("Sprint 7", Period::unbounded()).unwrap(),
1079                AnalyticsScope::milestone("v1", Period::unbounded()).unwrap(),
1080            ],
1081            vec!["points".to_string(), "hours".to_string()],
1082        );
1083        assert_eq!(state.mode, Mode::Count);
1084        assert_eq!(state.unit(), None, "a count is unitless");
1085        state.cycle_mode();
1086        assert_eq!(state.mode, Mode::Estimate);
1087        assert_eq!(state.unit(), Some("points"));
1088        state.cycle_unit();
1089        assert_eq!(state.unit(), Some("hours"));
1090        state.cycle_unit();
1091        assert_eq!(state.unit(), Some("points"), "the unit list wraps");
1092        state.cycle_period();
1093        assert_eq!(state.period, BurndownPeriod::Observed);
1094        assert_eq!(
1095            state.scope().unwrap().period,
1096            Period::unbounded(),
1097            "the observed source releases both bounds"
1098        );
1099        state.shift_scope(1);
1100        assert_eq!(state.selected().unwrap().id, "v1");
1101        state.shift_scope(1);
1102        assert_eq!(state.selected().unwrap().id, "Sprint 7", "the ring wraps");
1103        state.shift_scope(-1);
1104        assert_eq!(state.selected().unwrap().id, "v1");
1105    }
1106}