aven 0.1.6

Local-first task manager CLI and sync server
Documentation
use crate::config::TaskColumnConfig;
use crate::query::TaskListItem;

#[derive(Debug)]
pub(crate) struct TaskColumn<'a> {
    pub(crate) config: &'a TaskColumnConfig,
    pub(crate) task_indices: Vec<usize>,
}

#[derive(Debug)]
pub(crate) struct ColumnBoard<'a> {
    pub(crate) columns: Vec<TaskColumn<'a>>,
}

impl<'a> ColumnBoard<'a> {
    pub(crate) fn new(columns: &'a [TaskColumnConfig], tasks: &[TaskListItem]) -> Self {
        let columns = columns
            .iter()
            .map(|config| TaskColumn {
                config,
                task_indices: tasks
                    .iter()
                    .enumerate()
                    .filter(|(_, item)| {
                        config
                            .statuses
                            .iter()
                            .any(|status| status == item.task.status.as_str())
                    })
                    .map(|(index, _)| index)
                    .collect(),
            })
            .collect();
        Self { columns }
    }

    pub(crate) fn position(&self, task_index: usize) -> Option<(usize, usize)> {
        self.columns.iter().enumerate().find_map(|(column, lane)| {
            lane.task_indices
                .iter()
                .position(|index| *index == task_index)
                .map(|row| (column, row))
        })
    }

    pub(crate) fn move_vertical(&self, selected: Option<usize>, delta: isize) -> Option<usize> {
        let Some(selected) = selected else {
            return if delta < 0 { self.last() } else { self.first() };
        };
        let (column, row) = self.position(selected)?;
        let tasks = &self.columns[column].task_indices;
        let next = if delta < 0 {
            row.checked_sub(delta.unsigned_abs() % tasks.len())
                .unwrap_or_else(|| tasks.len() - (delta.unsigned_abs() - row) % tasks.len())
                % tasks.len()
        } else {
            (row + delta.unsigned_abs()) % tasks.len()
        };
        tasks.get(next).copied()
    }

    pub(crate) fn move_vertical_bounded(
        &self,
        selected: Option<usize>,
        delta: isize,
    ) -> Option<usize> {
        let Some(selected) = selected else {
            return if delta < 0 { self.last() } else { self.first() };
        };
        let (column, row) = self.position(selected)?;
        let tasks = &self.columns[column].task_indices;
        let next = if delta < 0 {
            row.saturating_sub(delta.unsigned_abs())
        } else {
            row.saturating_add(delta.unsigned_abs())
                .min(tasks.len() - 1)
        };
        tasks.get(next).copied()
    }

    pub(crate) fn move_horizontal(&self, selected: Option<usize>, delta: isize) -> Option<usize> {
        let selected = selected.or_else(|| self.first())?;
        let (column, row) = self.position(selected)?;
        let mut target = column as isize + delta.signum();
        while target >= 0 && (target as usize) < self.columns.len() {
            let tasks = &self.columns[target as usize].task_indices;
            if !tasks.is_empty() {
                return tasks.get(row.min(tasks.len() - 1)).copied();
            }
            target += delta.signum();
        }
        None
    }

    pub(crate) fn edge(&self, selected: Option<usize>, last: bool) -> Option<usize> {
        let Some(selected) = selected else {
            return if last { self.last() } else { self.first() };
        };
        let (column, _) = self.position(selected)?;
        if last {
            self.columns[column].task_indices.last().copied()
        } else {
            self.columns[column].task_indices.first().copied()
        }
    }

    pub(crate) fn first(&self) -> Option<usize> {
        self.columns
            .iter()
            .find_map(|column| column.task_indices.first().copied())
    }

    pub(crate) fn last(&self) -> Option<usize> {
        self.columns
            .iter()
            .rev()
            .find_map(|column| column.task_indices.last().copied())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::choices::{TaskPriority, TaskStatus};

    fn item(id: usize, status: &str) -> TaskListItem {
        TaskListItem {
            task: crate::types::Task {
                id: id.to_string(),
                workspace_id: "workspace".into(),
                title: format!("task {id}"),
                description: String::new(),
                project_id: "project".into(),
                project_key: "app".into(),
                project_prefix: "APP".into(),
                status: TaskStatus::parse(status).unwrap(),
                priority: TaskPriority::None,
                created_at: String::new(),
                updated_at: String::new(),
                queue_activity_at: String::new(),
                deleted: false,
                is_epic: false,
            },
            display_ref: format!("APP-{id}"),
            labels: Vec::new(),
            notes: Vec::new(),
            has_conflict: false,
            unresolved_blocker_count: 0,
            dependent_count: 0,
            depends_on: Vec::new(),
            blocks: Vec::new(),
            epic_children: Vec::new(),
            epic_parent: None,
            queue: Default::default(),
        }
    }

    fn columns() -> Vec<TaskColumnConfig> {
        vec![
            TaskColumnConfig {
                name: "One".into(),
                statuses: vec!["inbox".into()],
            },
            TaskColumnConfig {
                name: "Empty".into(),
                statuses: vec!["backlog".into()],
            },
            TaskColumnConfig {
                name: "Two".into(),
                statuses: vec!["todo".into(), "active".into()],
            },
        ]
    }

    #[test]
    fn groups_in_configured_and_task_order() {
        let tasks = vec![item(0, "todo"), item(1, "inbox"), item(2, "active")];
        let config = columns();
        let board = ColumnBoard::new(&config, &tasks);

        assert_eq!(board.columns[0].task_indices, [1]);
        assert!(board.columns[1].task_indices.is_empty());
        assert_eq!(board.columns[2].task_indices, [0, 2]);
    }

    #[test]
    fn navigation_skips_empty_columns_and_clamps_rows() {
        let tasks = vec![item(0, "inbox"), item(1, "inbox"), item(2, "todo")];
        let config = columns();
        let board = ColumnBoard::new(&config, &tasks);

        assert_eq!(board.move_horizontal(Some(1), 1), Some(2));
        assert_eq!(board.move_horizontal(Some(2), -1), Some(0));
        assert_eq!(board.move_horizontal(Some(2), 1), None);
        assert_eq!(board.move_vertical(Some(0), -1), Some(1));
        assert_eq!(board.move_vertical(Some(1), 1), Some(0));
        assert_eq!(board.move_vertical_bounded(Some(0), -1), Some(0));
        assert_eq!(board.move_vertical_bounded(Some(1), 1), Some(1));
        assert_eq!(board.move_vertical_bounded(Some(0), 1), Some(1));
    }

    #[test]
    fn edges_and_missing_selection_use_non_empty_lanes() {
        let tasks = vec![item(0, "inbox"), item(1, "inbox"), item(2, "todo")];
        let config = columns();
        let board = ColumnBoard::new(&config, &tasks);

        assert_eq!(board.move_vertical(None, 1), Some(0));
        assert_eq!(board.move_vertical(None, -1), Some(2));
        assert_eq!(board.edge(Some(1), false), Some(0));
        assert_eq!(board.edge(Some(0), true), Some(1));
        assert_eq!(board.edge(None, true), Some(2));
    }

    #[test]
    fn empty_board_has_no_selection() {
        let config = columns();
        let board = ColumnBoard::new(&config, &[]);
        assert_eq!(board.move_vertical(None, 1), None);
        assert_eq!(board.move_horizontal(None, 1), None);
        assert_eq!(board.edge(None, false), None);
    }
}