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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// The Burndown tab's keys (WP06 T029–T032): walk the daily rows.
///
/// The tab's **controls** (`[`/`]`, `p`, `t`, `u`, `v`, `d`/`D`) are read raw, ahead
/// of the binding layer — see [`App::handle_burndown_raw_key`] — because none of them
/// is bound in [`InputContext::Stats`]. Every key that reaches here is the page's own
/// or the tab's cursor, and none of them writes.
pub(in crate::app) fn handle_burndown_tab_key(&mut self, key: KeyEvent) -> Result<Action> {
if self.stats_burndown.is_none() {
// A path that reached the tab without the `Tab` key (a drawn strip, a test)
// still reads before it acts.
self.refresh_stats_burndown();
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if let Some(state) = self.stats_burndown.as_mut() {
state.move_cursor(-1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(state) = self.stats_burndown.as_mut() {
state.move_cursor(1);
}
}
_ => {}
}
Ok(Action::None)
}
/// The Burndown tab's **raw** controls (WP06 T030), read ahead of the binding layer.
///
/// Returns whether the key was the tab's. The keys are raw for the reason the `F8`
/// Schedule page's tabs' are: `[`, `]`, `p`, `t`, `u`, `v`, `d` and `D` are bound
/// nowhere, so the binding layer would rewrite them to the inert `F(12)` sentinel, and
/// none of them is named by a [`TuiAction`] — which is what keeps this package to
/// "no new action, no key-map row, no `docs/key-bindings.md` row" (analysis-report H4).
/// The keys that *are* bound (`j`/`k`, `Tab`, `?`, `q`) still resolve through the
/// bindings, so a rebind of one keeps working.
///
/// Each control re-folds through the shared seam, immediately: the document the next
/// frame draws is the one the current selection asks for, never a stale neighbour's.
pub(in crate::app) fn handle_burndown_raw_key(&mut self, key: KeyEvent) -> Result<bool> {
if self.stats_tab != StatsTab::Burndown {
return Ok(false);
}
if matches!(key.code, KeyCode::F(12)) {
// The unbound-key sentinel is not a control, and must not be swallowed: the
// page's own fall-through behavior stays what it was.
return Ok(false);
}
if !matches!(
key.code,
KeyCode::Char('[')
| KeyCode::Char(']')
| KeyCode::Char('p')
| KeyCode::Char('t')
| KeyCode::Char('u')
| KeyCode::Char('v')
| KeyCode::Char('d')
| KeyCode::Char('D')
) {
return Ok(false);
}
if self.stats_burndown.is_none() {
// A tab that was never opened (a drawn strip, a test) reads before it acts.
self.refresh_stats_burndown();
}
if let Some(mut state) = self.stats_burndown.take() {
match key.code {
KeyCode::Char('[') => state.shift_scope(-1),
KeyCode::Char(']') => state.shift_scope(1),
KeyCode::Char('p') => state.cycle_period(),
KeyCode::Char('t') => state.cycle_mode(),
KeyCode::Char('u') => state.cycle_unit(),
KeyCode::Char('v') => state.toggle_forecast(),
KeyCode::Char('d') => state.origin_earlier(),
KeyCode::Char('D') => state.origin_later(),
_ => {}
}
// The selection changed, so the fold behind it must: the scopes and the units
// are the record's and are not re-read here (a control is not a refresh).
self.store_stats_burndown(state);
}
Ok(true)
}
/// Take the Burndown tab's one read: the scopes and units the record offers, and the
/// shared chart document for the current selection (WP06 T029–T032).
///
/// The fold is the shared adapter's ([`crate::commands::export::chart_document`]) over
/// the crate's own scope resolver, so the tab, `tix analytics burndown`, `tix analytics
/// projection`, `tix analytics export` and WP07's tools all read one document (C-008,
/// NFR-003). Nothing is recomputed here: the mode, the unit, the period and the forecast
/// origin are selections the caller hands to that seam.
pub(in crate::app) fn refresh_stats_burndown(&mut self) {
let choices = self.burndown_choices();
let units = crate::analytics::units::declared_units(&self.store.config);
let state = match self.stats_burndown.take() {
Some(previous) => {
let mut state = previous;
state.choices = choices;
state.units = units;
state
}
None => crate::ui::analytics::BurndownState::new(choices, units),
};
self.store_stats_burndown(state);
}
/// Fold the Burndown tab's current selection and store what the next frame draws.
///
/// The fold is the only thing a control changes: it re-reads no scope list, no unit
/// list and no snapshot store, so a control costs one fold and nothing else.
pub(in crate::app) fn store_stats_burndown(
&mut self,
mut state: crate::ui::analytics::BurndownState,
) {
state.clamp();
match self.fold_burndown(&state) {
Ok(document) => {
state.document = Some(document);
state.problem = None;
}
Err(problem) => {
state.document = None;
state.problem = Some(problem.to_string());
}
}
state.clamp();
self.stats_burndown = Some(state);
}
/// The scopes the Burndown tab can select (WP06 T030).
///
/// The record's own planning data, in a stable order: every cycle the registry declares
/// (hand-written in `[cycles.<name>]` or a generated instance the cycle store holds),
/// then every milestone. Each scope is resolved through the crate's **own** resolver
/// ([`crate::analytics::events::cycle_scope`]/[`milestone_scope`]), so the id a reader
/// sees here selects the same scope `tix analytics --id <id>` does.
///
/// A cycle whose close evidence the project stores is bound to that snapshot's stable id
/// (C-006): the tab then reads the immutable historical view the close captured, and the
/// `closed_snapshot` finding says so in the shared vocabulary.
pub(in crate::app) fn burndown_choices(&self) -> Vec<crate::analytics::events::AnalyticsScope> {
use crate::analytics::events::{AnalyticsScope, cycle_scope, milestone_scope};
let root = self.project_root();
let planner =
crate::planning::Planner::from_config(self.store.config.clone(), root.clone());
let snapshots: std::collections::BTreeMap<String, String> = self.burndown_snapshots();
let mut choices: Vec<AnalyticsScope> = Vec::new();
for name in planner.cycle_names() {
let Ok(scope) = cycle_scope(&planner, &name) else {
continue;
};
let bound = snapshots.get(&name).map(String::as_str);
choices.push(match bound {
Some(snapshot_id) => scope.with_snapshot_id(snapshot_id),
None => scope,
});
}
for name in planner.milestone_names().unwrap_or_default() {
if let Ok(scope) = milestone_scope(&planner, &name) {
choices.push(scope);
}
}
choices
}
/// The closed snapshots the project stores, keyed by the cycle reference they belong to
/// (WP06 T032).
///
/// One listing for the whole refresh, read through
/// [`crate::cycle_schedule::snapshot::CycleSnapshotStore`] — the store the Schedule page's
/// Capacity tab already reads close evidence from — and keyed by **both** spellings a
/// cycle goes by (its stable id and its display name), so a scope resolved either way
/// finds its snapshot. The snapshot's stable identity is the crate's own
/// ([`crate::capacity::snapshot::snapshot_id`]), not a string this module assembles.
pub(in crate::app) fn burndown_snapshots(&self) -> std::collections::BTreeMap<String, String> {
use crate::cycle_schedule::identity::ScheduleIndex;
let root = self.project_root();
let mut bound = std::collections::BTreeMap::new();
let Ok(records) = crate::cycle_store::CycleStore::new(&root).list() else {
return bound;
};
let index = ScheduleIndex::build(&self.store.config, &records);
let stored = crate::cycle_schedule::snapshot::CycleSnapshotStore::new(&root)
.list()
.unwrap_or_default();
for record in &records {
let crate::cycle_schedule::identity::CycleReference::Generated { id, .. } =
crate::capacity::resolve_cycle(&index, &record.name)
else {
continue;
};
let Some(snapshot) = stored.iter().find(|snapshot| snapshot.cycle_id == id) else {
continue;
};
let snapshot_id =
crate::capacity::snapshot::snapshot_id(&snapshot.cycle_id, &snapshot.closed_at);
bound.insert(record.name.clone(), snapshot_id.clone());
bound.insert(id.as_str().to_string(), snapshot_id);
}
bound
}
/// The shared chart document for the Burndown tab's current selection.
///
/// The one refusal the tab raises itself is the one the CLI raises before any fold: an
/// estimate valuation needs an explicit unit conversion, and a project that declares none
/// has nothing to convert through (C-003). Everything else is the shared service's answer
/// — including a document that holds no day, which the tab draws with the fold's own
/// findings rather than refusing.
pub(in crate::app) fn fold_burndown(
&self,
state: &crate::ui::analytics::BurndownState,
) -> Result<crate::analytics::chart::ChartExport> {
let Some(scope) = state.scope() else {
return Err(TixError::InvalidInput(
"no cycle or milestone is recorded, so there is no scope to fold".into(),
));
};
let unit = state.unit();
if state.mode == crate::analytics::series::Mode::Estimate && unit.is_none() {
return Err(TixError::InvalidInput(
"estimate mode needs a unit conversion the project declares, and it declares \
none: choose `t` for count mode, or declare the unit under \
`[kanban.<board>.wip.units]`"
.into(),
));
}
let projection = state.projection();
crate::commands::export::chart_document(
&self.store.config,
&self.store,
&scope,
state.mode,
unit,
projection
.as_ref()
.map(|(as_of, end)| (as_of.as_str(), end.as_str())),
)
}
}