aven 0.1.3

Local-first task manager CLI and sync server
Documentation
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};

use crate::query::TaskListItem;
use crate::queue::unix_seconds;
use crate::tui::theme::{self, FG, FG_DIM, FG_MUTED, GREEN, ORANGE, RED};

pub(crate) fn priority_icon(priority: &str) -> &'static str {
    match priority {
        "urgent" => "",
        "high" => "",
        "medium" => "",
        "low" => "",
        _ => "",
    }
}

pub(crate) fn priority_short(priority: &str) -> &'static str {
    match priority {
        "urgent" => "▲ urgent",
        "high" => "● high",
        "medium" => "◐ med",
        "low" => "◌ low",
        _ => "─ none",
    }
}

pub(crate) fn status_chip(status: &str) -> Line<'static> {
    Line::from(status_span(status))
}

pub(crate) fn status_span(status: &str) -> Span<'static> {
    let label = match status {
        "active" => "● active",
        "todo" => "□ todo",
        "inbox" => "▣ inbox",
        "backlog" => "◌ back",
        "done" => "✓ done",
        "canceled" => "× cancel",
        _ => status,
    };
    Span::styled(
        label.to_string(),
        theme::status_style(status).add_modifier(Modifier::BOLD),
    )
}

pub(crate) fn age_style(created_at: &str, now_seconds: i64) -> Style {
    let days = unix_seconds(created_at)
        .map(|created| {
            now_seconds
                .saturating_sub(created)
                .max(0)
                .saturating_div(86_400)
        })
        .unwrap_or(0);
    let color = if days < 1 {
        GREEN
    } else if days < 4 {
        FG_MUTED
    } else if days < 8 {
        FG
    } else if days < 15 {
        ORANGE
    } else {
        RED
    };
    Style::new().fg(color)
}

pub(crate) fn title_cell(item: &TaskListItem, max_width: usize) -> Line<'static> {
    let marker = if item.has_conflict { "" } else { "" };
    let content_width = max_width.saturating_sub(1);
    let title_style = if item.task.deleted {
        Style::new()
            .fg(FG_MUTED)
            .add_modifier(Modifier::CROSSED_OUT)
    } else {
        Style::new().fg(FG)
    };
    let marker_width = marker.chars().count();
    let title_width = content_width.saturating_sub(marker_width);
    let title = truncate_title(&item.task.title, title_width);
    Line::from(vec![
        Span::styled(marker.to_string(), Style::new().fg(ORANGE)),
        Span::styled(title, title_style),
    ])
}

pub(crate) fn label_cell(labels: &[String], max_width: usize) -> Line<'static> {
    if max_width == 0 {
        return Line::from("");
    }
    let text = label_summary_text(labels);
    let text_width = text.chars().count();
    if text.is_empty() || text_width > max_width {
        return Line::from("");
    }
    let trailing_gap = usize::from(text_width < max_width);
    let padding = max_width.saturating_sub(text_width + trailing_gap);
    let mut spans = vec![
        Span::raw(" ".repeat(padding)),
        Span::styled(text, Style::new().fg(FG_DIM)),
    ];
    if trailing_gap > 0 {
        spans.push(Span::raw(" "));
    }
    Line::from(spans)
}

fn label_summary_text(labels: &[String]) -> String {
    let Some(first) = labels.first() else {
        return String::new();
    };
    let more = labels.len().saturating_sub(1);
    if more == 0 {
        first.clone()
    } else {
        format!("{first} +{more}")
    }
}

fn truncate_title(title: &str, max_width: usize) -> String {
    let title_len = title.chars().count();
    if title_len <= max_width {
        return title.to_string();
    }
    if max_width == 0 {
        return String::new();
    }
    if max_width == 1 {
        return "".to_string();
    }
    let mut truncated = title.chars().take(max_width - 1).collect::<String>();
    truncated.push('');
    truncated
}

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

    fn task_item(title: &str) -> TaskListItem {
        TaskListItem {
            task: crate::types::Task {
                id: "task-1".to_string(),
                workspace_id: "workspace-1".to_string(),
                title: title.to_string(),
                description: String::new(),
                project_id: "project-id".to_string(),
                project_key: "app".to_string(),
                project_prefix: "APP".to_string(),
                status: TaskStatus::Todo,
                priority: TaskPriority::None,
                created_at: "2026-06-20T00:00:00Z".to_string(),
                updated_at: "2026-06-20T00:00:00Z".to_string(),
                queue_activity_at: "2026-06-20T00:00:00Z".to_string(),
                deleted: false,
                is_epic: false,
            },
            display_ref: "APP-1".to_string(),
            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(),
        }
    }

    #[test]
    fn label_cell_right_aligns_summary_when_space_allows() {
        let mut item = task_item("Short title");
        item.labels = vec!["search".to_string(), "ux".to_string()];

        let rendered = label_cell(&item.labels, 11).to_string();

        assert_eq!(rendered, " search +1 ");
        assert_eq!(rendered.chars().count(), 11);
    }

    #[test]
    fn label_cell_uses_dim_text() {
        let mut item = task_item("Short title");
        item.labels = vec!["search".to_string()];

        let line = label_cell(&item.labels, 6);
        let rendered = line.to_string();

        assert_eq!(rendered, "search");
        let label = line
            .spans
            .iter()
            .find(|span| span.content == "search")
            .unwrap();
        assert_eq!(label.style.fg, Some(FG_DIM));
        assert_eq!(label.style.bg, None);
        assert!(!label.style.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn label_cell_hides_labels_when_space_is_tight() {
        let mut item = task_item("Short title");
        item.labels = vec!["search".to_string()];

        let rendered = label_cell(&item.labels, 5).to_string();

        assert_eq!(rendered, "");
    }

    #[test]
    fn title_cell_truncates_title_when_labels_are_hidden() {
        let mut item = task_item("A very long task title");
        item.labels = vec!["search".to_string()];

        let rendered = title_cell(&item, 12).to_string();

        assert_eq!(rendered, "A very lon…");
    }
}