use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, List, ListItem, Paragraph, Row, Table};
use ratatui::Frame;
use crate::app::{App, Theme};
use crate::ui::IconSet;
use super::heatmap;
use super::widgets::{format_minutes, section_panel, section_panel_bottom};
pub fn draw_stats(f: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(1),
Constraint::Min(6),
])
.split(area);
draw_heatmap_section(f, app, rows[0]);
draw_divider(f, rows[1], theme);
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Length(1),
Constraint::Percentage(33),
Constraint::Length(1),
Constraint::Percentage(34),
])
.split(rows[2]);
draw_summary(f, app, bottom_cols[0]);
draw_vdivider(f, bottom_cols[1], theme);
draw_week_bars(f, app, bottom_cols[2]);
draw_vdivider(f, bottom_cols[3], theme);
draw_recent_sessions(f, app, bottom_cols[4]);
}
fn draw_divider(f: &mut Frame, area: Rect, theme: &Theme) {
let border = Style::default().fg(theme.panel_border);
f.render_widget(
Paragraph::new(Span::styled("─".repeat(area.width as usize), border)),
area,
);
}
fn draw_vdivider(f: &mut Frame, area: Rect, theme: &Theme) {
if area.height == 0 {
return;
}
let border = Style::default().fg(theme.panel_border);
let pipe = Span::styled("│", border);
let lines: Vec<Line> = (0..area.height).map(|_| Line::from(pipe.clone())).collect();
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
}
fn draw_heatmap_section(f: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let icons = app.icons;
let block = section_panel(
theme,
Line::from(Span::styled(
format!(" {} Focus activity ", icons.calendar),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
);
let inner = block.inner(area);
f.render_widget(block, area);
let today_mins = crate::storage::today_focus_minutes(&app.data);
heatmap::draw_focus_heatmap(
f,
inner,
theme,
icons,
&app.heatmap_data,
app.data.daily_goal_minutes,
today_mins,
);
render_bottom_cap(f, theme, area);
}
fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let icons = app.icons;
let (focus_n, custom_n, break_n) = app.session_counts;
let today = crate::storage::today_focus_minutes(&app.data);
let dim_style = Style::default().fg(theme.dim);
let val_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD);
let summary_rows: [(&str, &str, String); 5] = [
(
icons.target,
"Today",
format!(
"{} / {}",
format_minutes(today),
format_minutes(app.data.daily_goal_minutes)
),
),
(
icons.fire,
"Streak",
format!(
"{}d / {}d goal",
app.data.streak_days, app.data.goal_streak_days
),
),
(
icons.timer,
"Sessions",
format!("{focus_n}p · {custom_n}c · {break_n}b"),
),
(
icons.chart,
"Total",
format_minutes(app.data.total_focus_minutes),
),
(
icons.star,
"Peak Time",
crate::storage::most_productive_hour_label(&app.data),
),
];
let block = section_panel(
theme,
Line::from(Span::styled(
format!(" {} Summary ", icons.stats),
Style::default().fg(theme.accent),
)),
);
let inner = block.inner(area);
f.render_widget(block, area);
let table_rows: Vec<Row> = summary_rows
.iter()
.map(|(icon, label, value)| {
Row::new([
Cell::from(Span::styled(format!("{icon} {label}"), dim_style)),
Cell::from(Span::styled(value.as_str(), val_style)),
])
})
.collect();
let table = Table::new(table_rows, [Constraint::Length(14), Constraint::Min(6)]);
f.render_widget(table, inner);
render_bottom_cap(f, theme, area);
}
fn draw_week_bars(f: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let icons = app.icons;
let data = &app.weekly_chart;
let block = section_panel(
theme,
Line::from(Span::styled(
format!(" {} This week ", icons.chart),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
);
let inner = block.inner(area);
f.render_widget(block, area);
if data.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
"No sessions this week",
Style::default().fg(theme.dim),
))
.alignment(Alignment::Center),
inner,
);
} else {
render_week_chart(f, theme, icons, data, inner);
}
render_bottom_cap(f, theme, area);
}
fn render_week_chart(
f: &mut Frame,
theme: &Theme,
icons: IconSet,
data: &[(String, u32)],
inner: Rect,
) {
let max_mins = data.iter().map(|(_, m)| *m).max().unwrap_or(1).max(1);
let last_idx = data.len() - 1;
let total_mins: u32 = data.iter().map(|(_, m)| *m).sum();
let avg_mins = total_mins / data.len() as u32;
const LABEL_W: usize = 4;
const MINS_W: usize = 6;
let bar_max = (inner.width as usize)
.saturating_sub(LABEL_W + MINS_W + 2)
.max(4);
let visible_days = (inner.height as usize)
.saturating_sub(3)
.min(7)
.min(data.len());
let start_idx = data.len() - visible_days;
let today_style = Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(theme.dim);
let text_style = Style::default().fg(theme.text);
let track_style = Style::default().fg(theme.progress_dim);
let hidden_marker = Style::default().fg(theme.bg);
let mut lines = Vec::with_capacity(visible_days + 2);
for (idx, (day_label, mins)) in data.iter().enumerate().skip(start_idx) {
let mins = *mins;
let is_today = idx == last_idx;
let fill = ((mins as u64 * bar_max as u64) / max_mins as u64) as usize;
let empty = bar_max - fill;
let (day_style, mins_style, bar_fg, marker, marker_style) = if is_today {
(today_style, today_style, theme.success, "▸", today_style)
} else {
(dim_style, text_style, theme.accent, " ", hidden_marker)
};
lines.push(Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(format!("{:<3} ", day_label), day_style),
Span::styled("█".repeat(fill), Style::default().fg(bar_fg)),
Span::styled("░".repeat(empty), track_style),
Span::styled(format!(" {:>4}", format_minutes(mins)), mins_style),
]));
}
if inner.height as usize > visible_days + 1 {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", icons.chart),
Style::default().fg(theme.accent),
),
Span::styled(
format!("{} total", format_minutes(total_mins)),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", icons.dot),
Style::default().fg(theme.panel_border),
),
Span::styled(format!("~{} avg/day", format_minutes(avg_mins)), dim_style),
]));
}
f.render_widget(Paragraph::new(lines).alignment(Alignment::Left), inner);
}
fn draw_recent_sessions(f: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let icons = app.icons;
let block = section_panel(
theme,
Line::from(Span::styled(
format!(" {} Recent sessions ", icons.calendar),
Style::default().fg(theme.accent),
)),
);
let inner = block.inner(area);
f.render_widget(block, area);
let items: Vec<ListItem> = if app.recent_sessions.is_empty() {
vec![ListItem::new(Span::styled(
"No sessions yet",
Style::default().fg(theme.dim),
))]
} else {
let dim_style = Style::default().fg(theme.dim);
let normal_style = Style::default().fg(theme.text);
let selected_style = Style::default()
.fg(theme.select_fg)
.bg(theme.select_bg)
.add_modifier(Modifier::BOLD);
let mins_style = Style::default().fg(theme.success);
app.recent_sessions
.iter()
.take(inner.height as usize)
.enumerate()
.map(|(idx, s)| {
let style = if idx == app.stats_session_selected {
selected_style
} else {
normal_style
};
ListItem::new(Line::from(vec![
Span::styled(
s.record.completed_at.format("%H:%M ").to_string(),
dim_style,
),
Span::styled(format!("{}m ", s.record.minutes), mins_style),
Span::styled(session_task_label(app, s.record.task_id), style),
]))
.style(style)
})
.collect()
};
f.render_widget(List::new(items), inner);
render_bottom_cap(f, theme, area);
}
#[inline]
fn render_bottom_cap(f: &mut Frame, theme: &Theme, area: Rect) {
let bottom = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width,
height: 1,
};
f.render_widget(section_panel_bottom(theme), bottom);
}
fn session_task_label(app: &App, task_id: Option<u64>) -> String {
match task_id {
None => "general".into(),
Some(id) => app
.data
.tasks
.iter()
.find(|t| t.id == id)
.map(|t| super::widgets::truncate(&t.title, 16))
.unwrap_or_else(|| "?".into()),
}
}