tempo_cli/ui/
dashboard.rs

1use anyhow::Result;
2use chrono::{Local, DateTime, Utc, TimeZone};
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5    backend::Backend,
6    layout::{Alignment, Constraint, Direction, Layout, Rect},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Gauge, Paragraph, Wrap, List, ListItem, BarChart, Sparkline},
10    Frame, Terminal,
11};
12use std::time::Duration;
13use tokio::runtime::Handle;
14use log::debug;
15
16use crate::{
17    models::{Project, Session},
18    db::{Database, get_database_path},
19    db::queries::ProjectQueries,
20    utils::ipc::{IpcClient, IpcMessage, IpcResponse, get_socket_path, is_daemon_running},
21    ui::formatter::Formatter,
22};
23
24pub struct Dashboard {
25    client: IpcClient,
26    show_project_switcher: bool,
27    available_projects: Vec<Project>,
28    selected_project_index: usize,
29}
30
31impl Dashboard {
32    pub async fn new() -> Result<Self> {
33        let socket_path = get_socket_path()?;
34        let client = if socket_path.exists() {
35            match IpcClient::connect(&socket_path).await {
36                Ok(client) => client,
37                Err(_) => IpcClient::new()?,
38            }
39        } else {
40            IpcClient::new()?
41        };
42
43        Ok(Self { 
44            client,
45            show_project_switcher: false,
46            available_projects: Vec::new(),
47            selected_project_index: 0,
48        })
49    }
50
51    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
52        let mut heartbeat_counter = 0;
53        
54        loop {
55            // Send activity heartbeat every 30 iterations (3 seconds at 100ms intervals)
56            if heartbeat_counter >= 30 {
57                if let Err(e) = self.send_activity_heartbeat().await {
58                    // Ignore heartbeat errors to avoid interrupting the dashboard
59                    debug!("Heartbeat error: {}", e);
60                }
61                heartbeat_counter = 0;
62            }
63            heartbeat_counter += 1;
64
65            // Get current status
66            let current_session = self.get_current_session().await?;
67            let current_project = if let Some(ref session) = current_session {
68                self.get_project_by_session(session).await?
69            } else {
70                None
71            };
72            let daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
73            let session_metrics = self.get_session_metrics().await.unwrap_or(None);
74
75            terminal.draw(|f| {
76                self.render_dashboard_sync(f, &current_session, &current_project, &daily_stats, &session_metrics);
77            })?;
78
79            // Handle input
80            if event::poll(Duration::from_millis(100))? {
81                match event::read()? {
82                    Event::Key(key) if key.kind == KeyEventKind::Press => {
83                        match key.code {
84                            KeyCode::Char('q') | KeyCode::Esc => {
85                                if self.show_project_switcher {
86                                    self.show_project_switcher = false;
87                                } else {
88                                    break;
89                                }
90                            }
91                            KeyCode::Char('p') => {
92                                self.toggle_project_switcher().await?;
93                            }
94                            KeyCode::Up => {
95                                if self.show_project_switcher {
96                                    self.navigate_projects(-1);
97                                }
98                            }
99                            KeyCode::Down => {
100                                if self.show_project_switcher {
101                                    self.navigate_projects(1);
102                                }
103                            }
104                            KeyCode::Enter => {
105                                if self.show_project_switcher {
106                                    self.switch_to_selected_project().await?;
107                                }
108                            }
109                            _ => {}
110                        }
111                    }
112                    _ => {}
113                }
114            }
115        }
116
117        Ok(())
118    }
119
120    fn render_dashboard_sync(
121        &self,
122        f: &mut Frame,
123        current_session: &Option<Session>,
124        current_project: &Option<Project>,
125        daily_stats: &(i64, i64, i64),
126        session_metrics: &Option<crate::utils::ipc::SessionMetrics>,
127    ) {
128        let chunks = Layout::default()
129            .direction(Direction::Vertical)
130            .constraints([
131                Constraint::Length(3),  // Title
132                Constraint::Length(10), // Enhanced session info with progress
133                Constraint::Length(6),  // Project info
134                Constraint::Length(8),  // Real-time metrics with visuals
135                Constraint::Min(0),     // Statistics with charts
136                Constraint::Length(3),  // Help
137            ])
138            .split(f.size());
139
140        // Title
141        let title = Paragraph::new("Vibe - Time Tracking Dashboard")
142            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
143            .alignment(Alignment::Center)
144            .block(Block::default().borders(Borders::ALL));
145        f.render_widget(title, chunks[0]);
146
147        // Current session info
148        self.render_session_info(f, chunks[1], current_session);
149
150        // Project info
151        self.render_project_info(f, chunks[2], current_project);
152
153        // Real-time metrics
154        self.render_session_metrics(f, chunks[3], session_metrics);
155
156        // Statistics  
157        self.render_statistics_sync(f, chunks[4], daily_stats);
158
159        // Help
160        self.render_help(f, chunks[5]);
161
162        // Project switcher overlay
163        if self.show_project_switcher {
164            self.render_project_switcher(f, f.size());
165        }
166    }
167
168    fn render_session_info(&self, f: &mut Frame, area: Rect, session: &Option<Session>) {
169        let block = Block::default()
170            .title("Current Session")
171            .borders(Borders::ALL)
172            .style(Style::default().fg(Color::White));
173
174        if let Some(session) = session {
175            let now = Local::now();
176            let elapsed_seconds = (now.timestamp() - session.start_time.timestamp()) 
177                - session.paused_duration.num_seconds();
178            
179            // Split the area for text and progress bar
180            let session_chunks = Layout::default()
181                .direction(Direction::Vertical)
182                .constraints([
183                    Constraint::Length(6), // Session info
184                    Constraint::Length(2), // Progress bar
185                ])
186                .split(area);
187            
188            // Session information
189            let status_text = vec![
190                Line::from(vec![
191                    Span::raw("Status: "),
192                    Span::styled("● ACTIVE", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
193                ]),
194                Line::from(vec![
195                    Span::raw("Started: "),
196                    Span::styled(
197                        Formatter::format_timestamp(&session.start_time.with_timezone(&Local)),
198                        Style::default().fg(Color::White)
199                    ),
200                ]),
201                Line::from(vec![
202                    Span::raw("Elapsed: "),
203                    Span::styled(
204                        Formatter::format_duration(elapsed_seconds),
205                        Formatter::create_highlight_style()
206                    ),
207                ]),
208                Line::from(vec![
209                    Span::raw("Context: "),
210                    Span::styled(
211                        session.context.to_string(),
212                        Style::default().fg(Color::Yellow)
213                    ),
214                ]),
215            ];
216
217            let session_block = Block::default()
218                .borders(Borders::ALL)
219                .title("Current Session")
220                .style(Style::default().fg(Color::White));
221
222            let paragraph = Paragraph::new(status_text)
223                .block(session_block)
224                .wrap(Wrap { trim: true });
225            f.render_widget(paragraph, session_chunks[0]);
226            
227            // Visual progress bar for session duration
228            let progress_ratio = self.calculate_session_progress(elapsed_seconds);
229            let progress_bar = Gauge::default()
230                .block(Block::default()
231                    .borders(Borders::ALL)
232                    .title("Session Progress")
233                    .style(Style::default().fg(Color::Cyan)))
234                .gauge_style(Style::default().fg(Color::Green).bg(Color::Black))
235                .percent((progress_ratio * 100.0) as u16)
236                .label(format!("{} / target: 2h", Formatter::format_duration(elapsed_seconds)));
237            f.render_widget(progress_bar, session_chunks[1]);
238            
239        } else {
240            let no_session_text = vec![
241                Line::from(Span::styled(
242                    "No active session",
243                    Formatter::create_warning_style()
244                )),
245                Line::from(Span::raw("")),
246                Line::from(Span::raw("Use 'tempo start' to begin tracking time")),
247                Line::from(Span::raw("")),
248                Line::from(Span::styled(
249                    "🎯 Set your focus and track your productivity",
250                    Style::default().fg(Color::Cyan)
251                )),
252            ];
253
254            let paragraph = Paragraph::new(no_session_text)
255                .block(block)
256                .wrap(Wrap { trim: true });
257            f.render_widget(paragraph, area);
258        }
259    }
260
261    fn render_project_info(&self, f: &mut Frame, area: Rect, project: &Option<Project>) {
262        let block = Block::default()
263            .title("Current Project")
264            .borders(Borders::ALL)
265            .style(Style::default().fg(Color::White));
266
267        if let Some(project) = project {
268            let project_text = vec![
269                Line::from(vec![
270                    Span::raw("Name: "),
271                    Span::styled(project.name.clone(), Formatter::create_highlight_style()),
272                ]),
273                Line::from(vec![
274                    Span::raw("Path: "),
275                    Span::styled(project.path.to_string_lossy().to_string(), Style::default().fg(Color::Gray)),
276                ]),
277            ];
278
279            let paragraph = Paragraph::new(project_text)
280                .block(block)
281                .wrap(Wrap { trim: true });
282            f.render_widget(paragraph, area);
283        } else {
284            let no_project_text = vec![
285                Line::from(Span::styled(
286                    "No active project",
287                    Formatter::create_warning_style()
288                )),
289            ];
290
291            let paragraph = Paragraph::new(no_project_text)
292                .block(block)
293                .wrap(Wrap { trim: true });
294            f.render_widget(paragraph, area);
295        }
296    }
297
298    fn render_statistics_sync(&self, f: &mut Frame, area: Rect, daily_stats: &(i64, i64, i64)) {
299        let (sessions_count, total_seconds, avg_seconds) = *daily_stats;
300        
301        if sessions_count > 0 {
302            // Split area for text and visual chart
303            let stats_chunks = Layout::default()
304                .direction(Direction::Horizontal)
305                .constraints([
306                    Constraint::Percentage(50), // Stats text
307                    Constraint::Percentage(50), // Visual chart
308                ])
309                .split(area);
310
311            // Statistics text
312            let stats_text = vec![
313                Line::from(vec![
314                    Span::raw("📊 Sessions: "),
315                    Span::styled(sessions_count.to_string(), Formatter::create_highlight_style()),
316                ]),
317                Line::from(vec![
318                    Span::raw("⏱️  Total time: "),
319                    Span::styled(
320                        Formatter::format_duration(total_seconds),
321                        Formatter::create_success_style()
322                    ),
323                ]),
324                Line::from(vec![
325                    Span::raw("📈 Avg session: "),
326                    Span::styled(
327                        Formatter::format_duration(avg_seconds),
328                        Style::default().fg(Color::Cyan)
329                    ),
330                ]),
331                Line::from(vec![
332                    Span::raw("🎯 Target: "),
333                    Span::styled(
334                        format!("{:.0}% complete", (total_seconds as f64 / (8.0 * 3600.0)) * 100.0),
335                        if total_seconds > 4 * 3600 { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Yellow) }
336                    ),
337                ]),
338            ];
339
340            let text_block = Block::default()
341                .title("Today's Summary")
342                .borders(Borders::ALL)
343                .style(Style::default().fg(Color::White));
344
345            let paragraph = Paragraph::new(stats_text)
346                .block(text_block)
347                .wrap(Wrap { trim: true });
348            f.render_widget(paragraph, stats_chunks[0]);
349
350            // Visual progress bar for daily goal (8 hours)
351            let daily_goal_seconds = 8 * 3600; // 8 hours
352            let progress_percentage = ((total_seconds as f64 / daily_goal_seconds as f64) * 100.0).min(100.0);
353            
354            let goal_chunks = Layout::default()
355                .direction(Direction::Vertical)
356                .constraints([
357                    Constraint::Length(3), // Daily goal progress
358                    Constraint::Min(0),    // Activity sparkline (placeholder)
359                ])
360                .split(stats_chunks[1]);
361
362            let daily_progress = Gauge::default()
363                .block(Block::default()
364                    .borders(Borders::ALL)
365                    .title("Daily Goal (8h)")
366                    .style(Style::default().fg(Color::Cyan)))
367                .gauge_style(Style::default().fg(
368                    if progress_percentage >= 100.0 { Color::Green }
369                    else if progress_percentage >= 50.0 { Color::Yellow }
370                    else { Color::Red }
371                ))
372                .percent(progress_percentage as u16)
373                .label(format!("{:.1}%", progress_percentage));
374            f.render_widget(daily_progress, goal_chunks[0]);
375
376            // Placeholder for activity sparkline or mini-chart
377            let activity_placeholder = Paragraph::new(vec![
378                Line::from(Span::styled("📈 Activity Timeline", Style::default().fg(Color::Cyan))),
379                Line::from(Span::raw("▁▂▃▅▇█▇▅▃▂▁ (simulated)")),
380            ])
381            .block(Block::default()
382                .borders(Borders::ALL)
383                .title("Activity Pattern")
384                .style(Style::default().fg(Color::Cyan)))
385            .alignment(Alignment::Center);
386            f.render_widget(activity_placeholder, goal_chunks[1]);
387
388        } else {
389            let no_stats_text = vec![
390                Line::from(Span::styled(
391                    "📊 No sessions today",
392                    Formatter::create_warning_style()
393                )),
394                Line::from(Span::raw("")),
395                Line::from(Span::raw("🚀 Start your first session to see:")),
396                Line::from(Span::raw("  • Session count and timing")),
397                Line::from(Span::raw("  • Daily goal progress")),
398                Line::from(Span::raw("  • Activity patterns")),
399                Line::from(Span::raw("  • Productivity insights")),
400            ];
401
402            let block = Block::default()
403                .title("Today's Summary")
404                .borders(Borders::ALL)
405                .style(Style::default().fg(Color::White));
406
407            let paragraph = Paragraph::new(no_stats_text)
408                .block(block)
409                .wrap(Wrap { trim: true });
410            f.render_widget(paragraph, area);
411        }
412    }
413    
414    fn render_session_metrics(&self, f: &mut Frame, area: Rect, metrics: &Option<crate::utils::ipc::SessionMetrics>) {
415        if let Some(metrics) = metrics {
416            // Split area for metrics text and visual indicators
417            let metrics_chunks = Layout::default()
418                .direction(Direction::Horizontal)
419                .constraints([
420                    Constraint::Percentage(60), // Metrics text
421                    Constraint::Percentage(40), // Visual indicators
422                ])
423                .split(area);
424
425            // Metrics text
426            let activity_color = match metrics.activity_score {
427                s if s > 0.7 => Color::Green,
428                s if s > 0.3 => Color::Yellow,
429                _ => Color::Red,
430            };
431            
432            let activity_indicator = match metrics.activity_score {
433                s if s > 0.8 => "🔥 Very Active",
434                s if s > 0.6 => "⚡ Active",
435                s if s > 0.3 => "⏳ Moderate",
436                _ => "😴 Low Activity",
437            };
438
439            let metrics_text = vec![
440                Line::from(vec![
441                    Span::raw("Activity: "),
442                    Span::styled(activity_indicator, Style::default().fg(activity_color)),
443                ]),
444                Line::from(vec![
445                    Span::raw("Score: "),
446                    Span::styled(
447                        format!("{:.1}%", metrics.activity_score * 100.0),
448                        Style::default().fg(activity_color)
449                    ),
450                ]),
451                Line::from(vec![
452                    Span::raw("Active: "),
453                    Span::styled(
454                        Formatter::format_duration(metrics.active_duration),
455                        Formatter::create_highlight_style()
456                    ),
457                ]),
458                Line::from(vec![
459                    Span::raw("Paused: "),
460                    Span::styled(
461                        Formatter::format_duration(metrics.paused_duration),
462                        Style::default().fg(Color::Gray)
463                    ),
464                ]),
465                Line::from(vec![
466                    Span::raw("Efficiency: "),
467                    Span::styled(
468                        format!("{:.0}%", self.calculate_efficiency_percentage(metrics)),
469                        Style::default().fg(if self.calculate_efficiency_percentage(metrics) > 70.0 { Color::Green } else { Color::Yellow })
470                    ),
471                ]),
472            ];
473
474            let text_block = Block::default()
475                .title("Real-time Metrics")
476                .borders(Borders::ALL)
477                .style(Style::default().fg(Color::White));
478
479            let paragraph = Paragraph::new(metrics_text)
480                .block(text_block)
481                .wrap(Wrap { trim: true });
482            f.render_widget(paragraph, metrics_chunks[0]);
483
484            // Visual activity indicator
485            let activity_chunks = Layout::default()
486                .direction(Direction::Vertical)
487                .constraints([
488                    Constraint::Length(3), // Activity gauge
489                    Constraint::Length(3), // Efficiency gauge
490                ])
491                .split(metrics_chunks[1]);
492
493            // Activity level gauge
494            let activity_gauge = Gauge::default()
495                .block(Block::default()
496                    .borders(Borders::ALL)
497                    .title("Activity")
498                    .style(Style::default().fg(Color::Cyan)))
499                .gauge_style(Style::default().fg(activity_color))
500                .percent((metrics.activity_score * 100.0) as u16)
501                .label(format!("{:.0}%", metrics.activity_score * 100.0));
502            f.render_widget(activity_gauge, activity_chunks[0]);
503
504            // Efficiency gauge
505            let efficiency = self.calculate_efficiency_percentage(metrics);
506            let efficiency_color = if efficiency > 80.0 { Color::Green } 
507                                 else if efficiency > 60.0 { Color::Yellow } 
508                                 else { Color::Red };
509
510            let efficiency_gauge = Gauge::default()
511                .block(Block::default()
512                    .borders(Borders::ALL)
513                    .title("Efficiency")
514                    .style(Style::default().fg(Color::Cyan)))
515                .gauge_style(Style::default().fg(efficiency_color))
516                .percent(efficiency as u16)
517                .label(format!("{:.0}%", efficiency));
518            f.render_widget(efficiency_gauge, activity_chunks[1]);
519
520        } else {
521            let no_metrics_block = Block::default()
522                .title("Real-time Metrics")
523                .borders(Borders::ALL)
524                .style(Style::default().fg(Color::White));
525
526            let no_metrics_text = vec![
527                Line::from(Span::styled(
528                    "No active session",
529                    Formatter::create_warning_style()
530                )),
531                Line::from(Span::raw("")),
532                Line::from(Span::raw("Start tracking to see:")),
533                Line::from(Span::raw("• Activity indicators")),
534                Line::from(Span::raw("• Efficiency metrics")),
535                Line::from(Span::raw("• Visual progress")),
536            ];
537
538            let paragraph = Paragraph::new(no_metrics_text)
539                .block(no_metrics_block)
540                .wrap(Wrap { trim: true });
541            f.render_widget(paragraph, area);
542        }
543    }
544
545    fn render_help(&self, f: &mut Frame, area: Rect) {
546        let help_text = if self.show_project_switcher {
547            "Project Switcher: ↑/↓ Navigate | Enter - Select | P/Esc - Close"
548        } else {
549            "Press 'q' or 'Esc' to quit | 'p' for project switcher | Updates every 100ms"
550        };
551        
552        let help_paragraph = Paragraph::new(help_text)
553            .style(Style::default().fg(Color::Gray))
554            .alignment(Alignment::Center)
555            .block(Block::default().borders(Borders::ALL));
556        f.render_widget(help_paragraph, area);
557    }
558
559    fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
560        // Create a centered popup
561        let popup_area = self.centered_rect(60, 70, area);
562        
563        // Clear the background
564        let background = Block::default()
565            .style(Style::default().bg(Color::Black))
566            .borders(Borders::ALL)
567            .title("🔄 Project Switcher")
568            .title_alignment(Alignment::Center)
569            .border_style(Style::default().fg(Color::Cyan));
570        f.render_widget(background, popup_area);
571
572        // Create the project list
573        let projects_area = Layout::default()
574            .direction(Direction::Vertical)
575            .margin(1)
576            .split(popup_area)[0];
577
578        if self.available_projects.is_empty() {
579            let no_projects = Paragraph::new("No projects found\n\nCreate a project first using:\nvibe init <project-name>")
580                .style(Style::default().fg(Color::Yellow))
581                .alignment(Alignment::Center)
582                .wrap(Wrap { trim: true });
583            f.render_widget(no_projects, projects_area);
584        } else {
585            let project_items: Vec<ListItem> = self.available_projects
586                .iter()
587                .enumerate()
588                .map(|(i, project)| {
589                    let style = if i == self.selected_project_index {
590                        Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
591                    } else {
592                        Style::default().fg(Color::White)
593                    };
594
595                    let content = vec![
596                        Line::from(vec![
597                            Span::styled(format!("{}", project.name), style),
598                        ]),
599                        Line::from(vec![
600                            Span::styled(
601                                format!("  📁 {}", project.path.to_string_lossy()),
602                                Style::default().fg(if i == self.selected_project_index { Color::DarkGray } else { Color::Gray })
603                            ),
604                        ]),
605                    ];
606
607                    ListItem::new(content).style(style)
608                })
609                .collect();
610
611            let projects_list = List::new(project_items)
612                .style(Style::default().fg(Color::White));
613            f.render_widget(projects_list, projects_area);
614        }
615    }
616
617    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
618        let popup_layout = Layout::default()
619            .direction(Direction::Vertical)
620            .constraints([
621                Constraint::Percentage((100 - percent_y) / 2),
622                Constraint::Percentage(percent_y),
623                Constraint::Percentage((100 - percent_y) / 2),
624            ])
625            .split(r);
626
627        Layout::default()
628            .direction(Direction::Horizontal)
629            .constraints([
630                Constraint::Percentage((100 - percent_x) / 2),
631                Constraint::Percentage(percent_x),
632                Constraint::Percentage((100 - percent_x) / 2),
633            ])
634            .split(popup_layout[1])[1]
635    }
636
637    async fn get_current_session(&mut self) -> Result<Option<Session>> {
638        if !is_daemon_running() {
639            return Ok(None);
640        }
641
642        let response = self.client.send_message(&IpcMessage::GetActiveSession).await?;
643        match response {
644            IpcResponse::ActiveSession(session) => Ok(session),
645            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
646            _ => Ok(None),
647        }
648    }
649
650    async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
651        if !is_daemon_running() {
652            return Ok(None);
653        }
654
655        let response = self.client.send_message(&IpcMessage::GetProject(session.project_id)).await?;
656        match response {
657            IpcResponse::Project(project) => Ok(project),
658            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
659            _ => Ok(None),
660        }
661    }
662
663    async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> { // (sessions_count, total_seconds, avg_seconds)
664        if !is_daemon_running() {
665            return Ok((0, 0, 0));
666        }
667
668        let today = chrono::Local::now().date_naive();
669        let response = self.client.send_message(&IpcMessage::GetDailyStats(today)).await?;
670        match response {
671            IpcResponse::DailyStats { sessions_count, total_seconds, avg_seconds } => {
672                Ok((sessions_count, total_seconds, avg_seconds))
673            },
674            IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
675            _ => Ok((0, 0, 0)),
676        }
677    }
678    
679    async fn get_session_metrics(&mut self) -> Result<Option<crate::utils::ipc::SessionMetrics>> {
680        if !is_daemon_running() {
681            return Ok(None);
682        }
683
684        let response = self.client.send_message(&IpcMessage::GetSessionMetrics(0)).await?;
685        match response {
686            IpcResponse::SessionMetrics(metrics) => Ok(Some(metrics)),
687            IpcResponse::Error(_) => Ok(None), // No active session
688            _ => Ok(None),
689        }
690    }
691    
692    async fn send_activity_heartbeat(&mut self) -> Result<()> {
693        if !is_daemon_running() {
694            return Ok(());
695        }
696
697        let _response = self.client.send_message(&IpcMessage::ActivityHeartbeat).await?;
698        Ok(())
699    }
700    
701    fn calculate_session_progress(&self, elapsed_seconds: i64) -> f64 {
702        // Progress towards 2-hour session target
703        let target_seconds = 2 * 3600; // 2 hours
704        (elapsed_seconds as f64 / target_seconds as f64).min(1.0)
705    }
706    
707    fn calculate_efficiency_percentage(&self, metrics: &crate::utils::ipc::SessionMetrics) -> f64 {
708        if metrics.total_duration == 0 {
709            return 0.0;
710        }
711        
712        let efficiency = (metrics.active_duration as f64 / metrics.total_duration as f64) * 100.0;
713        efficiency.min(100.0)
714    }
715
716    async fn toggle_project_switcher(&mut self) -> Result<()> {
717        if self.show_project_switcher {
718            self.show_project_switcher = false;
719        } else {
720            // Load available projects
721            self.available_projects = self.load_projects().await?;
722            self.selected_project_index = 0;
723            self.show_project_switcher = true;
724        }
725        Ok(())
726    }
727
728    fn navigate_projects(&mut self, direction: i32) {
729        if !self.available_projects.is_empty() {
730            let current = self.selected_project_index as i32;
731            let new_index = (current + direction).max(0).min(self.available_projects.len() as i32 - 1);
732            self.selected_project_index = new_index as usize;
733        }
734    }
735
736    async fn switch_to_selected_project(&mut self) -> Result<()> {
737        if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
738            // Switch to the selected project
739            let project_id = selected_project.id.unwrap_or(0);
740            let response = self.client.send_message(&IpcMessage::SwitchProject(project_id)).await?;
741            match response {
742                IpcResponse::Success => {
743                    self.show_project_switcher = false;
744                },
745                IpcResponse::Error(e) => return Err(anyhow::anyhow!("Failed to switch project: {}", e)),
746                _ => return Err(anyhow::anyhow!("Unexpected response")),
747            }
748        }
749        Ok(())
750    }
751
752    async fn load_projects(&mut self) -> Result<Vec<Project>> {
753        let db_path = get_database_path()?;
754        let db = Database::new(&db_path)?;
755        
756        let projects = ProjectQueries::list_all(&db.connection, false)?; // Don't include archived
757        Ok(projects)
758    }
759}
760
761fn should_quit(event: Event) -> bool {
762    match event {
763        Event::Key(key) if key.kind == KeyEventKind::Press => {
764            matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
765        }
766        _ => false,
767    }
768}