use chrono::{DateTime, Utc};
use kanban_domain::{Board, Sprint, SprintStatus};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use uuid::Uuid;
pub enum SprintAssignEntry<'a> {
Header(&'static str),
None,
ActiveOrPlanned(&'a Sprint),
Completed(&'a Sprint),
Ended(&'a Sprint),
}
pub const ACTIVE_PLANNED_HEADER: &str = "Active / Planned";
pub const COMPLETED_ENDED_HEADER: &str = "Completed / Ended";
fn is_selectable(entry: &SprintAssignEntry) -> bool {
!matches!(entry, SprintAssignEntry::Header(_))
}
pub fn build_entries<'a>(
sprints: &'a [Sprint],
board_id: Uuid,
now: DateTime<Utc>,
) -> Vec<SprintAssignEntry<'a>> {
let (active, completed_or_ended) = Sprint::for_assignment_dialog(sprints, board_id, now);
let active_header = if active.is_empty() { 0 } else { 1 };
let lower_header = if completed_or_ended.is_empty() { 0 } else { 1 };
let mut entries: Vec<SprintAssignEntry<'a>> = Vec::with_capacity(
1 + active.len() + completed_or_ended.len() + active_header + lower_header,
);
entries.push(SprintAssignEntry::None);
if !active.is_empty() {
entries.push(SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER));
for s in active {
entries.push(SprintAssignEntry::ActiveOrPlanned(s));
}
}
if !completed_or_ended.is_empty() {
entries.push(SprintAssignEntry::Header(COMPLETED_ENDED_HEADER));
for s in completed_or_ended {
if s.status == SprintStatus::Completed {
entries.push(SprintAssignEntry::Completed(s));
} else {
entries.push(SprintAssignEntry::Ended(s));
}
}
}
entries
}
fn first_selectable(entries: &[SprintAssignEntry]) -> Option<usize> {
entries.iter().position(|e| is_selectable(e))
}
fn last_selectable(entries: &[SprintAssignEntry]) -> Option<usize> {
entries.iter().rposition(|e| is_selectable(e))
}
fn cur_if_selectable(entries: &[SprintAssignEntry], cur: Option<usize>) -> Option<usize> {
cur.filter(|&c| entries.get(c).map(is_selectable).unwrap_or(false))
}
pub fn next_selectable(entries: &[SprintAssignEntry], cur: Option<usize>) -> Option<usize> {
let start = cur.map(|i| i + 1).unwrap_or(0);
entries
.iter()
.enumerate()
.skip(start)
.find(|(_, e)| is_selectable(e))
.map(|(i, _)| i)
.or_else(|| cur_if_selectable(entries, cur))
.or_else(|| last_selectable(entries))
}
pub fn prev_selectable(entries: &[SprintAssignEntry], cur: Option<usize>) -> Option<usize> {
let end = cur.unwrap_or(entries.len());
entries
.iter()
.enumerate()
.take(end)
.rev()
.find(|(_, e)| is_selectable(e))
.map(|(i, _)| i)
.or_else(|| cur_if_selectable(entries, cur))
.or_else(|| first_selectable(entries))
}
pub fn section_header_for(
entries: &[SprintAssignEntry],
idx: usize,
) -> Option<(usize, &'static str)> {
if idx >= entries.len() {
return None;
}
for i in (0..idx).rev() {
if let SprintAssignEntry::Header(label) = entries[i] {
return Some((i, label));
}
}
None
}
pub fn sprint_id_of(entry: &SprintAssignEntry) -> Option<Uuid> {
match entry {
SprintAssignEntry::ActiveOrPlanned(s)
| SprintAssignEntry::Completed(s)
| SprintAssignEntry::Ended(s) => Some(s.id),
SprintAssignEntry::Header(_) | SprintAssignEntry::None => None,
}
}
pub fn render_entry_line(
entry: &SprintAssignEntry<'_>,
is_selected: bool,
current_sprint_id: Option<Uuid>,
board: &Board,
) -> Line<'static> {
match entry {
SprintAssignEntry::Header(label) => Line::from(Span::styled(
(*label).to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
SprintAssignEntry::None => {
let is_current = current_sprint_id.is_none();
let prefix = if is_selected { "> " } else { " " };
let suffix = if is_current { " (current)" } else { "" };
let style = if is_selected {
Style::default().fg(Color::White).bg(Color::Blue)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::from(Span::styled(format!("{}(None){}", prefix, suffix), style))
}
SprintAssignEntry::ActiveOrPlanned(s) => {
let is_current = current_sprint_id == Some(s.id);
let prefix = if is_selected { "> " } else { " " };
let suffix = if is_current { " (current)" } else { "" };
let style = if is_selected {
Style::default().fg(Color::White).bg(Color::Blue)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::from(Span::styled(
format!("{}{}{}", prefix, s.formatted_name(board, "sprint"), suffix),
style,
))
}
SprintAssignEntry::Completed(s) | SprintAssignEntry::Ended(s) => {
let is_current = current_sprint_id == Some(s.id);
let prefix = if is_selected { "> " } else { " " };
let suffix = if is_current { " (current)" } else { "" };
let status_color = if matches!(entry, SprintAssignEntry::Completed(_)) {
Color::Green
} else {
Color::Red
};
let style = if is_selected {
Style::default().fg(Color::White).bg(Color::Blue)
} else {
Style::default().fg(status_color)
};
Line::from(Span::styled(
format!("{}{}{}", prefix, s.formatted_name(board, "sprint"), suffix),
style,
))
}
}
}
pub fn scroll_offset_to_show(selected: usize, total: usize, height: usize) -> usize {
if height == 0 || total <= height || selected < height {
return 0;
}
let max_offset = total.saturating_sub(height);
(selected + 1).saturating_sub(height).min(max_offset)
}
#[cfg(test)]
mod tests {
use super::*;
use kanban_domain::SprintStatus;
fn ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
fn make_sprint(
sprint_number: u32,
board_id: Uuid,
status: SprintStatus,
end_date: Option<DateTime<Utc>>,
) -> Sprint {
Sprint {
id: Uuid::new_v4(),
board_id,
sprint_number,
name_index: None,
prefix: None,
card_prefix: None,
status,
start_date: None,
end_date,
created_at: ts("2026-01-01T00:00:00Z"),
updated_at: ts("2026-01-01T00:00:00Z"),
}
}
fn is_none_entry(entry: &SprintAssignEntry) -> bool {
matches!(entry, SprintAssignEntry::None)
}
fn header_label<'a>(entry: &SprintAssignEntry<'a>) -> Option<&'static str> {
match entry {
SprintAssignEntry::Header(label) => Some(*label),
_ => None,
}
}
#[test]
fn test_build_entries_always_includes_none_at_index_0() {
let now = ts("2026-05-07T00:00:00Z");
let board = Uuid::new_v4();
let entries = build_entries(&[], board, now);
assert!(
!entries.is_empty(),
"entries must contain at least the None option"
);
assert!(
is_none_entry(&entries[0]),
"index 0 must be the None unassign entry"
);
}
#[test]
fn test_build_entries_omits_active_section_header_when_no_active_or_planned() {
let now = ts("2026-05-07T00:00:00Z");
let board = Uuid::new_v4();
let sprints = [make_sprint(
1,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
)];
let entries = build_entries(&sprints, board, now);
assert!(
entries
.iter()
.all(|e| header_label(e) != Some(ACTIVE_PLANNED_HEADER)),
"active/planned header must be omitted when section is empty"
);
assert!(
entries
.iter()
.any(|e| header_label(e) == Some(COMPLETED_ENDED_HEADER)),
"completed/ended header must appear when section is non-empty"
);
}
#[test]
fn test_build_entries_omits_completed_ended_section_header_when_section_empty() {
let now = ts("2026-05-07T00:00:00Z");
let board = Uuid::new_v4();
let sprints = [make_sprint(1, board, SprintStatus::Planning, None)];
let entries = build_entries(&sprints, board, now);
assert!(
entries
.iter()
.any(|e| header_label(e) == Some(ACTIVE_PLANNED_HEADER)),
"active/planned header must appear when section is non-empty"
);
assert!(
entries
.iter()
.all(|e| header_label(e) != Some(COMPLETED_ENDED_HEADER)),
"completed/ended header must be omitted when section is empty"
);
}
#[test]
fn test_build_entries_emits_completed_and_ended_in_same_section() {
let now = ts("2026-05-07T00:00:00Z");
let board = Uuid::new_v4();
let sprints = [
make_sprint(
5,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
),
make_sprint(
7,
board,
SprintStatus::Active,
Some(ts("2026-05-01T00:00:00Z")),
),
];
let entries = build_entries(&sprints, board, now);
let header_idx = entries
.iter()
.position(|e| header_label(e) == Some(COMPLETED_ENDED_HEADER))
.expect("completed/ended header present");
let after = &entries[header_idx + 1..];
let mut saw_completed = false;
let mut saw_ended = false;
for e in after {
match e {
SprintAssignEntry::Completed(_) => saw_completed = true,
SprintAssignEntry::Ended(_) => saw_ended = true,
SprintAssignEntry::Header(_) => panic!("no second header expected"),
_ => {}
}
}
assert!(
saw_completed && saw_ended,
"section must contain both Completed and Ended entries"
);
}
#[test]
fn test_next_selectable_skips_headers() {
let board = Uuid::new_v4();
let s1 = make_sprint(1, board, SprintStatus::Planning, None);
let s2 = make_sprint(
2,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER),
SprintAssignEntry::ActiveOrPlanned(&s1),
SprintAssignEntry::Header(COMPLETED_ENDED_HEADER),
SprintAssignEntry::Completed(&s2),
];
assert_eq!(next_selectable(&entries, Some(0)), Some(2));
assert_eq!(next_selectable(&entries, Some(2)), Some(4));
}
#[test]
fn test_prev_selectable_skips_headers() {
let board = Uuid::new_v4();
let s1 = make_sprint(1, board, SprintStatus::Planning, None);
let s2 = make_sprint(
2,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER),
SprintAssignEntry::ActiveOrPlanned(&s1),
SprintAssignEntry::Header(COMPLETED_ENDED_HEADER),
SprintAssignEntry::Completed(&s2),
];
assert_eq!(prev_selectable(&entries, Some(4)), Some(2));
assert_eq!(prev_selectable(&entries, Some(2)), Some(0));
}
#[test]
fn test_next_selectable_clamps_at_last_selectable() {
let board = Uuid::new_v4();
let s1 = make_sprint(1, board, SprintStatus::Planning, None);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER),
SprintAssignEntry::ActiveOrPlanned(&s1),
];
assert_eq!(next_selectable(&entries, Some(2)), Some(2));
}
#[test]
fn test_prev_selectable_clamps_at_first_selectable() {
let board = Uuid::new_v4();
let s1 = make_sprint(
1,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(COMPLETED_ENDED_HEADER),
SprintAssignEntry::Completed(&s1),
];
assert_eq!(prev_selectable(&entries, Some(0)), Some(0));
}
#[test]
fn test_sprint_id_of_returns_id_for_sprint_entries() {
let board = Uuid::new_v4();
let s = make_sprint(1, board, SprintStatus::Planning, None);
let active = SprintAssignEntry::ActiveOrPlanned(&s);
let completed = SprintAssignEntry::Completed(&s);
let ended = SprintAssignEntry::Ended(&s);
assert_eq!(sprint_id_of(&active), Some(s.id));
assert_eq!(sprint_id_of(&completed), Some(s.id));
assert_eq!(sprint_id_of(&ended), Some(s.id));
}
#[test]
fn test_sprint_id_of_returns_none_for_header_and_none_entries() {
let header = SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER);
let none = SprintAssignEntry::None;
assert_eq!(sprint_id_of(&header), None);
assert_eq!(sprint_id_of(&none), None);
}
#[test]
fn test_scroll_offset_is_zero_when_list_fits_in_viewport() {
assert_eq!(scroll_offset_to_show(0, 3, 5), 0);
assert_eq!(scroll_offset_to_show(2, 3, 5), 0);
assert_eq!(scroll_offset_to_show(5, 6, 6), 0);
}
#[test]
fn test_scroll_offset_is_zero_when_selected_is_in_first_viewport() {
assert_eq!(scroll_offset_to_show(0, 20, 5), 0);
assert_eq!(scroll_offset_to_show(4, 20, 5), 0);
}
#[test]
fn test_scroll_offset_keeps_selected_visible_when_below_viewport() {
assert_eq!(scroll_offset_to_show(5, 20, 5), 1);
assert_eq!(scroll_offset_to_show(10, 20, 5), 6);
}
#[test]
fn test_scroll_offset_clamps_at_last_full_viewport() {
assert_eq!(scroll_offset_to_show(19, 20, 5), 15);
}
#[test]
fn test_scroll_offset_handles_zero_height() {
assert_eq!(scroll_offset_to_show(5, 20, 0), 0);
}
#[test]
fn test_scroll_offset_handles_empty_list() {
assert_eq!(scroll_offset_to_show(0, 0, 5), 0);
}
#[test]
fn test_section_header_for_returns_none_for_index_zero() {
let entries = vec![SprintAssignEntry::None];
assert_eq!(section_header_for(&entries, 0), None);
}
#[test]
fn test_section_header_for_returns_none_for_out_of_bounds() {
let entries: Vec<SprintAssignEntry> = vec![SprintAssignEntry::None];
assert_eq!(section_header_for(&entries, 5), None);
}
#[test]
fn test_section_header_for_returns_active_header_for_entry_in_active_section() {
let board = Uuid::new_v4();
let s = make_sprint(1, board, SprintStatus::Planning, None);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER),
SprintAssignEntry::ActiveOrPlanned(&s),
];
assert_eq!(
section_header_for(&entries, 2),
Some((1, ACTIVE_PLANNED_HEADER))
);
}
#[test]
fn test_section_header_for_returns_completed_header_for_entry_in_lower_section() {
let board = Uuid::new_v4();
let s_active = make_sprint(1, board, SprintStatus::Planning, None);
let s_completed = make_sprint(
2,
board,
SprintStatus::Completed,
Some(ts("2026-04-01T00:00:00Z")),
);
let entries = vec![
SprintAssignEntry::None,
SprintAssignEntry::Header(ACTIVE_PLANNED_HEADER),
SprintAssignEntry::ActiveOrPlanned(&s_active),
SprintAssignEntry::Header(COMPLETED_ENDED_HEADER),
SprintAssignEntry::Completed(&s_completed),
];
assert_eq!(
section_header_for(&entries, 4),
Some((3, COMPLETED_ENDED_HEADER))
);
}
}