aven 0.1.9

Local-first task manager CLI and sync server
Documentation
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::Paragraph;

use super::super::dialog::{Dialog, dialog_hint_line};
use super::super::scroll::{clamp_scroll_start, render_vertical_scrollbar};
use super::super::timestamps::optional_local_timestamp_display;
use crate::tui::config_overlay::DATABASE_STATS_TITLE;
use crate::tui::store::TuiDatabaseStats;
use crate::tui::theme::{BG_ALT, FG, FG_MUTED};

pub(in crate::tui::ui) fn render_database_stats(
    frame: &mut Frame,
    stats: &TuiDatabaseStats,
    scroll: u16,
) {
    let width = frame.area().width.saturating_sub(8).clamp(72, 86);
    let lines = database_stats_lines(stats);
    let height = frame.area().height.saturating_sub(1).clamp(12, 30);
    let content_rows = height.saturating_sub(2);
    let visible_rows = content_rows.saturating_sub(2) as usize;
    let start = clamp_scroll_start(scroll, lines.len(), visible_rows);
    let visible = lines
        .iter()
        .skip(start)
        .take(visible_rows)
        .cloned()
        .collect::<Vec<_>>();
    let dialog = if let Some(title) = scroll_title(scroll, lines.len(), visible_rows) {
        Dialog::new(DATABASE_STATS_TITLE, width, height)
            .right_title(Line::from(Span::styled(title, Style::new().fg(FG_MUTED))))
    } else {
        Dialog::new(DATABASE_STATS_TITLE, width, height)
    };
    let content = dialog.render_block(frame);
    let stats_area = Rect {
        height: content.height.saturating_sub(2),
        ..content
    };
    let hint_area = Rect {
        y: content.y + content.height.saturating_sub(1),
        height: 1,
        ..content
    };

    frame.render_widget(
        Paragraph::new(Text::from(visible)).style(Style::new().fg(FG).bg(BG_ALT)),
        stats_area,
    );
    frame.render_widget(
        Paragraph::new(dialog_hint_line(&[
            ("j/k", "scroll"),
            ("Enter/Esc", "close"),
        ]))
        .style(Style::new().fg(FG).bg(BG_ALT)),
        hint_area,
    );
    render_vertical_scrollbar(frame, stats_area, lines.len(), scroll);
}

