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
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// Re-read the shared capacity pair and rebuild the display data (WP06).
///
/// The pair is [`crate::commands::capacity::snapshot_for`] — exactly what the CLI's
/// `tix capacity calculate` reads — and every value in the view is
/// [`crate::capacity::calculate::CapacityCalculation::for_scope`]'s, so the page
/// cannot print a number the CLI would not (C-007/NFR-003). Read-only: nothing
/// here writes a file, a record, a snapshot or a History entry.
///
/// A read failure leaves `None`, which the Capacity tab reports as its own state
/// rather than as "nothing is configured".
pub(in crate::app) fn refresh_capacity_view(&mut self) {
let root = self.project_root();
let Ok((index, snapshot)) =
crate::commands::capacity::snapshot_for(&self.store.config, &root, &self.store)
else {
self.capacity = None;
return;
};
let (view, any_committed) =
Self::build_capacity_view(&self.store.config, &root, &index, &snapshot);
self.capacity = Some(CapacityCache {
index,
snapshot,
view,
nothing_committed: !any_committed,
});
}
/// The Capacity tab's display data, derived **only** from the shared service
/// (WP06 T030/T032/T033).
///
/// For each configured entry it resolves the scope through the library's own
/// adapter ([`crate::capacity::calculate::CapacityScope::new`]) and takes the
/// shared `for_scope` over the pair the caller read — the same functions in the
/// same order `tix capacity calculate` calls, so the two cannot disagree about a
/// value or a rounding (C-007). A cycle scope with a recorded close gets the
/// stored evidence beside the live values, read through
/// [`crate::capacity::snapshot::CapacitySnapshot::from_cycle_snapshot`] — a
/// payload-less legacy snapshot is a value, not an error.
/// Returns the view and whether any calculation committed any work at all.
pub(in crate::app) fn build_capacity_view(
config: &Config,
root: &std::path::Path,
index: &crate::cycle_schedule::identity::ScheduleIndex,
snapshot: &crate::capacity::scope::IssueSnapshot,
) -> (crate::ui::schedule::CapacityView, bool) {
use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
let mut view = crate::ui::schedule::CapacityView::default();
let mut any_committed = false;
// **One** listing of the snapshot store for the whole refresh, keyed by the
// stable cycle identity. `CycleSnapshotStore::read` lists and parses every
// stored file to answer about one cycle, so reading it per cycle scope would be
// O(scopes × stored snapshots) per refresh to render one row's detail; the
// values are the same either way (the store's own `list` is the same reader).
// A store that cannot be listed yields no snapshot sections, which is the same
// answer `read`'s error arm reaches.
let stored: std::collections::BTreeMap<
String,
crate::cycle_schedule::snapshot::CycleSnapshot,
> = crate::cycle_schedule::snapshot::CycleSnapshotStore::new(root)
.list()
.unwrap_or_default()
.into_iter()
.map(|snapshot| (snapshot.cycle_id.as_str().to_string(), snapshot))
.collect();
for (_, entry) in config.capacity.iter() {
// A loaded section is validated, so every entry names exactly one scope.
let Some(kind) = entry.scope_kind() else {
continue;
};
let reference = entry.scope_id().unwrap_or_default();
let scope = CapacityScope::new(kind, reference, index);
let calculation = CapacityCalculation::for_scope(snapshot, index, config, scope);
// `None` is **not calculated** — an entry that is not in force (disabled,
// or without a unit) selects no ceiling and leaves every derived field
// absent — and that is not the same statement as "committed nothing". A
// scope with no answer therefore suppresses the page's
// "no committed work" note: an unknown value must never be rendered as the
// statement that the value is zero.
any_committed |= calculation.committed.is_none_or(|committed| committed > 0);
view.rows.push(capacity_row(&calculation));
view.details
.push(capacity_detail(index, &calculation, &stored));
if kind == crate::capacity::config::CapacityScopeKind::Cycle {
let cell = capacity_cell(&calculation);
if !cell.is_empty() {
// Both spellings find it: the reference a card names and the
// resolved display label are one scope, and an issue may store
// either.
view.summaries
.insert(calculation.scope.reference.clone(), cell.clone());
view.summaries
.insert(calculation.scope.display.clone(), cell);
}
}
}
(view, any_committed)
}
/// The capacity rows the Capacity tab lists (WP06).
pub fn capacity_rows(&self) -> &[crate::ui::schedule::CapacityRow] {
self.capacity
.as_ref()
.map_or(&[], |cache| cache.view.rows.as_slice())
}
/// Whether the last capacity read succeeded.
///
/// `false` makes the tab state that the snapshot could not be read, rather than
/// drawing its "nothing is configured" sentence over a read that failed.
pub fn capacity_read(&self) -> bool {
self.capacity.is_some()
}
/// Whether every configured scope's calculation was counted and came to zero, so
/// the tab can say so explicitly instead of leaving a row of `-` to be read two
/// ways. `false` when any scope has no answer at all.
pub fn capacity_nothing_committed(&self) -> bool {
self.capacity
.as_ref()
.is_some_and(|cache| cache.nothing_committed)
}
/// The selected scope's detail pane (WP06 T030/T033).
pub fn capacity_detail(&self, selected: usize) -> Option<&crate::ui::schedule::CapacityDetail> {
self.capacity.as_ref()?.view.details.get(selected)
}
/// The compact per-cycle cells the board's `[cycle]`-grouped headers append (WP06
/// T032), or `None` before the first read.
pub fn capacity_summaries(&self) -> Option<&crate::ui::board::CapacitySummaries> {
self.capacity.as_ref().map(|cache| &cache.view.summaries)
}
/// The capacity scopes an issue contributes to, as the pane shows them (WP06 T031).
///
/// **Scope selection is the issue's own explicit references** — each cycle its
/// `cycles` field names, and its assignee — resolved by the library's adapter, so
/// never a rule invented here. A scope with no ceiling in force has no budget to
/// report and counts the issue against none, so it is left out; the Capacity tab
/// states such a scope's explicit unconfigured state. Nothing is written, and a
/// non-contributing estimate is named rather than counted as zero.
pub(in crate::app) fn issue_capacity(
&self,
issue: &crate::model::Issue,
) -> Vec<crate::ui::detail::IssueCapacityView> {
use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
use crate::capacity::config::CapacityScopeKind;
let Some(cache) = self.capacity.as_ref() else {
return Vec::new();
};
let mut scopes: Vec<CapacityScope> = issue
.cycles
.iter()
.flatten()
.map(|reference| {
CapacityScope::new(CapacityScopeKind::Cycle, reference.clone(), &cache.index)
})
.collect();
if let Some(assignee) = issue.assignee.as_deref().filter(|a| !a.trim().is_empty()) {
scopes.push(CapacityScope::new(
CapacityScopeKind::Assignee,
assignee,
&cache.index,
));
}
scopes
.into_iter()
.filter_map(|scope| {
let calculation = CapacityCalculation::for_scope(
&cache.snapshot,
&cache.index,
&self.store.config,
scope,
);
let unit = calculation.unit.as_deref();
// No ceiling in force means no budget to report.
calculation.capacity.as_ref()?;
let estimate = cache.snapshot.estimate(issue.number);
let issue_line = issue_estimate_line(estimate, unit);
let diagnostics = calculation
.diagnostics
.iter()
.filter(|warning| {
warning.issues.is_empty() || warning.issues.contains(&issue.number)
})
.map(crate::capacity::warning::CapacityWarning::render)
.collect();
Some(crate::ui::detail::IssueCapacityView {
scope: format!(
"{kind} `{display}`",
kind = calculation.scope.kind.as_str(),
display = calculation.scope.display
),
issue_line,
fields: capacity_fields(&calculation),
diagnostics,
})
})
.collect()
}
}