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