1use anyhow::Result;
2use chrono::{Local, Timelike};
3use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
4use log::debug;
5use ratatui::{
6 backend::Backend,
7 buffer::Buffer,
8 layout::{Alignment, Constraint, Direction, Layout, Rect},
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
12 Frame, Terminal,
13};
14use std::time::{Duration, Instant};
15
16use crate::{
17 models::{Project, Session},
18 ui::animations::{AnimatedSpinner, PulsingIndicator, ViewTransition, TransitionDirection},
19 ui::formatter::Formatter,
20 ui::widgets::{ColorScheme, Spinner},
21 utils::ipc::{
22 get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse, ProjectWithStats,
23 },
24};
25
26#[derive(Clone, PartialEq)]
27pub enum DashboardView {
28 FocusedSession,
29 Overview,
30 History,
31 Projects,
32}
33
34#[derive(Clone)]
35pub struct SessionFilter {
36 pub start_date: Option<chrono::NaiveDate>,
37 pub end_date: Option<chrono::NaiveDate>,
38 pub project_filter: Option<String>,
39 pub duration_filter: Option<(i64, i64)>, pub search_text: String,
41}
42
43impl Default for SessionFilter {
44 fn default() -> Self {
45 Self {
46 start_date: None,
47 end_date: None,
48 project_filter: None,
49 duration_filter: None,
50 search_text: String::new(),
51 }
52 }
53}
54
55pub struct Dashboard {
56 client: IpcClient,
57 current_session: Option<Session>,
58 current_project: Option<Project>,
59 daily_stats: (i64, i64, i64),
60 weekly_stats: i64,
61 today_sessions: Vec<Session>,
62 recent_projects: Vec<ProjectWithStats>,
63 available_projects: Vec<Project>,
64 selected_project_index: usize,
65 show_project_switcher: bool,
66 current_view: DashboardView,
67
68 history_sessions: Vec<Session>,
70 selected_session_index: usize,
71 session_filter: SessionFilter,
72 filter_input_mode: bool,
73
74 selected_project_row: usize,
76 selected_project_col: usize,
77 projects_per_row: usize,
78
79 spinner: Spinner,
80 last_update: Instant,
81
82 animated_spinner: AnimatedSpinner,
84 pulsing_indicator: PulsingIndicator,
85 view_transition: Option<ViewTransition>,
86 previous_view: DashboardView,
87 frame_count: u64,
88}
89
90impl Dashboard {
91 pub async fn new() -> Result<Self> {
92 let socket_path = get_socket_path()?;
93 let client = if socket_path.exists() && is_daemon_running() {
94 IpcClient::connect(&socket_path)
95 .await
96 .unwrap_or_else(|_| IpcClient::new().unwrap())
97 } else {
98 IpcClient::new()?
99 };
100 Ok(Self {
101 client,
102 current_session: None,
103 current_project: None,
104 daily_stats: (0, 0, 0),
105 weekly_stats: 0,
106 today_sessions: Vec::new(),
107 recent_projects: Vec::new(),
108 available_projects: Vec::new(),
109 selected_project_index: 0,
110 show_project_switcher: false,
111 current_view: DashboardView::FocusedSession,
112
113 history_sessions: Vec::new(),
115 selected_session_index: 0,
116 session_filter: SessionFilter::default(),
117 filter_input_mode: false,
118
119 selected_project_row: 0,
121 selected_project_col: 0,
122 projects_per_row: 3,
123
124 spinner: Spinner::new(),
125 last_update: Instant::now(),
126
127 animated_spinner: AnimatedSpinner::braille(),
129 pulsing_indicator: PulsingIndicator::new(),
130 view_transition: None,
131 previous_view: DashboardView::FocusedSession,
132 frame_count: 0,
133 })
134 }
135
136 pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
137 use tokio::time::{interval, Duration as TokioDuration};
138
139 let mut frame_interval = interval(TokioDuration::from_millis(16));
141
142 loop {
143 frame_interval.tick().await;
145
146 self.update_animations();
148 self.update_state().await?;
149
150 terminal.draw(|f| self.render_dashboard_sync(f))?;
152
153 if event::poll(Duration::from_millis(0))? {
155 match event::read()? {
156 Event::Key(key) if key.kind == KeyEventKind::Press => {
157 if self.show_project_switcher {
158 self.handle_project_switcher_input(key).await?;
159 } else {
160 match key.code {
162 KeyCode::Char('q') => break,
163 KeyCode::Esc => {
164 if self.current_view == DashboardView::FocusedSession {
165 self.transition_to_view(DashboardView::Overview);
166 } else {
167 break;
168 }
169 }
170 _ => self.handle_dashboard_input(key).await?,
171 }
172 }
173 }
174 _ => {}
175 }
176 }
177 }
178 Ok(())
179 }
180
181 fn update_animations(&mut self) {
183 self.frame_count += 1;
185
186 self.animated_spinner.tick();
188
189 if let Some(transition) = &self.view_transition {
191 if transition.is_complete() {
192 self.view_transition = None;
193 }
194 }
195 }
196
197 async fn update_state(&mut self) -> Result<()> {
198 if self.last_update.elapsed() >= Duration::from_secs(3) {
200 if let Err(e) = self.send_activity_heartbeat().await {
201 debug!("Heartbeat error: {}", e);
202 }
203 self.last_update = Instant::now();
204 }
205
206 self.spinner.next();
208
209 self.current_session = self.get_current_session().await?;
211
212 let session_clone = self.current_session.clone();
214 if let Some(session) = session_clone {
215 self.current_project = self.get_project_by_session(&session).await?;
216 } else {
217 self.current_project = None;
218 }
219
220 self.daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
221 self.weekly_stats = self.get_weekly_stats().await.unwrap_or(0);
222 self.today_sessions = self.get_today_sessions().await.unwrap_or_default();
223 self.recent_projects = self.get_recent_projects().await.unwrap_or_default();
224
225 if self.current_view == DashboardView::History {
227 self.history_sessions = self.get_history_sessions().await.unwrap_or_default();
228 }
229
230 if self.current_view == DashboardView::Projects && self.available_projects.is_empty() {
232 if let Err(_) = self.refresh_projects().await {
233 }
235 }
236
237 Ok(())
238 }
239
240 async fn get_weekly_stats(&mut self) -> Result<i64> {
241 match self.client.send_message(&IpcMessage::GetWeeklyStats).await {
242 Ok(IpcResponse::WeeklyStats { total_seconds }) => Ok(total_seconds),
243 Ok(response) => {
244 debug!("Unexpected response for GetWeeklyStats: {:?}", response);
245 Err(anyhow::anyhow!("Unexpected response"))
246 }
247 Err(e) => {
248 debug!("Failed to receive GetWeeklyStats response: {}", e);
249 Err(anyhow::anyhow!("Failed to receive response"))
250 }
251 }
252 }
253
254 async fn get_recent_projects(&mut self) -> Result<Vec<ProjectWithStats>> {
255 match self
256 .client
257 .send_message(&IpcMessage::GetRecentProjects)
258 .await
259 {
260 Ok(IpcResponse::RecentProjects(projects)) => Ok(projects),
261 Ok(response) => {
262 debug!("Unexpected response for GetRecentProjects: {:?}", response);
263 Err(anyhow::anyhow!("Unexpected response"))
264 }
265 Err(e) => {
266 debug!("Failed to receive GetRecentProjects response: {}", e);
267 Err(anyhow::anyhow!("Failed to receive response"))
268 }
269 }
270 }
271
272 fn transition_to_view(&mut self, new_view: DashboardView) {
274 if self.current_view == new_view {
275 return;
276 }
277
278 let direction = match (&self.current_view, &new_view) {
280 (DashboardView::FocusedSession, _) => TransitionDirection::SlideLeft,
281 (_, DashboardView::FocusedSession) => TransitionDirection::SlideRight,
282 _ => TransitionDirection::FadeIn,
283 };
284
285 self.view_transition = Some(ViewTransition::new(direction, Duration::from_millis(200)));
287 self.previous_view = self.current_view.clone();
288 self.current_view = new_view;
289 }
290
291 async fn handle_dashboard_input(&mut self, key: KeyEvent) -> Result<()> {
292 match self.current_view {
294 DashboardView::History => {
295 return self.handle_history_input(key).await;
296 }
297 DashboardView::Projects => {
298 return self.handle_project_grid_input(key).await;
299 }
300 _ => {}
301 }
302
303 match key.code {
305 KeyCode::Char('1') => self.transition_to_view(DashboardView::FocusedSession),
307 KeyCode::Char('2') => self.transition_to_view(DashboardView::Overview),
308 KeyCode::Char('3') => self.transition_to_view(DashboardView::History),
309 KeyCode::Char('4') => self.transition_to_view(DashboardView::Projects),
310 KeyCode::Char('f') => self.transition_to_view(DashboardView::FocusedSession),
311 KeyCode::Tab => {
312 let next_view = match self.current_view {
313 DashboardView::FocusedSession => DashboardView::Overview,
314 DashboardView::Overview => DashboardView::History,
315 DashboardView::History => DashboardView::Projects,
316 DashboardView::Projects => DashboardView::FocusedSession,
317 };
318 self.transition_to_view(next_view);
319 }
320 KeyCode::Char('p') if self.current_view != DashboardView::Projects => {
322 self.refresh_projects().await?;
323 self.show_project_switcher = true;
324 }
325 _ => {}
326 }
327 Ok(())
328 }
329
330 async fn handle_history_input(&mut self, key: KeyEvent) -> Result<()> {
331 match key.code {
332 KeyCode::Up | KeyCode::Char('k') => {
334 if !self.history_sessions.is_empty() && self.selected_session_index > 0 {
335 self.selected_session_index -= 1;
336 }
337 }
338 KeyCode::Down | KeyCode::Char('j') => {
339 if self.selected_session_index < self.history_sessions.len().saturating_sub(1) {
340 self.selected_session_index += 1;
341 }
342 }
343 KeyCode::Char('/') => {
345 self.filter_input_mode = true;
346 }
347 KeyCode::Enter if self.filter_input_mode => {
348 self.filter_input_mode = false;
349 self.history_sessions = self.get_history_sessions().await.unwrap_or_default();
350 }
351 KeyCode::Char(c) if self.filter_input_mode => {
353 self.session_filter.search_text.push(c);
354 }
355 KeyCode::Backspace if self.filter_input_mode => {
356 self.session_filter.search_text.pop();
357 }
358 KeyCode::Esc if self.filter_input_mode => {
359 self.filter_input_mode = false;
360 self.session_filter.search_text.clear();
361 }
362 _ => {}
363 }
364 Ok(())
365 }
366
367 async fn handle_project_grid_input(&mut self, key: KeyEvent) -> Result<()> {
368 match key.code {
369 KeyCode::Up | KeyCode::Char('k') => {
371 if self.selected_project_row > 0 {
372 self.selected_project_row -= 1;
373 }
374 }
375 KeyCode::Down | KeyCode::Char('j') => {
376 let total_projects = self.available_projects.len();
377 let total_rows =
378 (total_projects + self.projects_per_row - 1) / self.projects_per_row;
379 if self.selected_project_row < total_rows.saturating_sub(1) {
380 let next_row_first_index =
382 (self.selected_project_row + 1) * self.projects_per_row;
383 if next_row_first_index < total_projects {
384 self.selected_project_row += 1;
385 }
386 }
387 }
388 KeyCode::Left | KeyCode::Char('h') => {
389 if self.selected_project_col > 0 {
390 self.selected_project_col -= 1;
391 }
392 }
393 KeyCode::Right | KeyCode::Char('l') => {
394 let row_start = self.selected_project_row * self.projects_per_row;
395 let row_end =
396 (row_start + self.projects_per_row).min(self.available_projects.len());
397 let max_col = (row_end - row_start).saturating_sub(1);
398 if self.selected_project_col < max_col {
399 self.selected_project_col += 1;
400 }
401 }
402 KeyCode::Enter => {
404 self.switch_to_grid_selected_project().await?;
405 }
406 _ => {}
407 }
408 Ok(())
409 }
410
411 async fn handle_project_switcher_input(&mut self, key: KeyEvent) -> Result<()> {
412 match key.code {
413 KeyCode::Esc => {
414 self.show_project_switcher = false;
415 }
416 KeyCode::Up | KeyCode::Char('k') => {
417 self.navigate_projects(-1);
418 }
419 KeyCode::Down | KeyCode::Char('j') => {
420 self.navigate_projects(1);
421 }
422 KeyCode::Enter => {
423 self.switch_to_selected_project().await?;
424 }
425 _ => {}
426 }
427 Ok(())
428 }
429
430 async fn ensure_connected(&mut self) -> Result<()> {
431 if !is_daemon_running() {
432 return Err(anyhow::anyhow!("Daemon is not running"));
433 }
434
435 if self.client.stream.is_some() {
437 return Ok(());
438 }
439
440 let socket_path = get_socket_path()?;
442 if socket_path.exists() {
443 self.client = IpcClient::connect(&socket_path).await?;
444 }
445 Ok(())
446 }
447
448 async fn switch_to_grid_selected_project(&mut self) -> Result<()> {
449 let selected_index =
450 self.selected_project_row * self.projects_per_row + self.selected_project_col;
451 if let Some(selected_project) = self.available_projects.get(selected_index) {
452 let project_id = selected_project.id.unwrap_or(0);
453
454 self.ensure_connected().await?;
455
456 let response = self
458 .client
459 .send_message(&IpcMessage::SwitchProject(project_id))
460 .await?;
461 match response {
462 IpcResponse::Success => {
463 self.transition_to_view(DashboardView::FocusedSession);
465 }
466 IpcResponse::Error(e) => {
467 return Err(anyhow::anyhow!("Failed to switch project: {}", e))
468 }
469 _ => return Err(anyhow::anyhow!("Unexpected response")),
470 }
471 }
472 Ok(())
473 }
474
475 fn render_keyboard_hints(&self, area: Rect, buf: &mut Buffer) {
476 let hints = match self.current_view {
477 DashboardView::FocusedSession => vec![
478 ("Esc", "Exit Focus"),
479 ("Tab", "Next View"),
480 ("p", "Projects"),
481 ],
482 DashboardView::History => vec![
483 ("↑/↓", "Navigate"),
484 ("/", "Search"),
485 ("Tab", "Next View"),
486 ("q", "Quit"),
487 ],
488 DashboardView::Projects => vec![
489 ("↑/↓/←/→", "Navigate"),
490 ("Enter", "Select"),
491 ("Tab", "Next View"),
492 ("q", "Quit"),
493 ],
494 _ => vec![
495 ("q", "Quit"),
496 ("f", "Focus"),
497 ("Tab", "Next View"),
498 ("1-4", "View"),
499 ("p", "Projects"),
500 ],
501 };
502
503 let spans: Vec<Span> = hints
504 .iter()
505 .flat_map(|(key, desc)| {
506 vec![
507 Span::styled(
508 format!(" {} ", key),
509 Style::default()
510 .fg(Color::Yellow)
511 .add_modifier(Modifier::BOLD),
512 ),
513 Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
514 ]
515 })
516 .collect();
517
518 let line = Line::from(spans);
519 let block = Block::default()
520 .borders(Borders::TOP)
521 .border_style(Style::default().fg(Color::DarkGray));
522 Paragraph::new(line).block(block).render(area, buf);
523 }
524
525 fn render_dashboard_sync(&mut self, f: &mut Frame) {
526 match self.current_view {
527 DashboardView::FocusedSession => self.render_focused_session_view(f),
528 DashboardView::Overview => self.render_overview_dashboard(f),
529 DashboardView::History => self.render_history_browser(f),
530 DashboardView::Projects => self.render_project_grid(f),
531 }
532
533 if self.show_project_switcher {
535 self.render_project_switcher(f, f.size());
536 }
537 }
538
539 fn render_focused_session_view(&mut self, f: &mut Frame) {
540 let chunks = Layout::default()
541 .direction(Direction::Vertical)
542 .constraints([
543 Constraint::Length(3), Constraint::Length(2), Constraint::Length(6), Constraint::Length(2), Constraint::Length(8), Constraint::Length(2), Constraint::Length(8), Constraint::Min(0), Constraint::Length(1), ])
553 .split(f.size());
554
555 let header_layout = Layout::default()
557 .direction(Direction::Horizontal)
558 .constraints([Constraint::Percentage(100)])
559 .split(chunks[0]);
560
561 f.render_widget(
562 Paragraph::new("Press ESC to exit focused mode.")
563 .alignment(Alignment::Center)
564 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
565 header_layout[0],
566 );
567
568 if let (Some(session), Some(project)) = (&self.current_session, &self.current_project) {
569 let project_area = self.centered_rect(60, 20, chunks[2]);
571 let project_block = Block::default()
572 .borders(Borders::ALL)
573 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
574 .style(Style::default().bg(ColorScheme::CLEAN_BG));
575
576 let project_layout = Layout::default()
577 .direction(Direction::Vertical)
578 .constraints([
579 Constraint::Length(1),
580 Constraint::Length(1),
581 Constraint::Length(1),
582 Constraint::Length(1),
583 ])
584 .margin(1)
585 .split(project_area);
586
587 f.render_widget(project_block, project_area);
588
589 f.render_widget(
591 Paragraph::new(project.name.clone())
592 .alignment(Alignment::Center)
593 .style(
594 Style::default()
595 .fg(ColorScheme::WHITE_TEXT)
596 .add_modifier(Modifier::BOLD),
597 ),
598 project_layout[0],
599 );
600
601 let default_description = "Refactor authentication module".to_string();
603 let description = project.description.as_ref().unwrap_or(&default_description);
604 f.render_widget(
605 Paragraph::new(description.clone())
606 .alignment(Alignment::Center)
607 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
608 project_layout[1],
609 );
610
611 let timer_area = self.centered_rect(40, 20, chunks[4]);
613
614 use crate::ui::animations::pulse_color;
616 let pulse_start = ColorScheme::CLEAN_GREEN;
617 let pulse_end = ColorScheme::PRIMARY_FOCUS;
618 let border_color = pulse_color(
619 pulse_start,
620 pulse_end,
621 self.pulsing_indicator.start_time.elapsed(),
622 Duration::from_millis(2000),
623 );
624
625 let timer_block = Block::default()
626 .borders(Borders::ALL)
627 .border_style(Style::default().fg(border_color))
628 .style(Style::default().bg(Color::Black));
629
630 let timer_inner = timer_block.inner(timer_area);
631 f.render_widget(timer_block, timer_area);
632
633 let now = Local::now();
635 let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
636 - session.paused_duration.num_seconds();
637 let duration_str = Formatter::format_duration_clock(elapsed_seconds);
638
639 f.render_widget(
640 Paragraph::new(duration_str)
641 .alignment(Alignment::Center)
642 .style(
643 Style::default()
644 .fg(ColorScheme::CLEAN_GREEN)
645 .add_modifier(Modifier::BOLD),
646 ),
647 timer_inner,
648 );
649
650 let details_area = self.centered_rect(60, 25, chunks[6]);
652 let details_block = Block::default()
653 .borders(Borders::ALL)
654 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
655 .style(Style::default().bg(ColorScheme::CLEAN_BG));
656
657 let details_layout = Layout::default()
658 .direction(Direction::Vertical)
659 .constraints([
660 Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), ])
664 .margin(1)
665 .split(details_area);
666
667 f.render_widget(details_block, details_area);
668
669 let start_time_layout = Layout::default()
671 .direction(Direction::Horizontal)
672 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
673 .split(details_layout[0]);
674
675 f.render_widget(
676 Paragraph::new("Start Time").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
677 start_time_layout[0],
678 );
679 f.render_widget(
680 Paragraph::new(
681 session
682 .start_time
683 .with_timezone(&Local)
684 .format("%H:%M")
685 .to_string(),
686 )
687 .alignment(Alignment::Right)
688 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
689 start_time_layout[1],
690 );
691
692 let session_type_layout = Layout::default()
694 .direction(Direction::Horizontal)
695 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
696 .split(details_layout[1]);
697
698 f.render_widget(
699 Paragraph::new("Session Type").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
700 session_type_layout[0],
701 );
702 f.render_widget(
703 Paragraph::new("Deep Work")
704 .alignment(Alignment::Right)
705 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
706 session_type_layout[1],
707 );
708
709 let tags_layout = Layout::default()
711 .direction(Direction::Horizontal)
712 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
713 .split(details_layout[2]);
714
715 f.render_widget(
716 Paragraph::new("Tags").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
717 tags_layout[0],
718 );
719
720 let tag_spans = vec![
722 Span::styled(
723 " Backend ",
724 Style::default()
725 .fg(ColorScheme::CLEAN_BG)
726 .bg(ColorScheme::GRAY_TEXT),
727 ),
728 Span::raw(" "),
729 Span::styled(
730 " Refactor ",
731 Style::default()
732 .fg(ColorScheme::CLEAN_BG)
733 .bg(ColorScheme::GRAY_TEXT),
734 ),
735 Span::raw(" "),
736 Span::styled(
737 " Security ",
738 Style::default()
739 .fg(ColorScheme::CLEAN_BG)
740 .bg(ColorScheme::GRAY_TEXT),
741 ),
742 ];
743
744 f.render_widget(
745 Paragraph::new(Line::from(tag_spans)).alignment(Alignment::Right),
746 tags_layout[1],
747 );
748 } else {
749 let idle_area = self.centered_rect(50, 20, chunks[4]);
751 let idle_block = Block::default()
752 .borders(Borders::ALL)
753 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
754 .style(Style::default().bg(ColorScheme::CLEAN_BG));
755
756 f.render_widget(idle_block.clone(), idle_area);
757
758 let idle_inner = idle_block.inner(idle_area);
759 f.render_widget(
760 Paragraph::new("No Active Session\n\nPress 's' to start tracking")
761 .alignment(Alignment::Center)
762 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
763 idle_inner,
764 );
765 }
766 }
767
768 fn render_overview_dashboard(&mut self, f: &mut Frame) {
769 let chunks = Layout::default()
770 .direction(Direction::Vertical)
771 .constraints([
772 Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
776 .split(f.size());
777
778 self.render_header(f, chunks[0]);
780
781 let grid_chunks = Layout::default()
783 .direction(Direction::Horizontal)
784 .constraints([
785 Constraint::Percentage(66), Constraint::Percentage(34), ])
788 .split(chunks[1]);
789
790 let left_col = grid_chunks[0];
791 let right_col = grid_chunks[1];
792
793 let left_chunks = Layout::default()
795 .direction(Direction::Vertical)
796 .constraints([
797 Constraint::Length(12), Constraint::Min(10), ])
800 .split(left_col);
801
802 let current_session = &self.current_session;
803 let current_project = &self.current_project;
804
805 self.render_active_session_panel(f, left_chunks[0], current_session, current_project);
807
808 self.render_projects_table(f, left_chunks[1]);
810
811 let right_chunks = Layout::default()
813 .direction(Direction::Vertical)
814 .constraints([
815 Constraint::Length(10), Constraint::Min(10), ])
818 .split(right_col);
819
820 let daily_stats = self.get_daily_stats();
822 self.render_quick_stats(f, right_chunks[0], daily_stats);
823
824 self.render_activity_timeline(f, right_chunks[1]);
826
827 self.render_keyboard_hints(chunks[2], f.buffer_mut());
829 }
830
831 fn render_history_browser(&mut self, f: &mut Frame) {
832 let chunks = Layout::default()
833 .direction(Direction::Horizontal)
834 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
835 .split(f.size());
836
837 let left_chunks = Layout::default()
838 .direction(Direction::Vertical)
839 .constraints([
840 Constraint::Length(3), Constraint::Length(8), Constraint::Min(10), ])
844 .split(chunks[0]);
845
846 let right_chunks = Layout::default()
847 .direction(Direction::Vertical)
848 .constraints([
849 Constraint::Percentage(60), Constraint::Length(4), Constraint::Min(0), ])
853 .split(chunks[1]);
854
855 f.render_widget(
857 Paragraph::new("Tempo TUI :: History Browser")
858 .style(
859 Style::default()
860 .fg(ColorScheme::CLEAN_BLUE)
861 .add_modifier(Modifier::BOLD),
862 )
863 .block(
864 Block::default()
865 .borders(Borders::BOTTOM)
866 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
867 ),
868 left_chunks[0],
869 );
870
871 self.render_history_filters(f, left_chunks[1]);
873
874 self.render_session_list(f, left_chunks[2]);
876
877 self.render_session_details(f, right_chunks[0]);
879
880 self.render_session_actions(f, right_chunks[1]);
882
883 self.render_history_summary(f, right_chunks[2]);
885 }
886
887 fn render_history_filters(&self, f: &mut Frame, area: Rect) {
888 let block = Block::default()
889 .borders(Borders::ALL)
890 .title(" Filters ")
891 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
892
893 let inner_area = block.inner(area);
894 f.render_widget(block, area);
895
896 let filter_chunks = Layout::default()
897 .direction(Direction::Vertical)
898 .constraints([
899 Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
904 .split(inner_area);
905
906 let date_layout = Layout::default()
908 .direction(Direction::Horizontal)
909 .constraints([
910 Constraint::Percentage(30),
911 Constraint::Percentage(35),
912 Constraint::Percentage(35),
913 ])
914 .split(filter_chunks[0]);
915
916 f.render_widget(
917 Paragraph::new("Start Date\nEnd Date")
918 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
919 date_layout[0],
920 );
921 f.render_widget(
922 Paragraph::new("2023-10-01\n2023-10-31")
923 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
924 date_layout[1],
925 );
926 f.render_widget(
927 Paragraph::new("Project\nDuration Filter")
928 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
929 date_layout[2],
930 );
931
932 let project_layout = Layout::default()
934 .direction(Direction::Horizontal)
935 .constraints([Constraint::Length(15), Constraint::Min(0)])
936 .split(filter_chunks[1]);
937
938 f.render_widget(
939 Paragraph::new("Project").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
940 project_layout[0],
941 );
942 f.render_widget(
943 Paragraph::new("Filter by project ▼")
944 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
945 project_layout[1],
946 );
947
948 let duration_layout = Layout::default()
950 .direction(Direction::Horizontal)
951 .constraints([Constraint::Length(15), Constraint::Min(0)])
952 .split(filter_chunks[2]);
953
954 f.render_widget(
955 Paragraph::new("Duration Filter").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
956 duration_layout[0],
957 );
958 f.render_widget(
959 Paragraph::new(">1h, <30m").style(Style::default().fg(ColorScheme::WHITE_TEXT)),
960 duration_layout[1],
961 );
962
963 let search_layout = Layout::default()
965 .direction(Direction::Horizontal)
966 .constraints([Constraint::Length(15), Constraint::Min(0)])
967 .split(filter_chunks[3]);
968
969 f.render_widget(
970 Paragraph::new("Free-text Search").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
971 search_layout[0],
972 );
973
974 let search_style = if self.filter_input_mode {
975 Style::default().fg(ColorScheme::CLEAN_BLUE)
976 } else {
977 Style::default().fg(ColorScheme::WHITE_TEXT)
978 };
979
980 let search_text = if self.session_filter.search_text.is_empty() {
981 "Search session notes and context..."
982 } else {
983 &self.session_filter.search_text
984 };
985
986 f.render_widget(
987 Paragraph::new(search_text).style(search_style),
988 search_layout[1],
989 );
990 }
991
992 fn render_session_list(&self, f: &mut Frame, area: Rect) {
993 let block = Block::default()
994 .borders(Borders::ALL)
995 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
996
997 let inner_area = block.inner(area);
998 f.render_widget(block, area);
999
1000 let header_row = Row::new(vec![
1002 Cell::from("DATE").style(Style::default().add_modifier(Modifier::BOLD)),
1003 Cell::from("PROJECT").style(Style::default().add_modifier(Modifier::BOLD)),
1004 Cell::from("DURATION").style(Style::default().add_modifier(Modifier::BOLD)),
1005 Cell::from("START").style(Style::default().add_modifier(Modifier::BOLD)),
1006 Cell::from("END").style(Style::default().add_modifier(Modifier::BOLD)),
1007 Cell::from("STATUS").style(Style::default().add_modifier(Modifier::BOLD)),
1008 ])
1009 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
1010 .bottom_margin(1);
1011
1012 let rows: Vec<Row> = self
1014 .history_sessions
1015 .iter()
1016 .enumerate()
1017 .map(|(i, session)| {
1018 let is_selected = i == self.selected_session_index;
1019 let style = if is_selected {
1020 Style::default()
1021 .bg(ColorScheme::CLEAN_BLUE)
1022 .fg(Color::Black)
1023 } else {
1024 Style::default().fg(ColorScheme::WHITE_TEXT)
1025 };
1026
1027 let status = if session.end_time.is_some() {
1028 "[✓] Completed"
1029 } else {
1030 "[▶] Running"
1031 };
1032
1033 let start_time = session
1034 .start_time
1035 .with_timezone(&Local)
1036 .format("%H:%M")
1037 .to_string();
1038 let end_time = if let Some(end) = session.end_time {
1039 end.with_timezone(&Local).format("%H:%M").to_string()
1040 } else {
1041 "--:--".to_string()
1042 };
1043
1044 let duration = if let Some(_) = session.end_time {
1045 let duration_secs =
1046 (session.start_time.timestamp() - session.start_time.timestamp()).abs();
1047 Formatter::format_duration(duration_secs)
1048 } else {
1049 "0h 0m".to_string()
1050 };
1051
1052 Row::new(vec![
1053 Cell::from(
1054 session
1055 .start_time
1056 .with_timezone(&Local)
1057 .format("%Y-%m-%d")
1058 .to_string(),
1059 ),
1060 Cell::from("Project Phoenix"), Cell::from(duration),
1062 Cell::from(start_time),
1063 Cell::from(end_time),
1064 Cell::from(status),
1065 ])
1066 .style(style)
1067 })
1068 .collect();
1069
1070 if rows.is_empty() {
1071 let sample_rows = vec![
1073 Row::new(vec![
1074 Cell::from("2023-10-26"),
1075 Cell::from("Project Phoenix"),
1076 Cell::from("2h 15m"),
1077 Cell::from("09:03"),
1078 Cell::from("11:18"),
1079 Cell::from("[✓] Completed"),
1080 ])
1081 .style(
1082 Style::default()
1083 .bg(ColorScheme::CLEAN_BLUE)
1084 .fg(Color::Black),
1085 ),
1086 Row::new(vec![
1087 Cell::from("2023-10-26"),
1088 Cell::from("Internal Tools"),
1089 Cell::from("0h 45m"),
1090 Cell::from("11:30"),
1091 Cell::from("12:15"),
1092 Cell::from("[✓] Completed"),
1093 ]),
1094 Row::new(vec![
1095 Cell::from("2023-10-25"),
1096 Cell::from("Project Phoenix"),
1097 Cell::from("4h 05m"),
1098 Cell::from("13:00"),
1099 Cell::from("17:05"),
1100 Cell::from("[✓] Completed"),
1101 ]),
1102 Row::new(vec![
1103 Cell::from("2023-10-25"),
1104 Cell::from("Client Support"),
1105 Cell::from("1h 00m"),
1106 Cell::from("10:00"),
1107 Cell::from("11:00"),
1108 Cell::from("[✓] Completed"),
1109 ]),
1110 Row::new(vec![
1111 Cell::from("2023-10-24"),
1112 Cell::from("Project Phoenix"),
1113 Cell::from("8h 00m"),
1114 Cell::from("09:00"),
1115 Cell::from("17:00"),
1116 Cell::from("[✓] Completed"),
1117 ]),
1118 Row::new(vec![
1119 Cell::from("2023-10-27"),
1120 Cell::from("Project Nova"),
1121 Cell::from("0h 22m"),
1122 Cell::from("14:00"),
1123 Cell::from("--:--"),
1124 Cell::from("[▶] Running"),
1125 ]),
1126 ];
1127
1128 let table = Table::new(sample_rows).header(header_row).widths(&[
1129 Constraint::Length(12),
1130 Constraint::Min(15),
1131 Constraint::Length(10),
1132 Constraint::Length(8),
1133 Constraint::Length(8),
1134 Constraint::Min(12),
1135 ]);
1136
1137 f.render_widget(table, inner_area);
1138 }
1139 }
1140
1141 fn render_session_details(&self, f: &mut Frame, area: Rect) {
1142 let block = Block::default()
1143 .borders(Borders::ALL)
1144 .title(" Session Details ")
1145 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1146
1147 let inner_area = block.inner(area);
1148 f.render_widget(block, area);
1149
1150 let details_chunks = Layout::default()
1151 .direction(Direction::Vertical)
1152 .constraints([
1153 Constraint::Length(3), Constraint::Length(2), Constraint::Length(3), ])
1157 .split(inner_area);
1158
1159 f.render_widget(
1161 Paragraph::new("SESSION NOTES\n\nWorked on the new authentication flow.\nImplemented JWT token refresh logic and fixed\nthe caching issue on the user profile page.\nReady for QA review.")
1162 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1163 .wrap(ratatui::widgets::Wrap { trim: true }),
1164 details_chunks[0],
1165 );
1166
1167 let tag_spans = vec![
1169 Span::styled(
1170 " #backend ",
1171 Style::default()
1172 .fg(Color::Black)
1173 .bg(ColorScheme::CLEAN_BLUE),
1174 ),
1175 Span::raw(" "),
1176 Span::styled(
1177 " #auth ",
1178 Style::default()
1179 .fg(Color::Black)
1180 .bg(ColorScheme::CLEAN_BLUE),
1181 ),
1182 Span::raw(" "),
1183 Span::styled(
1184 " #bugfix ",
1185 Style::default()
1186 .fg(Color::Black)
1187 .bg(ColorScheme::CLEAN_BLUE),
1188 ),
1189 ];
1190
1191 f.render_widget(
1192 Paragraph::new(vec![Line::from("TAGS"), Line::from(tag_spans)])
1193 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1194 details_chunks[1],
1195 );
1196
1197 let context_chunks = Layout::default()
1199 .direction(Direction::Vertical)
1200 .constraints([Constraint::Length(1), Constraint::Min(0)])
1201 .split(details_chunks[2]);
1202
1203 f.render_widget(
1204 Paragraph::new("CONTEXT").style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1205 context_chunks[0],
1206 );
1207
1208 let context_layout = Layout::default()
1209 .direction(Direction::Horizontal)
1210 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1211 .split(context_chunks[1]);
1212
1213 f.render_widget(
1214 Paragraph::new("Git\nBranch:\nIssue ID:\nCommit:")
1215 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1216 context_layout[0],
1217 );
1218 f.render_widget(
1219 Paragraph::new("feature/PHX-123-auth\nPHX-123\na1b2c3d")
1220 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1221 context_layout[1],
1222 );
1223 }
1224
1225 fn render_session_actions(&self, f: &mut Frame, area: Rect) {
1226 let button_layout = Layout::default()
1227 .direction(Direction::Horizontal)
1228 .constraints([
1229 Constraint::Percentage(25),
1230 Constraint::Percentage(25),
1231 Constraint::Percentage(25),
1232 Constraint::Percentage(25),
1233 ])
1234 .split(area);
1235
1236 let buttons = [
1237 ("[ Edit ]", ColorScheme::GRAY_TEXT),
1238 ("[ Duplicate ]", ColorScheme::GRAY_TEXT),
1239 ("[ Delete ]", Color::Red),
1240 ("", ColorScheme::GRAY_TEXT),
1241 ];
1242
1243 for (i, (text, color)) in buttons.iter().enumerate() {
1244 if !text.is_empty() {
1245 f.render_widget(
1246 Paragraph::new(*text)
1247 .alignment(Alignment::Center)
1248 .style(Style::default().fg(*color)),
1249 button_layout[i],
1250 );
1251 }
1252 }
1253 }
1254
1255 fn render_history_summary(&self, f: &mut Frame, area: Rect) {
1256 let block = Block::default()
1257 .borders(Borders::ALL)
1258 .title(" Summary ")
1259 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1260
1261 let inner_area = block.inner(area);
1262 f.render_widget(block, area);
1263
1264 f.render_widget(
1265 Paragraph::new("Showing 7 of 128 sessions. Total Duration: 17h 40m")
1266 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1267 .alignment(Alignment::Center),
1268 inner_area,
1269 );
1270 }
1271
1272 fn render_project_grid(&mut self, f: &mut Frame) {
1273 let area = f.size();
1274
1275 let main_layout = Layout::default()
1276 .direction(Direction::Vertical)
1277 .constraints([
1278 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(1), ])
1283 .split(area);
1284
1285 f.render_widget(
1287 Paragraph::new("Project Dashboard")
1288 .style(
1289 Style::default()
1290 .fg(ColorScheme::CLEAN_BLUE)
1291 .add_modifier(Modifier::BOLD),
1292 )
1293 .block(
1294 Block::default()
1295 .borders(Borders::BOTTOM)
1296 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1297 ),
1298 main_layout[0],
1299 );
1300
1301 self.render_project_cards(f, main_layout[1]);
1303
1304 self.render_project_stats_summary(f, main_layout[2]);
1306
1307 let hints = vec![
1309 ("↑/↓/←/→", "Navigate"),
1310 ("Enter", "Select"),
1311 ("Tab", "Next View"),
1312 ("q", "Quit"),
1313 ];
1314
1315 let spans: Vec<Span> = hints
1316 .iter()
1317 .flat_map(|(key, desc)| {
1318 vec![
1319 Span::styled(
1320 format!(" {} ", key),
1321 Style::default()
1322 .fg(Color::Yellow)
1323 .add_modifier(Modifier::BOLD),
1324 ),
1325 Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
1326 ]
1327 })
1328 .collect();
1329
1330 let line = Line::from(spans);
1331 let block = Block::default()
1332 .borders(Borders::TOP)
1333 .border_style(Style::default().fg(Color::DarkGray));
1334 Paragraph::new(line)
1335 .block(block)
1336 .render(main_layout[3], f.buffer_mut());
1337 }
1338
1339 fn render_project_cards(&mut self, f: &mut Frame, area: Rect) {
1340 if self.available_projects.is_empty() {
1341 let empty_block = Block::default()
1343 .borders(Borders::ALL)
1344 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
1345 .title(" No Projects Found ");
1346
1347 let empty_area = self.centered_rect(50, 30, area);
1348 f.render_widget(empty_block.clone(), empty_area);
1349
1350 let inner = empty_block.inner(empty_area);
1351 f.render_widget(
1352 Paragraph::new("No projects available.\n\nStart a session to create a project.")
1353 .alignment(Alignment::Center)
1354 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1355 inner,
1356 );
1357 return;
1358 }
1359
1360 let margin = 2;
1362 let card_height = 8;
1363 let card_spacing = 1;
1364
1365 let available_height = area.height.saturating_sub(margin * 2);
1367 let total_rows =
1368 (self.available_projects.len() + self.projects_per_row - 1) / self.projects_per_row;
1369 let visible_rows =
1370 (available_height / (card_height + card_spacing)).min(total_rows as u16) as usize;
1371
1372 for row in 0..visible_rows {
1374 let y_offset = margin + row as u16 * (card_height + card_spacing);
1375
1376 let row_area = Rect::new(area.x, area.y + y_offset, area.width, card_height);
1378 let card_constraints = vec![
1379 Constraint::Percentage(100 / self.projects_per_row as u16);
1380 self.projects_per_row
1381 ];
1382 let row_layout = Layout::default()
1383 .direction(Direction::Horizontal)
1384 .constraints(card_constraints)
1385 .margin(1)
1386 .split(row_area);
1387
1388 for col in 0..self.projects_per_row {
1390 let project_index = row * self.projects_per_row + col;
1391 if project_index >= self.available_projects.len() {
1392 break;
1393 }
1394
1395 let is_selected =
1396 row == self.selected_project_row && col == self.selected_project_col;
1397 self.render_project_card(f, row_layout[col], project_index, is_selected);
1398 }
1399 }
1400 }
1401
1402 fn render_project_card(
1403 &self,
1404 f: &mut Frame,
1405 area: Rect,
1406 project_index: usize,
1407 is_selected: bool,
1408 ) {
1409 if let Some(project) = self.available_projects.get(project_index) {
1410 let border_style = if is_selected {
1412 Style::default().fg(ColorScheme::CLEAN_BLUE)
1413 } else {
1414 Style::default().fg(ColorScheme::GRAY_TEXT)
1415 };
1416
1417 let bg_color = if is_selected {
1418 ColorScheme::CLEAN_BG
1419 } else {
1420 Color::Black
1421 };
1422
1423 let card_block = Block::default()
1424 .borders(Borders::ALL)
1425 .border_style(border_style)
1426 .style(Style::default().bg(bg_color));
1427
1428 f.render_widget(card_block.clone(), area);
1429
1430 let inner_area = card_block.inner(area);
1431 let card_layout = Layout::default()
1432 .direction(Direction::Vertical)
1433 .constraints([
1434 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), ])
1440 .split(inner_area);
1441
1442 let name = if project.name.len() > 20 {
1444 format!("{}...", &project.name[..17])
1445 } else {
1446 project.name.clone()
1447 };
1448
1449 f.render_widget(
1450 Paragraph::new(name)
1451 .style(
1452 Style::default()
1453 .fg(ColorScheme::WHITE_TEXT)
1454 .add_modifier(Modifier::BOLD),
1455 )
1456 .alignment(Alignment::Center),
1457 card_layout[0],
1458 );
1459
1460 let path_str = project.path.to_string_lossy();
1462 let short_path = if path_str.len() > 25 {
1463 format!("...{}", &path_str[path_str.len() - 22..])
1464 } else {
1465 path_str.to_string()
1466 };
1467
1468 f.render_widget(
1469 Paragraph::new(short_path)
1470 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
1471 .alignment(Alignment::Center),
1472 card_layout[1],
1473 );
1474
1475 let stats_layout = Layout::default()
1477 .direction(Direction::Horizontal)
1478 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1479 .split(card_layout[3]);
1480
1481 f.render_widget(
1482 Paragraph::new("Sessions\n42")
1483 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1484 .alignment(Alignment::Center),
1485 stats_layout[0],
1486 );
1487
1488 f.render_widget(
1489 Paragraph::new("Time\n24h 15m")
1490 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1491 .alignment(Alignment::Center),
1492 stats_layout[1],
1493 );
1494
1495 let status = if project.is_archived {
1497 (" Archived ", Color::Red)
1498 } else {
1499 (" Active ", ColorScheme::CLEAN_GREEN)
1500 };
1501
1502 f.render_widget(
1503 Paragraph::new(status.0)
1504 .style(Style::default().fg(status.1))
1505 .alignment(Alignment::Center),
1506 card_layout[4],
1507 );
1508 }
1509 }
1510
1511 fn render_project_stats_summary(&self, f: &mut Frame, area: Rect) {
1512 let block = Block::default()
1513 .borders(Borders::ALL)
1514 .title(" Summary ")
1515 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1516
1517 f.render_widget(block.clone(), area);
1518
1519 let inner = block.inner(area);
1520 let stats_layout = Layout::default()
1521 .direction(Direction::Horizontal)
1522 .constraints([
1523 Constraint::Percentage(25),
1524 Constraint::Percentage(25),
1525 Constraint::Percentage(25),
1526 Constraint::Percentage(25),
1527 ])
1528 .split(inner);
1529
1530 let total_projects = self.available_projects.len();
1531 let active_projects = self
1532 .available_projects
1533 .iter()
1534 .filter(|p| !p.is_archived)
1535 .count();
1536 let archived_projects = total_projects - active_projects;
1537
1538 let stats = [
1539 ("Total Projects", total_projects.to_string()),
1540 ("Active", active_projects.to_string()),
1541 ("Archived", archived_projects.to_string()),
1542 (
1543 "Selected",
1544 format!(
1545 "{}/{}",
1546 self.selected_project_row * self.projects_per_row
1547 + self.selected_project_col
1548 + 1,
1549 total_projects
1550 ),
1551 ),
1552 ];
1553
1554 for (i, (label, value)) in stats.iter().enumerate() {
1555 let content = Paragraph::new(vec![
1556 Line::from(Span::styled(
1557 *label,
1558 Style::default().fg(ColorScheme::GRAY_TEXT),
1559 )),
1560 Line::from(Span::styled(
1561 value.as_str(),
1562 Style::default()
1563 .fg(ColorScheme::WHITE_TEXT)
1564 .add_modifier(Modifier::BOLD),
1565 )),
1566 ])
1567 .alignment(Alignment::Center);
1568
1569 f.render_widget(content, stats_layout[i]);
1570 }
1571 }
1572
1573 fn render_active_session_panel(
1574 &self,
1575 f: &mut Frame,
1576 area: Rect,
1577 session: &Option<Session>,
1578 project: &Option<Project>,
1579 ) {
1580 use crate::ui::animations::pulse_color;
1582 let border_color = if session.is_some() {
1583 pulse_color(
1584 ColorScheme::PRIMARY_DASHBOARD,
1585 ColorScheme::PRIMARY_FOCUS,
1586 self.pulsing_indicator.start_time.elapsed(),
1587 Duration::from_millis(2000),
1588 )
1589 } else {
1590 ColorScheme::BORDER_DARK
1591 };
1592
1593 let block = Block::default()
1594 .borders(Borders::ALL)
1595 .border_style(Style::default().fg(border_color))
1596 .style(Style::default().bg(ColorScheme::BG_DARK));
1597
1598 f.render_widget(block.clone(), area);
1599
1600 let inner_area = block.inner(area);
1601 let layout = Layout::default()
1602 .direction(Direction::Vertical)
1603 .constraints([
1604 Constraint::Length(1), Constraint::Length(1), Constraint::Min(4), ])
1608 .margin(1)
1609 .split(inner_area);
1610
1611 f.render_widget(
1613 Paragraph::new("Active Session")
1614 .style(
1615 Style::default()
1616 .fg(ColorScheme::TEXT_MAIN)
1617 .add_modifier(Modifier::BOLD),
1618 )
1619 .block(
1620 Block::default()
1621 .borders(Borders::BOTTOM)
1622 .border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
1623 ),
1624 layout[0],
1625 );
1626
1627 if let Some(session) = session {
1628 let project_name = project
1629 .as_ref()
1630 .map(|p| p.name.as_str())
1631 .unwrap_or("Unknown Project");
1632
1633 let content_layout = Layout::default()
1634 .direction(Direction::Vertical)
1635 .constraints([
1636 Constraint::Length(3), Constraint::Min(3), ])
1639 .split(layout[2]);
1640
1641 f.render_widget(
1643 Paragraph::new(vec![
1644 Line::from(Span::styled(
1645 project_name,
1646 Style::default()
1647 .fg(ColorScheme::TEXT_MAIN)
1648 .add_modifier(Modifier::BOLD)
1649 .add_modifier(Modifier::UNDERLINED), )),
1651 Line::from(Span::styled(
1652 "▶ RUNNING",
1653 Style::default().fg(ColorScheme::SUCCESS),
1654 )),
1655 ])
1656 .alignment(Alignment::Center),
1657 content_layout[0],
1658 );
1659
1660 let now = Local::now();
1662 let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
1663 - session.paused_duration.num_seconds();
1664
1665 let hours = elapsed_seconds / 3600;
1666 let minutes = (elapsed_seconds % 3600) / 60;
1667 let seconds = elapsed_seconds % 60;
1668
1669 let timer_layout = Layout::default()
1674 .direction(Direction::Horizontal)
1675 .constraints([
1676 Constraint::Ratio(1, 3),
1677 Constraint::Ratio(1, 3),
1678 Constraint::Ratio(1, 3),
1679 ])
1680 .split(content_layout[1]);
1681
1682 self.render_timer_digit(f, timer_layout[0], hours, "Hours");
1683 self.render_timer_digit(f, timer_layout[1], minutes, "Minutes");
1684 self.render_timer_digit(f, timer_layout[2], seconds, "Seconds");
1685 } else {
1686 f.render_widget(
1688 Paragraph::new("No Active Session\n\nPress 's' to start tracking")
1689 .alignment(Alignment::Center)
1690 .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1691 layout[2],
1692 );
1693 }
1694 }
1695
1696 fn render_timer_digit(&self, f: &mut Frame, area: Rect, value: i64, label: &str) {
1697 let block = Block::default()
1698 .borders(Borders::ALL)
1699 .border_style(Style::default().fg(ColorScheme::BORDER_DARK))
1700 .style(Style::default().bg(ColorScheme::PANEL_DARK));
1701
1702 let inner = block.inner(area);
1703 f.render_widget(block, area);
1704
1705 let layout = Layout::default()
1706 .direction(Direction::Vertical)
1707 .constraints([
1708 Constraint::Min(1), Constraint::Length(1), ])
1711 .split(inner);
1712
1713 f.render_widget(
1714 Paragraph::new(format!("{:02}", value))
1715 .alignment(Alignment::Center)
1716 .style(
1717 Style::default()
1718 .fg(ColorScheme::TEXT_MAIN)
1719 .add_modifier(Modifier::BOLD),
1720 ),
1721 layout[0],
1722 );
1723
1724 f.render_widget(
1725 Paragraph::new(label)
1726 .alignment(Alignment::Center)
1727 .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1728 layout[1],
1729 );
1730 }
1731
1732 fn render_quick_stats(&self, f: &mut Frame, area: Rect, daily_stats: &(i64, i64, i64)) {
1733 let (_sessions_count, total_seconds, _avg_seconds) = *daily_stats;
1734
1735 let block = Block::default()
1736 .borders(Borders::ALL)
1737 .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
1738
1739 f.render_widget(block.clone(), area);
1740 let inner_area = block.inner(area);
1741
1742 let layout = Layout::default()
1743 .direction(Direction::Vertical)
1744 .constraints([
1745 Constraint::Length(2), Constraint::Min(1), ])
1748 .split(inner_area);
1749
1750 f.render_widget(
1752 Paragraph::new("Quick Stats")
1753 .style(
1754 Style::default()
1755 .fg(ColorScheme::TEXT_MAIN)
1756 .add_modifier(Modifier::BOLD),
1757 )
1758 .block(
1759 Block::default()
1760 .borders(Borders::BOTTOM)
1761 .border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
1762 ),
1763 layout[0],
1764 );
1765
1766 let stats_layout = Layout::default()
1768 .direction(Direction::Vertical)
1769 .constraints([
1770 Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), ])
1774 .margin(1)
1775 .split(layout[1]);
1776
1777 let stats = [
1778 (
1779 "Today's Total",
1780 Formatter::format_duration(total_seconds),
1781 stats_layout[0],
1782 ),
1783 (
1784 "This Week's Total",
1785 Formatter::format_duration(self.weekly_stats),
1786 stats_layout[1],
1787 ),
1788 (
1789 "Active Projects",
1790 self.available_projects
1791 .iter()
1792 .filter(|p| !p.is_archived)
1793 .count()
1794 .to_string(),
1795 stats_layout[2],
1796 ),
1797 ];
1798
1799 for (label, value, chunk) in stats.iter() {
1800 let item_block = Block::default()
1801 .borders(Borders::ALL)
1802 .border_style(Style::default().fg(ColorScheme::BORDER_DARK))
1803 .style(Style::default().bg(ColorScheme::PANEL_DARK));
1804
1805 f.render_widget(item_block.clone(), *chunk);
1806 let item_inner = item_block.inner(*chunk);
1807
1808 let item_layout = Layout::default()
1809 .direction(Direction::Vertical)
1810 .constraints([
1811 Constraint::Length(1), Constraint::Length(1), ])
1814 .split(item_inner);
1815
1816 f.render_widget(
1817 Paragraph::new(*label).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1818 item_layout[0],
1819 );
1820
1821 f.render_widget(
1822 Paragraph::new(value.as_str()).style(
1823 Style::default()
1824 .fg(ColorScheme::TEXT_MAIN)
1825 .add_modifier(Modifier::BOLD),
1826 ),
1827 item_layout[1],
1828 );
1829 }
1830 }
1831
1832 fn render_projects_table(&self, f: &mut Frame, area: Rect) {
1833 let block = Block::default()
1834 .borders(Borders::ALL)
1835 .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
1836
1837 f.render_widget(block.clone(), area);
1838 let inner_area = block.inner(area);
1839
1840 let layout = Layout::default()
1841 .direction(Direction::Vertical)
1842 .constraints([
1843 Constraint::Length(2), Constraint::Min(1), ])
1846 .split(inner_area);
1847
1848 f.render_widget(
1850 Paragraph::new("Project List")
1851 .style(
1852 Style::default()
1853 .fg(ColorScheme::TEXT_MAIN)
1854 .add_modifier(Modifier::BOLD),
1855 )
1856 .block(
1857 Block::default()
1858 .borders(Borders::BOTTOM)
1859 .border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
1860 ),
1861 layout[0],
1862 );
1863
1864 let header_row = Row::new(vec![
1866 Cell::from("PROJECT NAME").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1867 Cell::from("TIME TODAY").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1868 Cell::from("TOTAL TIME").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1869 Cell::from("LAST ACTIVITY").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1870 ])
1871 .bottom_margin(1);
1872
1873 let rows: Vec<Row> = self
1874 .recent_projects
1875 .iter()
1876 .map(|p| {
1877 let time_today = Formatter::format_duration(p.today_seconds);
1878 let total_time = Formatter::format_duration(p.total_seconds);
1879 let last_activity = if let Some(last) = p.last_active {
1880 let now = chrono::Utc::now();
1881 let diff = now - last;
1882 if diff.num_days() == 0 {
1883 format!("Today, {}", last.with_timezone(&Local).format("%H:%M"))
1884 } else if diff.num_days() == 1 {
1885 "Yesterday".to_string()
1886 } else {
1887 format!("{} days ago", diff.num_days())
1888 }
1889 } else {
1890 "Never".to_string()
1891 };
1892
1893 Row::new(vec![
1894 Cell::from(p.project.name.clone()).style(
1895 Style::default()
1896 .fg(ColorScheme::TEXT_MAIN)
1897 .add_modifier(Modifier::BOLD),
1898 ),
1899 Cell::from(time_today).style(Style::default().fg(if p.today_seconds > 0 {
1900 ColorScheme::SUCCESS
1901 } else {
1902 ColorScheme::TEXT_SECONDARY
1903 })),
1904 Cell::from(total_time).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1905 Cell::from(last_activity)
1906 .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
1907 ])
1908 })
1909 .collect();
1910
1911 let table = Table::new(rows)
1912 .header(header_row)
1913 .widths(&[
1914 Constraint::Percentage(30),
1915 Constraint::Percentage(20),
1916 Constraint::Percentage(20),
1917 Constraint::Percentage(30),
1918 ])
1919 .column_spacing(1);
1920
1921 f.render_widget(table, layout[1]);
1922 }
1923
1924 fn render_activity_timeline(&self, f: &mut Frame, area: Rect) {
1925 let block = Block::default()
1926 .borders(Borders::ALL)
1927 .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
1928
1929 f.render_widget(block.clone(), area);
1930 let inner_area = block.inner(area);
1931
1932 let layout = Layout::default()
1933 .direction(Direction::Vertical)
1934 .constraints([
1935 Constraint::Length(2), Constraint::Min(1), ])
1938 .split(inner_area);
1939
1940 f.render_widget(
1942 Paragraph::new("Activity Timeline")
1943 .style(
1944 Style::default()
1945 .fg(ColorScheme::TEXT_MAIN)
1946 .add_modifier(Modifier::BOLD),
1947 )
1948 .block(
1949 Block::default()
1950 .borders(Borders::BOTTOM)
1951 .border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
1952 ),
1953 layout[0],
1954 );
1955
1956 let timeline_area = layout[1];
1957 let bar_area = Rect::new(
1958 timeline_area.x,
1959 timeline_area.y + 1,
1960 timeline_area.width,
1961 2, );
1963
1964 f.render_widget(
1966 Block::default().style(Style::default().bg(ColorScheme::PANEL_DARK)),
1967 bar_area,
1968 );
1969
1970 let total_width = bar_area.width as f64;
1972 let seconds_in_day = 86400.0;
1973
1974 for session in &self.today_sessions {
1975 let start_seconds = session
1976 .start_time
1977 .with_timezone(&Local)
1978 .num_seconds_from_midnight() as f64;
1979 let end_seconds = if let Some(end) = session.end_time {
1980 end.with_timezone(&Local).num_seconds_from_midnight() as f64
1981 } else {
1982 Local::now().num_seconds_from_midnight() as f64
1983 };
1984
1985 let start_x = (start_seconds / seconds_in_day * total_width).floor() as u16;
1986 let width =
1987 ((end_seconds - start_seconds) / seconds_in_day * total_width).ceil() as u16;
1988
1989 let draw_width = width.min(bar_area.width.saturating_sub(start_x));
1990
1991 if draw_width > 0 {
1992 let segment_area = Rect::new(
1993 bar_area.x + start_x,
1994 bar_area.y,
1995 draw_width,
1996 bar_area.height,
1997 );
1998
1999 let color = if session.end_time.is_none() {
2000 ColorScheme::SUCCESS
2001 } else {
2002 ColorScheme::SUCCESS };
2004
2005 f.render_widget(
2006 Block::default().style(Style::default().bg(color)),
2007 segment_area,
2008 );
2009 }
2010 }
2011
2012 let labels_y = bar_area.y + bar_area.height + 1;
2014 let labels = ["00:00", "06:00", "12:00", "18:00", "24:00"];
2015 let positions = [0.0, 0.25, 0.5, 0.75, 1.0];
2016
2017 for (label, pos) in labels.iter().zip(positions.iter()) {
2018 let x = (timeline_area.x as f64 + (timeline_area.width as f64 * pos)
2019 - (label.len() as f64 / 2.0)) as u16;
2020 let x = x
2021 .max(timeline_area.x)
2022 .min(timeline_area.x + timeline_area.width - label.len() as u16);
2023
2024 f.render_widget(
2025 Paragraph::new(*label).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
2026 Rect::new(x, labels_y, label.len() as u16, 1),
2027 );
2028 }
2029 }
2030
2031 fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
2032 let popup_area = self.centered_rect(60, 50, area);
2033
2034 let block = Block::default()
2035 .borders(Borders::ALL)
2036 .border_style(Style::default().fg(ColorScheme::CLEAN_BLUE))
2037 .title(" Select Project ")
2038 .title_alignment(Alignment::Center)
2039 .style(Style::default().bg(ColorScheme::CLEAN_BG));
2040
2041 f.render_widget(block.clone(), popup_area);
2042
2043 let list_area = block.inner(popup_area);
2044
2045 if self.available_projects.is_empty() {
2046 let no_projects = Paragraph::new("No projects found")
2047 .alignment(Alignment::Center)
2048 .style(Style::default().fg(ColorScheme::GRAY_TEXT));
2049 f.render_widget(no_projects, list_area);
2050 } else {
2051 let items: Vec<ListItem> = self
2052 .available_projects
2053 .iter()
2054 .enumerate()
2055 .map(|(i, p)| {
2056 let style = if i == self.selected_project_index {
2057 Style::default()
2058 .fg(ColorScheme::CLEAN_BG)
2059 .bg(ColorScheme::CLEAN_BLUE)
2060 } else {
2061 Style::default().fg(ColorScheme::WHITE_TEXT)
2062 };
2063 ListItem::new(format!(" {} ", p.name)).style(style)
2064 })
2065 .collect();
2066
2067 let list = List::new(items);
2068 f.render_widget(list, list_area);
2069 }
2070 }
2071
2072 fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2073 let popup_layout = Layout::default()
2074 .direction(Direction::Vertical)
2075 .constraints([
2076 Constraint::Percentage((100 - percent_y) / 2),
2077 Constraint::Percentage(percent_y),
2078 Constraint::Percentage((100 - percent_y) / 2),
2079 ])
2080 .split(r);
2081
2082 Layout::default()
2083 .direction(Direction::Horizontal)
2084 .constraints([
2085 Constraint::Percentage((100 - percent_x) / 2),
2086 Constraint::Percentage(percent_x),
2087 Constraint::Percentage((100 - percent_x) / 2),
2088 ])
2089 .split(popup_layout[1])[1]
2090 }
2091
2092 async fn get_current_session(&mut self) -> Result<Option<Session>> {
2093 if !is_daemon_running() {
2094 return Ok(None);
2095 }
2096
2097 self.ensure_connected().await?;
2098
2099 let response = self
2100 .client
2101 .send_message(&IpcMessage::GetActiveSession)
2102 .await?;
2103 match response {
2104 IpcResponse::ActiveSession(session) => Ok(session),
2105 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
2106 _ => Ok(None),
2107 }
2108 }
2109
2110 async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
2111 if !is_daemon_running() {
2112 return Ok(None);
2113 }
2114
2115 self.ensure_connected().await?;
2116
2117 let response = self
2118 .client
2119 .send_message(&IpcMessage::GetProject(session.project_id))
2120 .await?;
2121 match response {
2122 IpcResponse::Project(project) => Ok(project),
2123 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
2124 _ => Ok(None),
2125 }
2126 }
2127
2128 async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> {
2129 if !is_daemon_running() {
2131 return Ok((0, 0, 0));
2132 }
2133
2134 self.ensure_connected().await?;
2135
2136 let today = chrono::Local::now().date_naive();
2137 let response = self
2138 .client
2139 .send_message(&IpcMessage::GetDailyStats(today))
2140 .await?;
2141 match response {
2142 IpcResponse::DailyStats {
2143 sessions_count,
2144 total_seconds,
2145 avg_seconds,
2146 } => Ok((sessions_count, total_seconds, avg_seconds)),
2147 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
2148 _ => Ok((0, 0, 0)),
2149 }
2150 }
2151
2152 async fn get_today_sessions(&mut self) -> Result<Vec<Session>> {
2153 if !is_daemon_running() {
2154 return Ok(Vec::new());
2155 }
2156
2157 self.ensure_connected().await?;
2158
2159 let today = chrono::Local::now().date_naive();
2160 let response = self
2161 .client
2162 .send_message(&IpcMessage::GetSessionsForDate(today))
2163 .await?;
2164 match response {
2165 IpcResponse::SessionList(sessions) => Ok(sessions),
2166 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get sessions: {}", e)),
2167 _ => Ok(Vec::new()),
2168 }
2169 }
2170
2171 async fn get_history_sessions(&mut self) -> Result<Vec<Session>> {
2172 if !is_daemon_running() {
2173 return Ok(Vec::new());
2174 }
2175
2176 self.ensure_connected().await?;
2177
2178 let end_date = chrono::Local::now().date_naive();
2180 let _start_date = end_date - chrono::Duration::days(30);
2181
2182 let mut all_sessions = Vec::new();
2185 for days_ago in 0..30 {
2186 let date = end_date - chrono::Duration::days(days_ago);
2187 if let Ok(IpcResponse::SessionList(sessions)) = self
2188 .client
2189 .send_message(&IpcMessage::GetSessionsForDate(date))
2190 .await
2191 {
2192 all_sessions.extend(sessions);
2193 }
2194 }
2195
2196 let filtered_sessions: Vec<Session> = all_sessions
2198 .into_iter()
2199 .filter(|session| {
2200 if !self.session_filter.search_text.is_empty() {
2202 if let Some(notes) = &session.notes {
2203 if !notes
2204 .to_lowercase()
2205 .contains(&self.session_filter.search_text.to_lowercase())
2206 {
2207 return false;
2208 }
2209 } else {
2210 return false;
2211 }
2212 }
2213 true
2214 })
2215 .collect();
2216
2217 Ok(filtered_sessions)
2218 }
2219
2220 async fn send_activity_heartbeat(&mut self) -> Result<()> {
2221 if !is_daemon_running() {
2222 return Ok(());
2223 }
2224
2225 self.ensure_connected().await?;
2226
2227 let _response = self
2228 .client
2229 .send_message(&IpcMessage::ActivityHeartbeat)
2230 .await?;
2231 Ok(())
2232 }
2233
2234 fn navigate_projects(&mut self, direction: i32) {
2237 if self.available_projects.is_empty() {
2238 return;
2239 }
2240
2241 let new_index = self.selected_project_index as i32 + direction;
2242 if new_index >= 0 && new_index < self.available_projects.len() as i32 {
2243 self.selected_project_index = new_index as usize;
2244 }
2245 }
2246
2247 async fn refresh_projects(&mut self) -> Result<()> {
2248 if !is_daemon_running() {
2249 return Ok(());
2250 }
2251
2252 self.ensure_connected().await?;
2253
2254 let response = self.client.send_message(&IpcMessage::ListProjects).await?;
2255 if let IpcResponse::ProjectList(projects) = response {
2256 self.available_projects = projects;
2257 self.selected_project_index = 0;
2258 }
2259 Ok(())
2260 }
2261
2262 async fn switch_to_selected_project(&mut self) -> Result<()> {
2263 if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
2264 let project_id = selected_project.id.unwrap_or(0);
2265
2266 self.ensure_connected().await?;
2267
2268 let response = self
2270 .client
2271 .send_message(&IpcMessage::SwitchProject(project_id))
2272 .await?;
2273 match response {
2274 IpcResponse::Success => {
2275 self.show_project_switcher = false;
2276 }
2277 IpcResponse::Error(e) => {
2278 return Err(anyhow::anyhow!("Failed to switch project: {}", e))
2279 }
2280 _ => return Err(anyhow::anyhow!("Unexpected response")),
2281 }
2282 }
2283 Ok(())
2284 }
2285
2286 fn render_header(&self, f: &mut Frame, area: Rect) {
2287 let time_str = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
2288
2289 let header_layout = Layout::default()
2290 .direction(Direction::Horizontal)
2291 .constraints([
2292 Constraint::Min(20), Constraint::Min(30), ])
2295 .split(area);
2296
2297 let title_block = Block::default()
2299 .borders(Borders::BOTTOM)
2300 .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
2301
2302 let title_inner = title_block.inner(header_layout[0]);
2303 f.render_widget(title_block, header_layout[0]);
2304
2305 f.render_widget(
2306 Paragraph::new("Tempo TUI").style(
2307 Style::default()
2308 .fg(ColorScheme::TEXT_MAIN)
2309 .add_modifier(Modifier::BOLD),
2310 ),
2311 title_inner,
2312 );
2313
2314 let status_block = Block::default()
2316 .borders(Borders::BOTTOM)
2317 .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
2318
2319 let status_inner = status_block.inner(header_layout[1]);
2320 f.render_widget(status_block, header_layout[1]);
2321
2322 let status_layout = Layout::default()
2323 .direction(Direction::Horizontal)
2324 .constraints([
2325 Constraint::Min(20), Constraint::Length(15), ])
2328 .split(status_inner);
2329
2330 f.render_widget(
2331 Paragraph::new(time_str)
2332 .alignment(Alignment::Right)
2333 .style(Style::default().fg(ColorScheme::TEXT_MAIN)),
2334 status_layout[0],
2335 );
2336
2337 let daemon_status_line = if is_daemon_running() {
2339 let spinner_frame = self.animated_spinner.current();
2340 Line::from(vec![
2341 Span::raw("Daemon: "),
2342 Span::styled(
2343 spinner_frame,
2344 Style::default().fg(ColorScheme::SUCCESS),
2345 ),
2346 Span::raw(" "),
2347 Span::styled(
2348 "Running",
2349 Style::default()
2350 .fg(ColorScheme::SUCCESS)
2351 .add_modifier(Modifier::BOLD),
2352 ),
2353 ])
2354 } else {
2355 Line::from(vec![
2356 Span::raw("Daemon: "),
2357 Span::styled(
2358 "Offline",
2359 Style::default()
2360 .fg(ColorScheme::ERROR)
2361 .add_modifier(Modifier::BOLD),
2362 ),
2363 ])
2364 };
2365
2366 f.render_widget(
2367 Paragraph::new(daemon_status_line)
2368 .alignment(Alignment::Right),
2369 status_layout[1],
2370 );
2371 }
2372
2373 fn get_daily_stats(&self) -> &(i64, i64, i64) {
2374 &self.daily_stats
2375 }
2376}