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