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