Skip to main content

tix/ui/
cycle.rs

1//! The F3 Cycle mode: cycles grouped chronologically with their statistics.
2//!
3//! The mode is a pure view + a cursor (`CycleState`): the app builds the
4//! [`CycleEntry`] list through [`crate::planning::Planner`] and the cycle store
5//! and the widget only groups, renders, and reports the row rectangles it drew
6//! so a click selects a cycle.
7//!
8//! Grouping is chronological and absolute-date based — a cycle is *current*
9//! when today sits inside its window (or it has no end date at all), *future*
10//! when its start date is still ahead, and *archived* once its due date has
11//! passed. It is derived, never stored: the same cycle moves between groups as
12//! the clock advances, which is exactly what "current" has to mean.
13
14use ratatui::Frame;
15use ratatui::layout::{Constraint, Direction, Layout, Rect};
16use ratatui::style::{Color, Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Block, Borders, Paragraph};
19
20use crate::app::{Action, App};
21use crate::keybindings::{InputContext, TuiAction};
22
23pub use crate::planning::Progress;
24
25/// Where a cycle sits relative to today.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CycleGroup {
28    /// Today is inside the cycle's window, or the cycle has no end date.
29    Current,
30    /// The cycle's start date is still ahead.
31    Future,
32    /// The cycle's due date has passed.
33    Archived,
34}
35
36impl CycleGroup {
37    /// The three groups, in the order the mode lists them.
38    pub const ALL: [Self; 3] = [Self::Current, Self::Future, Self::Archived];
39
40    /// The heading shown above the group.
41    pub const fn label(self) -> &'static str {
42        match self {
43            Self::Current => "Current",
44            Self::Future => "Future",
45            Self::Archived => "Archived",
46        }
47    }
48}
49
50/// One cycle as the mode lists it: identity, dates, and its issue statistics.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct CycleEntry {
53    pub name: String,
54    pub description: String,
55    pub start_date: Option<String>,
56    pub due_date: Option<String>,
57    pub progress: Progress,
58}
59
60impl CycleEntry {
61    /// Which chronological group this cycle belongs to on `today`.
62    ///
63    /// A passed due date wins outright (a cycle is archived the day after it
64    /// ends, even if its start is somehow in the future); otherwise a start
65    /// date still ahead makes it future; everything else — including the
66    /// implicit `Default` with no dates — is current.
67    pub fn group(&self, today: &str) -> CycleGroup {
68        if self.due_date.as_deref().is_some_and(|due| due < today) {
69            CycleGroup::Archived
70        } else if self
71            .start_date
72            .as_deref()
73            .is_some_and(|start| start > today)
74        {
75            CycleGroup::Future
76        } else {
77            CycleGroup::Current
78        }
79    }
80
81    /// The mode's one-line row: name, start date, due date. A missing date
82    /// renders as `—`, never as an empty column that looks like a rendering
83    /// glitch.
84    pub fn row_line(&self) -> String {
85        let date = |value: &Option<String>| value.clone().unwrap_or_else(|| "—".to_string());
86        format!(
87            "{}   {} → {}",
88            self.name,
89            date(&self.start_date),
90            date(&self.due_date)
91        )
92    }
93}
94
95/// The mode's cursor state. Session-only; the selected index is over the
96/// **entries** (never a group heading), so `j`/`k` walk cycles and nothing
97/// else.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99pub struct CycleState {
100    pub selected: usize,
101}
102
103impl CycleState {
104    /// Move the cursor, clamped to `len` entries.
105    pub fn move_selected(&mut self, delta: isize, len: usize) {
106        if len == 0 {
107            self.selected = 0;
108            return;
109        }
110        let next = self.selected as isize + delta;
111        self.selected = next.clamp(0, len as isize - 1) as usize;
112    }
113
114    /// Jump to the first entry of the group `delta` groups away, clamped at the
115    /// ends: `h` in the first section and `l` in the last stay in that section
116    /// rather than wrapping (tui-cycle-mode).
117    ///
118    /// Only groups that actually have entries are landing sites. An empty
119    /// section renders nothing, so jumping to it would be a keystroke with no
120    /// visible effect; skipping it is what "the rendered groups" means.
121    pub fn move_group(&mut self, groups: &[CycleGrouping], delta: isize) {
122        let starts = group_starts(groups);
123        if starts.is_empty() {
124            self.selected = 0;
125            return;
126        }
127        // The group holding the cursor: the last one that starts at or before
128        // it. A stale index past the end belongs to the last group rather than
129        // to a nonexistent one.
130        let current = starts
131            .iter()
132            .rposition(|start| *start <= self.selected)
133            .unwrap_or(0);
134        let last = starts.len() as isize - 1;
135        self.selected = starts[(current as isize + delta).clamp(0, last) as usize];
136    }
137}
138
139/// The flat index of each non-empty group's first entry, in listing order.
140fn group_starts(groups: &[CycleGrouping]) -> Vec<usize> {
141    let mut starts = Vec::new();
142    let mut offset = 0usize;
143    for grouping in groups {
144        if grouping.entries.is_empty() {
145            continue;
146        }
147        starts.push(offset);
148        offset += grouping.entries.len();
149    }
150    starts
151}
152
153/// One chronological section of the listing.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct CycleGrouping {
156    pub group: CycleGroup,
157    pub entries: Vec<CycleEntry>,
158}
159
160/// Group `entries` into current/future/archived, each section ordered oldest
161/// first (start date, then due date, then name) so the listing reads
162/// chronologically top to bottom.
163///
164/// Empty sections are kept (they carry a count of zero and render as nothing),
165/// so the caller can reason about all three without re-deriving them.
166pub fn group_entries(entries: Vec<CycleEntry>, today: &str) -> Vec<CycleGrouping> {
167    let mut groups: Vec<CycleGrouping> = CycleGroup::ALL
168        .iter()
169        .map(|group| CycleGrouping {
170            group: *group,
171            entries: Vec::new(),
172        })
173        .collect();
174    for entry in entries {
175        let group = entry.group(today);
176        let slot = groups
177            .iter_mut()
178            .find(|slot| slot.group == group)
179            .expect("all three groups exist");
180        slot.entries.push(entry);
181    }
182    for slot in &mut groups {
183        slot.entries.sort_by_key(|entry| {
184            (
185                entry.start_date.clone().unwrap_or_default(),
186                entry.due_date.clone().unwrap_or_default(),
187                entry.name.clone(),
188            )
189        });
190    }
191    groups
192}
193
194/// The flat, order-preserving list of entries across the groups — the order
195/// the cursor indexes into.
196pub fn flat_entries(groups: &[CycleGrouping]) -> Vec<&CycleEntry> {
197    groups
198        .iter()
199        .flat_map(|group| group.entries.iter())
200        .collect()
201}
202
203fn percent(progress: &Progress) -> usize {
204    (progress.done * 100)
205        .checked_div(progress.total)
206        .unwrap_or(0)
207}
208
209/// Draw the Cycle mode into `area` and record one click region per visible row.
210#[allow(clippy::too_many_lines)]
211pub fn render(app: &App, f: &mut Frame, area: Rect, state: &CycleState, groups: &[CycleGrouping]) {
212    let block = Block::default()
213        .borders(Borders::ALL)
214        .title(" cycles ")
215        .border_style(Style::default().fg(Color::Cyan));
216    let inner = block.inner(area);
217    f.render_widget(block, area);
218
219    let chunks = Layout::default()
220        .direction(Direction::Vertical)
221        .constraints([
222            Constraint::Min(3),
223            Constraint::Length(2),
224            // The focused cycle's flat issue table (shared with the Milestones
225            // mode) sits below the listing and its statistics.
226            Constraint::Min(4),
227            Constraint::Length(1),
228        ])
229        .split(inner);
230
231    let entries = flat_entries(groups);
232    let selected = entries
233        .len()
234        .checked_sub(1)
235        .map_or(0, |last| usize::min(state.selected, last));
236
237    // The list: group headings interleaved with their rows, cursor-highlighted.
238    let mut lines: Vec<Line> = Vec::new();
239    let mut rows: Vec<(usize, u16)> = Vec::new();
240    let mut ordinal = 0usize;
241    if entries.is_empty() {
242        lines.push(Line::from(Span::styled(
243            "No cycles yet — press m to manage cycles and milestones.",
244            Style::default().fg(Color::DarkGray),
245        )));
246    }
247    for grouping in groups {
248        if grouping.entries.is_empty() {
249            continue;
250        }
251        lines.push(Line::from(Span::styled(
252            format!("{} ({})", grouping.group.label(), grouping.entries.len()),
253            Style::default()
254                .fg(Color::Cyan)
255                .add_modifier(Modifier::BOLD),
256        )));
257        for entry in &grouping.entries {
258            let cursor = if ordinal == selected { "> " } else { "  " };
259            let style = if ordinal == selected {
260                Style::default()
261                    .fg(Color::Yellow)
262                    .add_modifier(Modifier::BOLD)
263            } else {
264                Style::default().fg(Color::White)
265            };
266            lines.push(Line::from(vec![
267                Span::styled(cursor, style),
268                Span::styled(entry.row_line(), style),
269            ]));
270            rows.push((ordinal, (lines.len() - 1) as u16));
271            ordinal += 1;
272        }
273    }
274
275    let view_h = chunks[0].height;
276    let selected_line = rows
277        .iter()
278        .find(|(index, _)| *index == selected)
279        .map_or(0, |(_, line)| *line);
280    let top = selected_line.saturating_sub(view_h.saturating_sub(1));
281    let list = Paragraph::new(lines).scroll((top, 0));
282    f.render_widget(list, chunks[0]);
283
284    // One click target per entry, computed from the scroll actually applied so a
285    // click maps to the row that was painted (the triage/tab pattern).
286    for (index, line) in &rows {
287        if *line < top || *line - top >= view_h {
288            continue;
289        }
290        let rect = Rect {
291            x: chunks[0].x,
292            y: chunks[0].y + (*line - top),
293            width: chunks[0].width,
294            height: 1,
295        };
296        app.record_region(rect, Action::CycleSelect(*index));
297    }
298
299    // Statistics for the selected cycle. An empty cycle is `0/0 (0%)`, never an
300    // error and never a division by zero.
301    let stats = match entries.get(selected) {
302        Some(entry) => vec![
303            Line::from(Span::styled(
304                format!(" {} ", entry.name),
305                Style::default()
306                    .fg(Color::Yellow)
307                    .add_modifier(Modifier::BOLD),
308            )),
309            Line::from(format!(
310                " Issues: {}   Completed: {}/{} ({}%)",
311                entry.progress.total,
312                entry.progress.done,
313                entry.progress.total,
314                percent(&entry.progress),
315            )),
316        ],
317        None => vec![
318            Line::from(Span::styled(
319                " Statistics ",
320                Style::default().fg(Color::DarkGray),
321            )),
322            Line::from(" Issues: 0   Completed: 0/0 (0%)"),
323        ],
324    };
325    f.render_widget(Paragraph::new(stats), chunks[1]);
326
327    // The focused cycle's issues, as a flat table: membership is exact string
328    // matching on `cycles`, the same rule the statistics above are counted
329    // with, and the same table the Milestones mode draws.
330    let (issues_title, issues) = match entries.get(selected) {
331        Some(entry) => (
332            format!("issues — {}", entry.name),
333            crate::ui::issue_table::cycle_rows(&app.store, &entry.name),
334        ),
335        None => ("issues".to_string(), Vec::new()),
336    };
337    crate::ui::issue_table::render(app, f, chunks[2], &issues_title, &issues);
338
339    let hints = format!(
340        "{}: move · {}: group · {}: manage cycles/milestones · {}: close",
341        app.bindings.label(InputContext::Cycle, TuiAction::MoveDown),
342        app.bindings
343            .label(InputContext::Cycle, TuiAction::MoveRight),
344        app.bindings.label(InputContext::Cycle, TuiAction::Planning),
345        app.bindings.label(InputContext::Cycle, TuiAction::Close),
346    );
347    f.render_widget(
348        Paragraph::new(Line::from(Span::styled(
349            hints,
350            Style::default().fg(Color::DarkGray),
351        ))),
352        chunks[3],
353    );
354}
355
356#[cfg(test)]
357mod tests {
358    #[allow(clippy::wildcard_imports)]
359    use super::*;
360
361    fn entry(name: &str, start: Option<&str>, due: Option<&str>) -> CycleEntry {
362        CycleEntry {
363            name: name.to_string(),
364            description: String::new(),
365            start_date: start.map(str::to_string),
366            due_date: due.map(str::to_string),
367            progress: Progress { done: 0, total: 0 },
368        }
369    }
370
371    const TODAY: &str = "2026-10-02";
372
373    #[test]
374    fn classifies_current_future_and_archived() {
375        let past = entry("Past", Some("2026-01-01"), Some("2026-03-01"));
376        let current = entry("Current", Some("2026-09-01"), Some("2026-11-01"));
377        let future = entry("Future", Some("2027-01-01"), Some("2027-02-01"));
378        assert_eq!(past.group(TODAY), CycleGroup::Archived);
379        assert_eq!(current.group(TODAY), CycleGroup::Current);
380        assert_eq!(future.group(TODAY), CycleGroup::Future);
381    }
382
383    #[test]
384    fn a_cycle_with_no_end_date_is_current() {
385        let open = entry("Open", Some("2026-01-01"), None);
386        assert_eq!(open.group(TODAY), CycleGroup::Current);
387        // ...and so is the implicit `Default`, which has no dates at all.
388        let default = entry("Default", None, None);
389        assert_eq!(default.group(TODAY), CycleGroup::Current);
390    }
391
392    #[test]
393    fn groups_are_ordered_current_future_archived_and_chronological() {
394        let entries = vec![
395            entry("Archived B", Some("2025-02-01"), Some("2025-03-01")),
396            entry("Future", Some("2027-01-01"), Some("2027-02-01")),
397            entry("Current", Some("2026-09-01"), Some("2026-11-01")),
398            entry("Archived A", Some("2025-01-01"), Some("2025-01-20")),
399        ];
400        let groups = group_entries(entries, TODAY);
401        let names: Vec<&str> = flat_entries(&groups)
402            .into_iter()
403            .map(|e| e.name.as_str())
404            .collect();
405        assert_eq!(
406            names,
407            vec!["Current", "Future", "Archived A", "Archived B"],
408            "current first, then future, then archived oldest-first"
409        );
410        assert_eq!(groups[0].group, CycleGroup::Current);
411        assert_eq!(groups[1].group, CycleGroup::Future);
412        assert_eq!(groups[2].group, CycleGroup::Archived);
413    }
414
415    #[test]
416    fn row_line_shows_name_start_and_due_with_a_dash_for_missing_dates() {
417        assert_eq!(
418            entry("Sprint", Some("2026-09-01"), Some("2026-09-14")).row_line(),
419            "Sprint   2026-09-01 → 2026-09-14"
420        );
421        assert_eq!(entry("Default", None, None).row_line(), "Default   — → —");
422    }
423
424    #[test]
425    fn cursor_moves_and_clamps_and_empty_is_zero() {
426        let mut state = CycleState::default();
427        state.move_selected(1, 2);
428        assert_eq!(state.selected, 1);
429        state.move_selected(1, 2);
430        assert_eq!(state.selected, 1, "clamped at the last entry");
431        state.move_selected(-1, 2);
432        assert_eq!(state.selected, 0);
433        state.move_selected(-1, 2);
434        assert_eq!(state.selected, 0, "clamped at the first entry");
435        state.move_selected(1, 0);
436        assert_eq!(state.selected, 0, "an empty list keeps the cursor at zero");
437    }
438
439    #[test]
440    fn percent_is_zero_for_an_empty_cycle() {
441        assert_eq!(percent(&Progress { done: 0, total: 0 }), 0);
442        assert_eq!(percent(&Progress { done: 1, total: 4 }), 25);
443        assert_eq!(percent(&Progress { done: 4, total: 4 }), 100);
444    }
445
446    #[test]
447    fn a_group_jump_lands_on_the_first_entry_of_the_next_rendered_group() {
448        let entries = vec![
449            entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
450            entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
451            entry("Future", Some("2027-01-01"), Some("2027-02-01")),
452            entry("Archived A", Some("2025-01-01"), Some("2025-02-01")),
453            entry("Archived B", Some("2025-02-01"), Some("2025-03-01")),
454        ];
455        let groups = group_entries(entries, TODAY);
456        let mut state = CycleState::default();
457
458        state.move_group(&groups, 1);
459        assert_eq!(state.selected, 2, "the first Future entry");
460        state.move_group(&groups, 1);
461        assert_eq!(state.selected, 3, "the first Archived entry");
462        state.move_group(&groups, 1);
463        assert_eq!(state.selected, 3, "the last group clamps");
464        state.move_group(&groups, -1);
465        assert_eq!(state.selected, 2);
466        state.move_group(&groups, -1);
467        assert_eq!(state.selected, 0, "back to the first Current entry");
468        state.move_group(&groups, -1);
469        assert_eq!(state.selected, 0, "the first group clamps");
470    }
471
472    #[test]
473    fn a_group_jump_from_inside_a_group_lands_on_that_groups_first_entry() {
474        let entries = vec![
475            entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
476            entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
477            entry("Future", Some("2027-01-01"), Some("2027-02-01")),
478        ];
479        let groups = group_entries(entries, TODAY);
480        let mut state = CycleState { selected: 1 };
481        state.move_group(&groups, -1);
482        assert_eq!(state.selected, 0, "clamped, and still the first entry");
483    }
484
485    #[test]
486    fn a_group_jump_skips_an_empty_section_and_survives_an_empty_list() {
487        // Current has two entries, Future none, Archived one: the empty one
488        // renders nothing, so it is not a landing site.
489        let entries = vec![
490            entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
491            entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
492            entry("Archived", Some("2025-01-01"), Some("2025-02-01")),
493        ];
494        let groups = group_entries(entries, TODAY);
495        assert!(groups[1].entries.is_empty(), "Future really is empty");
496        let mut state = CycleState::default();
497        state.move_group(&groups, 1);
498        assert_eq!(
499            state.selected, 2,
500            "the Archived entry, not the empty Future"
501        );
502
503        let mut empty = CycleState { selected: 4 };
504        empty.move_group(&[], 1);
505        assert_eq!(
506            empty.selected, 0,
507            "an empty listing keeps the cursor at zero"
508        );
509    }
510}