tempo_cli/ui/
dashboard.rs

1use anyhow::Result;
2use chrono::Local;
3use crossterm::event::{self, Event, KeyCode, KeyEvent, 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, Instant};
14
15use crate::{
16    models::{Project, Session},
17    ui::formatter::Formatter,
18    ui::widgets::{ColorScheme, Spinner},
19    utils::ipc::{get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse},
20};
21
22pub struct Dashboard {
23    client: IpcClient,
24    current_session: Option<Session>,
25    current_project: Option<Project>,
26    daily_stats: (i64, i64, i64),
27    available_projects: Vec<Project>,
28    selected_project_index: usize,
29    show_project_switcher: bool,
30    spinner: Spinner,
31    last_update: Instant,
32}
33
34impl Dashboard {
35    pub async fn new() -> Result<Self> {
36        let socket_path = get_socket_path()?;
37        let client = if socket_path.exists() && is_daemon_running() {
38            IpcClient::connect(&socket_path).await.unwrap_or_else(|_| IpcClient::new().unwrap())
39        } else {
40            IpcClient::new()?
41        };
42        Ok(Self {
43            client,
44            current_session: None,
45            current_project: None,
46            daily_stats: (0, 0, 0),
47            available_projects: Vec::new(),
48            selected_project_index: 0,
49            show_project_switcher: false,
50            spinner: Spinner::new(),
51            last_update: Instant::now(),
52        })
53    }
54
55    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
56        loop {
57            // Update state
58            self.update_state().await?;
59
60            terminal.draw(|f| self.render_dashboard_sync(f))?;
61
62            if event::poll(Duration::from_millis(100))? {
63                match event::read()? {
64                    Event::Key(key) if key.kind == KeyEventKind::Press => {
65                        if self.show_project_switcher {
66                            self.handle_project_switcher_input(key).await?;
67                        } else {
68                            // Handle global exit here
69                            if let KeyCode::Char('q') | KeyCode::Esc = key.code {
70                                break;
71                            }
72                            self.handle_dashboard_input(key).await?;
73                        }
74                    }
75                    _ => {}
76                }
77            }
78        }
79        Ok(())
80    }
81    async fn update_state(&mut self) -> Result<()> {
82        // Send activity heartbeat (throttled)
83        if self.last_update.elapsed() >= Duration::from_secs(3) {
84            if let Err(e) = self.send_activity_heartbeat().await {
85                debug!("Heartbeat error: {}", e);
86            }
87            self.last_update = Instant::now();
88        }
89
90        // Tick animations
91        self.spinner.next();
92
93        // Get current status
94        self.current_session = self.get_current_session().await?;
95
96        // Clone session to avoid borrow conflict
97        let session_clone = self.current_session.clone();
98        if let Some(session) = session_clone {
99            self.current_project = self.get_project_by_session(&session).await?;
100        } else {
101            self.current_project = None;
102        }
103
104        self.daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
105
106        Ok(())
107    }
108
109    async fn handle_dashboard_input(&mut self, key: KeyEvent) -> Result<()> {
110        match key.code {
111            // 'q' and 'Esc' are handled in run()
112            KeyCode::Char('p') => {
113                self.refresh_projects().await?;
114                self.show_project_switcher = true;
115            }
116            _ => {}
117        }
118        Ok(())
119    }
120
121    async fn handle_project_switcher_input(&mut self, key: KeyEvent) -> Result<()> {
122        match key.code {
123            KeyCode::Esc => {
124                self.show_project_switcher = false;
125            }
126            KeyCode::Up | KeyCode::Char('k') => {
127                self.navigate_projects(-1);
128            }
129            KeyCode::Down | KeyCode::Char('j') => {
130                self.navigate_projects(1);
131            }
132            KeyCode::Enter => {
133                self.switch_to_selected_project().await?;
134            }
135            _ => {}
136        }
137        Ok(())
138    }
139
140    async fn ensure_connected(&mut self) -> Result<()> {
141        if !is_daemon_running() {
142            return Err(anyhow::anyhow!("Daemon is not running"));
143        }
144        
145        // Test if we have a working connection
146        if self.client.stream.is_some() {
147            return Ok(());
148        }
149        
150        // Reconnect if needed
151        let socket_path = get_socket_path()?;
152        if socket_path.exists() {
153            self.client = IpcClient::connect(&socket_path).await?;
154        }
155        Ok(())
156    }
157
158    fn render_dashboard_sync(&mut self, f: &mut Frame) {
159        let chunks = Layout::default()
160            .direction(Direction::Vertical)
161            .constraints([
162                Constraint::Length(3),  // Header
163                Constraint::Length(1),  // Spacer
164                Constraint::Length(10), // Active Session Panel
165                Constraint::Length(1),  // Spacer
166                Constraint::Length(3),  // Quick Stats Header
167                Constraint::Length(5),  // Quick Stats Grid
168                Constraint::Length(1),  // Spacer
169                Constraint::Min(10),    // Recent Projects & Timeline
170                Constraint::Length(1),  // Bottom bar
171            ])
172            .split(f.size());
173
174        // Header
175        self.render_header(f, chunks[0]);
176
177        let daily_stats = self.get_daily_stats();
178        let current_session = &self.current_session;
179        let current_project = &self.current_project;
180
181        // 1. Active Session Panel
182        self.render_active_session_panel(f, chunks[2], current_session, current_project);
183
184        // 2. Quick Stats
185        self.render_quick_stats(f, chunks[4], chunks[5], daily_stats);
186
187        // 3. Recent Projects & Timeline
188        self.render_projects_and_timeline(f, chunks[5]);
189
190        // 4. Bottom Bar
191        self.render_bottom_bar(f, chunks[6]);
192
193        // Project switcher overlay
194        if self.show_project_switcher {
195            self.render_project_switcher(f, f.size());
196        }
197    }
198
199    fn render_active_session_panel(
200        &self,
201        f: &mut Frame,
202        area: Rect,
203        session: &Option<Session>,
204        project: &Option<Project>,
205    ) {
206        let block = Block::default().style(Style::default().bg(ColorScheme::CLEAN_BG));
207
208        f.render_widget(block, area);
209
210        let layout = Layout::default()
211            .direction(Direction::Vertical)
212            .constraints([
213                Constraint::Length(1), // "Active Session" label
214                Constraint::Length(2), // Project Name & State
215                Constraint::Length(1), // Spacer
216                Constraint::Length(3), // Large Timer
217            ])
218            .margin(1)
219            .split(area);
220
221        // Label
222        f.render_widget(
223            Paragraph::new("Active Session").style(
224                Style::default()
225                    .fg(ColorScheme::GRAY_TEXT)
226                    .add_modifier(Modifier::BOLD),
227            ),
228            layout[0],
229        );
230
231        if let Some(session) = session {
232            let project_name = project
233                .as_ref()
234                .map(|p| p.name.as_str())
235                .unwrap_or("Unknown Project");
236
237            // Project Name & State
238            let info_layout = Layout::default()
239                .direction(Direction::Horizontal)
240                .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
241                .split(layout[1]);
242
243            f.render_widget(
244                Paragraph::new(project_name).style(
245                    Style::default()
246                        .fg(ColorScheme::GRAY_TEXT)
247                        .add_modifier(Modifier::BOLD),
248                ),
249                info_layout[0],
250            );
251
252            f.render_widget(
253                Paragraph::new("State: ACTIVE")
254                    .alignment(Alignment::Right)
255                    .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
256                info_layout[1],
257            );
258
259            // Timer
260            let now = Local::now();
261            let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
262                - session.paused_duration.num_seconds();
263            let duration_str = Formatter::format_duration(elapsed_seconds);
264
265            f.render_widget(
266                Paragraph::new(duration_str)
267                    .alignment(Alignment::Center)
268                    .style(
269                        Style::default()
270                            .fg(ColorScheme::WHITE_TEXT)
271                            .add_modifier(Modifier::BOLD),
272                    ),
273                layout[3],
274            );
275        } else {
276            // Idle State
277            f.render_widget(
278                Paragraph::new("No Active Session")
279                    .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
280                layout[1],
281            );
282            f.render_widget(
283                Paragraph::new("--:--:--")
284                    .alignment(Alignment::Center)
285                    .style(
286                        Style::default()
287                            .fg(ColorScheme::GRAY_TEXT)
288                            .add_modifier(Modifier::DIM),
289                    ),
290                layout[3],
291            );
292        }
293    }
294
295    fn render_quick_stats(
296        &self,
297        f: &mut Frame,
298        header_area: Rect,
299        grid_area: Rect,
300        daily_stats: &(i64, i64, i64),
301    ) {
302        let (sessions_count, total_seconds, _avg_seconds) = *daily_stats;
303
304        // Header
305        f.render_widget(
306            Paragraph::new("Quick Stats").style(
307                Style::default()
308                    .fg(ColorScheme::WHITE_TEXT)
309                    .add_modifier(Modifier::BOLD),
310            ),
311            header_area,
312        );
313
314        // Grid
315        let cols = Layout::default()
316            .direction(Direction::Horizontal)
317            .constraints([
318                Constraint::Percentage(25),
319                Constraint::Percentage(25),
320                Constraint::Percentage(25),
321                Constraint::Percentage(25),
322            ])
323            .split(grid_area);
324
325        let stats = [
326            ("Today", Formatter::format_duration(total_seconds)),
327            ("This Week", "12h 30m".to_string()), // Placeholder for now
328            ("Active", sessions_count.to_string()),
329            ("Projects", self.available_projects.len().to_string()),
330        ];
331
332        for (i, (label, value)) in stats.iter().enumerate() {
333            let block = Block::default()
334                .borders(Borders::ALL)
335                .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
336                .style(Style::default().bg(ColorScheme::CLEAN_BG));
337
338            let content = Paragraph::new(vec![
339                Line::from(Span::styled(
340                    *label,
341                    Style::default().fg(ColorScheme::GRAY_TEXT),
342                )),
343                Line::from(Span::styled(
344                    value.as_str(),
345                    Style::default()
346                        .fg(ColorScheme::WHITE_TEXT)
347                        .add_modifier(Modifier::BOLD),
348                )),
349            ])
350            .block(block)
351            .alignment(Alignment::Center);
352
353            f.render_widget(content, cols[i]);
354        }
355    }
356
357    fn render_bottom_bar(&self, f: &mut Frame, area: Rect) {
358        let help_text = if self.show_project_switcher {
359            vec![
360                Span::styled(
361                    "[Q]",
362                    Style::default()
363                        .fg(ColorScheme::CLEAN_ACCENT)
364                        .add_modifier(Modifier::BOLD),
365                ),
366                Span::raw(" Close  "),
367                Span::raw("[↑/↓] Navigate  "),
368                Span::raw("[Enter] Select"),
369            ]
370        } else {
371            vec![
372                Span::styled(
373                    "[Q]",
374                    Style::default()
375                        .fg(ColorScheme::CLEAN_ACCENT)
376                        .add_modifier(Modifier::BOLD),
377                ),
378                Span::raw(" Quit  "),
379                Span::raw("[P] Projects  "),
380                Span::raw("[R] Refresh"),
381            ]
382        };
383
384        let help_paragraph = Paragraph::new(Line::from(help_text))
385            .alignment(Alignment::Center)
386            .style(Style::default().fg(ColorScheme::GRAY_TEXT));
387
388        f.render_widget(help_paragraph, area);
389    }
390
391    fn render_projects_and_timeline(&self, f: &mut Frame, area: Rect) {
392        let chunks = Layout::default()
393            .direction(Direction::Vertical)
394            .constraints([
395                Constraint::Length(2), // Header
396                Constraint::Min(5),    // Project List
397                Constraint::Length(2), // Timeline Header
398                Constraint::Length(3), // Timeline Bar
399            ])
400            .split(area);
401
402        // Projects Header
403        f.render_widget(
404            Paragraph::new("Recent Projects").style(
405                Style::default()
406                    .fg(ColorScheme::WHITE_TEXT)
407                    .add_modifier(Modifier::BOLD),
408            ),
409            chunks[0],
410        );
411
412        // Project List (Mock data for visual alignment)
413        let projects = &self.available_projects;
414        let items: Vec<ListItem> = projects
415            .iter()
416            .take(5)
417            .map(|p| {
418                let content = format!(
419                    "{:<20} {:<10} {:<10} {:<10}",
420                    p.name,
421                    "0h 00m", // Placeholder
422                    "0h 00m", // Placeholder
423                    "Today"   // Placeholder
424                );
425                ListItem::new(content).style(Style::default().fg(ColorScheme::GRAY_TEXT))
426            })
427            .collect();
428
429        // Header row
430        let header = Paragraph::new(format!(
431            "{:<20} {:<10} {:<10} {:<10}",
432            "Name", "Today", "Total", "Last Active"
433        ))
434        .style(
435            Style::default()
436                .fg(ColorScheme::GRAY_TEXT)
437                .add_modifier(Modifier::UNDERLINED),
438        );
439
440        let list_area = chunks[1];
441        let header_area = Rect::new(list_area.x, list_area.y, list_area.width, 1);
442        let items_area = Rect::new(
443            list_area.x,
444            list_area.y + 1,
445            list_area.width,
446            list_area.height - 1,
447        );
448
449        f.render_widget(header, header_area);
450        f.render_widget(List::new(items), items_area);
451
452        // Timeline Header
453        f.render_widget(
454            Paragraph::new("Activity Timeline").style(
455                Style::default()
456                    .fg(ColorScheme::WHITE_TEXT)
457                    .add_modifier(Modifier::BOLD),
458            ),
459            chunks[2],
460        );
461
462        // Timeline Bar (Visual Mock)
463        let bar = Block::default().style(Style::default().bg(ColorScheme::CLEAN_BLUE)); // Simple bar for now
464        f.render_widget(bar, chunks[3]);
465
466        // Timeline labels
467        let labels = Paragraph::new("08:00       12:00       16:00")
468            .style(Style::default().fg(ColorScheme::GRAY_TEXT));
469        f.render_widget(
470            labels,
471            Rect::new(chunks[3].x, chunks[3].y + 1, chunks[3].width, 1),
472        );
473    }
474
475    fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
476        let popup_area = self.centered_rect(60, 50, area);
477
478        let block = Block::default()
479            .borders(Borders::ALL)
480            .border_style(Style::default().fg(ColorScheme::CLEAN_BLUE))
481            .title(" Select Project ")
482            .title_alignment(Alignment::Center)
483            .style(Style::default().bg(ColorScheme::CLEAN_BG));
484
485        f.render_widget(block.clone(), popup_area);
486
487        let list_area = block.inner(popup_area);
488
489        if self.available_projects.is_empty() {
490            let no_projects = Paragraph::new("No projects found")
491                .alignment(Alignment::Center)
492                .style(Style::default().fg(ColorScheme::GRAY_TEXT));
493            f.render_widget(no_projects, list_area);
494        } else {
495            let items: Vec<ListItem> = self
496                .available_projects
497                .iter()
498                .enumerate()
499                .map(|(i, p)| {
500                    let style = if i == self.selected_project_index {
501                        Style::default()
502                            .fg(ColorScheme::CLEAN_BG)
503                            .bg(ColorScheme::CLEAN_BLUE)
504                    } else {
505                        Style::default().fg(ColorScheme::WHITE_TEXT)
506                    };
507                    ListItem::new(format!(" {} ", p.name)).style(style)
508                })
509                .collect();
510
511            let list = List::new(items);
512            f.render_widget(list, list_area);
513        }
514    }
515
516    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
517        let popup_layout = Layout::default()
518            .direction(Direction::Vertical)
519            .constraints([
520                Constraint::Percentage((100 - percent_y) / 2),
521                Constraint::Percentage(percent_y),
522                Constraint::Percentage((100 - percent_y) / 2),
523            ])
524            .split(r);
525
526        Layout::default()
527            .direction(Direction::Horizontal)
528            .constraints([
529                Constraint::Percentage((100 - percent_x) / 2),
530                Constraint::Percentage(percent_x),
531                Constraint::Percentage((100 - percent_x) / 2),
532            ])
533            .split(popup_layout[1])[1]
534    }
535
536    async fn get_current_session(&mut self) -> Result<Option<Session>> {
537        if !is_daemon_running() {
538            return Ok(None);
539        }
540
541        self.ensure_connected().await?;
542
543        let response = self
544            .client
545            .send_message(&IpcMessage::GetActiveSession)
546            .await?;
547        match response {
548            IpcResponse::ActiveSession(session) => Ok(session),
549            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
550            _ => Ok(None),
551        }
552    }
553
554    async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
555        if !is_daemon_running() {
556            return Ok(None);
557        }
558
559        self.ensure_connected().await?;
560
561        let response = self
562            .client
563            .send_message(&IpcMessage::GetProject(session.project_id))
564            .await?;
565        match response {
566            IpcResponse::Project(project) => Ok(project),
567            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
568            _ => Ok(None),
569        }
570    }
571
572    async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> {
573        // (sessions_count, total_seconds, avg_seconds)
574        if !is_daemon_running() {
575            return Ok((0, 0, 0));
576        }
577
578        self.ensure_connected().await?;
579
580        let today = chrono::Local::now().date_naive();
581        let response = self
582            .client
583            .send_message(&IpcMessage::GetDailyStats(today))
584            .await?;
585        match response {
586            IpcResponse::DailyStats {
587                sessions_count,
588                total_seconds,
589                avg_seconds,
590            } => Ok((sessions_count, total_seconds, avg_seconds)),
591            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
592            _ => Ok((0, 0, 0)),
593        }
594    }
595
596
597    async fn send_activity_heartbeat(&mut self) -> Result<()> {
598        if !is_daemon_running() {
599            return Ok(());
600        }
601
602        self.ensure_connected().await?;
603
604        let _response = self
605            .client
606            .send_message(&IpcMessage::ActivityHeartbeat)
607            .await?;
608        Ok(())
609    }
610
611    // Helper methods for project switcher navigation
612
613    fn navigate_projects(&mut self, direction: i32) {
614        if self.available_projects.is_empty() {
615            return;
616        }
617
618        let new_index = self.selected_project_index as i32 + direction;
619        if new_index >= 0 && new_index < self.available_projects.len() as i32 {
620            self.selected_project_index = new_index as usize;
621        }
622    }
623
624    async fn refresh_projects(&mut self) -> Result<()> {
625        if !is_daemon_running() {
626            return Ok(());
627        }
628
629        self.ensure_connected().await?;
630
631        let response = self.client.send_message(&IpcMessage::ListProjects).await?;
632        if let IpcResponse::ProjectList(projects) = response {
633            self.available_projects = projects;
634            self.selected_project_index = 0;
635        }
636        Ok(())
637    }
638
639    async fn switch_to_selected_project(&mut self) -> Result<()> {
640        if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
641            let project_id = selected_project.id.unwrap_or(0);
642            
643            self.ensure_connected().await?;
644            
645            // Switch to the selected project
646            let response = self
647                .client
648                .send_message(&IpcMessage::SwitchProject(project_id))
649                .await?;
650            match response {
651                IpcResponse::Success => {
652                    self.show_project_switcher = false;
653                }
654                IpcResponse::Error(e) => {
655                    return Err(anyhow::anyhow!("Failed to switch project: {}", e))
656                }
657                _ => return Err(anyhow::anyhow!("Unexpected response")),
658            }
659        }
660        Ok(())
661    }
662
663    fn render_header(&self, f: &mut Frame, area: Rect) {
664        let time_str = Local::now().format("%H:%M").to_string();
665        let date_str = Local::now().format("%A, %B %d").to_string();
666
667        let header_layout = Layout::default()
668            .direction(Direction::Horizontal)
669            .constraints([
670                Constraint::Percentage(50), // Title
671                Constraint::Percentage(50), // Date/Time
672            ])
673            .split(area);
674
675        f.render_widget(
676            Paragraph::new("TEMPO").style(
677                Style::default()
678                    .fg(ColorScheme::CLEAN_GOLD)
679                    .add_modifier(Modifier::BOLD),
680            ),
681            header_layout[0],
682        );
683
684        f.render_widget(
685            Paragraph::new(format!("{}  {}", date_str, time_str))
686                .alignment(Alignment::Right)
687                .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
688            header_layout[1],
689        );
690    }
691
692    fn get_daily_stats(&self) -> &(i64, i64, i64) {
693        &self.daily_stats
694    }
695}