use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use crate::stats::TestResult;
use crate::storage::Storage;
use crate::ui::theme::Theme;
#[derive(Debug, Clone)]
pub struct ProfileState {
pub selected_index: usize,
pub total_items: usize,
}
impl Default for ProfileState {
fn default() -> Self {
Self::new()
}
}
impl ProfileState {
#[must_use]
pub fn new() -> Self {
Self {
selected_index: 0,
total_items: 0,
}
}
pub fn set_total_items(&mut self, count: usize) {
self.total_items = count;
if self.selected_index >= self.total_items && self.total_items > 0 {
self.selected_index = self.total_items - 1;
}
}
pub fn next(&mut self) {
if self.total_items > 0 {
self.selected_index = (self.selected_index + 1) % self.total_items;
}
}
pub fn prev(&mut self) {
if self.total_items > 0 {
self.selected_index = if self.selected_index == 0 {
self.total_items - 1
} else {
self.selected_index - 1
};
}
}
#[must_use]
pub fn is_selected(&self, index: usize) -> bool {
self.selected_index == index && self.total_items > 0
}
}
pub fn render(f: &mut Frame, state: &ProfileState, 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]);
let pb_count = render_personal_bests(f, state, theme, chunks[1]);
let recent_count = render_recent_history(f, state, &results, theme, chunks[2], pb_count);
render_activity_heatmap(f, &results, theme, chunks[3]);
render_help(f, state, theme, chunks[4], pb_count + recent_count > 0);
}
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!("{total_tests} 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!(" {total_chars} chars "), theme.style_muted()),
]),
])
.alignment(Alignment::Center);
f.render_widget(title, area);
}
fn render_personal_bests(f: &mut Frame, state: &ProfileState, theme: &Theme, area: Rect) -> usize {
let bests = Storage::personal_bests().unwrap_or_default();
let mut lines = vec![
Line::from(vec![Span::styled(" personal bests ", theme.style_accent())]),
Line::from(""),
];
let count = if bests.is_empty() {
lines.push(Line::from(vec![Span::styled(
"no completed tests yet. start typing!",
theme.style_muted(),
)]));
0
} 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("?");
let is_selected = state.is_selected(i);
let arrow = if is_selected { " <" } else { " " };
let arrow_style = if is_selected {
theme.style_accent()
} else {
theme.style_muted()
};
let num_style = if is_selected {
theme.style(theme.accent).add_modifier(Modifier::BOLD)
} else {
theme.style_muted()
};
let wpm_style = if is_selected {
theme.style(theme.success).add_modifier(Modifier::BOLD)
} else {
theme.style(theme.success)
};
lines.push(Line::from(vec![
Span::styled(format!("{}. ", i + 1), num_style),
Span::styled(format!("{wpm:.0} wpm"), wpm_style),
Span::styled(" • ", theme.style_muted()),
Span::styled(format!("{mode} mode"), theme.style(theme.accent)),
Span::styled(" • ", theme.style_muted()),
Span::styled(format!("{duration}s"), theme.style(theme.accent)),
Span::styled(arrow, arrow_style),
]));
}
sorted_bests.len().min(6)
};
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
count
}
fn render_recent_history(
f: &mut Frame,
state: &ProfileState,
results: &[TestResult],
theme: &Theme,
area: Rect,
pb_offset: usize,
) -> usize {
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(""),
];
let count = if recent.is_empty() {
lines.push(Line::from(vec![Span::styled(
"no history yet. complete your first test!",
theme.style_muted(),
)]));
0
} else {
for (i, result) in recent.iter().enumerate() {
let global_index = pb_offset + i;
let timestamp = result
.timestamp
.split('T')
.next()
.unwrap_or("?")
.to_string();
let is_selected = state.is_selected(global_index);
let arrow = if is_selected { " <" } else { " " };
let arrow_style = if is_selected {
theme.style_accent()
} else {
theme.style_muted()
};
let wpm_base_style = if result.wpm >= 80.0 {
theme.success
} else if result.wpm >= 50.0 {
theme.accent
} else {
theme.warning
};
let wpm_style = if is_selected {
theme.style(wpm_base_style).add_modifier(Modifier::BOLD)
} else {
theme.style(wpm_base_style)
};
let ts_style = if is_selected {
theme.style(theme.accent).add_modifier(Modifier::BOLD)
} else {
theme.style_muted()
};
lines.push(Line::from(vec![
Span::styled(format!("{timestamp} "), ts_style),
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)),
Span::styled(arrow, arrow_style),
]));
}
recent.len()
};
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
count
}
#[allow(clippy::too_many_lines)]
fn render_activity_heatmap(f: &mut Frame, _results: &[TestResult], theme: &Theme, area: Rect) {
let by_day = Storage::results_by_day().unwrap_or_default();
let day_labels = ["S", "M", "T", "W", "T", "F", "S"];
let day_offsets = [1, 2, 3, 4, 5, 6, 7];
let mut dates: Vec<String> = by_day.keys().cloned().collect();
dates.sort();
if dates.is_empty() {
let lines = vec![
Line::from(vec![Span::styled(
" activity (tests per day) ",
theme.style_accent(),
)]),
Line::from(""),
Line::from(vec![Span::styled(
"no activity recorded yet.",
theme.style_muted(),
)]),
];
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
return;
}
let available_width = area.width.saturating_sub(4) as usize; let max_weeks = (available_width / 2).clamp(12, 52);
let today = jiff::Zoned::now();
let end_date = today.date();
let today_sat_index: u8 = match end_date.weekday() {
jiff::civil::Weekday::Saturday => 0,
jiff::civil::Weekday::Sunday => 1,
jiff::civil::Weekday::Monday => 2,
jiff::civil::Weekday::Tuesday => 3,
jiff::civil::Weekday::Wednesday => 4,
jiff::civil::Weekday::Thursday => 5,
jiff::civil::Weekday::Friday => 6,
};
let current_week_start = end_date
.checked_sub(jiff::Span::new().days(i64::from(today_sat_index)))
.unwrap_or(end_date);
let start_date = current_week_start
.checked_sub(
jiff::Span::new().weeks(i64::try_from(max_weeks.saturating_sub(1)).unwrap_or(i64::MAX)),
)
.unwrap_or(current_week_start);
let mut day_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (date, results) in &by_day {
day_counts.insert(date.clone(), results.len());
}
let max_count = day_counts.values().copied().max().unwrap_or(1);
let mut grid: Vec<Vec<u8>> = vec![vec![0; max_weeks]; 7];
#[allow(clippy::needless_range_loop)]
for week_idx in 0..max_weeks {
let week_start = start_date
.checked_add(jiff::Span::new().weeks(i64::try_from(week_idx).unwrap_or(i64::MAX)))
.unwrap_or(start_date);
for display_row in 0..7 {
let offset = day_offsets[display_row];
let date = week_start
.checked_add(jiff::Span::new().days(i64::from(offset)))
.unwrap_or(week_start);
if date > end_date {
grid[display_row][week_idx] = 5; continue;
}
let date_str = format!(
"{:04}-{:02}-{:02}",
date.year(),
date.month() as u8,
date.day()
);
let count = day_counts.get(&date_str).copied().unwrap_or(0);
let level = if count == 0 {
0
} else if max_count == 1 {
4 } else {
let ratio = count as f64 / max_count as f64;
match ratio {
r if r <= 0.20 => 1,
r if r <= 0.40 => 2,
r if r <= 0.60 => 3,
_ => 4,
}
};
grid[display_row][week_idx] = level;
}
}
let mut month_labels: Vec<(usize, &'static str)> = Vec::new(); let month_names = [
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
for week_idx in 0..max_weeks {
let week_start = start_date
.checked_add(jiff::Span::new().weeks(i64::try_from(week_idx).unwrap_or(i64::MAX)))
.unwrap_or(start_date);
for day_offset in 0..7 {
let date = week_start
.checked_add(jiff::Span::new().days(i64::from(day_offset)))
.unwrap_or(week_start);
if date.day() == 1 {
let month_name = month_names[date.month() as usize];
if week_idx < max_weeks {
month_labels.push((week_idx, month_name));
}
}
}
}
let prefix_len = 2; let week_width = 2;
let mut month_line_spans = vec![Span::styled("\u{00A0} ", theme.style_muted())];
let mut current_pos = prefix_len;
for (week_idx, month_name) in month_labels {
let target_pos = prefix_len + week_idx * week_width;
while current_pos < target_pos {
month_line_spans.push(Span::styled(" ", theme.style_muted()));
current_pos += 1;
}
month_line_spans.push(Span::styled(month_name, theme.style_muted()));
current_pos += month_name.len(); }
let mut lines = vec![Line::from(vec![Span::styled(
" activity (last year) ",
theme.style_accent(),
)])];
lines.push(Line::from(""));
lines.push(Line::from(month_line_spans));
for day_idx in 0..7 {
let mut spans = vec![Span::styled(
format!("{} ", day_labels[day_idx]),
theme.style_muted(),
)];
for level in grid[day_idx].iter().take(max_weeks) {
let level = *level;
if level == 5 {
continue;
}
let color = theme.heatmap_color(level);
spans.push(Span::styled("■", theme.style(color)));
spans.push(Span::styled(" ", Style::default()));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let legend = vec![
Span::styled("less ", theme.style_muted()),
Span::styled("■", theme.style(theme.heatmap_color(0))),
Span::styled(" ", Style::default()),
Span::styled("■", theme.style(theme.heatmap_color(1))),
Span::styled(" ", Style::default()),
Span::styled("■", theme.style(theme.heatmap_color(2))),
Span::styled(" ", Style::default()),
Span::styled("■", theme.style(theme.heatmap_color(3))),
Span::styled(" ", Style::default()),
Span::styled("■", theme.style(theme.heatmap_color(4))),
Span::styled(" more", theme.style_muted()),
];
lines.push(Line::from(legend));
let para = Paragraph::new(lines).wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn render_help(f: &mut Frame, _state: &ProfileState, theme: &Theme, area: Rect, has_items: bool) {
let mut spans = vec![];
if has_items {
spans.push(Span::styled("↑↓", theme.style_accent()));
spans.push(Span::styled(" navigate • ", theme.style_muted()));
spans.push(Span::styled("enter", theme.style_accent()));
spans.push(Span::styled(" view • ", theme.style_muted()));
}
spans.extend([
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()),
]);
let help = Paragraph::new(Line::from(spans)).alignment(Alignment::Center);
f.render_widget(help, area);
}