Skip to main content

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}