Skip to main content

tix/app/
stats.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Which tab of the `F7` **Stats** page is showing (mission
5/// `per-issue-time-tracking-01M3C9A4`, WP05 T026; mission
6/// `burndown-scope-analytics-01M3C9J1`, WP06 T029).
7///
8/// A **tab**, not a mode. The page, its `TopMode::Stats`, its
9/// [`InputContext::Stats`] and its `F7` binding all ship from
10/// `project-issue-health-status-01M3C8HX`, and the same tab-not-mode rule
11/// [`crate::ui::roadmap`] documents for the `F10` Portfolio page applies here — a
12/// further `TopMode` (or a second `InputContext`, or a nav limb) would break
13/// `every_mode_maps_to_a_distinct_shell_action`. The tab is additive in this enum
14/// and nowhere else: the strip, the hint and the switch all read [`Self::ALL`].
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
16pub enum StatsTab {
17    /// The Health tab: the shared health document's scopes, states and diagnostics.
18    /// It ships first, so `F7` still opens on what it always showed.
19    #[default]
20    Health,
21    /// The Time tab: the scope's shared total, its entry rows, its filters and the
22    /// three mutations.
23    Time,
24    /// The Burndown tab (mission `burndown-scope-analytics-01M3C9J1`, WP06 T029): the
25    /// shared daily series, its coverage markers and the read-only forecast, over one
26    /// cycle or milestone.
27    Burndown,
28}
29
30impl StatsTab {
31    /// The tabs in display order.
32    pub const ALL: [Self; 3] = [Self::Health, Self::Time, Self::Burndown];
33
34    /// The tab's label in the strip.
35    pub const fn label(self) -> &'static str {
36        match self {
37            Self::Health => "Health",
38            Self::Time => "Time",
39            Self::Burndown => "Burndown",
40        }
41    }
42
43    /// The one-line hint under the strip, in the active tab's own vocabulary — the
44    /// words match the `[stats]` bindings exactly.
45    pub const fn hint(self) -> &'static str {
46        match self {
47            Self::Health => "j/k row · Enter open issue · Tab tabs · ? help · q close",
48            Self::Time => {
49                "j/k entry · a add · e correct · x delete · f filter · s breakdown · H history · \
50                 Tab tabs · ? help · q close"
51            }
52            Self::Burndown => {
53                "j/k day · [ ] scope · p period · t mode · u unit · v forecast · d/D origin · \
54                 Tab tabs · ? help · q close"
55            }
56        }
57    }
58
59    /// The tab `delta` steps away, wrapping.
60    ///
61    /// Wrapping is what makes two tabs reachable with one key from either side;
62    /// nothing else is reset because each tab owns its own cursor and state.
63    pub fn shifted(self, delta: isize) -> Self {
64        let current = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0);
65        let len = Self::ALL.len();
66        Self::ALL[(current as isize + delta).rem_euclid(len as isize) as usize]
67    }
68}
69
70impl App {
71    /// The F6 Repos mode's keys (tix #1529): walk the repository list, run tix in
72    /// the selected one, open help, or close.
73    /// One row of the `F7` Health tab: a scope and what the document says about it.
74    pub(in crate::app) fn stats_rows(&self) -> Vec<HealthRow> {
75        let Some(document) = self.health.as_ref() else {
76            return Vec::new();
77        };
78        document
79            .scopes
80            .iter()
81            .map(|entry| HealthRow {
82                scope: crate::health::HealthScope::parse(&entry.scope)
83                    .unwrap_or(crate::health::HealthScope::Issue(0)),
84                state: entry.state.clone(),
85                layer: entry.source.clone(),
86                derived: entry
87                    .rollup
88                    .as_ref()
89                    .map(crate::commands::health::render_derived),
90            })
91            .collect()
92    }
93
94    /// The `F7` Stats page's keys: walk the active tab's rows, act, leave.
95    ///
96    /// The page's two tabs share the strip keys (`Tab`/`Shift+Tab`), the close key and
97    /// `?`; each tab then reads its own keys. The Health tab stays **read-only** — a
98    /// row opens the issue's detail, where the health form lives — while the Time tab
99    /// adds, corrects and deletes entries through the shared service, so a stray key
100    /// can never write on the Health tab and the Time tab's writes always show their
101    /// fields first. No key here writes on its own.
102    pub(in crate::app) fn handle_stats_key(&mut self, key: KeyEvent) -> Result<Action> {
103        if !matches!(self.mode, Mode::Stats) {
104            return Ok(Action::None);
105        }
106        // The strip and the exits are the page's, not a tab's: `Tab`/`Shift+Tab`
107        // switch, `?` opens help, and `q`/`esc` leave — all of them read-only.
108        match key.code {
109            KeyCode::Tab => {
110                self.switch_stats_tab(1);
111                return Ok(Action::None);
112            }
113            KeyCode::BackTab => {
114                self.switch_stats_tab(-1);
115                return Ok(Action::None);
116            }
117            KeyCode::Char('?') => {
118                self.open_help(Mode::Stats);
119                return Ok(Action::None);
120            }
121            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
122                self.mode = Mode::Board;
123                return Ok(Action::None);
124            }
125            _ => {}
126        }
127        match self.stats_tab {
128            StatsTab::Health => self.handle_health_tab_key(key),
129            StatsTab::Time => self.handle_time_tab_key(key),
130            StatsTab::Burndown => self.handle_burndown_tab_key(key),
131        }
132    }
133
134    /// Switch to the tab `delta` steps away, taking the Time tab's read when it first
135    /// becomes visible (so the tab draws a read it just made, never a stale one).
136    pub(in crate::app) fn switch_stats_tab(&mut self, delta: isize) {
137        self.stats_tab = self.stats_tab.shifted(delta);
138        if self.stats_tab == StatsTab::Time && self.stats_time.is_none() {
139            self.refresh_stats_time();
140        }
141        // WP06: the Burndown tab's fold is taken on the same rule, and for the same
142        // reason: a tab drawn from a read it has not made would show a series the files
143        // no longer hold.
144        if self.stats_tab == StatsTab::Burndown && self.stats_burndown.is_none() {
145            self.refresh_stats_burndown();
146        }
147    }
148
149    /// The `F7` Stats page's active tab, and `None` in every other mode (WP05).
150    ///
151    /// The same reason [`Self::schedule_help_tab`] exists, and the same shape: the
152    /// page's two tabs act on different keys — the Health tab has no mutation key at
153    /// all, and the Time tab's six commands act on nothing on Health — so an overlay
154    /// generated from the binding context alone would advertise keys that do nothing.
155    /// The page the help was **opened from** is what counts, because the overlay is
156    /// drawn while `mode` is [`Mode::Help`] and the tab is still held.
157    pub fn stats_help_tab(&self) -> Option<StatsTab> {
158        let on_stats = matches!(self.mode, Mode::Stats)
159            || matches!(&self.mode, Mode::Help { return_to } if matches!(**return_to, Mode::Stats));
160        on_stats.then_some(self.stats_tab)
161    }
162}