tempo_cli/ui/
dashboard.rs

1use anyhow::Result;
2use chrono::Local;
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use log::debug;
5use ratatui::{
6    backend::Backend,
7    layout::{Alignment, Constraint, Direction, Layout, Rect},
8    style::{Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, List, ListItem, Paragraph},
11    Frame, Terminal,
12};
13use std::time::Duration;
14
15use crate::{
16    db::queries::ProjectQueries,
17    db::{get_database_path, Database},
18    models::{Project, Session},
19    ui::formatter::Formatter,
20    ui::widgets::{ColorScheme, Spinner, Throbber},
21    utils::ipc::{get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse},
22};
23
24pub struct Dashboard {
25    client: IpcClient,
26    show_project_switcher: bool,
27    available_projects: Vec<Project>,
28    selected_project_index: usize,
29    spinner: Spinner,
30    throbber: Throbber,
31}
32
33impl Dashboard {
34    pub async fn new() -> Result<Self> {
35        let socket_path = get_socket_path()?;
36        let client = if socket_path.exists() {
37            match IpcClient::connect(&socket_path).await {
38                Ok(client) => client,
39                Err(_) => IpcClient::new()?,
40            }
41        } else {
42            IpcClient::new()?
43        };
44
45        Ok(Self {
46            client,
47            show_project_switcher: false,
48            available_projects: Vec::new(),
49            selected_project_index: 0,
50            spinner: Spinner::new(),
51            throbber: Throbber::new(),
52        })
53    }
54
55    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
56        let mut heartbeat_counter = 0;
57
58        loop {
59            // Send activity heartbeat every 30 iterations (3 seconds at 100ms intervals)
60            if heartbeat_counter >= 30 {
61                if let Err(e) = self.send_activity_heartbeat().await {
62                    // Ignore heartbeat errors to avoid interrupting the dashboard
63                    debug!("Heartbeat error: {}", e);
64                }
65                heartbeat_counter = 0;
66            }
67            heartbeat_counter += 1;
68
69            // Tick animations
70            self.spinner.next();
71            self.throbber.next();
72
73            // Get current status
74            let current_session = self.get_current_session().await?;
75            let current_project = if let Some(ref session) = current_session {
76                self.get_project_by_session(session).await?
77            } else {
78                None
79            };
80            let daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
81            let session_metrics = self.get_session_metrics().await.unwrap_or(None);
82
83            terminal.draw(|f| {
84                self.render_dashboard_sync(
85                    f,
86                    &current_session,
87                    &current_project,
88                    &daily_stats,
89                    &session_metrics,
90                );
91            })?;
92
93            // Handle input
94            if event::poll(Duration::from_millis(100))? {
95                match event::read()? {
96                    Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
97                        KeyCode::Char('q') | KeyCode::Esc => {
98                            if self.show_project_switcher {
99                                self.show_project_switcher = false;
100                            } else {
101                                break;
102                            }
103                        }
104                        KeyCode::Char('p') => {
105                            self.toggle_project_switcher().await?;
106                        }
107                        KeyCode::Up => {
108                            if self.show_project_switcher {
109                                self.navigate_projects(-1);
110                            }
111                        }
112                        KeyCode::Down => {
113                            if self.show_project_switcher {
114                                self.navigate_projects(1);
115                            }
116                        }
117                        KeyCode::Enter => {
118                            if self.show_project_switcher {
119                                self.switch_to_selected_project().await?;
120                            }
121                        }
122                        _ => {}
123                    },
124                    _ => {}
125                }
126            }
127        }
128
129        Ok(())
130    }
131
132    fn render_dashboard_sync(
133        &self,
134        f: &mut Frame,
135        current_session: &Option<Session>,
136        current_project: &Option<Project>,
137        daily_stats: &(i64, i64, i64),
138        session_metrics: &Option<crate::utils::ipc::SessionMetrics>,
139    ) {
140        // Modern clean layout: Minimal, focused, professional
141        let chunks = Layout::default()
142            .direction(Direction::Vertical)
143            .constraints([
144                Constraint::Length(1),  // Top bar (Logo/Status)
145                Constraint::Length(1),  // Spacer
146                Constraint::Length(10), // Main Status / Activity Stream
147                Constraint::Length(1),  // Spacer
148                Constraint::Min(0),     // Details / Metrics
149                Constraint::Length(1),  // Bottom bar (Input/Help)
150            ])
151            .split(f.size());
152
153        // Top Bar
154        self.render_top_bar(f, chunks[0]);
155
156        // Main Status Area (The "Stream")
157        self.render_main_status(f, chunks[2], current_session, current_project);
158
159        // Details / Metrics Area
160        self.render_metrics_area(f, chunks[4], daily_stats, session_metrics);
161
162        // Bottom Bar
163        self.render_bottom_bar(f, chunks[5]);
164
165        // Project switcher overlay
166        if self.show_project_switcher {
167            self.render_project_switcher(f, f.size());
168        }
169    }
170
171    fn render_top_bar(&self, f: &mut Frame, area: Rect) {
172        let title_text = vec![
173            Span::styled(
174                "Tempo",
175                Style::default()
176                    .fg(ColorScheme::CLEAN_ACCENT)
177                    .add_modifier(Modifier::BOLD),
178            ),
179            Span::raw(" "),
180            Span::styled("CLI", Style::default().fg(ColorScheme::GRAY_TEXT)),
181        ];
182
183        let title = Paragraph::new(Line::from(title_text)).alignment(Alignment::Left);
184
185        f.render_widget(title, area);
186
187        // Right side status
188        let status_text = if is_daemon_running() {
189            Span::styled(
190                "Daemon Active",
191                Style::default().fg(ColorScheme::CLEAN_GREEN),
192            )
193        } else {
194            Span::styled(
195                "Daemon Offline",
196                Style::default().fg(ColorScheme::NEON_PINK),
197            )
198        };
199
200        let status = Paragraph::new(Line::from(status_text)).alignment(Alignment::Right);
201
202        f.render_widget(status, area);
203    }
204
205    fn render_main_status(
206        &self,
207        f: &mut Frame,
208        area: Rect,
209        session: &Option<Session>,
210        project: &Option<Project>,
211    ) {
212        let block = ColorScheme::clean_block();
213
214        if let Some(session) = session {
215            let now = Local::now();
216            let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
217                - session.paused_duration.num_seconds();
218
219            let project_name = project
220                .as_ref()
221                .map(|p| p.name.as_str())
222                .unwrap_or("Unknown Project");
223
224            let status_lines = vec![
225                Line::from(vec![
226                    Span::styled(
227                        session.context.to_string(),
228                        Style::default().fg(ColorScheme::CLEAN_ACCENT),
229                    ),
230                    Span::styled(
231                        "Tracking ",
232                        Style::default()
233                            .fg(ColorScheme::CLEAN_BLUE)
234                            .add_modifier(Modifier::BOLD),
235                    ),
236                    Span::styled(project_name, Style::default().fg(ColorScheme::WHITE_TEXT)),
237                ]),
238                Line::from(vec![
239                    Span::raw("  "),
240                    Span::styled("L ", Style::default().fg(ColorScheme::GRAY_TEXT)),
241                    Span::raw("Context: "),
242                    Span::styled(
243                        session.context.to_string(),
244                        Style::default().fg(ColorScheme::CLEAN_ACCENT),
245                    ),
246                ]),
247                Line::from(vec![
248                    Span::raw("  "),
249                    Span::styled("L ", Style::default().fg(ColorScheme::GRAY_TEXT)),
250                    Span::raw("Duration: "),
251                    Span::styled(
252                        Formatter::format_duration(elapsed_seconds),
253                        Style::default().fg(ColorScheme::CLEAN_GREEN),
254                    ),
255                    Span::raw(" "),
256                    Span::styled(
257                        self.throbber.current(),
258                        Style::default().fg(ColorScheme::GRAY_TEXT),
259                    ),
260                ]),
261            ];
262
263            let paragraph = Paragraph::new(status_lines).block(block);
264            f.render_widget(paragraph, area);
265        } else {
266            let idle_lines = vec![
267                Line::from(vec![
268                    Span::styled("- ", Style::default().fg(ColorScheme::GRAY_TEXT)),
269                    Span::styled("Idle", Style::default().fg(ColorScheme::GRAY_TEXT)),
270                ]),
271                Line::from(vec![
272                    Span::raw("  "),
273                    Span::styled("L ", Style::default().fg(ColorScheme::GRAY_TEXT)),
274                    Span::raw("Waiting for command..."),
275                ]),
276                Line::from(vec![
277                    Span::raw("  "),
278                    Span::styled("L ", Style::default().fg(ColorScheme::GRAY_TEXT)),
279                    Span::raw("Try: "),
280                    Span::styled(
281                        "tempo start <project>",
282                        Style::default().fg(ColorScheme::CLEAN_ACCENT),
283                    ),
284                ]),
285            ];
286
287            let paragraph = Paragraph::new(idle_lines).block(block);
288            f.render_widget(paragraph, area);
289        }
290    }
291
292    fn render_metrics_area(
293        &self,
294        f: &mut Frame,
295        area: Rect,
296        daily_stats: &(i64, i64, i64),
297        session_metrics: &Option<crate::utils::ipc::SessionMetrics>,
298    ) {
299        let (sessions_count, total_seconds, avg_seconds) = *daily_stats;
300
301        let chunks = Layout::default()
302            .direction(Direction::Horizontal)
303            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
304            .split(area);
305
306        // Daily Stats Column
307        let stats_lines = vec![
308            Line::from(Span::styled(
309                "Daily Summary",
310                Style::default()
311                    .fg(ColorScheme::GRAY_TEXT)
312                    .add_modifier(Modifier::UNDERLINED),
313            )),
314            Line::from(""),
315            Line::from(vec![
316                Span::raw("Sessions: "),
317                Span::styled(
318                    sessions_count.to_string(),
319                    Style::default().fg(ColorScheme::WHITE_TEXT),
320                ),
321            ]),
322            Line::from(vec![
323                Span::raw("Total:    "),
324                Span::styled(
325                    Formatter::format_duration(total_seconds),
326                    Style::default().fg(ColorScheme::WHITE_TEXT),
327                ),
328            ]),
329            Line::from(vec![
330                Span::raw("Average:  "),
331                Span::styled(
332                    Formatter::format_duration(avg_seconds),
333                    Style::default().fg(ColorScheme::WHITE_TEXT),
334                ),
335            ]),
336        ];
337
338        f.render_widget(
339            Paragraph::new(stats_lines).block(ColorScheme::clean_block()),
340            chunks[0],
341        );
342
343        // Session Metrics Column
344        if let Some(metrics) = session_metrics {
345            let efficiency = self.calculate_efficiency_percentage(metrics);
346            let activity_score = metrics.activity_score * 100.0;
347
348            let metrics_lines = vec![
349                Line::from(Span::styled(
350                    "Current Session",
351                    Style::default()
352                        .fg(ColorScheme::GRAY_TEXT)
353                        .add_modifier(Modifier::UNDERLINED),
354                )),
355                Line::from(""),
356                Line::from(vec![
357                    Span::raw("Activity:   "),
358                    Span::styled(
359                        format!("{:.0}%", activity_score),
360                        Style::default().fg(if activity_score > 80.0 {
361                            ColorScheme::CLEAN_GREEN
362                        } else {
363                            ColorScheme::CLEAN_ACCENT
364                        }),
365                    ),
366                ]),
367                Line::from(vec![
368                    Span::raw("Efficiency: "),
369                    Span::styled(
370                        format!("{:.0}%", efficiency),
371                        Style::default().fg(if efficiency > 80.0 {
372                            ColorScheme::CLEAN_GREEN
373                        } else {
374                            ColorScheme::CLEAN_ACCENT
375                        }),
376                    ),
377                ]),
378            ];
379
380            f.render_widget(
381                Paragraph::new(metrics_lines).block(ColorScheme::clean_block()),
382                chunks[1],
383            );
384        } else {
385            let no_metrics_lines = vec![
386                Line::from(Span::styled(
387                    "Current Session",
388                    Style::default()
389                        .fg(ColorScheme::GRAY_TEXT)
390                        .add_modifier(Modifier::UNDERLINED),
391                )),
392                Line::from(""),
393                Line::from(Span::styled(
394                    "No active session metrics.",
395                    Style::default().fg(ColorScheme::GRAY_TEXT),
396                )),
397                Line::from(Span::styled(
398                    "Start a session to see real-time data.",
399                    Style::default().fg(ColorScheme::GRAY_TEXT),
400                )),
401            ];
402            f.render_widget(
403                Paragraph::new(no_metrics_lines).block(ColorScheme::clean_block()),
404                chunks[1],
405            );
406        }
407    }
408
409    fn render_bottom_bar(&self, f: &mut Frame, area: Rect) {
410        let help_text = if self.show_project_switcher {
411            "Select Project: Up/Down | Enter to Confirm | Esc to Cancel"
412        } else {
413            "> Press 'p' for projects, 'q' to quit"
414        };
415
416        let help = Paragraph::new(help_text)
417            .style(Style::default().fg(ColorScheme::GRAY_TEXT))
418            .block(
419                Block::default()
420                    .borders(Borders::TOP)
421                    .border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
422            );
423
424        f.render_widget(help, area);
425    }
426
427    fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
428        let popup_area = self.centered_rect(60, 50, area);
429
430        let block = Block::default()
431            .borders(Borders::ALL)
432            .border_style(Style::default().fg(ColorScheme::CLEAN_BLUE))
433            .title(" Select Project ")
434            .title_alignment(Alignment::Center)
435            .style(Style::default().bg(ColorScheme::CLEAN_BG));
436
437        f.render_widget(block.clone(), popup_area);
438
439        let list_area = block.inner(popup_area);
440
441        if self.available_projects.is_empty() {
442            let no_projects = Paragraph::new("No projects found")
443                .alignment(Alignment::Center)
444                .style(Style::default().fg(ColorScheme::GRAY_TEXT));
445            f.render_widget(no_projects, list_area);
446        } else {
447            let items: Vec<ListItem> = self
448                .available_projects
449                .iter()
450                .enumerate()
451                .map(|(i, p)| {
452                    let style = if i == self.selected_project_index {
453                        Style::default()
454                            .fg(ColorScheme::CLEAN_BG)
455                            .bg(ColorScheme::CLEAN_BLUE)
456                    } else {
457                        Style::default().fg(ColorScheme::WHITE_TEXT)
458                    };
459                    ListItem::new(format!(" {} ", p.name)).style(style)
460                })
461                .collect();
462
463            let list = List::new(items);
464            f.render_widget(list, list_area);
465        }
466    }
467
468    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
469        let popup_layout = Layout::default()
470            .direction(Direction::Vertical)
471            .constraints([
472                Constraint::Percentage((100 - percent_y) / 2),
473                Constraint::Percentage(percent_y),
474                Constraint::Percentage((100 - percent_y) / 2),
475            ])
476            .split(r);
477
478        Layout::default()
479            .direction(Direction::Horizontal)
480            .constraints([
481                Constraint::Percentage((100 - percent_x) / 2),
482                Constraint::Percentage(percent_x),
483                Constraint::Percentage((100 - percent_x) / 2),
484            ])
485            .split(popup_layout[1])[1]
486    }
487
488    async fn get_current_session(&mut self) -> Result<Option<Session>> {
489        if !is_daemon_running() {
490            return Ok(None);
491        }
492
493        let response = self
494            .client
495            .send_message(&IpcMessage::GetActiveSession)
496            .await?;
497        match response {
498            IpcResponse::ActiveSession(session) => Ok(session),
499            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
500            _ => Ok(None),
501        }
502    }
503
504    async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
505        if !is_daemon_running() {
506            return Ok(None);
507        }
508
509        let response = self
510            .client
511            .send_message(&IpcMessage::GetProject(session.project_id))
512            .await?;
513        match response {
514            IpcResponse::Project(project) => Ok(project),
515            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
516            _ => Ok(None),
517        }
518    }
519
520    async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> {
521        // (sessions_count, total_seconds, avg_seconds)
522        if !is_daemon_running() {
523            return Ok((0, 0, 0));
524        }
525
526        let today = chrono::Local::now().date_naive();
527        let response = self
528            .client
529            .send_message(&IpcMessage::GetDailyStats(today))
530            .await?;
531        match response {
532            IpcResponse::DailyStats {
533                sessions_count,
534                total_seconds,
535                avg_seconds,
536            } => Ok((sessions_count, total_seconds, avg_seconds)),
537            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
538            _ => Ok((0, 0, 0)),
539        }
540    }
541
542    async fn get_session_metrics(&mut self) -> Result<Option<crate::utils::ipc::SessionMetrics>> {
543        if !is_daemon_running() {
544            return Ok(None);
545        }
546
547        let response = self
548            .client
549            .send_message(&IpcMessage::GetSessionMetrics(0))
550            .await?;
551        match response {
552            IpcResponse::SessionMetrics(metrics) => Ok(Some(metrics)),
553            IpcResponse::Error(_) => Ok(None), // No active session
554            _ => Ok(None),
555        }
556    }
557
558    async fn send_activity_heartbeat(&mut self) -> Result<()> {
559        if !is_daemon_running() {
560            return Ok(());
561        }
562
563        let _response = self
564            .client
565            .send_message(&IpcMessage::ActivityHeartbeat)
566            .await?;
567        Ok(())
568    }
569
570    // Helper methods for project switcher navigation
571    async fn toggle_project_switcher(&mut self) -> Result<()> {
572        self.show_project_switcher = !self.show_project_switcher;
573        if self.show_project_switcher {
574            // Fetch projects when opening switcher
575            self.refresh_projects().await?;
576        }
577        Ok(())
578    }
579
580    fn navigate_projects(&mut self, direction: i32) {
581        if self.available_projects.is_empty() {
582            return;
583        }
584
585        let new_index = self.selected_project_index as i32 + direction;
586        if new_index >= 0 && new_index < self.available_projects.len() as i32 {
587            self.selected_project_index = new_index as usize;
588        }
589    }
590
591    async fn refresh_projects(&mut self) -> Result<()> {
592        if !is_daemon_running() {
593            return Ok(());
594        }
595
596        let response = self.client.send_message(&IpcMessage::ListProjects).await?;
597        if let IpcResponse::ProjectList(projects) = response {
598            self.available_projects = projects;
599            self.selected_project_index = 0;
600        }
601        Ok(())
602    }
603
604    fn calculate_efficiency_percentage(&self, metrics: &crate::utils::ipc::SessionMetrics) -> f64 {
605        if metrics.total_duration == 0 {
606            return 0.0;
607        }
608        let active_ratio = metrics.active_duration as f64 / metrics.total_duration as f64;
609        (active_ratio * 100.0).min(100.0)
610    }
611
612    async fn switch_to_selected_project(&mut self) -> Result<()> {
613        if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
614            // Switch to the selected project
615            let project_id = selected_project.id.unwrap_or(0);
616            let response = self
617                .client
618                .send_message(&IpcMessage::SwitchProject(project_id))
619                .await?;
620            match response {
621                IpcResponse::Success => {
622                    self.show_project_switcher = false;
623                }
624                IpcResponse::Error(e) => {
625                    return Err(anyhow::anyhow!("Failed to switch project: {}", e))
626                }
627                _ => return Err(anyhow::anyhow!("Unexpected response")),
628            }
629        }
630        Ok(())
631    }
632
633    async fn load_projects(&mut self) -> Result<Vec<Project>> {
634        let db_path = get_database_path()?;
635        let db = Database::new(&db_path)?;
636
637        let projects = ProjectQueries::list_all(&db.connection, false)?; // Don't include archived
638        Ok(projects)
639    }
640}