use crate::components::sprint_assign_list::{
build_entries, build_entries_active_only, next_selectable, prev_selectable, sprint_id_of,
SprintAssignEntry,
};
use crate::components::sprint_picker_view::SprintPickerView;
use chrono::{DateTime, Utc};
use crossterm::event::KeyCode;
use kanban_domain::{Board, Sprint};
use ratatui::{layout::Rect, Frame};
use uuid::Uuid;
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Selection {
#[default]
Unset,
NoSprint,
Sprint(Uuid),
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
enum Cursor {
#[default]
NoSprint,
Sprint(Uuid),
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SprintFilter {
#[default]
ActiveOnly,
All,
}
#[derive(Default)]
pub struct SprintPicker {
selection: Selection,
cursor: Cursor,
filter: SprintFilter,
current_sprint_id: Option<Uuid>,
bound_board_id: Option<Uuid>,
}
impl SprintPicker {
pub fn new() -> Self {
Self {
filter: SprintFilter::ActiveOnly,
..Default::default()
}
}
pub fn with_filter(filter: SprintFilter) -> Self {
Self {
filter,
..Default::default()
}
}
pub fn reset_for_board(&mut self, sprints: &[Sprint], board: &Board, now: DateTime<Utc>) {
let view = SprintPickerView::for_new_card(sprints, board, now);
let entries = self.build_entries(sprints, board, now);
self.cursor = view
.initial_selection()
.and_then(|idx| entries.get(idx).map(Self::entry_to_cursor))
.unwrap_or(Cursor::NoSprint);
self.selection = match self.cursor {
Cursor::Sprint(id) => Selection::Sprint(id),
Cursor::NoSprint => Selection::Unset,
};
self.bound_board_id = Some(board.id);
}
pub fn reset_for_card_assignment(
&mut self,
current_sprint_id: Option<Uuid>,
sprints: &[Sprint],
board: &Board,
now: DateTime<Utc>,
) {
self.current_sprint_id = current_sprint_id;
let entries = self.build_entries(sprints, board, now);
let (cursor, selection) = match current_sprint_id {
Some(id) => {
let cursor = if entries.iter().any(|e| sprint_id_of(e) == Some(id)) {
Cursor::Sprint(id)
} else {
Cursor::NoSprint
};
(cursor, Selection::Sprint(id))
}
None => (Cursor::NoSprint, Selection::NoSprint),
};
self.cursor = cursor;
self.selection = selection;
self.bound_board_id = Some(board.id);
}
pub fn reset_for_bulk_card_assignment(
&mut self,
_sprints: &[Sprint],
board: &Board,
_now: DateTime<Utc>,
) {
self.current_sprint_id = None;
self.cursor = Cursor::NoSprint;
self.selection = Selection::Unset;
self.bound_board_id = Some(board.id);
}
pub fn clear(&mut self) {
self.selection = Selection::Unset;
self.cursor = Cursor::NoSprint;
self.current_sprint_id = None;
self.bound_board_id = None;
}
pub fn bound_board_id(&self) -> Option<Uuid> {
self.bound_board_id
}
pub fn selected_sprint_id_for(&self, board_id: Uuid) -> Option<Uuid> {
if self.bound_board_id == Some(board_id) {
self.selected_sprint_id()
} else {
None
}
}
pub fn handle_key(
&mut self,
key_code: KeyCode,
sprints: &[Sprint],
board: &Board,
now: DateTime<Utc>,
) -> bool {
if matches!(key_code, KeyCode::Char(' ')) {
let cursor_as_selection = match self.cursor {
Cursor::NoSprint => Selection::NoSprint,
Cursor::Sprint(id) => Selection::Sprint(id),
};
self.selection = if self.selection == cursor_as_selection {
Selection::Unset
} else {
cursor_as_selection
};
return true;
}
let entries = self.build_entries(sprints, board, now);
let cur = self.cursor_index(&entries);
let next_idx = match key_code {
KeyCode::Down | KeyCode::Char('j') => next_selectable(&entries, cur),
KeyCode::Up | KeyCode::Char('k') => prev_selectable(&entries, cur),
_ => return false,
};
if let Some(idx) = next_idx {
if let Some(entry) = entries.get(idx) {
self.cursor = Self::entry_to_cursor(entry);
}
}
true
}
pub fn selected_sprint_id(&self) -> Option<Uuid> {
match self.selection {
Selection::Sprint(id) => Some(id),
Selection::NoSprint | Selection::Unset => None,
}
}
pub fn explicitly_unassigned(&self) -> bool {
matches!(self.selection, Selection::NoSprint)
}
pub fn render(
&self,
frame: &mut Frame,
area: Rect,
sprints: &[Sprint],
board: &Board,
now: DateTime<Utc>,
) {
let view = self.build_view(sprints, board, now);
let checked = match self.selection {
Selection::Unset => None,
Selection::NoSprint => view.index_of_sprint(None),
Selection::Sprint(id) => view.index_of_sprint(Some(id)),
};
let cursor = match self.cursor {
Cursor::NoSprint => view.index_of_sprint(None),
Cursor::Sprint(id) => view.index_of_sprint(Some(id)),
};
view.render_with_cursor(frame, area, checked, cursor);
}
fn build_entries<'a>(
&self,
sprints: &'a [Sprint],
board: &Board,
now: DateTime<Utc>,
) -> Vec<SprintAssignEntry<'a>> {
match self.filter {
SprintFilter::ActiveOnly => build_entries_active_only(sprints, board.id, now),
SprintFilter::All => build_entries(sprints, board.id, now),
}
}
fn build_view<'a>(
&self,
sprints: &'a [Sprint],
board: &'a Board,
now: DateTime<Utc>,
) -> SprintPickerView<'a> {
match self.filter {
SprintFilter::ActiveOnly => SprintPickerView::for_new_card(sprints, board, now),
SprintFilter::All => {
SprintPickerView::for_card_assignment(sprints, board, self.current_sprint_id, now)
}
}
}
fn cursor_index(&self, entries: &[SprintAssignEntry]) -> Option<usize> {
match self.cursor {
Cursor::NoSprint => entries
.iter()
.position(|e| matches!(e, SprintAssignEntry::None)),
Cursor::Sprint(id) => entries.iter().position(|e| sprint_id_of(e) == Some(id)),
}
}
fn entry_to_cursor(entry: &SprintAssignEntry) -> Cursor {
match entry {
SprintAssignEntry::None => Cursor::NoSprint,
SprintAssignEntry::Header(_) => {
debug_assert!(false, "navigation landed on a section header entry");
Cursor::NoSprint
}
SprintAssignEntry::ActiveOrPlanned(s)
| SprintAssignEntry::Completed(s)
| SprintAssignEntry::Ended(s) => Cursor::Sprint(s.id),
}
}
#[cfg(test)]
pub(crate) fn raw_selection(&self) -> Selection {
self.selection
}
pub fn cursor_sprint_id(&self) -> Option<Uuid> {
match self.cursor {
Cursor::NoSprint => None,
Cursor::Sprint(id) => Some(id),
}
}
#[cfg(test)]
pub(crate) fn current_index(&self, entries: &[SprintAssignEntry]) -> Option<usize> {
match self.selection {
Selection::Unset => None,
Selection::NoSprint => entries
.iter()
.position(|e| matches!(e, SprintAssignEntry::None)),
Selection::Sprint(id) => entries.iter().position(|e| sprint_id_of(e) == Some(id)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use kanban_domain::SprintStatus;
fn make_board() -> Board {
Board::new("B", Some("TST"))
}
fn active_sprint(board_id: Uuid, n: u32, now: DateTime<Utc>) -> Sprint {
Sprint {
id: Uuid::new_v4(),
board_id,
sprint_number: n,
name_index: None,
prefix: None,
card_prefix: None,
status: SprintStatus::Active,
start_date: Some(now - Duration::days(1)),
end_date: Some(now + Duration::days(7)),
created_at: now,
updated_at: now,
}
}
#[test]
fn test_reset_with_sole_active_sprint_pre_checks_it() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), Some(sprint.id));
assert_eq!(picker.raw_selection(), Selection::Sprint(sprint.id));
assert_eq!(picker.cursor_sprint_id(), Some(sprint.id));
}
#[test]
fn test_space_on_already_checked_row_unchecks_it() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Sprint(sprint.id));
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Unset);
assert_eq!(picker.selected_sprint_id(), None);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Sprint(sprint.id));
}
#[test]
fn test_space_on_none_row_toggles_between_nosprint_and_unset() {
let now = Utc::now();
let board = make_board();
let sprints: Vec<Sprint> = vec![];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Unset);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::NoSprint);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Unset);
}
#[test]
fn test_arrow_moves_cursor_without_changing_selection() {
let now = Utc::now();
let board = make_board();
let planning_a = Sprint::new(board.id, 1, None, None::<String>);
let planning_b = Sprint::new(board.id, 2, None, None::<String>);
let sprints = vec![planning_a, planning_b];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
let before_selection = picker.raw_selection();
picker.handle_key(KeyCode::Down, &sprints, &board, now);
assert_eq!(
picker.raw_selection(),
before_selection,
"Down must not flip a [x] on; only Space places one"
);
}
#[test]
fn test_space_at_uncrossed_sprint_cursor_places_check_there() {
let now = Utc::now();
let board = make_board();
let p_a = Sprint::new(board.id, 1, None, None::<String>);
let p_b = Sprint::new(board.id, 2, None, None::<String>);
let sprints = vec![p_a, p_b];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), None);
assert_eq!(picker.raw_selection(), Selection::Unset);
picker.handle_key(KeyCode::Down, &sprints, &board, now);
let cursor_id = picker.cursor_sprint_id().expect("cursor on sprint");
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), Some(cursor_id));
}
#[test]
fn test_space_after_arrow_switches_check_to_new_cursor_sprint() {
let now = Utc::now();
let board = make_board();
let planning_a = Sprint::new(board.id, 1, None, None::<String>);
let planning_b = Sprint::new(board.id, 2, None, None::<String>);
let sprints = vec![planning_a.clone(), planning_b.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
picker.handle_key(KeyCode::Down, &sprints, &board, now);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
let first_checked = picker.selected_sprint_id();
assert!(first_checked.is_some());
picker.handle_key(KeyCode::Down, &sprints, &board, now);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
let second_checked = picker.selected_sprint_id();
assert!(second_checked.is_some());
assert_ne!(
first_checked, second_checked,
"Space at a new cursor position must move [x] there"
);
}
#[test]
fn test_space_on_none_row_marks_none_as_explicitly_chosen() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
picker.handle_key(KeyCode::Up, &sprints, &board, now);
picker.handle_key(KeyCode::Char(' '), &sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::NoSprint);
assert_eq!(picker.selected_sprint_id(), None);
}
#[test]
fn test_reset_with_no_active_sprint_parks_cursor_on_none_row() {
let now = Utc::now();
let board = make_board();
let sprints: Vec<Sprint> = vec![];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), None);
assert_eq!(picker.raw_selection(), Selection::Unset);
assert_eq!(picker.cursor_sprint_id(), None);
}
#[test]
fn test_reset_with_multiple_active_sprints_parks_cursor_on_none_row() {
let now = Utc::now();
let board = make_board();
let s1 = active_sprint(board.id, 1, now);
let s2 = active_sprint(board.id, 2, now);
let sprints = vec![s1, s2];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), None);
assert_eq!(picker.raw_selection(), Selection::Unset);
assert_eq!(picker.cursor_sprint_id(), None);
}
#[test]
fn test_handle_key_down_moves_cursor_returns_true() {
let now = Utc::now();
let board = make_board();
let planning_a = Sprint::new(board.id, 1, None, None::<String>);
let planning_b = Sprint::new(board.id, 2, None, None::<String>);
let sprints = vec![planning_a, planning_b];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
let before = picker.cursor_sprint_id();
let consumed = picker.handle_key(KeyCode::Down, &sprints, &board, now);
let after = picker.cursor_sprint_id();
assert!(consumed, "Down should be consumed by the picker");
assert_ne!(before, after, "Down should advance the cursor");
}
#[test]
fn test_handle_key_other_returns_false_so_host_can_handle_it() {
let now = Utc::now();
let board = make_board();
let sprints: Vec<Sprint> = vec![];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert!(!picker.handle_key(KeyCode::Char('a'), &sprints, &board, now));
assert!(!picker.handle_key(KeyCode::Enter, &sprints, &board, now));
assert!(!picker.handle_key(KeyCode::Backspace, &sprints, &board, now));
}
#[test]
fn test_selection_survives_clock_advance_that_ends_the_selected_sprint() {
let board = make_board();
let now_open = Utc::now();
let now_confirm = now_open + Duration::days(30);
let sprint_a = Sprint {
id: Uuid::new_v4(),
board_id: board.id,
sprint_number: 1,
name_index: None,
prefix: None,
card_prefix: None,
status: SprintStatus::Active,
start_date: Some(now_open - Duration::days(1)),
end_date: Some(now_open + Duration::days(1)),
created_at: now_open,
updated_at: now_open,
};
let sprint_b = Sprint::new(board.id, 2, None, None::<String>);
let sprints = vec![sprint_a.clone(), sprint_b];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now_open);
assert_eq!(picker.selected_sprint_id(), Some(sprint_a.id));
assert_eq!(picker.raw_selection(), Selection::Sprint(sprint_a.id));
let entries_open =
crate::components::sprint_assign_list::build_entries(&sprints, board.id, now_open);
let entries_confirm =
crate::components::sprint_assign_list::build_entries(&sprints, board.id, now_confirm);
let idx_open = entries_open
.iter()
.position(|e| sprint_id_of(e) == Some(sprint_a.id));
let idx_confirm = entries_confirm
.iter()
.position(|e| sprint_id_of(e) == Some(sprint_a.id));
assert!(idx_open.is_some());
assert!(idx_confirm.is_some());
assert_ne!(
idx_open, idx_confirm,
"precondition: A's row position must differ between open and confirm"
);
assert_eq!(picker.current_index(&entries_confirm), idx_confirm);
assert_eq!(picker.selected_sprint_id(), Some(sprint_a.id));
}
#[test]
fn test_picker_skips_completed_and_ended_sprints_during_navigation() {
let now = Utc::now();
let board = make_board();
let active = active_sprint(board.id, 1, now);
let mut completed = Sprint::new(board.id, 2, None, None::<String>);
completed.status = SprintStatus::Completed;
completed.start_date = Some(now - Duration::days(30));
completed.end_date = Some(now - Duration::days(15));
let sprints = vec![active.clone(), completed.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.cursor_sprint_id(), Some(active.id));
for _ in 0..3 {
picker.handle_key(KeyCode::Down, &sprints, &board, now);
}
assert_ne!(
picker.cursor_sprint_id(),
Some(completed.id),
"Down must not reach a completed sprint in the new-card picker"
);
}
#[test]
fn test_clear_resets_selection_to_unset() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Sprint(sprint.id));
picker.clear();
assert_eq!(picker.raw_selection(), Selection::Unset);
}
#[test]
fn test_bound_board_id_returns_id_passed_to_reset() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint];
let mut picker = SprintPicker::new();
assert_eq!(picker.bound_board_id(), None);
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.bound_board_id(), Some(board.id));
}
#[test]
fn test_selected_sprint_id_for_returns_none_on_board_mismatch() {
let now = Utc::now();
let board_a = make_board();
let sprint_a = active_sprint(board_a.id, 1, now);
let board_b = make_board();
let sprints = vec![sprint_a.clone()];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board_a, now);
assert_eq!(picker.selected_sprint_id(), Some(sprint_a.id));
assert_eq!(picker.selected_sprint_id_for(board_a.id), Some(sprint_a.id));
assert_eq!(picker.selected_sprint_id_for(board_b.id), None);
}
#[test]
fn test_clear_drops_bound_board_id() {
let now = Utc::now();
let board = make_board();
let sprints: Vec<Sprint> = vec![];
let mut picker = SprintPicker::new();
picker.reset_for_board(&sprints, &board, now);
assert_eq!(picker.bound_board_id(), Some(board.id));
picker.clear();
assert_eq!(picker.bound_board_id(), None);
}
#[test]
fn test_reset_for_bulk_assignment_opens_with_selection_unset() {
let now = Utc::now();
let board = make_board();
let sprint = active_sprint(board.id, 1, now);
let sprints = vec![sprint];
let mut picker = SprintPicker::with_filter(SprintFilter::All);
picker.reset_for_bulk_card_assignment(&sprints, &board, now);
assert_eq!(picker.raw_selection(), Selection::Unset);
assert!(!picker.explicitly_unassigned());
assert_eq!(picker.selected_sprint_id(), None);
}
#[test]
fn test_reset_for_assignment_preserves_current_sprint_even_when_filter_hides_it() {
let now = Utc::now();
let board = make_board();
let mut completed = Sprint::new(board.id, 1, None, None::<String>);
completed.status = SprintStatus::Completed;
completed.start_date = Some(now - Duration::days(30));
completed.end_date = Some(now - Duration::days(15));
let sprints = vec![completed.clone()];
let mut picker = SprintPicker::with_filter(SprintFilter::ActiveOnly);
picker.reset_for_card_assignment(Some(completed.id), &sprints, &board, now);
assert_eq!(picker.selected_sprint_id(), Some(completed.id));
assert_eq!(picker.raw_selection(), Selection::Sprint(completed.id));
}
}