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}