tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Which tab of the `F7` **Stats** page is showing (mission
/// `per-issue-time-tracking-01M3C9A4`, WP05 T026; mission
/// `burndown-scope-analytics-01M3C9J1`, WP06 T029).
///
/// A **tab**, not a mode. The page, its `TopMode::Stats`, its
/// [`InputContext::Stats`] and its `F7` binding all ship from
/// `project-issue-health-status-01M3C8HX`, and the same tab-not-mode rule
/// [`crate::ui::roadmap`] documents for the `F10` Portfolio page applies here — a
/// further `TopMode` (or a second `InputContext`, or a nav limb) would break
/// `every_mode_maps_to_a_distinct_shell_action`. The tab is additive in this enum
/// and nowhere else: the strip, the hint and the switch all read [`Self::ALL`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum StatsTab {
    /// The Health tab: the shared health document's scopes, states and diagnostics.
    /// It ships first, so `F7` still opens on what it always showed.
    #[default]
    Health,
    /// The Time tab: the scope's shared total, its entry rows, its filters and the
    /// three mutations.
    Time,
    /// The Burndown tab (mission `burndown-scope-analytics-01M3C9J1`, WP06 T029): the
    /// shared daily series, its coverage markers and the read-only forecast, over one
    /// cycle or milestone.
    Burndown,
}

impl StatsTab {
    /// The tabs in display order.
    pub const ALL: [Self; 3] = [Self::Health, Self::Time, Self::Burndown];

    /// The tab's label in the strip.
    pub const fn label(self) -> &'static str {
        match self {
            Self::Health => "Health",
            Self::Time => "Time",
            Self::Burndown => "Burndown",
        }
    }

    /// The one-line hint under the strip, in the active tab's own vocabulary — the
    /// words match the `[keybindings.stats]` bindings exactly.
    pub const fn hint(self) -> &'static str {
        match self {
            Self::Health => "j/k row · Enter open issue · Tab tabs · ? help · q close",
            Self::Time => {
                "j/k entry · a add · e correct · x delete · f filter · s breakdown · h history · \
                 Tab tabs · ? help · q close"
            }
            Self::Burndown => {
                "j/k day · [ ] scope · p period · t mode · u unit · v forecast · d/D origin · \
                 Tab tabs · ? help · q close"
            }
        }
    }

    /// The tab `delta` steps away, wrapping.
    ///
    /// Wrapping is what makes two tabs reachable with one key from either side;
    /// nothing else is reset because each tab owns its own cursor and state.
    pub fn shifted(self, delta: isize) -> Self {
        let current = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0);
        let len = Self::ALL.len();
        Self::ALL[(current as isize + delta).rem_euclid(len as isize) as usize]
    }
}

impl App {
    /// The F6 Repos mode's keys (tix #1529): walk the repository list, run tix in
    /// the selected one, open help, or close.
    /// One row of the `F7` Health tab: a scope and what the document says about it.
    pub(in crate::app) fn stats_rows(&self) -> Vec<HealthRow> {
        let Some(document) = self.health.as_ref() else {
            return Vec::new();
        };
        document
            .scopes
            .iter()
            .map(|entry| HealthRow {
                scope: crate::health::HealthScope::parse(&entry.scope)
                    .unwrap_or(crate::health::HealthScope::Issue(0)),
                state: entry.state.clone(),
                layer: entry.source.clone(),
                derived: entry
                    .rollup
                    .as_ref()
                    .map(crate::commands::health::render_derived),
            })
            .collect()
    }

    /// The `F7` Stats page's keys: walk the active tab's rows, act, leave.
    ///
    /// The page's two tabs share the strip keys (`Tab`/`Shift+Tab`), the close key and
    /// `?`; each tab then reads its own keys. The Health tab stays **read-only** — a
    /// row opens the issue's detail, where the health form lives — while the Time tab
    /// adds, corrects and deletes entries through the shared service, so a stray key
    /// can never write on the Health tab and the Time tab's writes always show their
    /// fields first. No key here writes on its own.
    pub(in crate::app) fn handle_stats_key(&mut self, key: KeyEvent) -> Result<Action> {
        if !matches!(self.mode, Mode::Stats) {
            return Ok(Action::None);
        }
        // The strip and the exits are the page's, not a tab's: `Tab`/`Shift+Tab`
        // switch, `?` opens help, and `q`/`esc` leave — all of them read-only.
        match key.code {
            KeyCode::Tab => {
                self.switch_stats_tab(1);
                return Ok(Action::None);
            }
            KeyCode::BackTab => {
                self.switch_stats_tab(-1);
                return Ok(Action::None);
            }
            KeyCode::Char('?') => {
                self.open_help(Mode::Stats);
                return Ok(Action::None);
            }
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.mode = Mode::Board;
                return Ok(Action::None);
            }
            _ => {}
        }
        match self.stats_tab {
            StatsTab::Health => self.handle_health_tab_key(key),
            StatsTab::Time => self.handle_time_tab_key(key),
            StatsTab::Burndown => self.handle_burndown_tab_key(key),
        }
    }

    /// Switch to the tab `delta` steps away, taking the Time tab's read when it first
    /// becomes visible (so the tab draws a read it just made, never a stale one).
    pub(in crate::app) fn switch_stats_tab(&mut self, delta: isize) {
        self.stats_tab = self.stats_tab.shifted(delta);
        if self.stats_tab == StatsTab::Time && self.stats_time.is_none() {
            self.refresh_stats_time();
        }
        // WP06: the Burndown tab's fold is taken on the same rule, and for the same
        // reason: a tab drawn from a read it has not made would show a series the files
        // no longer hold.
        if self.stats_tab == StatsTab::Burndown && self.stats_burndown.is_none() {
            self.refresh_stats_burndown();
        }
    }

    /// The `F7` Stats page's active tab, and `None` in every other mode (WP05).
    ///
    /// The same reason [`Self::schedule_help_tab`] exists, and the same shape: the
    /// page's two tabs act on different keys — the Health tab has no mutation key at
    /// all, and the Time tab's six commands act on nothing on Health — so an overlay
    /// generated from the binding context alone would advertise keys that do nothing.
    /// The page the help was **opened from** is what counts, because the overlay is
    /// drawn while `mode` is [`Mode::Help`] and the tab is still held.
    pub fn stats_help_tab(&self) -> Option<StatsTab> {
        let on_stats = matches!(self.mode, Mode::Stats)
            || matches!(&self.mode, Mode::Help { return_to } if matches!(**return_to, Mode::Stats));
        on_stats.then_some(self.stats_tab)
    }
}