tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The recurring-issue due warning: a non-mutating overlay (mission
//! `recurring-issues-01M3C7G7`, WP07; `data-model.md` §5).
//!
//! # Opening it writes nothing
//!
//! Detection runs on start and on `r`, and its result is *shown* — never applied.
//! The overlay is the question the mission's C-003 asks for: "these occurrences
//! are due, what do you want to do?" with a closed set of answers. Dismissing it
//! (`Esc`, `q`, `c`) changes nothing; the next refresh asks again, because the
//! answer was not a decision.
//!
//! # The four answers, and which service each one reaches
//!
//! | key | action    | service |
//! |-----|-----------|---------|
//! | `g` | generate  | [`crate::recurrence::generate`] — creates the occurrence, idempotently |
//! | `d` | defer     | [`crate::recurrence::lifecycle`] — the cursor moves past the date |
//! | `i` | ignore    | [`crate::recurrence::lifecycle`] — the date is recorded as skipped |
//! | `c` | close     | nothing at all |
//!
//! Every one of the first three is a *confirmed* action by construction: the user
//! pressed the key that names it after seeing the warning, and the widget passes
//! that on as `confirmed`. The overlay never decides anything by itself.
//!
//! # Where a warning comes from
//!
//! The rows are [`DueWarning`] values from the shared detector, so a TUI warning
//! and `tix recurring detect` describe the same occurrence in the same words
//! (C-008/NFR-003). The widget renders them; it does not re-derive a due date.

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

use crate::app::App;
use crate::recurrence::detect::DueWarning;

/// The keys the overlay answers to, in the order it advertises them.
pub const WARNING_KEYS: &str = "g: generate  d: defer  i: ignore  c/Esc: close";

/// The overlay's own state: the warnings it is showing and the row it is on.
///
/// Held by [`crate::app::App`] as `Some` exactly while the overlay is open, so a
/// closed overlay has no rows to act on — the same arrangement the other modals
/// use, and the reason an action cannot outlive the warning it answered.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecurringWarning {
    /// The due warnings, in the detector's order (oldest due first).
    pub warnings: Vec<DueWarning>,
    /// The selected row.
    pub selected: usize,
}

impl RecurringWarning {
    /// The overlay for `warnings`, on the first row.
    pub fn new(warnings: Vec<DueWarning>) -> Self {
        Self {
            warnings,
            selected: 0,
        }
    }

    /// How many warnings the overlay holds.
    pub fn len(&self) -> usize {
        self.warnings.len()
    }

    /// Whether there is nothing to show.
    pub fn is_empty(&self) -> bool {
        self.warnings.is_empty()
    }

    /// The warning under the cursor.
    pub fn current(&self) -> Option<&DueWarning> {
        self.warnings.get(self.selected)
    }

    /// Move the cursor by `delta`, clamped to the rows.
    pub fn move_selected(&mut self, delta: isize) -> bool {
        let last = self.warnings.len().saturating_sub(1);
        let before = self.selected;
        let next = if delta.is_negative() {
            self.selected.saturating_sub(delta.unsigned_abs())
        } else {
            self.selected.saturating_add(delta as usize)
        };
        self.selected = next.min(last);
        self.selected != before
    }

    /// Drop the row that was just acted on, keeping the cursor in range.
    ///
    /// An applied action resolves exactly one warning, so the overlay shrinks by
    /// one and stays open for the rest — the user is not sent back to the board to
    /// find out that three more occurrences are due.
    pub fn remove_current(&mut self) {
        if self.selected < self.warnings.len() {
            self.warnings.remove(self.selected);
        }
        if self.selected >= self.warnings.len() {
            self.selected = self.warnings.len().saturating_sub(1);
        }
    }
}

/// One warning as the two lines the overlay draws.
fn warning_lines(warning: &DueWarning, selected: bool) -> Vec<Line<'static>> {
    let marker = if selected { "> " } else { "  " };
    let title_style = if selected {
        Style::default().add_modifier(Modifier::BOLD)
    } else {
        Style::default()
    };
    vec![
        Line::from(vec![
            Span::raw(marker),
            Span::styled(
                format!("{} · {}", warning.series_name, warning.due_date),
                title_style,
            ),
        ]),
        Line::from(Span::styled(
            format!("    {} · {}", warning.occurrence_key, warning.rule_summary),
            Style::default().fg(Color::DarkGray),
        )),
    ]
}

