Passage drill improvements, stats page cleanup
This commit is contained in:
@@ -42,7 +42,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
|
||||
// Count sessions per day
|
||||
let mut day_counts: HashMap<NaiveDate, usize> = HashMap::new();
|
||||
for result in self.history {
|
||||
for result in self.history.iter().filter(|r| !r.partial) {
|
||||
let date = result.timestamp.date_naive();
|
||||
*day_counts.entry(date).or_insert(0) += 1;
|
||||
}
|
||||
@@ -126,8 +126,10 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
}
|
||||
|
||||
let count = day_counts.get(&date).copied().unwrap_or(0);
|
||||
let (ch, color) = intensity_cell(count, colors);
|
||||
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
|
||||
let color = intensity_cell_bg(count, colors);
|
||||
// Fill both columns so low-activity cells render as blocks instead of glyphs.
|
||||
// This avoids cursor-like artifacts in some terminal fonts.
|
||||
buf.set_string(x, y, " ", Style::default().bg(color).fg(colors.bg()));
|
||||
}
|
||||
|
||||
current_date += chrono::Duration::weeks(1);
|
||||
@@ -147,13 +149,13 @@ fn scale_color(base: Color, factor: f64) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn intensity_cell(count: usize, colors: &crate::ui::theme::ThemeColors) -> (char, Color) {
|
||||
fn intensity_cell_bg(count: usize, colors: &crate::ui::theme::ThemeColors) -> Color {
|
||||
let success = colors.success();
|
||||
match count {
|
||||
0 => ('·', colors.accent_dim()),
|
||||
1..=2 => ('▪', scale_color(success, 0.4)),
|
||||
3..=5 => ('▪', scale_color(success, 0.65)),
|
||||
6..=15 => ('█', scale_color(success, 0.85)),
|
||||
_ => ('█', success),
|
||||
0 => scale_color(colors.accent_dim(), 0.35),
|
||||
1..=2 => scale_color(success, 0.35),
|
||||
3..=5 => scale_color(success, 0.6),
|
||||
6..=15 => scale_color(success, 0.8),
|
||||
_ => success,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,19 +92,31 @@ impl Widget for BranchProgressList<'_> {
|
||||
let total = self.skill_tree.total_unique_keys;
|
||||
let unlocked = self.skill_tree.total_unlocked_count();
|
||||
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
let left_pad = if area.width >= 90 {
|
||||
3
|
||||
} else if area.width >= 70 {
|
||||
2
|
||||
} else if area.width >= 55 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let right_pad = if area.width >= 75 { 2 } else { 0 };
|
||||
let label = format!("{}Overall Key Progress ", " ".repeat(left_pad));
|
||||
let suffix = format!(
|
||||
" {unlocked}/{total} unlocked ({mastered} mastered){}",
|
||||
" ".repeat(right_pad)
|
||||
);
|
||||
let reserved = label.len() + suffix.len();
|
||||
let bar_width = (area.width as usize).saturating_sub(reserved).max(6);
|
||||
let (m_bar, u_bar, e_bar) =
|
||||
compact_dual_bar_parts(mastered, unlocked, total, bar_width);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", "Overall"),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
Span::styled(label, Style::default().fg(colors.fg())),
|
||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
Span::styled(suffix, Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -146,10 +146,7 @@ impl SkillTreeWidget<'_> {
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BranchStatus::Available => (
|
||||
" ",
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
BranchStatus::Available => (" ", Style::default().fg(colors.fg())),
|
||||
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
|
||||
};
|
||||
|
||||
@@ -359,7 +356,11 @@ impl SkillTreeWidget<'_> {
|
||||
}
|
||||
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||
let scroll = self.detail_scroll.min(max_scroll);
|
||||
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
|
||||
let visible_lines: Vec<Line> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.collect();
|
||||
let paragraph = Paragraph::new(visible_lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
use std::collections::BTreeSet;
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
@@ -16,6 +16,9 @@ pub struct StatsDashboard<'a> {
|
||||
pub key_stats: &'a KeyStatsStore,
|
||||
pub active_tab: usize,
|
||||
pub target_wpm: u32,
|
||||
pub overall_unlocked: usize,
|
||||
pub overall_mastered: usize,
|
||||
pub overall_total: usize,
|
||||
pub theme: &'a Theme,
|
||||
pub history_selected: usize,
|
||||
pub history_confirm_delete: bool,
|
||||
@@ -28,6 +31,9 @@ impl<'a> StatsDashboard<'a> {
|
||||
key_stats: &'a KeyStatsStore,
|
||||
active_tab: usize,
|
||||
target_wpm: u32,
|
||||
overall_unlocked: usize,
|
||||
overall_mastered: usize,
|
||||
overall_total: usize,
|
||||
theme: &'a Theme,
|
||||
history_selected: usize,
|
||||
history_confirm_delete: bool,
|
||||
@@ -38,6 +44,9 @@ impl<'a> StatsDashboard<'a> {
|
||||
key_stats,
|
||||
active_tab,
|
||||
target_wpm,
|
||||
overall_unlocked,
|
||||
overall_mastered,
|
||||
overall_total,
|
||||
theme,
|
||||
history_selected,
|
||||
history_confirm_delete,
|
||||
@@ -125,6 +134,7 @@ impl Widget for StatsDashboard<'_> {
|
||||
let idx = self.history.len().saturating_sub(self.history_selected);
|
||||
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
||||
|
||||
Clear.render(dialog_area, buf);
|
||||
let dialog = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
@@ -132,6 +142,7 @@ impl Widget for StatsDashboard<'_> {
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
])
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Confirm ")
|
||||
@@ -158,7 +169,7 @@ impl StatsDashboard<'_> {
|
||||
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(9), Constraint::Length(4)])
|
||||
.constraints([Constraint::Min(9), Constraint::Length(6)])
|
||||
.split(area);
|
||||
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
|
||||
self.render_activity_stats(layout[1], buf);
|
||||
@@ -393,6 +404,10 @@ impl StatsDashboard<'_> {
|
||||
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
if bar_height == 0 {
|
||||
let y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, y, "▁", Style::default().fg(colors.text_pending()));
|
||||
}
|
||||
|
||||
// WPM label on top row
|
||||
if bar_spacing >= 3 {
|
||||
@@ -527,22 +542,19 @@ impl StatsDashboard<'_> {
|
||||
buf,
|
||||
);
|
||||
|
||||
// Level progress
|
||||
let total_score: f64 = self
|
||||
.history
|
||||
.iter()
|
||||
.map(|r| r.wpm * r.accuracy / 100.0)
|
||||
.sum();
|
||||
let level = ((total_score / 100.0).sqrt() as u32).max(1);
|
||||
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
||||
let current_level_score = (level as f64).powi(2) * 100.0;
|
||||
let level_pct = ((total_score - current_level_score)
|
||||
/ (next_level_score - current_level_score))
|
||||
.clamp(0.0, 1.0);
|
||||
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
|
||||
// Overall key progress (unlocked coverage + mastered detail).
|
||||
let key_pct = if self.overall_total > 0 {
|
||||
self.overall_unlocked as f64 / self.overall_total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let level_label = format!(
|
||||
" Keys: {}/{} ({} mastered)",
|
||||
self.overall_unlocked, self.overall_total, self.overall_mastered
|
||||
);
|
||||
render_text_bar(
|
||||
&level_label,
|
||||
level_pct,
|
||||
key_pct,
|
||||
colors.focused_key(),
|
||||
colors.bar_empty(),
|
||||
layout[2],
|
||||
@@ -566,7 +578,7 @@ impl StatsDashboard<'_> {
|
||||
table_block.render(area, buf);
|
||||
|
||||
let header = Line::from(vec![Span::styled(
|
||||
" # WPM Raw Acc% Time Date Mode",
|
||||
" # WPM Raw Acc% Time Date Mode Ranked Partial",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -575,7 +587,7 @@ impl StatsDashboard<'_> {
|
||||
let mut lines = vec![
|
||||
header,
|
||||
Line::from(Span::styled(
|
||||
" ─────────────────────────────────────────────",
|
||||
" ─────────────────────────────────────────────────────────────────────",
|
||||
Style::default().fg(colors.border()),
|
||||
)),
|
||||
];
|
||||
@@ -601,9 +613,15 @@ impl StatsDashboard<'_> {
|
||||
" "
|
||||
};
|
||||
|
||||
let mode_str = if result.ranked { "" } else { " (unranked)" };
|
||||
let rank_str = if result.ranked { "yes" } else { "no" };
|
||||
let partial_pct = if result.partial {
|
||||
result.completion_percent
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
let partial_str = format!("{:>6.0}%", partial_pct);
|
||||
let row = format!(
|
||||
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}",
|
||||
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode:<9} {rank_str:<6} {partial_str:>7}",
|
||||
mode = result.drill_mode,
|
||||
);
|
||||
|
||||
@@ -618,6 +636,8 @@ impl StatsDashboard<'_> {
|
||||
let is_selected = i == self.history_selected;
|
||||
let style = if is_selected {
|
||||
Style::default().fg(acc_color).bg(colors.accent_dim())
|
||||
} else if result.partial {
|
||||
Style::default().fg(colors.warning())
|
||||
} else if !result.ranked {
|
||||
// Muted styling for unranked drills
|
||||
Style::default().fg(colors.text_pending())
|
||||
@@ -846,7 +866,7 @@ impl StatsDashboard<'_> {
|
||||
.filter(|(_, s)| s.sample_count > 0)
|
||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
let max_time = key_times.first().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
@@ -893,7 +913,7 @@ impl StatsDashboard<'_> {
|
||||
.filter(|(_, s)| s.sample_count > 0)
|
||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
@@ -955,7 +975,7 @@ impl StatsDashboard<'_> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
buf.set_string(
|
||||
@@ -1027,7 +1047,7 @@ impl StatsDashboard<'_> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
buf.set_string(
|
||||
@@ -1078,14 +1098,19 @@ impl StatsDashboard<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut day_counts: HashMap<chrono::NaiveDate, usize> = HashMap::new();
|
||||
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
||||
for r in self.history {
|
||||
active_days.insert(r.timestamp.date_naive());
|
||||
for r in self.history.iter().filter(|r| !r.partial) {
|
||||
let day = r.timestamp.date_naive();
|
||||
active_days.insert(day);
|
||||
*day_counts.entry(day).or_insert(0) += 1;
|
||||
}
|
||||
let (current_streak, best_streak) = compute_streaks(&active_days);
|
||||
let active_days_count = active_days.len();
|
||||
let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect();
|
||||
top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
|
||||
|
||||
let lines = vec![Line::from(vec![
|
||||
let mut lines = vec![Line::from(vec![
|
||||
Span::styled(" Current: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{current_streak}d"),
|
||||
@@ -1096,7 +1121,9 @@ impl StatsDashboard<'_> {
|
||||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{best_streak}d"),
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
@@ -1104,9 +1131,24 @@ impl StatsDashboard<'_> {
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
])];
|
||||
|
||||
let top_days_text = if top_days.is_empty() {
|
||||
" Top Days: none".to_string()
|
||||
} else {
|
||||
let parts: Vec<String> = top_days
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c))
|
||||
.collect();
|
||||
format!(" Top Days: {}", parts.join(" | "))
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
top_days_text,
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
|
||||
Reference in New Issue
Block a user