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 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 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 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 self.spinner.next();
87
88 self.current_session = self.get_current_session().await?;
90
91 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 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), Constraint::Length(1), Constraint::Length(10), Constraint::Length(1), Constraint::Length(3), Constraint::Length(5), Constraint::Length(1), Constraint::Min(10), Constraint::Length(1), ])
148 .split(f.size());
149
150 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 self.render_active_session_panel(f, chunks[2], current_session, current_project);
159
160 self.render_quick_stats(f, chunks[4], chunks[5], daily_stats);
162
163 self.render_projects_and_timeline(f, chunks[5]);
165
166 self.render_bottom_bar(f, chunks[6]);
168
169 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), Constraint::Length(2), Constraint::Length(1), Constraint::Length(3), ])
194 .margin(1)
195 .split(area);
196
197 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 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 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 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 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 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()), ("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), Constraint::Min(5), Constraint::Length(2), Constraint::Length(3), ])
376 .split(area);
377
378 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 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", "0h 00m", "Today" );
401 ListItem::new(content).style(Style::default().fg(ColorScheme::GRAY_TEXT))
402 })
403 .collect();
404
405 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 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 let bar = Block::default().style(Style::default().bg(ColorScheme::CLEAN_BLUE)); f.render_widget(bar, chunks[3]);
441
442 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 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 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 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), Constraint::Percentage(50), ])
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}