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};
use crate::app::{Action, App};
use crate::keybindings::{InputContext, TuiAction};
pub use crate::planning::Progress;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CycleGroup {
Current,
Future,
Archived,
}
impl CycleGroup {
pub const ALL: [Self; 3] = [Self::Current, Self::Future, Self::Archived];
pub const fn label(self) -> &'static str {
match self {
Self::Current => "Current",
Self::Future => "Future",
Self::Archived => "Archived",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleEntry {
pub name: String,
pub description: String,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub progress: Progress,
}
impl CycleEntry {
pub fn group(&self, today: &str) -> CycleGroup {
if self.due_date.as_deref().is_some_and(|due| due < today) {
CycleGroup::Archived
} else if self
.start_date
.as_deref()
.is_some_and(|start| start > today)
{
CycleGroup::Future
} else {
CycleGroup::Current
}
}
pub fn row_line(&self) -> String {
let date = |value: &Option<String>| value.clone().unwrap_or_else(|| "—".to_string());
format!(
"{} {} → {}",
self.name,
date(&self.start_date),
date(&self.due_date)
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CycleState {
pub selected: usize,
}
impl CycleState {
pub fn move_selected(&mut self, delta: isize, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
let next = self.selected as isize + delta;
self.selected = next.clamp(0, len as isize - 1) as usize;
}
pub fn move_group(&mut self, groups: &[CycleGrouping], delta: isize) {
let starts = group_starts(groups);
if starts.is_empty() {
self.selected = 0;
return;
}
let current = starts
.iter()
.rposition(|start| *start <= self.selected)
.unwrap_or(0);
let last = starts.len() as isize - 1;
self.selected = starts[(current as isize + delta).clamp(0, last) as usize];
}
}
fn group_starts(groups: &[CycleGrouping]) -> Vec<usize> {
let mut starts = Vec::new();
let mut offset = 0usize;
for grouping in groups {
if grouping.entries.is_empty() {
continue;
}
starts.push(offset);
offset += grouping.entries.len();
}
starts
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleGrouping {
pub group: CycleGroup,
pub entries: Vec<CycleEntry>,
}
pub fn group_entries(entries: Vec<CycleEntry>, today: &str) -> Vec<CycleGrouping> {
let mut groups: Vec<CycleGrouping> = CycleGroup::ALL
.iter()
.map(|group| CycleGrouping {
group: *group,
entries: Vec::new(),
})
.collect();
for entry in entries {
let group = entry.group(today);
let slot = groups
.iter_mut()
.find(|slot| slot.group == group)
.expect("all three groups exist");
slot.entries.push(entry);
}
for slot in &mut groups {
slot.entries.sort_by_key(|entry| {
(
entry.start_date.clone().unwrap_or_default(),
entry.due_date.clone().unwrap_or_default(),
entry.name.clone(),
)
});
}
groups
}
pub fn flat_entries(groups: &[CycleGrouping]) -> Vec<&CycleEntry> {
groups
.iter()
.flat_map(|group| group.entries.iter())
.collect()
}
fn percent(progress: &Progress) -> usize {
(progress.done * 100)
.checked_div(progress.total)
.unwrap_or(0)
}
#[allow(clippy::too_many_lines)]
pub fn render(app: &App, f: &mut Frame, area: Rect, state: &CycleState, groups: &[CycleGrouping]) {
let block = Block::default()
.borders(Borders::ALL)
.title(" cycles ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(2),
Constraint::Min(4),
Constraint::Length(1),
])
.split(inner);
let entries = flat_entries(groups);
let selected = entries
.len()
.checked_sub(1)
.map_or(0, |last| usize::min(state.selected, last));
let mut lines: Vec<Line> = Vec::new();
let mut rows: Vec<(usize, u16)> = Vec::new();
let mut ordinal = 0usize;
if entries.is_empty() {
lines.push(Line::from(Span::styled(
"No cycles yet — press m to manage cycles and milestones.",
Style::default().fg(Color::DarkGray),
)));
}
for grouping in groups {
if grouping.entries.is_empty() {
continue;
}
lines.push(Line::from(Span::styled(
format!("{} ({})", grouping.group.label(), grouping.entries.len()),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
for entry in &grouping.entries {
let cursor = if ordinal == selected { "> " } else { " " };
let style = if ordinal == selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(entry.row_line(), style),
]));
rows.push((ordinal, (lines.len() - 1) as u16));
ordinal += 1;
}
}
let view_h = chunks[0].height;
let selected_line = rows
.iter()
.find(|(index, _)| *index == selected)
.map_or(0, |(_, line)| *line);
let top = selected_line.saturating_sub(view_h.saturating_sub(1));
let list = Paragraph::new(lines).scroll((top, 0));
f.render_widget(list, chunks[0]);
for (index, line) in &rows {
if *line < top || *line - top >= view_h {
continue;
}
let rect = Rect {
x: chunks[0].x,
y: chunks[0].y + (*line - top),
width: chunks[0].width,
height: 1,
};
app.record_region(rect, Action::CycleSelect(*index));
}
let stats = match entries.get(selected) {
Some(entry) => vec![
Line::from(Span::styled(
format!(" {} ", entry.name),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(format!(
" Issues: {} Completed: {}/{} ({}%)",
entry.progress.total,
entry.progress.done,
entry.progress.total,
percent(&entry.progress),
)),
],
None => vec![
Line::from(Span::styled(
" Statistics ",
Style::default().fg(Color::DarkGray),
)),
Line::from(" Issues: 0 Completed: 0/0 (0%)"),
],
};
f.render_widget(Paragraph::new(stats), chunks[1]);
let (issues_title, issues) = match entries.get(selected) {
Some(entry) => (
format!("issues — {}", entry.name),
crate::ui::issue_table::cycle_rows(&app.store, &entry.name),
),
None => ("issues".to_string(), Vec::new()),
};
crate::ui::issue_table::render(app, f, chunks[2], &issues_title, &issues);
let hints = format!(
"{}: move · {}: group · {}: manage cycles/milestones · {}: close",
app.bindings.label(InputContext::Cycle, TuiAction::MoveDown),
app.bindings
.label(InputContext::Cycle, TuiAction::MoveRight),
app.bindings.label(InputContext::Cycle, TuiAction::Planning),
app.bindings.label(InputContext::Cycle, TuiAction::Close),
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hints,
Style::default().fg(Color::DarkGray),
))),
chunks[3],
);
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn entry(name: &str, start: Option<&str>, due: Option<&str>) -> CycleEntry {
CycleEntry {
name: name.to_string(),
description: String::new(),
start_date: start.map(str::to_string),
due_date: due.map(str::to_string),
progress: Progress { done: 0, total: 0 },
}
}
const TODAY: &str = "2026-10-02";
#[test]
fn classifies_current_future_and_archived() {
let past = entry("Past", Some("2026-01-01"), Some("2026-03-01"));
let current = entry("Current", Some("2026-09-01"), Some("2026-11-01"));
let future = entry("Future", Some("2027-01-01"), Some("2027-02-01"));
assert_eq!(past.group(TODAY), CycleGroup::Archived);
assert_eq!(current.group(TODAY), CycleGroup::Current);
assert_eq!(future.group(TODAY), CycleGroup::Future);
}
#[test]
fn a_cycle_with_no_end_date_is_current() {
let open = entry("Open", Some("2026-01-01"), None);
assert_eq!(open.group(TODAY), CycleGroup::Current);
let default = entry("Default", None, None);
assert_eq!(default.group(TODAY), CycleGroup::Current);
}
#[test]
fn groups_are_ordered_current_future_archived_and_chronological() {
let entries = vec![
entry("Archived B", Some("2025-02-01"), Some("2025-03-01")),
entry("Future", Some("2027-01-01"), Some("2027-02-01")),
entry("Current", Some("2026-09-01"), Some("2026-11-01")),
entry("Archived A", Some("2025-01-01"), Some("2025-01-20")),
];
let groups = group_entries(entries, TODAY);
let names: Vec<&str> = flat_entries(&groups)
.into_iter()
.map(|e| e.name.as_str())
.collect();
assert_eq!(
names,
vec!["Current", "Future", "Archived A", "Archived B"],
"current first, then future, then archived oldest-first"
);
assert_eq!(groups[0].group, CycleGroup::Current);
assert_eq!(groups[1].group, CycleGroup::Future);
assert_eq!(groups[2].group, CycleGroup::Archived);
}
#[test]
fn row_line_shows_name_start_and_due_with_a_dash_for_missing_dates() {
assert_eq!(
entry("Sprint", Some("2026-09-01"), Some("2026-09-14")).row_line(),
"Sprint 2026-09-01 → 2026-09-14"
);
assert_eq!(entry("Default", None, None).row_line(), "Default — → —");
}
#[test]
fn cursor_moves_and_clamps_and_empty_is_zero() {
let mut state = CycleState::default();
state.move_selected(1, 2);
assert_eq!(state.selected, 1);
state.move_selected(1, 2);
assert_eq!(state.selected, 1, "clamped at the last entry");
state.move_selected(-1, 2);
assert_eq!(state.selected, 0);
state.move_selected(-1, 2);
assert_eq!(state.selected, 0, "clamped at the first entry");
state.move_selected(1, 0);
assert_eq!(state.selected, 0, "an empty list keeps the cursor at zero");
}
#[test]
fn percent_is_zero_for_an_empty_cycle() {
assert_eq!(percent(&Progress { done: 0, total: 0 }), 0);
assert_eq!(percent(&Progress { done: 1, total: 4 }), 25);
assert_eq!(percent(&Progress { done: 4, total: 4 }), 100);
}
#[test]
fn a_group_jump_lands_on_the_first_entry_of_the_next_rendered_group() {
let entries = vec![
entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
entry("Future", Some("2027-01-01"), Some("2027-02-01")),
entry("Archived A", Some("2025-01-01"), Some("2025-02-01")),
entry("Archived B", Some("2025-02-01"), Some("2025-03-01")),
];
let groups = group_entries(entries, TODAY);
let mut state = CycleState::default();
state.move_group(&groups, 1);
assert_eq!(state.selected, 2, "the first Future entry");
state.move_group(&groups, 1);
assert_eq!(state.selected, 3, "the first Archived entry");
state.move_group(&groups, 1);
assert_eq!(state.selected, 3, "the last group clamps");
state.move_group(&groups, -1);
assert_eq!(state.selected, 2);
state.move_group(&groups, -1);
assert_eq!(state.selected, 0, "back to the first Current entry");
state.move_group(&groups, -1);
assert_eq!(state.selected, 0, "the first group clamps");
}
#[test]
fn a_group_jump_from_inside_a_group_lands_on_that_groups_first_entry() {
let entries = vec![
entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
entry("Future", Some("2027-01-01"), Some("2027-02-01")),
];
let groups = group_entries(entries, TODAY);
let mut state = CycleState { selected: 1 };
state.move_group(&groups, -1);
assert_eq!(state.selected, 0, "clamped, and still the first entry");
}
#[test]
fn a_group_jump_skips_an_empty_section_and_survives_an_empty_list() {
let entries = vec![
entry("Current A", Some("2026-09-01"), Some("2026-11-01")),
entry("Current B", Some("2026-09-02"), Some("2026-11-02")),
entry("Archived", Some("2025-01-01"), Some("2025-02-01")),
];
let groups = group_entries(entries, TODAY);
assert!(groups[1].entries.is_empty(), "Future really is empty");
let mut state = CycleState::default();
state.move_group(&groups, 1);
assert_eq!(
state.selected, 2,
"the Archived entry, not the empty Future"
);
let mut empty = CycleState { selected: 4 };
empty.move_group(&[], 1);
assert_eq!(
empty.selected, 0,
"an empty listing keeps the cursor at zero"
);
}
}