opencode-stats 1.3.6

A terminal dashboard for OpenCode usage statistics inspired by the /stats command in Claude Code
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};

use crate::analytics::heatmap_data::{HeatmapData, weekday_labels};
use crate::ui::theme::Theme;

pub struct HeatmapWidget<'a> {
    pub data: &'a HeatmapData,
    pub theme: &'a Theme,
}

const ROW_LABEL_WIDTH: usize = 4;

impl Widget for HeatmapWidget<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let mut lines = Vec::new();

        lines.push(Line::styled(
            build_month_header(self.data),
            self.theme.muted_style(),
        ));

        for (row_index, weekday) in weekday_labels().iter().enumerate() {
            let name = weekday_label(row_index, weekday);
            let mut spans = vec![Span::styled(name, self.theme.muted_style())];
            for week in &self.data.weeks {
                let cell = &week[row_index];
                let (symbol, style) = if cell.is_future {
                    (' ', Style::default())
                } else {
                    match cell.intensity {
                        0 => ('·', Style::default().fg(self.theme.heat_0)),
                        1 => ('', Style::default().fg(self.theme.heat_3)),
                        2 => ('', Style::default().fg(self.theme.heat_3)),
                        3 => ('', Style::default().fg(self.theme.heat_3)),
                        _ => ('', Style::default().fg(self.theme.heat_3)),
                    }
                };
                spans.push(Span::styled(symbol.to_string(), style));
            }
            lines.push(Line::from(spans));
        }

        Paragraph::new(lines).render(area, buf);
    }
}

pub fn heatmap_legend_line(theme: &Theme) -> Line<'static> {
    Line::from(vec![
        Span::styled("    Less ", theme.muted_style()),
        Span::styled("·", Style::default().fg(theme.heat_0)),
        Span::raw(" "),
        Span::styled("", Style::default().fg(theme.heat_3)),
        Span::raw(" "),
        Span::styled("", Style::default().fg(theme.heat_3)),
        Span::raw(" "),
        Span::styled("", Style::default().fg(theme.heat_3)),
        Span::raw(" "),
        Span::styled("", Style::default().fg(theme.heat_3)),
        Span::styled(" More", theme.muted_style()),
    ])
}

fn build_month_header(data: &HeatmapData) -> String {
    let mut header = vec![' '; ROW_LABEL_WIDTH + data.weeks.len()];
    let mut next_free_column = ROW_LABEL_WIDTH;

    for (week_index, label) in &data.month_labels {
        let start = ROW_LABEL_WIDTH + *week_index;
        if start < next_free_column || start >= header.len() {
            continue;
        }

        for (offset, ch) in label.chars().enumerate() {
            let column = start + offset;
            if column >= header.len() {
                break;
            }
            header[column] = ch;
        }

        next_free_column = start + label.chars().count() + 1;
    }

    header.into_iter().collect()
}

fn weekday_label(row_index: usize, weekday: &chrono::Weekday) -> String {
    match row_index {
        0 | 2 | 4 | 6 => format!(
            "{:>3} ",
            format!("{:?}", weekday).chars().take(3).collect::<String>()
        ),
        _ => " ".repeat(ROW_LABEL_WIDTH),
    }
}