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