sara-tasks 2.7.9

Sara — folder-aware task manager
use chrono::Local;
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span},
};

use crate::commands::shared::short_id;
use crate::infrastructure::db;
use crate::infrastructure::tui::theme::{Ink, ink};

use super::super::lines::{bar, feedback_mark};
use super::Body;
use crate::commands::info::handler::verification_rows;
use crate::commands::info::types::{Focusable, SectionId};

impl Body<'_> {
    pub(super) fn checklist(&mut self) {
        let d = self.d;
        let st = self.st;
        if !d.checklist.is_empty() {
            let (mut steps_done, mut steps_total, mut acc_done, mut acc_total) = (0, 0, 0, 0);
            for it in &d.checklist {
                if it.kind == db::STEP_KIND_ACCEPTANCE {
                    acc_total += 1;
                    acc_done += it.done as i32;
                } else {
                    steps_total += 1;
                    steps_done += it.done as i32;
                }
            }
            if !self.fold(SectionId::Steps, "Checklist") {
                return;
            }
            for (label, done, total) in [
                ("steps", steps_done, steps_total),
                ("acceptance", acc_done, acc_total),
            ] {
                if total > 0 {
                    self.lines
                        .push(progress_bar(label, done as usize, total as usize));
                }
            }
            let current = d
                .checklist
                .iter()
                .position(|it| it.kind != db::STEP_KIND_ACCEPTANCE && !it.done);
            for (i, item) in d.checklist.iter().enumerate() {
                let is_sel = self.sel == Some(Focusable::Checklist(i));
                let is_current = current == Some(i);
                let is_acc = item.kind == db::STEP_KIND_ACCEPTANCE;
                let row_bg = if is_sel {
                    ink(Ink::Select)
                } else {
                    ink(Ink::Plain)
                };

                let (mark, mark_color) = if item.done {
                    ("✓", ink(Ink::Ok))
                } else if is_current {
                    ("◉", ink(Ink::Accent))
                } else if is_acc {
                    ("◇", ink(Ink::Info))
                } else {
                    ("○", ink(Ink::Soft))
                };
                let text_style = if item.done {
                    Style::default()
                        .fg(ink(Ink::Muted))
                        .bg(row_bg)
                        .add_modifier(Modifier::CROSSED_OUT)
                } else if is_sel {
                    Style::default()
                        .fg(ink(Ink::Text))
                        .bg(row_bg)
                        .add_modifier(Modifier::BOLD)
                } else if is_current {
                    Style::default()
                        .fg(ink(Ink::Text))
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default()
                };
                let target_k = if is_acc { "acceptance" } else { "step" };
                let item_id_str = item.id.to_string();
                let fb: Vec<&crate::domain::Annotation> = d
                    .annotations
                    .iter()
                    .filter(|a| {
                        a.kind == "comment"
                            && a.status == "open"
                            && a.target_kind.as_deref() == Some(target_k)
                            && a.target_id.as_deref() == Some(item_id_str.as_str())
                    })
                    .collect();
                let prefix_style = Style::default().fg(ink(Ink::Text)).bg(row_bg);
                let mut spans = vec![
                    Span::styled(if is_sel { " ▶ " } else { "   " }, prefix_style),
                    Span::styled(
                        format!("{mark} "),
                        Style::default()
                            .fg(if is_sel { ink(Ink::Text) } else { mark_color })
                            .bg(row_bg),
                    ),
                    Span::styled(item.text.clone(), text_style),
                ];
                if is_current {
                    spans.push(Span::styled(
                        "  ← next",
                        Style::default()
                            .fg(ink(Ink::Accent))
                            .add_modifier(Modifier::BOLD),
                    ));
                }
                if is_acc {
                    spans.push(Span::styled(
                        "  acceptance",
                        Style::default().fg(ink(Ink::Info)),
                    ));
                }
                if item.source == "ai" {
                    spans.push(Span::styled(
                        " (ai)",
                        Style::default().fg(ink(Ink::Special)),
                    ));
                }
                spans.extend(feedback_mark(
                    fb.len(),
                    fb.iter().any(|a| a.request_revision),
                    ink(Ink::Plain),
                ));
                if is_sel {
                    self.sel_range = Some((self.lines.len(), self.lines.len()));
                }
                self.lines.push(Line::from(spans));
                let show_detail = is_sel || st.verbose;
                if (show_detail || is_current)
                    && let Some(intent) = &item.intent
                {
                    self.lines.push(Line::from(Span::styled(
                        format!("     {intent}"),
                        Style::default().fg(ink(Ink::Muted)),
                    )));
                }
                if (show_detail || is_acc)
                    && let Some(row) = verify_row(item)
                {
                    self.lines.push(row);
                }
                if show_detail
                    && item.done
                    && (item.done_commit.is_some() || item.done_at.is_some())
                {
                    let commit = item
                        .done_commit
                        .as_deref()
                        .map(|c| format!("@ {}", short_id(c)))
                        .unwrap_or_default();
                    let when = item
                        .done_at
                        .as_deref()
                        .map(|w| format!("  {w}"))
                        .unwrap_or_default();
                    self.lines.push(Line::from(Span::styled(
                        format!("     done {commit}{when}"),
                        Style::default().fg(ink(Ink::Muted)),
                    )));
                }
                for a in &fb {
                    let date = a.entry.with_timezone(&Local).format("%H:%M");
                    let flag = if a.request_revision { " ⟳" } else { "" };
                    self.lines.push(Line::from(vec![
                        Span::styled("     ╰ ".to_string(), Style::default().fg(ink(Ink::Muted))),
                        Span::styled(
                            format!("{date}{flag}  "),
                            Style::default().fg(ink(Ink::Muted)),
                        ),
                        Span::styled(a.text.clone(), Style::default().fg(ink(Ink::Muted))),
                    ]));
                }
                if is_sel && let Some((start, _)) = self.sel_range {
                    self.sel_range = Some((start, self.lines.len() - 1));
                }
            }
        }
    }

    pub(super) fn verification(&mut self) {
        let d = self.d;
        let verif = verification_rows(d);
        if !verif.is_empty() {
            if !self.fold(SectionId::Verification, "Verification") {
                return;
            }
            for (scope, label, cmd) in &verif {
                self.lines.push(Line::from(vec![
                    Span::styled(
                        format!("   {label:<7}"),
                        Style::default()
                            .fg(ink(Ink::Soft))
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(format!("$ {cmd}"), Style::default().fg(ink(Ink::Info))),
                    Span::styled(format!("  ({scope})"), Style::default().fg(ink(Ink::Muted))),
                ]));
            }
        }
    }
}

