sara-tasks 2.7.5

Sara — folder-aware task manager
use chrono::{Datelike, Duration, NaiveDate};
use ratatui::{
    Frame,
    layout::{Constraint, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
};

use super::types::{ActivityState, Drill};
use crate::commands::shared::{month_abbr, truncate};
use crate::infrastructure::tui::screen;
use crate::infrastructure::tui::theme::{GLYPH_DONE, GLYPH_OPEN, Ink, Theme, heat, ink};

const DOT: &str = "●";
const EMPTY: &str = "·";
const LABEL_W: usize = 4;
const CELL_W: usize = 2;

pub(super) const MIN_SIZE: (u16, u16) = (50, 18);

pub(super) const FOOTER: [(&str, &str); 5] = [
    ("h/l", "week"),
    ("j/k", "day"),
    ("↵", "tasks"),
    ("Esc", "close"),
    ("q", "quit"),
];

pub(super) fn render(f: &mut Frame, st: &ActivityState) {
    if screen::too_small(f, MIN_SIZE.0, MIN_SIZE.1) {
        return;
    }
    let theme = Theme::detect();
    let c = screen::chrome(f.area());
    let scope = st.data.project.as_deref().unwrap_or("all projects");
    let status = vec![
        Span::styled(format!("{scope} · "), theme.muted()),
        Span::styled(format!("{}d streak ", st.data.cur_streak), theme.ok()),
    ];
    screen::render_header(f, c.header, &theme, "activity", &status);

    let [stats, _, grid, _, day] = Layout::vertical([
        Constraint::Length(2),
        Constraint::Length(1),
        Constraint::Length(10),
        Constraint::Length(1),
        Constraint::Min(0),
    ])
    .areas(c.body);
    render_stats(f, st, &theme, stats);
    render_grid(f, st, &theme, grid);
    render_day(f, st, &theme, day);
    screen::render_footer(f, c.footer, &theme, &FOOTER);
}

fn section(theme: &Theme, title: String, note: String) -> Line<'static> {
    Line::from(vec![
        Span::styled("▌ ", theme.accent()),
        Span::styled(title, theme.accent()),
        Span::styled(format!("  {note}"), theme.muted()),
    ])
}

fn render_stats(f: &mut Frame, st: &ActivityState, theme: &Theme, area: Rect) {
    let d = &st.data;
    let rate = (d.total_completed * 100)
        .checked_div(d.total_created)
        .map(|r| format!("{r}%"))
        .unwrap_or_else(|| "—".into());
    let value = Style::default()
        .fg(ink(Ink::Text))
        .add_modifier(Modifier::BOLD);
    let mut spans = vec![Span::raw("  ")];
    let stats = [
        (d.total_created.to_string(), "created"),
        (d.total_completed.to_string(), "completed"),
        (rate, "done rate"),
        (format!("{}d", d.cur_streak), "streak"),
        (format!("{}d", d.longest_streak), "best"),
    ];
    for (i, (v, label)) in stats.into_iter().enumerate() {
        if i > 0 {
            spans.push(Span::styled("  ·  ", theme.muted()));
        }
        spans.push(Span::styled(v, value));
        spans.push(Span::styled(format!("\u{a0}{label}"), theme.muted()));
    }
    f.render_widget(
        Paragraph::new(vec![
            section(theme, "Overview".into(), String::new()),
            Line::from(spans),
        ]),
        area,
    );
}

pub(super) fn week_start(day: NaiveDate) -> NaiveDate {
    day - Duration::days(day.weekday().num_days_from_sunday() as i64)
}

pub(super) fn grid_start(st: &ActivityState, weeks: i64) -> NaiveDate {
    let latest = week_start(st.today) - Duration::weeks(weeks - 1);
    latest.min(week_start(st.cursor))
}

