chronis 0.5.3

Event-sourced task CLI powered by the AllSource embedded database (all-source.xyz)
Documentation
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Wrap},
};

use super::super::app::App;
use crate::{
    domain::{repository::TaskRepository, task::TaskStatus},
    presentation::shared::TaskTree,
};

pub fn render<R: TaskRepository>(f: &mut Frame, area: Rect, app: &App<R>) {
    let block = Block::default()
        .title(" Dependency Graph ")
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan));

    let mut lines: Vec<Line> = Vec::new();
    let tree = TaskTree::build(&app.tasks);

    for group in &tree.epics {
        lines.push(task_line(group.epic, 0));
        for child in &group.children {
            lines.push(task_line(child, 1));
            for blocker_id in &child.blocked_by {
                lines.push(Line::from(vec![
                    Span::raw("        "),
                    Span::styled(
                        format!("blocked by {blocker_id}"),
                        Style::default().fg(Color::Red),
                    ),
                ]));
            }
        }
        lines.push(Line::from(""));
    }

    if !tree.standalone.is_empty() && !tree.epics.is_empty() {
        lines.push(Line::styled(
            "Standalone Tasks",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ));
    }

    for task in &tree.standalone {
        lines.push(task_line(task, 0));
        for blocker_id in &task.blocked_by {
            lines.push(Line::from(vec![
                Span::raw("    "),
                Span::styled(
                    format!("blocked by {blocker_id}"),
                    Style::default().fg(Color::Red),
                ),
            ]));
        }
    }

    if lines.is_empty() {
        lines.push(Line::styled(
            "No tasks",
            Style::default().fg(Color::DarkGray),
        ));
    }

    let paragraph = Paragraph::new(lines)
        .block(block)
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn task_line(task: &crate::domain::task::Task, depth: usize) -> Line<'_> {
    let indent = "    ".repeat(depth);
    let prefix = if depth > 0 { "├── " } else { "" };

    let status_style = crate::presentation::shared::status_style(task.status);

    let icon = if task.task_type == crate::domain::task::TaskType::Epic {
        ""
    } else if !task.blocked_by.is_empty() && task.status != TaskStatus::Done {
        ""
    } else {
        ""
    };

    let claimed = task
        .claimed_by
        .as_deref()
        .map(|c| format!(" @{c}"))
        .unwrap_or_default();

    Line::from(vec![
        Span::raw(format!("{indent}{prefix}")),
        Span::styled(icon, status_style),
        Span::styled(&task.id, Style::default().fg(Color::Yellow)),
        Span::raw(" "),
        Span::styled(&task.title, status_style),
        Span::styled(
            format!(" [{}, {}]", task.priority, task.status),
            Style::default().fg(Color::DarkGray),
        ),
        Span::styled(claimed, Style::default().fg(Color::Cyan)),
    ])
}