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