/// Draw the overlay over `area`, which is the board it was raised from: the board
/// stays visible underneath, exactly as it does under the other modals, so the
/// cards the warning is about are still on screen.
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    let Some(warning) = app.recurring_warning.as_ref() else {
        return;
    };
    let title = format!("Due occurrences ({}) — {}", warning.len(), WARNING_KEYS);
    let block = Block::default().borders(Borders::ALL).title(title);
    let inner = block.inner(area);
    f.render_widget(block, area);
    if warning.is_empty() {
        f.render_widget(
            Paragraph::new("Nothing is due any more. Press Esc to close.")
                .style(Style::default().fg(Color::DarkGray)),
            inner,
        );
        return;
    }

    // One row per warning on the left, the selected warning's detail on the right,
    // so a long rule summary never pushes the list off screen.
    let panes = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(55), Constraint::Min(24)])
        .split(inner);
    let mut lines: Vec<Line<'static>> = Vec::new();
    for (index, item) in warning.warnings.iter().enumerate() {
        lines.extend(warning_lines(item, index == warning.selected));
    }
    f.render_widget(Paragraph::new(lines), panes[0]);

    if let Some(current) = warning.current() {
        let detail = vec![
            Line::from(vec![
                Span::styled("series: ", Style::default().fg(Color::DarkGray)),
                Span::raw(current.series_id.as_str().to_string()),
            ]),
            Line::from(vec![
                Span::styled("occurrence: ", Style::default().fg(Color::DarkGray)),
                Span::raw(current.occurrence_key.as_str().to_string()),
            ]),
            Line::from(vec![
                Span::styled("due: ", Style::default().fg(Color::DarkGray)),
                Span::raw(current.due_date.as_str().to_string()),
            ]),
            Line::from(vec![
                Span::styled("rule: ", Style::default().fg(Color::DarkGray)),
                Span::raw(current.rule_summary.clone()),
            ]),
            Line::from(vec![
                Span::styled("actions: ", Style::default().fg(Color::DarkGray)),
                Span::raw(
                    current
                        .available_actions
                        .iter()
                        .map(|action| action.as_str())
                        .collect::<Vec<_>>()
                        .join(", "),
                ),
            ]),
        ];
        f.render_widget(Paragraph::new(detail).wrap(Wrap { trim: false }), panes[1]);
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::recurrence::detect::WarningAction;
    use crate::recurrence::rule::DateKey;
    use crate::recurrence::series::{OccurrenceKey, SeriesId};

    fn warning(number: u32) -> DueWarning {
        let id = SeriesId::parse(&format!("s-{number}")).expect("a valid id");
        let date = DateKey::from_date(
            time::Date::from_calendar_date(2026, time::Month::January, number as u8)
                .expect("a real date"),
        );
        DueWarning {
            series_id: id.clone(),
            series_name: format!("Series {number}"),
            occurrence_key: OccurrenceKey::new(&id, &date),
            due_date: date,
            rule_summary: "daily".to_string(),
            available_actions: WarningAction::ALL.to_vec(),
        }
    }

    #[test]
    fn the_overlay_starts_on_the_first_row_and_clamps() {
        let mut overlay = RecurringWarning::new(vec![warning(1), warning(2)]);
        assert_eq!(overlay.len(), 2);
        assert!(!overlay.is_empty());
        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
        assert!(overlay.move_selected(1));
        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-2"));
        assert!(!overlay.move_selected(1), "the last row is the last row");
        assert!(overlay.move_selected(-5), "clamped to the first row");
        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
    }

    #[test]
    fn acting_on_a_row_drops_it_and_keeps_the_cursor_in_range() {
        let mut overlay = RecurringWarning::new(vec![warning(1), warning(2), warning(3)]);
        overlay.move_selected(2);
        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-3"));
        overlay.remove_current();
        assert_eq!(overlay.len(), 2);
        assert_eq!(
            overlay.current().map(|w| w.series_id.as_str()),
            Some("s-2"),
            "the cursor stays on the last available row"
        );
        overlay.remove_current();
        overlay.remove_current();
        assert!(overlay.is_empty());
        assert!(overlay.current().is_none());
        overlay.remove_current();
        assert!(overlay.is_empty(), "removing past the end is inert");
    }

    #[test]
    fn the_advertised_keys_are_the_four_documented_answers() {
        for expected in ["g: generate", "d: defer", "i: ignore", "close"] {
            assert!(
                WARNING_KEYS.contains(expected),
                "the overlay advertises `{expected}`: {WARNING_KEYS}"
            );
        }
    }
}