tix/app/schedule_capacity.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 /// Re-read the shared capacity pair and rebuild the display data (WP06).
6 ///
7 /// The pair is [`crate::commands::capacity::snapshot_for`] — exactly what the CLI's
8 /// `tix capacity calculate` reads — and every value in the view is
9 /// [`crate::capacity::calculate::CapacityCalculation::for_scope`]'s, so the page
10 /// cannot print a number the CLI would not (C-007/NFR-003). Read-only: nothing
11 /// here writes a file, a record, a snapshot or a History entry.
12 ///
13 /// A read failure leaves `None`, which the Capacity tab reports as its own state
14 /// rather than as "nothing is configured".
15 pub(in crate::app) fn refresh_capacity_view(&mut self) {
16 let root = self.project_root();
17 let Ok((index, snapshot)) =
18 crate::commands::capacity::snapshot_for(&self.store.config, &root, &self.store)
19 else {
20 self.capacity = None;
21 return;
22 };
23 let (view, any_committed) =
24 Self::build_capacity_view(&self.store.config, &root, &index, &snapshot);
25 self.capacity = Some(CapacityCache {
26 index,
27 snapshot,
28 view,
29 nothing_committed: !any_committed,
30 });
31 }
32
33 /// The Capacity tab's display data, derived **only** from the shared service
34 /// (WP06 T030/T032/T033).
35 ///
36 /// For each configured entry it resolves the scope through the library's own
37 /// adapter ([`crate::capacity::calculate::CapacityScope::new`]) and takes the
38 /// shared `for_scope` over the pair the caller read — the same functions in the
39 /// same order `tix capacity calculate` calls, so the two cannot disagree about a
40 /// value or a rounding (C-007). A cycle scope with a recorded close gets the
41 /// stored evidence beside the live values, read through
42 /// [`crate::capacity::snapshot::CapacitySnapshot::from_cycle_snapshot`] — a
43 /// payload-less legacy snapshot is a value, not an error.
44 /// Returns the view and whether any calculation committed any work at all.
45 pub(in crate::app) fn build_capacity_view(
46 config: &Config,
47 root: &std::path::Path,
48 index: &crate::cycle_schedule::identity::ScheduleIndex,
49 snapshot: &crate::capacity::scope::IssueSnapshot,
50 ) -> (crate::ui::schedule::CapacityView, bool) {
51 use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
52
53 let mut view = crate::ui::schedule::CapacityView::default();
54 let mut any_committed = false;
55 // **One** listing of the snapshot store for the whole refresh, keyed by the
56 // stable cycle identity. `CycleSnapshotStore::read` lists and parses every
57 // stored file to answer about one cycle, so reading it per cycle scope would be
58 // O(scopes × stored snapshots) per refresh to render one row's detail; the
59 // values are the same either way (the store's own `list` is the same reader).
60 // A store that cannot be listed yields no snapshot sections, which is the same
61 // answer `read`'s error arm reaches.
62 let stored: std::collections::BTreeMap<
63 String,
64 crate::cycle_schedule::snapshot::CycleSnapshot,
65 > = crate::cycle_schedule::snapshot::CycleSnapshotStore::new(root)
66 .list()
67 .unwrap_or_default()
68 .into_iter()
69 .map(|snapshot| (snapshot.cycle_id.as_str().to_string(), snapshot))
70 .collect();
71 for (_, entry) in config.capacity.iter() {
72 // A loaded section is validated, so every entry names exactly one scope.
73 let Some(kind) = entry.scope_kind() else {
74 continue;
75 };
76 let reference = entry.scope_id().unwrap_or_default();
77 let scope = CapacityScope::new(kind, reference, index);
78 let calculation = CapacityCalculation::for_scope(snapshot, index, config, scope);
79 // `None` is **not calculated** — an entry that is not in force (disabled,
80 // or without a unit) selects no ceiling and leaves every derived field
81 // absent — and that is not the same statement as "committed nothing". A
82 // scope with no answer therefore suppresses the page's
83 // "no committed work" note: an unknown value must never be rendered as the
84 // statement that the value is zero.
85 any_committed |= calculation.committed.is_none_or(|committed| committed > 0);
86 view.rows.push(capacity_row(&calculation));
87 view.details
88 .push(capacity_detail(index, &calculation, &stored));
89 if kind == crate::capacity::config::CapacityScopeKind::Cycle {
90 let cell = capacity_cell(&calculation);
91 if !cell.is_empty() {
92 // Both spellings find it: the reference a card names and the
93 // resolved display label are one scope, and an issue may store
94 // either.
95 view.summaries
96 .insert(calculation.scope.reference.clone(), cell.clone());
97 view.summaries
98 .insert(calculation.scope.display.clone(), cell);
99 }
100 }
101 }
102 (view, any_committed)
103 }
104
105 /// The capacity rows the Capacity tab lists (WP06).
106 pub fn capacity_rows(&self) -> &[crate::ui::schedule::CapacityRow] {
107 self.capacity
108 .as_ref()
109 .map_or(&[], |cache| cache.view.rows.as_slice())
110 }
111
112 /// Whether the last capacity read succeeded.
113 ///
114 /// `false` makes the tab state that the snapshot could not be read, rather than
115 /// drawing its "nothing is configured" sentence over a read that failed.
116 pub fn capacity_read(&self) -> bool {
117 self.capacity.is_some()
118 }
119
120 /// Whether every configured scope's calculation was counted and came to zero, so
121 /// the tab can say so explicitly instead of leaving a row of `-` to be read two
122 /// ways. `false` when any scope has no answer at all.
123 pub fn capacity_nothing_committed(&self) -> bool {
124 self.capacity
125 .as_ref()
126 .is_some_and(|cache| cache.nothing_committed)
127 }
128
129 /// The selected scope's detail pane (WP06 T030/T033).
130 pub fn capacity_detail(&self, selected: usize) -> Option<&crate::ui::schedule::CapacityDetail> {
131 self.capacity.as_ref()?.view.details.get(selected)
132 }
133
134 /// The compact per-cycle cells the board's `[cycle]`-grouped headers append (WP06
135 /// T032), or `None` before the first read.
136 pub fn capacity_summaries(&self) -> Option<&crate::ui::board::CapacitySummaries> {
137 self.capacity.as_ref().map(|cache| &cache.view.summaries)
138 }
139
140 /// The capacity scopes an issue contributes to, as the pane shows them (WP06 T031).
141 ///
142 /// **Scope selection is the issue's own explicit references** — each cycle its
143 /// `cycles` field names, and its assignee — resolved by the library's adapter, so
144 /// never a rule invented here. A scope with no ceiling in force has no budget to
145 /// report and counts the issue against none, so it is left out; the Capacity tab
146 /// states such a scope's explicit unconfigured state. Nothing is written, and a
147 /// non-contributing estimate is named rather than counted as zero.
148 pub(in crate::app) fn issue_capacity(
149 &self,
150 issue: &crate::model::Issue,
151 ) -> Vec<crate::ui::detail::IssueCapacityView> {
152 use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
153 use crate::capacity::config::CapacityScopeKind;
154
155 let Some(cache) = self.capacity.as_ref() else {
156 return Vec::new();
157 };
158 let mut scopes: Vec<CapacityScope> = issue
159 .cycles
160 .iter()
161 .flatten()
162 .map(|reference| {
163 CapacityScope::new(CapacityScopeKind::Cycle, reference.clone(), &cache.index)
164 })
165 .collect();
166 if let Some(assignee) = issue.assignee.as_deref().filter(|a| !a.trim().is_empty()) {
167 scopes.push(CapacityScope::new(
168 CapacityScopeKind::Assignee,
169 assignee,
170 &cache.index,
171 ));
172 }
173 scopes
174 .into_iter()
175 .filter_map(|scope| {
176 let calculation = CapacityCalculation::for_scope(
177 &cache.snapshot,
178 &cache.index,
179 &self.store.config,
180 scope,
181 );
182 let unit = calculation.unit.as_deref();
183 // No ceiling in force means no budget to report.
184 calculation.capacity.as_ref()?;
185 let estimate = cache.snapshot.estimate(issue.number);
186 let issue_line = issue_estimate_line(estimate, unit);
187 let diagnostics = calculation
188 .diagnostics
189 .iter()
190 .filter(|warning| {
191 warning.issues.is_empty() || warning.issues.contains(&issue.number)
192 })
193 .map(crate::capacity::warning::CapacityWarning::render)
194 .collect();
195 Some(crate::ui::detail::IssueCapacityView {
196 scope: format!(
197 "{kind} `{display}`",
198 kind = calculation.scope.kind.as_str(),
199 display = calculation.scope.display
200 ),
201 issue_line,
202 fields: capacity_fields(&calculation),
203 diagnostics,
204 })
205 })
206 .collect()
207 }
208}