Skip to main content

tix/ui/
recurring_warning.rs

1//! The recurring-issue due warning: a non-mutating overlay (mission
2//! `recurring-issues-01M3C7G7`, WP07; `data-model.md` §5).
3//!
4//! # Opening it writes nothing
5//!
6//! Detection runs on start and on `r`, and its result is *shown* — never applied.
7//! The overlay is the question the mission's C-003 asks for: "these occurrences
8//! are due, what do you want to do?" with a closed set of answers. Dismissing it
9//! (`Esc`, `q`, `c`) changes nothing; the next refresh asks again, because the
10//! answer was not a decision.
11//!
12//! # The four answers, and which service each one reaches
13//!
14//! | key | action    | service |
15//! |-----|-----------|---------|
16//! | `g` | generate  | [`crate::recurrence::generate`] — creates the occurrence, idempotently |
17//! | `d` | defer     | [`crate::recurrence::lifecycle`] — the cursor moves past the date |
18//! | `i` | ignore    | [`crate::recurrence::lifecycle`] — the date is recorded as skipped |
19//! | `c` | close     | nothing at all |
20//!
21//! Every one of the first three is a *confirmed* action by construction: the user
22//! pressed the key that names it after seeing the warning, and the widget passes
23//! that on as `confirmed`. The overlay never decides anything by itself.
24//!
25//! # Where a warning comes from
26//!
27//! The rows are [`DueWarning`] values from the shared detector, so a TUI warning
28//! and `tix recurring detect` describe the same occurrence in the same words
29//! (C-008/NFR-003). The widget renders them; it does not re-derive a due date.
30
31use ratatui::Frame;
32use ratatui::layout::{Constraint, Direction, Layout, Rect};
33use ratatui::style::{Color, Modifier, Style};
34use ratatui::text::{Line, Span};
35use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
36
37use crate::app::App;
38use crate::recurrence::detect::DueWarning;
39
40/// The keys the overlay answers to, in the order it advertises them.
41pub const WARNING_KEYS: &str = "g: generate  d: defer  i: ignore  c/Esc: close";
42
43/// The overlay's own state: the warnings it is showing and the row it is on.
44///
45/// Held by [`crate::app::App`] as `Some` exactly while the overlay is open, so a
46/// closed overlay has no rows to act on — the same arrangement the other modals
47/// use, and the reason an action cannot outlive the warning it answered.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct RecurringWarning {
50    /// The due warnings, in the detector's order (oldest due first).
51    pub warnings: Vec<DueWarning>,
52    /// The selected row.
53    pub selected: usize,
54}
55
56impl RecurringWarning {
57    /// The overlay for `warnings`, on the first row.
58    pub fn new(warnings: Vec<DueWarning>) -> Self {
59        Self {
60            warnings,
61            selected: 0,
62        }
63    }
64
65    /// How many warnings the overlay holds.
66    pub fn len(&self) -> usize {
67        self.warnings.len()
68    }
69
70    /// Whether there is nothing to show.
71    pub fn is_empty(&self) -> bool {
72        self.warnings.is_empty()
73    }
74
75    /// The warning under the cursor.
76    pub fn current(&self) -> Option<&DueWarning> {
77        self.warnings.get(self.selected)
78    }
79
80    /// Move the cursor by `delta`, clamped to the rows.
81    pub fn move_selected(&mut self, delta: isize) -> bool {
82        let last = self.warnings.len().saturating_sub(1);
83        let before = self.selected;
84        let next = if delta.is_negative() {
85            self.selected.saturating_sub(delta.unsigned_abs())
86        } else {
87            self.selected.saturating_add(delta as usize)
88        };
89        self.selected = next.min(last);
90        self.selected != before
91    }
92
93    /// Drop the row that was just acted on, keeping the cursor in range.
94    ///
95    /// An applied action resolves exactly one warning, so the overlay shrinks by
96    /// one and stays open for the rest — the user is not sent back to the board to
97    /// find out that three more occurrences are due.
98    pub fn remove_current(&mut self) {
99        if self.selected < self.warnings.len() {
100            self.warnings.remove(self.selected);
101        }
102        if self.selected >= self.warnings.len() {
103            self.selected = self.warnings.len().saturating_sub(1);
104        }
105    }
106}
107
108/// One warning as the two lines the overlay draws.
109fn warning_lines(warning: &DueWarning, selected: bool) -> Vec<Line<'static>> {
110    let marker = if selected { "> " } else { "  " };
111    let title_style = if selected {
112        Style::default().add_modifier(Modifier::BOLD)
113    } else {
114        Style::default()
115    };
116    vec![
117        Line::from(vec![
118            Span::raw(marker),
119            Span::styled(
120                format!("{} · {}", warning.series_name, warning.due_date),
121                title_style,
122            ),
123        ]),
124        Line::from(Span::styled(
125            format!("    {} · {}", warning.occurrence_key, warning.rule_summary),
126            Style::default().fg(Color::DarkGray),
127        )),
128    ]
129}
130
131/// Draw the overlay over `area`, which is the board it was raised from: the board
132/// stays visible underneath, exactly as it does under the other modals, so the
133/// cards the warning is about are still on screen.
134pub fn render(app: &App, f: &mut Frame, area: Rect) {
135    let Some(warning) = app.recurring_warning.as_ref() else {
136        return;
137    };
138    let title = format!("Due occurrences ({}) — {}", warning.len(), WARNING_KEYS);
139    let block = Block::default().borders(Borders::ALL).title(title);
140    let inner = block.inner(area);
141    f.render_widget(block, area);
142    if warning.is_empty() {
143        f.render_widget(
144            Paragraph::new("Nothing is due any more. Press Esc to close.")
145                .style(Style::default().fg(Color::DarkGray)),
146            inner,
147        );
148        return;
149    }
150
151    // One row per warning on the left, the selected warning's detail on the right,
152    // so a long rule summary never pushes the list off screen.
153    let panes = Layout::default()
154        .direction(Direction::Horizontal)
155        .constraints([Constraint::Percentage(55), Constraint::Min(24)])
156        .split(inner);
157    let mut lines: Vec<Line<'static>> = Vec::new();
158    for (index, item) in warning.warnings.iter().enumerate() {
159        lines.extend(warning_lines(item, index == warning.selected));
160    }
161    f.render_widget(Paragraph::new(lines), panes[0]);
162
163    if let Some(current) = warning.current() {
164        let detail = vec![
165            Line::from(vec![
166                Span::styled("series: ", Style::default().fg(Color::DarkGray)),
167                Span::raw(current.series_id.as_str().to_string()),
168            ]),
169            Line::from(vec![
170                Span::styled("occurrence: ", Style::default().fg(Color::DarkGray)),
171                Span::raw(current.occurrence_key.as_str().to_string()),
172            ]),
173            Line::from(vec![
174                Span::styled("due: ", Style::default().fg(Color::DarkGray)),
175                Span::raw(current.due_date.as_str().to_string()),
176            ]),
177            Line::from(vec![
178                Span::styled("rule: ", Style::default().fg(Color::DarkGray)),
179                Span::raw(current.rule_summary.clone()),
180            ]),
181            Line::from(vec![
182                Span::styled("actions: ", Style::default().fg(Color::DarkGray)),
183                Span::raw(
184                    current
185                        .available_actions
186                        .iter()
187                        .map(|action| action.as_str())
188                        .collect::<Vec<_>>()
189                        .join(", "),
190                ),
191            ]),
192        ];
193        f.render_widget(Paragraph::new(detail).wrap(Wrap { trim: false }), panes[1]);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    #[allow(clippy::wildcard_imports)]
200    use super::*;
201    use crate::recurrence::detect::WarningAction;
202    use crate::recurrence::rule::DateKey;
203    use crate::recurrence::series::{OccurrenceKey, SeriesId};
204
205    fn warning(number: u32) -> DueWarning {
206        let id = SeriesId::parse(&format!("s-{number}")).expect("a valid id");
207        let date = DateKey::from_date(
208            time::Date::from_calendar_date(2026, time::Month::January, number as u8)
209                .expect("a real date"),
210        );
211        DueWarning {
212            series_id: id.clone(),
213            series_name: format!("Series {number}"),
214            occurrence_key: OccurrenceKey::new(&id, &date),
215            due_date: date,
216            rule_summary: "daily".to_string(),
217            available_actions: WarningAction::ALL.to_vec(),
218        }
219    }
220
221    #[test]
222    fn the_overlay_starts_on_the_first_row_and_clamps() {
223        let mut overlay = RecurringWarning::new(vec![warning(1), warning(2)]);
224        assert_eq!(overlay.len(), 2);
225        assert!(!overlay.is_empty());
226        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
227        assert!(overlay.move_selected(1));
228        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-2"));
229        assert!(!overlay.move_selected(1), "the last row is the last row");
230        assert!(overlay.move_selected(-5), "clamped to the first row");
231        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
232    }
233
234    #[test]
235    fn acting_on_a_row_drops_it_and_keeps_the_cursor_in_range() {
236        let mut overlay = RecurringWarning::new(vec![warning(1), warning(2), warning(3)]);
237        overlay.move_selected(2);
238        assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-3"));
239        overlay.remove_current();
240        assert_eq!(overlay.len(), 2);
241        assert_eq!(
242            overlay.current().map(|w| w.series_id.as_str()),
243            Some("s-2"),
244            "the cursor stays on the last available row"
245        );
246        overlay.remove_current();
247        overlay.remove_current();
248        assert!(overlay.is_empty());
249        assert!(overlay.current().is_none());
250        overlay.remove_current();
251        assert!(overlay.is_empty(), "removing past the end is inert");
252    }
253
254    #[test]
255    fn the_advertised_keys_are_the_four_documented_answers() {
256        for expected in ["g: generate", "d: defer", "i: ignore", "close"] {
257            assert!(
258                WARNING_KEYS.contains(expected),
259                "the overlay advertises `{expected}`: {WARNING_KEYS}"
260            );
261        }
262    }
263}