use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame;
use crate::stats::TestResult;
use crate::storage::Storage;
use crate::ui::theme::Theme;
pub fn render(f: &mut Frame, theme: &Theme, area: Rect) {
let results = Storage::load_results().unwrap_or_default();
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Min(0),
Constraint::Length(3),
])
.split(area);
render_title(f, &results, theme, chunks[0]);
render_personal_bests(f, theme, chunks[1]);
render_recent_history(f, &results, theme, chunks[2]);
render_activity_heatmap(f, &results, theme, chunks[3]);
render_help(f, theme, chunks[4]);
}
fn render_title(f: &mut Frame, results: &[TestResult], theme: &Theme, area: Rect) {
let total_tests = results.len();
let total_time: u64 = results.iter().map(|r| r.duration).sum();
let total_chars: u32 = results.iter().map(|r| r.total_chars).sum();
let title = Paragraph::new(vec![
Line::from(Span::styled(
" profile dashboard ",
theme.style_accent().add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(format!("{} tests ", total_tests), theme.style_muted()),
Span::styled("•", theme.style_muted()),
Span::styled(format!(" {}m typed ", total_time / 60), theme.style_muted()),
Span::styled("•", theme.style_muted()),
Span::styled(format!(" {} chars ", total_chars), theme.style_muted()),
]),
])
.alignment(Alignment::Center);
f.render_widget(title, area);
}
fn render_personal_bests(f: &mut Frame, theme: &Theme, area: Rect) {
let bests = Storage::personal_bests().unwrap_or_default();
let mut lines = vec![
Line::from(vec![Span::styled(
" personal bests ",
theme.style_accent(),
)]),
Line::from(""),
];
if bests.is_empty() {
lines.push(Line::from(vec![Span::styled(
"no completed tests yet. start typing!",
theme.style_muted(),
)]));
} else {
let mut sorted_bests = bests;
sorted_bests.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (i, (key, wpm)) in sorted_bests.iter().take(6).enumerate() {
let parts: Vec<&str> = key.split('-').collect();
let mode = parts.first().copied().unwrap_or("?");
let duration = parts.get(1).copied().unwrap_or("?");
lines.push(Line::from(vec![
Span::styled(format!("{}. ", i + 1), theme.style_muted()),
Span::styled(format!("{:.0} wpm", wpm), theme.style(theme.success)),
Span::styled(" • ", theme.style_muted()),
Span::styled(format!("{} mode", mode), theme.style(theme.accent)),
Span::styled(" • ", theme.style_muted()),
Span::styled(format!("{}s", duration), theme.style(theme.accent)),
]));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn render_recent_history(f: &mut Frame, results: &[TestResult], theme: &Theme, area: Rect) {
let recent: Vec<_> = results.iter().rev().take(5).collect();
let mut lines = vec![
Line::from(vec![Span::styled(
" recent history ",
theme.style_accent(),
)]),
Line::from(""),
];
if recent.is_empty() {
lines.push(Line::from(vec![Span::styled(
"no history yet. complete your first test!",
theme.style_muted(),
)]));
} else {
for result in recent {
let timestamp = result
.timestamp
.split('T')
.next()
.unwrap_or("?")
.to_string();
let wpm_style = if result.wpm >= 80.0 {
theme.style(theme.success)
} else if result.wpm >= 50.0 {
theme.style(theme.accent)
} else {
theme.style(theme.warning)
};
lines.push(Line::from(vec![
Span::styled(format!("{} ", timestamp), theme.style_muted()),
Span::styled("•", theme.style(theme.border)),
Span::styled(format!(" {:.0} wpm", result.wpm), wpm_style),
Span::styled(" •", theme.style_muted()),
Span::styled(
format!(" {:.1}% acc", result.accuracy),
theme.style(theme.accent),
),
Span::styled(" •", theme.style_muted()),
Span::styled(format!(" {}s", result.duration), theme.style(theme.accent)),
]));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn render_activity_heatmap(
f: &mut Frame,
_results: &[TestResult],
theme: &Theme,
area: Rect,
) {
let by_day = Storage::results_by_day().unwrap_or_default();
let mut days: Vec<_> = by_day.iter().collect();
days.sort_by(|a, b| a.0.cmp(b.0));
days.reverse();
let mut lines = vec![
Line::from(vec![Span::styled(
" activity (tests per day) ",
theme.style_accent(),
)]),
Line::from(""),
];
if days.is_empty() {
lines.push(Line::from(vec![Span::styled(
"no activity recorded yet.",
theme.style_muted(),
)]));
} else {
let max_count = days.iter().map(|(_, v)| v.len()).max().unwrap_or(1) as f64;
for (day, results) in days.iter().take(10) {
let count = results.len();
let intensity = (count as f64 / max_count).min(1.0);
let bar_len = (intensity * 20.0) as usize;
let bar = "█".repeat(bar_len);
let bar_style = if intensity > 0.8 {
theme.style(theme.success)
} else if intensity > 0.5 {
theme.style(theme.accent)
} else if intensity > 0.2 {
theme.style(theme.warning)
} else {
theme.style(theme.muted)
};
lines.push(Line::from(vec![
Span::styled(format!("{} ", day), theme.style_muted()),
Span::styled(bar, bar_style),
Span::styled(format!(" {}", count), theme.style(theme.foreground)),
]));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn render_help(f: &mut Frame, theme: &Theme, area: Rect) {
let help = Paragraph::new(Line::from(vec![
Span::styled("ctrl+l", theme.style_accent()),
Span::styled(" lang • ", theme.style_muted()),
Span::styled("ctrl+m", theme.style_accent()),
Span::styled(" mode • ", theme.style_muted()),
Span::styled("ctrl+d", theme.style_accent()),
Span::styled(" duration • ", theme.style_muted()),
Span::styled("ctrl+t", theme.style_accent()),
Span::styled(" theme • ", theme.style_muted()),
Span::styled("tab", theme.style_accent()),
Span::styled(" test • ", theme.style_muted()),
Span::styled("esc", theme.style_accent()),
Span::styled(" menu", theme.style_muted()),
]))
.alignment(Alignment::Center);
f.render_widget(help, area);
}