1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CycleGroup {
28 Current,
30 Future,
32 Archived,
34}
35
36impl CycleGroup {
37 pub const ALL: [Self; 3] = [Self::Current, Self::Future, Self::Archived];
39
40 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#[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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99pub struct CycleState {
100 pub selected: usize,
101}
102
103impl CycleState {
104 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 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 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
139fn 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#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct CycleGrouping {
156 pub group: CycleGroup,
157 pub entries: Vec<CycleEntry>,
158}
159
160pub 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
194pub 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#[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 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 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 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 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 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 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 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}