fn database_stats_lines(stats: &TuiDatabaseStats) -> Vec<Line<'static>> {
    let sections = [
        section(
            "workspace",
            vec![
                value_row("name", stats.workspace_name.clone(), Style::new().fg(FG)),
                value_row(
                    "key",
                    stats.workspace_key.clone(),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "tasks",
            vec![
                value_row(
                    "total",
                    stats.total_tasks.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "open",
                    stats.open_tasks.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "deleted",
                    stats.deleted_tasks.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "statuses",
            vec![
                value_row(
                    "inbox",
                    stats.statuses.inbox.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "backlog",
                    stats.statuses.backlog.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "todo",
                    stats.statuses.todo.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "active",
                    stats.statuses.active.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "done",
                    stats.statuses.done.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "canceled",
                    stats.statuses.canceled.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "priorities",
            vec![
                value_row(
                    "none",
                    stats.priorities.none.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "low",
                    stats.priorities.low.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "medium",
                    stats.priorities.medium.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "high",
                    stats.priorities.high.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "urgent",
                    stats.priorities.urgent.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "related rows",
            vec![
                value_row(
                    "projects",
                    stats.projects.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "labels",
                    stats.labels.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row("notes", stats.notes.to_string(), Style::new().fg(FG_MUTED)),
                value_row(
                    "task labels",
                    stats.task_labels.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "sync history",
            vec![
                count_row("change rows", stats.sync_history.total_change_rows),
                count_row("pending", stats.sync_history.pending_change_rows),
                count_row("synced", stats.sync_history.synced_change_rows),
                value_row(
                    "min server_seq",
                    format_optional_i64(stats.sync_history.min_server_seq),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "max server_seq",
                    format_optional_i64(stats.sync_history.max_server_seq),
                    Style::new().fg(FG_MUTED),
                ),
                count_row("payload bytes", stats.sync_history.payload_bytes),
                count_row("conflicts", stats.conflicts),
            ],
        ),
        section(
            "sqlite",
            vec![
                value_row(
                    "page size",
                    format!("{} bytes", stats.sqlite_page_size),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "pages",
                    stats.sqlite_page_count.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "free pages",
                    stats.sqlite_freelist_count.to_string(),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "main db size",
                    format_bytes(stats.sqlite_page_size * stats.sqlite_page_count),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "free",
                    format_bytes(stats.sqlite_page_size * stats.sqlite_freelist_count),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
        section(
            "latest task timestamps",
            vec![
                value_row(
                    "created",
                    optional_local_timestamp_display(stats.latest_created_at.as_deref(), "none"),
                    Style::new().fg(FG_MUTED),
                ),
                value_row(
                    "updated",
                    optional_local_timestamp_display(stats.latest_updated_at.as_deref(), "none"),
                    Style::new().fg(FG_MUTED),
                ),
            ],
        ),
    ];

    let mut lines = Vec::new();
    for (pair_index, pair) in sections.chunks(2).enumerate() {
        let left = &pair[0];
        let right = pair.get(1);
        let rows = left
            .lines
            .len()
            .max(right.map_or(0, |section| section.lines.len()));
        for index in 0..rows {
            lines.push(two_column_line(
                left.lines.get(index),
                right.and_then(|section| section.lines.get(index)),
            ));
        }
        if pair_index + 1 < sections.len().div_ceil(2) {
            lines.push(Line::from(""));
        }
    }
    lines
}

struct StatsSection {
    lines: Vec<Line<'static>>,
}

fn section(title: &str, rows: Vec<Line<'static>>) -> StatsSection {
    let mut lines = vec![section_line(title)];
    lines.extend(rows);
    StatsSection { lines }
}

fn two_column_line(left: Option<&Line<'static>>, right: Option<&Line<'static>>) -> Line<'static> {
    let mut spans = Vec::new();
    if let Some(line) = left {
        spans.extend(line.spans.clone());
    }
    spans.push(Span::raw(
        " ".repeat(34usize.saturating_sub(line_width(left))),
    ));
    if let Some(line) = right {
        spans.extend(line.spans.clone());
    }
    Line::from(spans)
}

fn line_width(line: Option<&Line<'static>>) -> usize {
    line.map(|line| {
        line.spans
            .iter()
            .map(|span| span.content.chars().count())
            .sum()
    })
    .unwrap_or(0)
}

const LABEL_WIDTH: usize = 14;

fn section_line(label: &str) -> Line<'static> {
    super::shared::section_line(label)
}

fn count_row(label: &str, value: i64) -> Line<'static> {
    super::shared::count_row(LABEL_WIDTH, label, value)
}

fn value_row(label: &str, value: impl Into<String>, value_style: Style) -> Line<'static> {
    super::shared::value_row(LABEL_WIDTH, label, value, value_style)
}

fn format_optional_i64(value: Option<i64>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "none".to_string())
}

fn scroll_title(scroll: u16, content_height: usize, visible_rows: usize) -> Option<String> {
    if content_height <= visible_rows {
        return None;
    }
    let total = content_height
        .saturating_sub(visible_rows)
        .saturating_add(1);
    let current = clamp_scroll_start(scroll, content_height, visible_rows)
        .saturating_add(1)
        .min(total);
    Some(format!(" {current}/{total} "))
}

pub(crate) fn database_stats_scroll_cap(frame_height: u16) -> u16 {
    let content_rows = frame_height
        .saturating_sub(1)
        .clamp(12, 30)
        .saturating_sub(2);
    let visible_rows = content_rows.saturating_sub(2) as usize;
    database_stats_lines(&TuiDatabaseStats::default())
        .len()
        .saturating_sub(visible_rows) as u16
}

fn format_bytes(bytes: i64) -> String {
    const KIB: i64 = 1024;
    const MIB: i64 = KIB * 1024;
    const GIB: i64 = MIB * 1024;
    const TIB: i64 = GIB * 1024;
    if bytes >= TIB {
        format!("{:.1} TiB", bytes as f64 / TIB as f64)
    } else if bytes >= GIB {
        format!("{:.1} GiB", bytes as f64 / GIB as f64)
    } else if bytes >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB as f64)
    } else if bytes >= KIB {
        format!("{:.1} KiB", bytes as f64 / KIB as f64)
    } else {
        format!("{bytes} B")
    }
}