Skip to main content

void/ui/
dashboard.rs

1use super::*;
2
3pub(crate) fn draw_dashboard(f: &mut Frame, app: &App, area: Rect) {
4    let theme = &app.theme;
5    let icons = app.icons;
6    let chunks = Layout::default()
7        .direction(Direction::Vertical)
8        .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
9        .split(area);
10
11    draw_compact_timer_block(f, app, chunks[0]);
12
13    let today = crate::storage::today_focus_minutes(&app.data);
14    let goal = app.data.daily_goal_minutes.max(1);
15    let progress_ratio = (today as f64 / goal as f64).min(1.0);
16
17    let task_chunks = Layout::default()
18        .direction(Direction::Horizontal)
19        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
20        .split(chunks[1]);
21
22    let task_block = dense_panel(
23        theme,
24        Line::from(Span::styled(
25            format!(" {} Tasks ", icons.tasks),
26            Style::default().fg(theme.accent),
27        )),
28    );
29    let pending_tasks = app.dashboard_tasks();
30    if pending_tasks.is_empty() {
31        let empty_msg = if app.queue_empty() && !app.data.tasks.is_empty() {
32            "All tasks done — free focus or [a] add more"
33        } else {
34            "No tasks yet — press [a] to add one"
35        };
36        let empty_list = List::new(vec![ListItem::new(Span::styled(
37            empty_msg,
38            Style::default().fg(if app.queue_empty() && !app.data.tasks.is_empty() {
39                theme.success
40            } else {
41                theme.dim
42            }),
43        ))])
44        .block(task_block);
45        f.render_widget(empty_list, task_chunks[0]);
46    } else {
47        let visible = chunks[1].height.saturating_sub(4) as usize;
48        let pending: Vec<ListItem> = pending_tasks
49            .into_iter()
50            .take(visible.max(4))
51            .enumerate()
52            .map(|(idx, t)| {
53                let selected = idx == app.dashboard_task_selected;
54                let marker = match t.priority {
55                    crate::model::Priority::High => icons.alert,
56                    crate::model::Priority::Medium => icons.dot,
57                    crate::model::Priority::Low => " ",
58                };
59                let active = if app.active_task == Some(t.id) {
60                    format!("{} ", icons.task_active)
61                } else if selected {
62                    format!("{} ", icons.chevron)
63                } else {
64                    "  ".into()
65                };
66                let status_color = task_status_color(theme, t.status);
67                let row_style = if selected {
68                    Style::default()
69                        .bg(theme.select_bg)
70                        .fg(theme.select_fg)
71                        .add_modifier(Modifier::BOLD)
72                } else {
73                    Style::default().fg(theme.text)
74                };
75                ListItem::new(Line::from(vec![
76                    Span::styled(active, Style::default().fg(theme.accent)),
77                    Span::styled(
78                        format!("{} ", task_status_icon(icons, t.status)),
79                        Style::default().fg(status_color),
80                    ),
81                    Span::styled(format!("[{}] ", marker), Style::default().fg(theme.warning)),
82                    Span::styled(t.title.clone(), row_style),
83                    Span::styled(
84                        format!("  {}m", t.estimated_minutes),
85                        Style::default().fg(theme.dim),
86                    ),
87                ]))
88                .style(row_style)
89            })
90            .collect();
91        let list = List::new(pending).block(task_block);
92        f.render_widget(list, task_chunks[0]);
93    }
94
95    let goal_met = app.daily_goal_met();
96    let remaining = goal.saturating_sub(today);
97    let goal_reached = progress_ratio >= 1.0;
98    let gauge_color = if goal_reached {
99        theme.accent
100    } else {
101        theme.success
102    };
103
104    let goal_inner = Layout::default()
105        .direction(Direction::Vertical)
106        .constraints([Constraint::Length(2), Constraint::Min(2)])
107        .split(task_chunks[1]);
108
109    f.render_widget(
110        Gauge::default()
111            .gauge_style(Style::default().fg(gauge_color).bg(theme.task_track))
112            .ratio(progress_ratio)
113            .label(format!(
114                "{} {}/{} ({}%)",
115                icons.target,
116                format_minutes(today),
117                format_minutes(goal),
118                (progress_ratio * 100.0) as u32
119            ))
120            .block(dense_panel(
121                theme,
122                Line::from(Span::styled(
123                    format!(" {} Daily goal ", icons.target),
124                    Style::default().fg(theme.accent),
125                )),
126            )),
127        goal_inner[0],
128    );
129
130    let goal_lines = vec![
131        Line::from(Span::styled(
132            if goal_met {
133                format!("{} Goal complete!", icons.check)
134            } else {
135                format!(
136                    "{} {} remaining today",
137                    icons.dot,
138                    format_minutes(remaining)
139                )
140            },
141            Style::default().fg(if goal_met { theme.success } else { theme.text }),
142        )),
143        Line::from(vec![
144            Span::styled(format!("{} ", icons.fire), Style::default().fg(theme.info)),
145            Span::styled(
146                format!(
147                    "{}d · {}d goal · {} open",
148                    app.data.streak_days,
149                    app.data.goal_streak_days,
150                    crate::storage::pending_tasks(&app.data).count()
151                ),
152                Style::default().fg(theme.dim),
153            ),
154        ]),
155        Line::from(vec![
156            Span::styled(format!("{} ", icons.chart), Style::default().fg(theme.dim)),
157            Span::styled(
158                format!(
159                    "{} all-time focus",
160                    format_minutes(app.data.total_focus_minutes)
161                ),
162                Style::default().fg(theme.dim),
163            ),
164        ]),
165    ];
166    f.render_widget(
167        Paragraph::new(goal_lines).block(dense_panel(
168            theme,
169            Line::from(Span::styled(
170                format!(" {} Today ", icons.stats),
171                Style::default().fg(theme.accent),
172            )),
173        )),
174        goal_inner[1],
175    );
176}
177
178pub(crate) fn draw_compact_timer_block(f: &mut Frame, app: &App, area: Rect) {
179    let theme = &app.theme;
180    let t = &app.timer;
181    let mc = mode_color(theme, t.mode);
182
183    let is_finished = t.state == crate::model::TimerState::Finished;
184    let border_color = if is_finished {
185        theme.success
186    } else {
187        theme.panel_border
188    };
189    let title_suffix = if is_finished {
190        format!(" {} DONE ", app.icons.check)
191    } else {
192        String::new()
193    };
194    let outer = timer_panel(
195        theme,
196        Line::from(Span::styled(
197            format!(" {} {}", t.mode.label(), title_suffix),
198            Style::default()
199                .fg(if is_finished { theme.success } else { mc })
200                .add_modifier(Modifier::BOLD),
201        )),
202        border_color,
203    );
204    f.render_widget(outer, area);
205
206    let inner = Rect {
207        x: area.x + 1,
208        y: area.y + 1,
209        width: area.width.saturating_sub(2),
210        height: area.height.saturating_sub(2),
211    };
212
213    let on_break = is_break_mode(t.mode);
214    let layout = Layout::default()
215        .direction(Direction::Vertical)
216        .constraints(if on_break {
217            [
218                Constraint::Min(5),
219                Constraint::Length(2),
220                Constraint::Length(1),
221                Constraint::Length(1),
222                Constraint::Length(2),
223            ]
224        } else {
225            [
226                Constraint::Min(5),
227                Constraint::Length(2),
228                Constraint::Length(1),
229                Constraint::Length(1),
230                Constraint::Length(1),
231            ]
232        })
233        .split(inner);
234
235    let cycle = t.config.long_break_every.max(1);
236    let style = theme.scene_style(mc);
237    let options = DashboardSceneOptions {
238        task_progress: app.active_task_progress(),
239        pending_tasks: app.pending_task_count(),
240        active_task_index: app.active_task_pending_index(),
241        sessions_done: t.completed_focus_sessions % cycle,
242        sessions_total: cycle,
243        layout: crate::canvas_timer::SceneLayout::Dashboard,
244    };
245    draw_dashboard_canvas(f, layout[0], t, &style, &options);
246
247    let (main_time, tenths, _) = format_time_stack(t);
248    let today_logged = crate::storage::today_focus_minutes(&app.data);
249    f.render_widget(
250        Paragraph::new(vec![
251            Line::from(vec![
252                Span::styled(
253                    main_time,
254                    Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
255                ),
256                Span::styled(tenths, Style::default().fg(theme.dim)),
257            ]),
258            Line::from(Span::styled(
259                format!(
260                    "{} logged today",
261                    super::widgets::format_minutes(today_logged)
262                ),
263                Style::default().fg(theme.dim),
264            )),
265        ])
266        .alignment(Alignment::Center),
267        layout[1],
268    );
269
270    draw_timer_footer(f, app, &layout[2..], mc);
271}
272
273pub(crate) fn draw_timer_footer(f: &mut Frame, app: &App, areas: &[Rect], mc: Color) {
274    let theme = &app.theme;
275    let t = &app.timer;
276
277    let cycle = t.config.long_break_every.max(1);
278    let done_in_cycle = t.completed_focus_sessions % cycle;
279    let in_focus = t.mode == TimerMode::Focus
280        && matches!(
281            t.state,
282            crate::model::TimerState::Running | crate::model::TimerState::Paused
283        );
284    let dots = session_dots(done_in_cycle, cycle, in_focus);
285
286    f.render_widget(
287        Paragraph::new(Line::from(vec![
288            Span::styled(dots, Style::default().fg(mc)),
289            Span::styled(
290                format!("  {}  ", t.cycle_label()),
291                Style::default().fg(theme.dim),
292            ),
293            Span::styled(
294                format!("{}% left", ((1.0 - t.progress()) * 100.0) as u32),
295                Style::default().fg(theme.dim),
296            ),
297        ]))
298        .alignment(Alignment::Center),
299        areas[0],
300    );
301
302    if let Some(mut spans) = active_task_spans(app, theme) {
303        if let Some(id) = app.active_task {
304            if let Some(task) = app.data.tasks.iter().find(|t| t.id == id) {
305                let left = crate::storage::sessions_remaining_hint(task, app.data.focus_minutes);
306                if left > 0 {
307                    spans.push(Span::styled(
308                        format!("  ~{} left", left),
309                        Style::default().fg(theme.dim),
310                    ));
311                }
312                f.render_widget(
313                    Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
314                    areas[1],
315                );
316            }
317        }
318    } else if areas.len() > 1 {
319        let msg = if app.queue_empty() {
320            "All tasks done — free focus (general sessions)"
321        } else {
322            "No active task — Tasks tab, Space to set one"
323        };
324        f.render_widget(
325            Paragraph::new(Span::styled(msg, Style::default().fg(theme.dim)))
326                .alignment(Alignment::Center),
327            areas[1],
328        );
329    }
330
331    if areas.len() > 2 {
332        if is_break_mode(t.mode) {
333            draw_break_tip(f, areas[2], t, mc, theme.text, theme.dim, app.icons.heart);
334        } else {
335            let state_label = match t.state {
336                crate::model::TimerState::Idle => "ready",
337                crate::model::TimerState::Running => "focusing",
338                crate::model::TimerState::Paused => "paused",
339                crate::model::TimerState::Finished => "complete",
340            };
341            f.render_widget(
342                Paragraph::new(Span::styled(state_label, Style::default().fg(mc)))
343                    .alignment(Alignment::Center),
344                areas[2],
345            );
346        }
347    }
348}
349
350pub(crate) fn mode_color(theme: &crate::app::Theme, mode: TimerMode) -> Color {
351    match mode {
352        TimerMode::Focus => theme.accent,
353        TimerMode::ShortBreak => theme.success,
354        TimerMode::LongBreak => theme.warning,
355        TimerMode::Custom => theme.info,
356    }
357}