tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[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()
    }
}