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 {
    /// 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())),
        )
    }
}