fn verify_row(item: &crate::domain::ChecklistItem) -> Option<Line<'static>> {
    let muted = Style::default().fg(ink(Ink::Muted));
    let mut spans = vec![Span::styled("     ".to_string(), muted)];
    if let Some(v) = &item.verify_cmd {
        spans.push(Span::styled(
            format!("$ {v}"),
            Style::default().fg(ink(Ink::Info)),
        ));
        spans.push(Span::raw("  "));
    }
    match &item.result {
        Some(r) => spans.push(Span::styled(
            format!("→ {r}"),
            Style::default().fg(ink(Ink::Ok)),
        )),
        None if item.verify_cmd.is_some() => {
            spans.push(Span::styled("→ not run".to_string(), muted))
        }
        None => return None,
    }
    Some(Line::from(spans))
}

pub(super) fn progress_bar(label: &str, done: usize, total: usize) -> Line<'static> {
    let mut spans = vec![Span::styled(
        format!("   {label:<12}"),
        Style::default().fg(ink(Ink::Muted)),
    )];
    spans.extend(bar(done, total, 20));
    spans.push(Span::styled(
        format!("  {done}/{total}"),
        Style::default()
            .fg(if done == total {
                ink(Ink::Ok)
            } else {
                ink(Ink::Soft)
            })
            .add_modifier(Modifier::BOLD),
    ));
    Line::from(spans)
}