fn render_grid(f: &mut Frame, st: &ActivityState, theme: &Theme, area: Rect) {
    let weeks = ((area.width as usize).saturating_sub(LABEL_W) / CELL_W).clamp(4, 53) as i64;
    let start = grid_start(st, weeks);
    let max = st.data.counts.values().copied().max().unwrap_or(1).max(1);

    let mut lines = vec![section(theme, format!("Last {weeks} weeks"), String::new())];

    let mut months = " ".repeat(LABEL_W);
    let mut last = 0;
    for w in 0..weeks {
        let first = start + Duration::weeks(w);
        let col = LABEL_W + w as usize * CELL_W;
        if first.month() != last && months.chars().count() <= col {
            months.push_str(&" ".repeat(col - months.chars().count()));
            months.push_str(month_abbr(first.month()));
            last = first.month();
        }
    }
    lines.push(Line::from(Span::styled(
        months,
        Style::default().fg(ink(Ink::Soft)),
    )));

    const DAYS: [&str; 7] = ["", "Mon", "", "Wed", "", "Fri", ""];
    for (row, label) in DAYS.iter().enumerate() {
        let mut spans = vec![Span::styled(format!("{label:<LABEL_W$}"), theme.muted())];
        for w in 0..weeks {
            let day = start + Duration::weeks(w) + Duration::days(row as i64);
            spans.push(cell(st, theme, day, max));
            spans.push(Span::raw(" "));
        }
        lines.push(Line::from(spans));
    }

    let mut legend = vec![Span::styled(format!("{:LABEL_W$}less ", ""), theme.muted())];
    for n in [0, 1, 2, 3, 4] {
        let glyph = if n == 0 { EMPTY } else { DOT };
        legend.push(Span::styled(
            format!("{glyph} "),
            Style::default().fg(heat(n, 4)),
        ));
    }
    legend.push(Span::styled("more", theme.muted()));
    lines.push(Line::from(legend));

    f.render_widget(Paragraph::new(lines), area);
}

fn cell(st: &ActivityState, theme: &Theme, day: NaiveDate, max: u32) -> Span<'static> {
    if day > st.today {
        return Span::raw(" ");
    }
    let n = st.count(day);
    let glyph = if n == 0 { EMPTY } else { DOT };
    let mut style = Style::default().fg(heat(n, max));
    if day == st.cursor {
        style = if theme.color {
            style.bg(ink(Ink::Select)).add_modifier(Modifier::BOLD)
        } else {
            style.add_modifier(Modifier::REVERSED)
        };
    }
    Span::styled(glyph, style)
}

fn day_title(day: NaiveDate) -> String {
    format!(
        "{} {} {} {}",
        day.weekday(),
        day.day(),
        month_abbr(day.month()),
        day.year()
    )
}

fn render_day(f: &mut Frame, st: &ActivityState, theme: &Theme, area: Rect) {
    let n = st.count(st.cursor);
    let mut lines = Vec::new();
    match &st.drill {
        None => {
            lines.push(section(
                theme,
                day_title(st.cursor),
                format!("{n} events · ↵ to see the tasks touched"),
            ));
        }
        Some(Drill { day, tasks }) => {
            lines.push(section(
                theme,
                day_title(*day),
                format!("{} tasks touched", tasks.len()),
            ));
            if tasks.is_empty() {
                lines.push(Line::from(Span::styled(
                    "  Nothing touched on this day.",
                    theme.muted(),
                )));
            }
            let room = (area.height as usize).saturating_sub(1);
            let width = area.width as usize;
            for t in tasks.iter().take(room) {
                let done = t.status == "completed";
                let (glyph, tone) = if done {
                    (GLYPH_DONE, theme.ok())
                } else {
                    (GLYPH_OPEN, theme.accent())
                };
                let id = t.id.map(|i| format!("#{i}")).unwrap_or_default();
                let project = if st.data.project.is_none() {
                    format!("  {}", t.project)
                } else {
                    String::new()
                };
                let title_w = width.saturating_sub(12 + project.chars().count());
                lines.push(Line::from(vec![
                    Span::styled(format!("  {glyph}\u{a0}"), tone),
                    Span::styled(format!("{id:>5}  "), theme.muted()),
                    Span::styled(
                        truncate(&t.title, title_w),
                        Style::default().fg(ink(Ink::Text)),
                    ),
                    Span::styled(project, theme.muted()),
                ]));
            }
            if tasks.len() > room {
                lines.pop();
                lines.push(Line::from(Span::styled(
                    format!("  … {} more", tasks.len() - room + 1),
                    theme.muted(),
                )));
            }
        }
    }
    f.render_widget(Paragraph::new(lines), area);
}

#[cfg(test)]
#[path = "../../../tests/unit/commands/activity/render.rs"]
mod tests;