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