use anyhow::Result;
use chrono::{Datelike, Duration, Local, NaiveDate};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use rusqlite::Connection;
use std::collections::HashMap;
use crate::{db, tui};
fn heat_color(count: u32, max: u32) -> Color {
if count == 0 {
return Color::Rgb(22, 27, 34); }
let ratio = count as f64 / max.max(1) as f64;
if ratio < 0.25 {
Color::Rgb(14, 68, 41)
} else if ratio < 0.5 {
Color::Rgb(0, 109, 50)
} else if ratio < 0.75 {
Color::Rgb(38, 166, 65)
} else {
Color::Rgb(57, 211, 83)
}
}
const CELL: &str = "██";
pub fn run(conn: &Connection, project: Option<&str>) -> Result<()> {
let counts = db::activity_counts(conn, 365, project)?;
let stats = db::activity_stats(conn, project)?;
let (total_created, total_completed, cur_streak, longest_streak) = stats;
let mut terminal = tui::init_terminal()?;
loop {
terminal.draw(|f| {
render(
f,
&counts,
project,
total_created,
total_completed,
cur_streak,
longest_streak,
)
})?;
if event::poll(std::time::Duration::from_millis(200))?
&& let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => break,
_ => {}
}
}
}
tui::restore_terminal()?;
Ok(())
}
fn render(
f: &mut Frame,
counts: &HashMap<NaiveDate, u32>,
project: Option<&str>,
total_created: u32,
total_completed: u32,
cur_streak: u32,
longest_streak: u32,
) {
let area = f.area();
let max = counts.values().copied().max().unwrap_or(1).max(1);
let title = if let Some(p) = project {
format!(" Activity — {p} ")
} else {
" Activity — all projects ".to_string()
};
let outer = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan));
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(2), Constraint::Length(7), Constraint::Length(2), Constraint::Min(1), ])
.split(inner);
let rate = if total_created > 0 {
format!(
"{:.0}%",
total_completed as f64 / total_created as f64 * 100.0
)
} else {
"—".to_string()
};
let stats_line = Line::from(vec![
stat_span("Created", &total_created.to_string()),
Span::raw(" "),
stat_span("Completed", &total_completed.to_string()),
Span::raw(" "),
stat_span("Completion rate", &rate),
Span::raw(" "),
stat_span("Current streak", &format!("{cur_streak}d")),
Span::raw(" "),
stat_span("Longest streak", &format!("{longest_streak}d")),
]);
f.render_widget(
Paragraph::new(stats_line).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
),
chunks[0],
);
let today = Local::now().date_naive();
let days_since_sunday = today.weekday().num_days_from_sunday();
let grid_end = today - Duration::days(days_since_sunday as i64);
let cell_width = CELL.len() as u16 + 1; let label_width: u16 = 4; let available_width = area.width.saturating_sub(label_width + 2);
let num_weeks = ((available_width / cell_width) as i64).clamp(4, 52);
let grid_start = grid_end - Duration::weeks(num_weeks) + Duration::days(1);
let mut month_label_line: Vec<Span> = vec![Span::raw(format!(
"{:<width$}",
"",
width = label_width as usize
))];
{
let mut last_month = 0u32;
let mut week_start = grid_start;
for _col in 0..num_weeks {
let month = week_start.month();
if month != last_month {
let name = month_abbr(month);
month_label_line.push(Span::styled(
format!("{:<width$}", name, width = cell_width as usize),
Style::default().fg(Color::Gray),
));
last_month = month;
} else {
month_label_line.push(Span::raw(format!(
"{:<width$}",
"",
width = cell_width as usize
)));
}
week_start += Duration::weeks(1);
}
}
f.render_widget(Paragraph::new(Line::from(month_label_line)), chunks[1]);
const DAY_LABELS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const SHOW_LABEL: [bool; 7] = [false, true, false, true, false, true, false];
for row in 0..7u32 {
let mut spans: Vec<Span> = vec![];
let label = if SHOW_LABEL[row as usize] {
DAY_LABELS[row as usize]
} else {
" "
};
spans.push(Span::styled(
format!("{label} "),
Style::default().fg(Color::DarkGray),
));
let mut week_start = grid_start;
for _col in 0..num_weeks {
let day = week_start + Duration::days(row as i64);
let in_future = day > today;
let count = if in_future {
0
} else {
counts.get(&day).copied().unwrap_or(0)
};
let color = if in_future {
Color::Rgb(12, 14, 18)
} else {
heat_color(count, max)
};
spans.push(Span::styled(
format!("{CELL} "),
Style::default().bg(color).fg(color),
));
week_start += Duration::weeks(1);
}
let row_area = ratatui::layout::Rect {
x: chunks[2].x,
y: chunks[2].y + row as u16,
width: chunks[2].width,
height: 1,
};
f.render_widget(Paragraph::new(Line::from(spans)), row_area);
}
let legend_levels = [
(0u32, "none"),
(1, "low"),
(3, "med"),
(6, "high"),
(max, "peak"),
];
let mut legend_spans: Vec<Span> = vec![Span::styled(
" Less ",
Style::default().fg(Color::DarkGray),
)];
for (count, _) in &legend_levels {
let color = heat_color(*count, max);
legend_spans.push(Span::styled(CELL, Style::default().bg(color).fg(color)));
legend_spans.push(Span::raw(" "));
}
legend_spans.push(Span::styled("More", Style::default().fg(Color::DarkGray)));
legend_spans.push(Span::styled(
" q/Esc to close",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
f.render_widget(Paragraph::new(Line::from(legend_spans)), chunks[3]);
}
fn stat_span(label: &str, value: &str) -> Span<'static> {
Span::raw(format!("{label}: {value}")).style(Style::default().fg(Color::White))
}
pub fn month_abbr(m: u32) -> &'static str {
match m {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "???",
}
}