Skip to main content

cli_tutor/ui/
progress_overlay.rs

1// progress_summary.OVERLAY.1 — modal showing per-module completion and best times
2use crate::app::App;
3use ratatui::{
4    layout::{Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, Borders, Clear, Paragraph},
8    Frame,
9};
10
11pub fn render(app: &App, frame: &mut Frame, area: Rect) {
12    let overlay = centered_rect(70, 80, area);
13    frame.render_widget(Clear, overlay);
14
15    let nc = app.config.no_color;
16
17    // progress_summary.OVERLAY.2 — per-module rows
18    // gamification.XP.4 / gamification.STREAK.5 — show XP and streak above module table
19    let streak_label = if app.stats.streak_days == 1 {
20        "day".to_string()
21    } else {
22        "days".to_string()
23    };
24    let xp_streak = format!(
25        "XP: {}   Streak: {} {}",
26        app.stats.total_xp, app.stats.streak_days, streak_label
27    );
28
29    let mut lines: Vec<Line<'static>> = vec![
30        Line::from(Span::styled(
31            "Progress Summary",
32            crate::ui::s(
33                Style::default()
34                    .fg(Color::Cyan)
35                    .add_modifier(Modifier::BOLD),
36                nc,
37            ),
38        )),
39        Line::from(Span::styled(
40            xp_streak,
41            crate::ui::s(Style::default().fg(Color::Yellow), nc),
42        )),
43        Line::from(""),
44    ];
45
46    let show_times = app.config.timed_challenge;
47    let header = if show_times {
48        "  Module          Completed    Best Time"
49    } else {
50        "  Module          Completed"
51    };
52    lines.push(Line::from(Span::styled(
53        header,
54        crate::ui::s(
55            Style::default().add_modifier(Modifier::UNDERLINED),
56            nc,
57        ),
58    )));
59    lines.push(Line::from(""));
60
61    let mut total_completed = 0usize;
62    let mut total_exercises = 0usize;
63
64    for m in &app.modules {
65        let ex_count = m.exercises.len();
66        let completed = app
67            .progress
68            .modules
69            .get(&m.module.name)
70            .map(|p| p.completed.len())
71            .unwrap_or(0);
72        total_completed += completed;
73        total_exercises += ex_count;
74
75        let pct = if ex_count > 0 {
76            completed * 100 / ex_count
77        } else {
78            0
79        };
80        let bar = progress_bar(pct);
81
82        // progress_summary.OVERLAY.4 — show best times when timed_challenge enabled
83        let time_str = if show_times {
84            let best = m
85                .exercises
86                .iter()
87                .filter_map(|ex| app.progress.best_time(&m.module.name, &ex.id))
88                .min();
89            match best {
90                Some(ms) => format!("  {:.1}s", ms as f64 / 1000.0),
91                None => "  —".to_string(),
92            }
93        } else {
94            String::new()
95        };
96
97        let done_mark = if completed == ex_count && ex_count > 0 {
98            " ✓"
99        } else {
100            "  "
101        };
102        let color = if completed == ex_count && ex_count > 0 {
103            Color::Green
104        } else if completed > 0 {
105            Color::Yellow
106        } else {
107            Color::DarkGray
108        };
109
110        let row = format!(
111            "  {:<16}{}{}/{}  {}{}{}",
112            m.module.name,
113            done_mark,
114            completed,
115            ex_count,
116            bar,
117            if pct < 100 {
118                format!(" {pct}%")
119            } else {
120                "    ".to_string()
121            },
122            time_str,
123        );
124        lines.push(Line::from(Span::styled(
125            row,
126            crate::ui::s(Style::default().fg(color), nc),
127        )));
128    }
129
130    // progress_summary.OVERLAY.3 — totals row
131    lines.push(Line::from(""));
132    let total_pct = if total_exercises > 0 {
133        total_completed * 100 / total_exercises
134    } else {
135        0
136    };
137    lines.push(Line::from(Span::styled(
138        format!(
139            "  Total             {}/{} ({total_pct}%)",
140            total_completed, total_exercises
141        ),
142        crate::ui::s(Style::default().add_modifier(Modifier::BOLD), nc),
143    )));
144    lines.push(Line::from(""));
145    lines.push(Line::from(Span::styled(
146        "Press P to close",
147        crate::ui::s(Style::default().fg(Color::DarkGray), nc),
148    )));
149
150    let para = Paragraph::new(lines).block(
151        Block::default()
152            .borders(Borders::ALL)
153            .title(" Progress ")
154            .style(crate::ui::s(Style::default().bg(Color::Black), nc)),
155    );
156    frame.render_widget(para, overlay);
157}
158
159fn progress_bar(pct: usize) -> String {
160    let filled = pct / 10; // 0-10 blocks
161    let empty = 10 - filled;
162    format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
163}
164
165fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
166    let vert = Layout::default()
167        .direction(Direction::Vertical)
168        .constraints([
169            Constraint::Percentage((100 - percent_y) / 2),
170            Constraint::Percentage(percent_y),
171            Constraint::Percentage((100 - percent_y) / 2),
172        ])
173        .split(r);
174    Layout::default()
175        .direction(Direction::Horizontal)
176        .constraints([
177            Constraint::Percentage((100 - percent_x) / 2),
178            Constraint::Percentage(percent_x),
179            Constraint::Percentage((100 - percent_x) / 2),
180        ])
181        .split(vert[1])[1]
182}