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, SessionStatsWidget, 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 = (total_projects + self.projects_per_row - 1) / self.projects_per_row;
315 if self.selected_project_row < total_rows.saturating_sub(1) {
316 let next_row_first_index = (self.selected_project_row + 1) * self.projects_per_row;
318 if next_row_first_index < total_projects {
319 self.selected_project_row += 1;
320 }
321 }
322 }
323 KeyCode::Left | KeyCode::Char('h') => {
324 if self.selected_project_col > 0 {
325 self.selected_project_col -= 1;
326 }
327 }
328 KeyCode::Right | KeyCode::Char('l') => {
329 let row_start = self.selected_project_row * self.projects_per_row;
330 let row_end = (row_start + self.projects_per_row).min(self.available_projects.len());
331 let max_col = (row_end - row_start).saturating_sub(1);
332 if self.selected_project_col < max_col {
333 self.selected_project_col += 1;
334 }
335 }
336 KeyCode::Enter => {
338 self.switch_to_grid_selected_project().await?;
339 }
340 _ => {}
341 }
342 Ok(())
343 }
344
345 async fn handle_project_switcher_input(&mut self, key: KeyEvent) -> Result<()> {
346 match key.code {
347 KeyCode::Esc => {
348 self.show_project_switcher = false;
349 }
350 KeyCode::Up | KeyCode::Char('k') => {
351 self.navigate_projects(-1);
352 }
353 KeyCode::Down | KeyCode::Char('j') => {
354 self.navigate_projects(1);
355 }
356 KeyCode::Enter => {
357 self.switch_to_selected_project().await?;
358 }
359 _ => {}
360 }
361 Ok(())
362 }
363
364 async fn ensure_connected(&mut self) -> Result<()> {
365 if !is_daemon_running() {
366 return Err(anyhow::anyhow!("Daemon is not running"));
367 }
368
369 if self.client.stream.is_some() {
371 return Ok(());
372 }
373
374 let socket_path = get_socket_path()?;
376 if socket_path.exists() {
377 self.client = IpcClient::connect(&socket_path).await?;
378 }
379 Ok(())
380 }
381
382 async fn switch_to_grid_selected_project(&mut self) -> Result<()> {
383 let selected_index = self.selected_project_row * self.projects_per_row + self.selected_project_col;
384 if let Some(selected_project) = self.available_projects.get(selected_index) {
385 let project_id = selected_project.id.unwrap_or(0);
386
387 self.ensure_connected().await?;
388
389 let response = self
391 .client
392 .send_message(&IpcMessage::SwitchProject(project_id))
393 .await?;
394 match response {
395 IpcResponse::Success => {
396 self.current_view = DashboardView::FocusedSession;
398 }
399 IpcResponse::Error(e) => {
400 return Err(anyhow::anyhow!("Failed to switch project: {}", e))
401 }
402 _ => return Err(anyhow::anyhow!("Unexpected response")),
403 }
404 }
405 Ok(())
406 }
407
408 fn render_keyboard_hints(&self, area: Rect, buf: &mut Buffer) {
409 let hints = match self.current_view {
410 DashboardView::FocusedSession => vec![
411 ("Esc", "Exit Focus"),
412 ("Tab", "Next View"),
413 ("p", "Projects"),
414 ],
415 DashboardView::History => vec![
416 ("↑/↓", "Navigate"),
417 ("/", "Search"),
418 ("Tab", "Next View"),
419 ("q", "Quit"),
420 ],
421 DashboardView::Projects => vec![
422 ("↑/↓/←/→", "Navigate"),
423 ("Enter", "Select"),
424 ("Tab", "Next View"),
425 ("q", "Quit"),
426 ],
427 _ => vec![
428 ("q", "Quit"),
429 ("f", "Focus"),
430 ("Tab", "Next View"),
431 ("1-4", "View"),
432 ("p", "Projects"),
433 ],
434 };
435
436 let spans: Vec<Span> = hints
437 .iter()
438 .flat_map(|(key, desc)| {
439 vec![
440 Span::styled(
441 format!(" {} ", key),
442 Style::default()
443 .fg(Color::Yellow)
444 .add_modifier(Modifier::BOLD),
445 ),
446 Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
447 ]
448 })
449 .collect();
450
451 let line = Line::from(spans);
452 let block = Block::default()
453 .borders(Borders::TOP)
454 .border_style(Style::default().fg(Color::DarkGray));
455 Paragraph::new(line).block(block).render(area, buf);
456 }
457
458 fn render_dashboard_sync(&mut self, f: &mut Frame) {
459 match self.current_view {
460 DashboardView::FocusedSession => self.render_focused_session_view(f),
461 DashboardView::Overview => self.render_overview_dashboard(f),
462 DashboardView::History => self.render_history_browser(f),
463 DashboardView::Projects => self.render_project_grid(f),
464 }
465
466 if self.show_project_switcher {
468 self.render_project_switcher(f, f.size());
469 }
470 }
471
472 fn render_focused_session_view(&mut self, f: &mut Frame) {
473 let chunks = Layout::default()
474 .direction(Direction::Vertical)
475 .constraints([
476 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), ])
486 .split(f.size());
487
488 let header_layout = Layout::default()
490 .direction(Direction::Horizontal)
491 .constraints([Constraint::Percentage(100)])
492 .split(chunks[0]);
493
494 f.render_widget(
495 Paragraph::new("Press ESC to exit focused mode.")
496 .alignment(Alignment::Center)
497 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
498 header_layout[0],
499 );
500
501 if let (Some(session), Some(project)) = (&self.current_session, &self.current_project) {
502 let project_area = self.centered_rect(60, 20, chunks[2]);
504 let project_block = Block::default()
505 .borders(Borders::ALL)
506 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
507 .style(Style::default().bg(ColorScheme::CLEAN_BG));
508
509 let project_layout = Layout::default()
510 .direction(Direction::Vertical)
511 .constraints([
512 Constraint::Length(1),
513 Constraint::Length(1),
514 Constraint::Length(1),
515 Constraint::Length(1),
516 ])
517 .margin(1)
518 .split(project_area);
519
520 f.render_widget(project_block, project_area);
521
522 f.render_widget(
524 Paragraph::new(project.name.clone())
525 .alignment(Alignment::Center)
526 .style(
527 Style::default()
528 .fg(ColorScheme::WHITE_TEXT)
529 .add_modifier(Modifier::BOLD),
530 ),
531 project_layout[0],
532 );
533
534 let default_description = "Refactor authentication module".to_string();
536 let description = project
537 .description
538 .as_ref()
539 .unwrap_or(&default_description);
540 f.render_widget(
541 Paragraph::new(description.clone())
542 .alignment(Alignment::Center)
543 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
544 project_layout[1],
545 );
546
547 let timer_area = self.centered_rect(40, 20, chunks[4]);
549 let timer_block = Block::default()
550 .borders(Borders::ALL)
551 .border_style(Style::default().fg(ColorScheme::CLEAN_GREEN))
552 .style(Style::default().bg(Color::Black));
553
554 let timer_inner = timer_block.inner(timer_area);
555 f.render_widget(timer_block, timer_area);
556
557 let now = Local::now();
559 let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
560 - session.paused_duration.num_seconds();
561 let duration_str = Formatter::format_duration_clock(elapsed_seconds);
562
563 f.render_widget(
564 Paragraph::new(duration_str)
565 .alignment(Alignment::Center)
566 .style(
567 Style::default()
568 .fg(ColorScheme::CLEAN_GREEN)
569 .add_modifier(Modifier::BOLD),
570 ),
571 timer_inner,
572 );
573
574 let details_area = self.centered_rect(60, 25, chunks[6]);
576 let details_block = Block::default()
577 .borders(Borders::ALL)
578 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
579 .style(Style::default().bg(ColorScheme::CLEAN_BG));
580
581 let details_layout = Layout::default()
582 .direction(Direction::Vertical)
583 .constraints([
584 Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), ])
588 .margin(1)
589 .split(details_area);
590
591 f.render_widget(details_block, details_area);
592
593 let start_time_layout = Layout::default()
595 .direction(Direction::Horizontal)
596 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
597 .split(details_layout[0]);
598
599 f.render_widget(
600 Paragraph::new("Start Time")
601 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
602 start_time_layout[0],
603 );
604 f.render_widget(
605 Paragraph::new(session.start_time.with_timezone(&Local).format("%H:%M").to_string())
606 .alignment(Alignment::Right)
607 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
608 start_time_layout[1],
609 );
610
611 let session_type_layout = Layout::default()
613 .direction(Direction::Horizontal)
614 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
615 .split(details_layout[1]);
616
617 f.render_widget(
618 Paragraph::new("Session Type")
619 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
620 session_type_layout[0],
621 );
622 f.render_widget(
623 Paragraph::new("Deep Work")
624 .alignment(Alignment::Right)
625 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
626 session_type_layout[1],
627 );
628
629 let tags_layout = Layout::default()
631 .direction(Direction::Horizontal)
632 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
633 .split(details_layout[2]);
634
635 f.render_widget(
636 Paragraph::new("Tags")
637 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
638 tags_layout[0],
639 );
640
641 let tag_spans = vec![
643 Span::styled(
644 " Backend ",
645 Style::default()
646 .fg(ColorScheme::CLEAN_BG)
647 .bg(ColorScheme::GRAY_TEXT),
648 ),
649 Span::raw(" "),
650 Span::styled(
651 " Refactor ",
652 Style::default()
653 .fg(ColorScheme::CLEAN_BG)
654 .bg(ColorScheme::GRAY_TEXT),
655 ),
656 Span::raw(" "),
657 Span::styled(
658 " Security ",
659 Style::default()
660 .fg(ColorScheme::CLEAN_BG)
661 .bg(ColorScheme::GRAY_TEXT),
662 ),
663 ];
664
665 f.render_widget(
666 Paragraph::new(Line::from(tag_spans))
667 .alignment(Alignment::Right),
668 tags_layout[1],
669 );
670
671 } else {
672 let idle_area = self.centered_rect(50, 20, chunks[4]);
674 let idle_block = Block::default()
675 .borders(Borders::ALL)
676 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
677 .style(Style::default().bg(ColorScheme::CLEAN_BG));
678
679 f.render_widget(idle_block.clone(), idle_area);
680
681 let idle_inner = idle_block.inner(idle_area);
682 f.render_widget(
683 Paragraph::new("No Active Session\n\nPress 's' to start tracking")
684 .alignment(Alignment::Center)
685 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
686 idle_inner,
687 );
688 }
689 }
690
691 fn render_overview_dashboard(&mut self, f: &mut Frame) {
692 let chunks = Layout::default()
693 .direction(Direction::Vertical)
694 .constraints([
695 Constraint::Length(3), Constraint::Length(1), Constraint::Length(10), Constraint::Length(1), Constraint::Length(3), Constraint::Length(5), Constraint::Length(1), Constraint::Min(10), Constraint::Length(1), ])
705 .split(f.size());
706
707 self.render_header(f, chunks[0]);
709
710 let daily_stats = self.get_daily_stats();
711 let current_session = &self.current_session;
712 let current_project = &self.current_project;
713
714 self.render_active_session_panel(f, chunks[2], current_session, current_project);
716
717 SessionStatsWidget::render(daily_stats, self.weekly_stats, chunks[5], f.buffer_mut());
719 self.render_quick_stats(f, chunks[4], chunks[5], daily_stats);
720
721 self.render_projects_and_timeline(f, chunks[5]);
723
724 self.render_keyboard_hints(chunks[8], f.buffer_mut());
726
727 self.render_bottom_bar(f, chunks[6]);
728 }
729
730 fn render_history_browser(&mut self, f: &mut Frame) {
731 let chunks = Layout::default()
732 .direction(Direction::Horizontal)
733 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
734 .split(f.size());
735
736 let left_chunks = Layout::default()
737 .direction(Direction::Vertical)
738 .constraints([
739 Constraint::Length(3), Constraint::Length(8), Constraint::Min(10), ])
743 .split(chunks[0]);
744
745 let right_chunks = Layout::default()
746 .direction(Direction::Vertical)
747 .constraints([
748 Constraint::Percentage(60), Constraint::Length(4), Constraint::Min(0), ])
752 .split(chunks[1]);
753
754 f.render_widget(
756 Paragraph::new("Tempo TUI :: History Browser")
757 .style(Style::default().fg(ColorScheme::CLEAN_BLUE).add_modifier(Modifier::BOLD))
758 .block(
759 Block::default()
760 .borders(Borders::BOTTOM)
761 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
762 ),
763 left_chunks[0],
764 );
765
766 self.render_history_filters(f, left_chunks[1]);
768
769 self.render_session_list(f, left_chunks[2]);
771
772 self.render_session_details(f, right_chunks[0]);
774
775 self.render_session_actions(f, right_chunks[1]);
777
778 self.render_history_summary(f, right_chunks[2]);
780 }
781
782 fn render_history_filters(&self, f: &mut Frame, area: Rect) {
783 let block = Block::default()
784 .borders(Borders::ALL)
785 .title(" Filters ")
786 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
787
788 let inner_area = block.inner(area);
789 f.render_widget(block, area);
790
791 let filter_chunks = Layout::default()
792 .direction(Direction::Vertical)
793 .constraints([
794 Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
799 .split(inner_area);
800
801 let date_layout = Layout::default()
803 .direction(Direction::Horizontal)
804 .constraints([Constraint::Percentage(30), Constraint::Percentage(35), Constraint::Percentage(35)])
805 .split(filter_chunks[0]);
806
807 f.render_widget(
808 Paragraph::new("Start Date\nEnd Date")
809 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
810 date_layout[0],
811 );
812 f.render_widget(
813 Paragraph::new("2023-10-01\n2023-10-31")
814 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
815 date_layout[1],
816 );
817 f.render_widget(
818 Paragraph::new("Project\nDuration Filter")
819 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
820 date_layout[2],
821 );
822
823 let project_layout = Layout::default()
825 .direction(Direction::Horizontal)
826 .constraints([Constraint::Length(15), Constraint::Min(0)])
827 .split(filter_chunks[1]);
828
829 f.render_widget(
830 Paragraph::new("Project")
831 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
832 project_layout[0],
833 );
834 f.render_widget(
835 Paragraph::new("Filter by project ▼")
836 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
837 project_layout[1],
838 );
839
840 let duration_layout = Layout::default()
842 .direction(Direction::Horizontal)
843 .constraints([Constraint::Length(15), Constraint::Min(0)])
844 .split(filter_chunks[2]);
845
846 f.render_widget(
847 Paragraph::new("Duration Filter")
848 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
849 duration_layout[0],
850 );
851 f.render_widget(
852 Paragraph::new(">1h, <30m")
853 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
854 duration_layout[1],
855 );
856
857 let search_layout = Layout::default()
859 .direction(Direction::Horizontal)
860 .constraints([Constraint::Length(15), Constraint::Min(0)])
861 .split(filter_chunks[3]);
862
863 f.render_widget(
864 Paragraph::new("Free-text Search")
865 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
866 search_layout[0],
867 );
868
869 let search_style = if self.filter_input_mode {
870 Style::default().fg(ColorScheme::CLEAN_BLUE)
871 } else {
872 Style::default().fg(ColorScheme::WHITE_TEXT)
873 };
874
875 let search_text = if self.session_filter.search_text.is_empty() {
876 "Search session notes and context..."
877 } else {
878 &self.session_filter.search_text
879 };
880
881 f.render_widget(
882 Paragraph::new(search_text)
883 .style(search_style),
884 search_layout[1],
885 );
886 }
887
888 fn render_session_list(&self, f: &mut Frame, area: Rect) {
889 let block = Block::default()
890 .borders(Borders::ALL)
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 header_row = Row::new(vec![
898 Cell::from("DATE").style(Style::default().add_modifier(Modifier::BOLD)),
899 Cell::from("PROJECT").style(Style::default().add_modifier(Modifier::BOLD)),
900 Cell::from("DURATION").style(Style::default().add_modifier(Modifier::BOLD)),
901 Cell::from("START").style(Style::default().add_modifier(Modifier::BOLD)),
902 Cell::from("END").style(Style::default().add_modifier(Modifier::BOLD)),
903 Cell::from("STATUS").style(Style::default().add_modifier(Modifier::BOLD)),
904 ])
905 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
906 .bottom_margin(1);
907
908 let rows: Vec<Row> = self.history_sessions
910 .iter()
911 .enumerate()
912 .map(|(i, session)| {
913 let is_selected = i == self.selected_session_index;
914 let style = if is_selected {
915 Style::default().bg(ColorScheme::CLEAN_BLUE).fg(Color::Black)
916 } else {
917 Style::default().fg(ColorScheme::WHITE_TEXT)
918 };
919
920 let status = if session.end_time.is_some() {
921 "[✓] Completed"
922 } else {
923 "[▶] Running"
924 };
925
926 let start_time = session.start_time.with_timezone(&Local).format("%H:%M").to_string();
927 let end_time = if let Some(end) = session.end_time {
928 end.with_timezone(&Local).format("%H:%M").to_string()
929 } else {
930 "--:--".to_string()
931 };
932
933 let duration = if let Some(_) = session.end_time {
934 let duration_secs = (session.start_time.timestamp() - session.start_time.timestamp()).abs();
935 Formatter::format_duration(duration_secs)
936 } else {
937 "0h 0m".to_string()
938 };
939
940 Row::new(vec![
941 Cell::from(session.start_time.with_timezone(&Local).format("%Y-%m-%d").to_string()),
942 Cell::from("Project Phoenix"), Cell::from(duration),
944 Cell::from(start_time),
945 Cell::from(end_time),
946 Cell::from(status),
947 ])
948 .style(style)
949 })
950 .collect();
951
952 if rows.is_empty() {
953 let sample_rows = vec![
955 Row::new(vec![
956 Cell::from("2023-10-26"),
957 Cell::from("Project Phoenix"),
958 Cell::from("2h 15m"),
959 Cell::from("09:03"),
960 Cell::from("11:18"),
961 Cell::from("[✓] Completed"),
962 ]).style(Style::default().bg(ColorScheme::CLEAN_BLUE).fg(Color::Black)),
963 Row::new(vec![
964 Cell::from("2023-10-26"),
965 Cell::from("Internal Tools"),
966 Cell::from("0h 45m"),
967 Cell::from("11:30"),
968 Cell::from("12:15"),
969 Cell::from("[✓] Completed"),
970 ]),
971 Row::new(vec![
972 Cell::from("2023-10-25"),
973 Cell::from("Project Phoenix"),
974 Cell::from("4h 05m"),
975 Cell::from("13:00"),
976 Cell::from("17:05"),
977 Cell::from("[✓] Completed"),
978 ]),
979 Row::new(vec![
980 Cell::from("2023-10-25"),
981 Cell::from("Client Support"),
982 Cell::from("1h 00m"),
983 Cell::from("10:00"),
984 Cell::from("11:00"),
985 Cell::from("[✓] Completed"),
986 ]),
987 Row::new(vec![
988 Cell::from("2023-10-24"),
989 Cell::from("Project Phoenix"),
990 Cell::from("8h 00m"),
991 Cell::from("09:00"),
992 Cell::from("17:00"),
993 Cell::from("[✓] Completed"),
994 ]),
995 Row::new(vec![
996 Cell::from("2023-10-27"),
997 Cell::from("Project Nova"),
998 Cell::from("0h 22m"),
999 Cell::from("14:00"),
1000 Cell::from("--:--"),
1001 Cell::from("[▶] Running"),
1002 ]),
1003 ];
1004
1005 let table = Table::new(sample_rows)
1006 .header(header_row)
1007 .widths(&[
1008 Constraint::Length(12),
1009 Constraint::Min(15),
1010 Constraint::Length(10),
1011 Constraint::Length(8),
1012 Constraint::Length(8),
1013 Constraint::Min(12),
1014 ]);
1015
1016 f.render_widget(table, inner_area);
1017 }
1018 }
1019
1020 fn render_session_details(&self, f: &mut Frame, area: Rect) {
1021 let block = Block::default()
1022 .borders(Borders::ALL)
1023 .title(" Session Details ")
1024 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1025
1026 let inner_area = block.inner(area);
1027 f.render_widget(block, area);
1028
1029 let details_chunks = Layout::default()
1030 .direction(Direction::Vertical)
1031 .constraints([
1032 Constraint::Length(3), Constraint::Length(2), Constraint::Length(3), ])
1036 .split(inner_area);
1037
1038 f.render_widget(
1040 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.")
1041 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1042 .wrap(ratatui::widgets::Wrap { trim: true }),
1043 details_chunks[0],
1044 );
1045
1046 let tag_spans = vec![
1048 Span::styled(
1049 " #backend ",
1050 Style::default()
1051 .fg(Color::Black)
1052 .bg(ColorScheme::CLEAN_BLUE),
1053 ),
1054 Span::raw(" "),
1055 Span::styled(
1056 " #auth ",
1057 Style::default()
1058 .fg(Color::Black)
1059 .bg(ColorScheme::CLEAN_BLUE),
1060 ),
1061 Span::raw(" "),
1062 Span::styled(
1063 " #bugfix ",
1064 Style::default()
1065 .fg(Color::Black)
1066 .bg(ColorScheme::CLEAN_BLUE),
1067 ),
1068 ];
1069
1070 f.render_widget(
1071 Paragraph::new(vec![
1072 Line::from("TAGS"),
1073 Line::from(tag_spans),
1074 ])
1075 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1076 details_chunks[1],
1077 );
1078
1079 let context_chunks = Layout::default()
1081 .direction(Direction::Vertical)
1082 .constraints([Constraint::Length(1), Constraint::Min(0)])
1083 .split(details_chunks[2]);
1084
1085 f.render_widget(
1086 Paragraph::new("CONTEXT")
1087 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1088 context_chunks[0],
1089 );
1090
1091 let context_layout = Layout::default()
1092 .direction(Direction::Horizontal)
1093 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1094 .split(context_chunks[1]);
1095
1096 f.render_widget(
1097 Paragraph::new("Git\nBranch:\nIssue ID:\nCommit:")
1098 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1099 context_layout[0],
1100 );
1101 f.render_widget(
1102 Paragraph::new("feature/PHX-123-auth\nPHX-123\na1b2c3d")
1103 .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
1104 context_layout[1],
1105 );
1106 }
1107
1108 fn render_session_actions(&self, f: &mut Frame, area: Rect) {
1109 let button_layout = Layout::default()
1110 .direction(Direction::Horizontal)
1111 .constraints([
1112 Constraint::Percentage(25),
1113 Constraint::Percentage(25),
1114 Constraint::Percentage(25),
1115 Constraint::Percentage(25),
1116 ])
1117 .split(area);
1118
1119 let buttons = [
1120 ("[ Edit ]", ColorScheme::GRAY_TEXT),
1121 ("[ Duplicate ]", ColorScheme::GRAY_TEXT),
1122 ("[ Delete ]", Color::Red),
1123 ("", ColorScheme::GRAY_TEXT),
1124 ];
1125
1126 for (i, (text, color)) in buttons.iter().enumerate() {
1127 if !text.is_empty() {
1128 f.render_widget(
1129 Paragraph::new(*text)
1130 .alignment(Alignment::Center)
1131 .style(Style::default().fg(*color)),
1132 button_layout[i],
1133 );
1134 }
1135 }
1136 }
1137
1138 fn render_history_summary(&self, f: &mut Frame, area: Rect) {
1139 let block = Block::default()
1140 .borders(Borders::ALL)
1141 .title(" Summary ")
1142 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1143
1144 let inner_area = block.inner(area);
1145 f.render_widget(block, area);
1146
1147 f.render_widget(
1148 Paragraph::new("Showing 7 of 128 sessions. Total Duration: 17h 40m")
1149 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1150 .alignment(Alignment::Center),
1151 inner_area,
1152 );
1153 }
1154
1155 fn render_project_grid(&mut self, f: &mut Frame) {
1156 let area = f.size();
1157
1158 let main_layout = Layout::default()
1159 .direction(Direction::Vertical)
1160 .constraints([
1161 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(1), ])
1166 .split(area);
1167
1168 f.render_widget(
1170 Paragraph::new("Project Dashboard")
1171 .style(Style::default().fg(ColorScheme::CLEAN_BLUE).add_modifier(Modifier::BOLD))
1172 .block(
1173 Block::default()
1174 .borders(Borders::BOTTOM)
1175 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1176 ),
1177 main_layout[0],
1178 );
1179
1180 self.render_project_cards(f, main_layout[1]);
1182
1183 self.render_project_stats_summary(f, main_layout[2]);
1185
1186 let hints = vec![
1188 ("↑/↓/←/→", "Navigate"),
1189 ("Enter", "Select"),
1190 ("Tab", "Next View"),
1191 ("q", "Quit"),
1192 ];
1193
1194 let spans: Vec<Span> = hints
1195 .iter()
1196 .flat_map(|(key, desc)| {
1197 vec![
1198 Span::styled(
1199 format!(" {} ", key),
1200 Style::default()
1201 .fg(Color::Yellow)
1202 .add_modifier(Modifier::BOLD),
1203 ),
1204 Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
1205 ]
1206 })
1207 .collect();
1208
1209 let line = Line::from(spans);
1210 let block = Block::default()
1211 .borders(Borders::TOP)
1212 .border_style(Style::default().fg(Color::DarkGray));
1213 Paragraph::new(line).block(block).render(main_layout[3], f.buffer_mut());
1214 }
1215
1216 fn render_project_cards(&mut self, f: &mut Frame, area: Rect) {
1217 if self.available_projects.is_empty() {
1218 let empty_block = Block::default()
1220 .borders(Borders::ALL)
1221 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
1222 .title(" No Projects Found ");
1223
1224 let empty_area = self.centered_rect(50, 30, area);
1225 f.render_widget(empty_block.clone(), empty_area);
1226
1227 let inner = empty_block.inner(empty_area);
1228 f.render_widget(
1229 Paragraph::new("No projects available.\n\nStart a session to create a project.")
1230 .alignment(Alignment::Center)
1231 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1232 inner,
1233 );
1234 return;
1235 }
1236
1237 let margin = 2;
1239 let card_height = 8;
1240 let card_spacing = 1;
1241
1242 let available_height = area.height.saturating_sub(margin * 2);
1244 let total_rows = (self.available_projects.len() + self.projects_per_row - 1) / self.projects_per_row;
1245 let visible_rows = (available_height / (card_height + card_spacing)).min(total_rows as u16) as usize;
1246
1247 for row in 0..visible_rows {
1249 let y_offset = margin + row as u16 * (card_height + card_spacing);
1250
1251 let row_area = Rect::new(area.x, area.y + y_offset, area.width, card_height);
1253 let card_constraints = vec![Constraint::Percentage(100 / self.projects_per_row as u16); self.projects_per_row];
1254 let row_layout = Layout::default()
1255 .direction(Direction::Horizontal)
1256 .constraints(card_constraints)
1257 .margin(1)
1258 .split(row_area);
1259
1260 for col in 0..self.projects_per_row {
1262 let project_index = row * self.projects_per_row + col;
1263 if project_index >= self.available_projects.len() {
1264 break;
1265 }
1266
1267 let is_selected = row == self.selected_project_row && col == self.selected_project_col;
1268 self.render_project_card(f, row_layout[col], project_index, is_selected);
1269 }
1270 }
1271 }
1272
1273 fn render_project_card(&self, f: &mut Frame, area: Rect, project_index: usize, is_selected: bool) {
1274 if let Some(project) = self.available_projects.get(project_index) {
1275 let border_style = if is_selected {
1277 Style::default().fg(ColorScheme::CLEAN_BLUE)
1278 } else {
1279 Style::default().fg(ColorScheme::GRAY_TEXT)
1280 };
1281
1282 let bg_color = if is_selected {
1283 ColorScheme::CLEAN_BG
1284 } else {
1285 Color::Black
1286 };
1287
1288 let card_block = Block::default()
1289 .borders(Borders::ALL)
1290 .border_style(border_style)
1291 .style(Style::default().bg(bg_color));
1292
1293 f.render_widget(card_block.clone(), area);
1294
1295 let inner_area = card_block.inner(area);
1296 let card_layout = Layout::default()
1297 .direction(Direction::Vertical)
1298 .constraints([
1299 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), ])
1305 .split(inner_area);
1306
1307 let name = if project.name.len() > 20 {
1309 format!("{}...", &project.name[..17])
1310 } else {
1311 project.name.clone()
1312 };
1313
1314 f.render_widget(
1315 Paragraph::new(name)
1316 .style(Style::default().fg(ColorScheme::WHITE_TEXT).add_modifier(Modifier::BOLD))
1317 .alignment(Alignment::Center),
1318 card_layout[0],
1319 );
1320
1321 let path_str = project.path.to_string_lossy();
1323 let short_path = if path_str.len() > 25 {
1324 format!("...{}", &path_str[path_str.len()-22..])
1325 } else {
1326 path_str.to_string()
1327 };
1328
1329 f.render_widget(
1330 Paragraph::new(short_path)
1331 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
1332 .alignment(Alignment::Center),
1333 card_layout[1],
1334 );
1335
1336 let stats_layout = Layout::default()
1338 .direction(Direction::Horizontal)
1339 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1340 .split(card_layout[3]);
1341
1342 f.render_widget(
1343 Paragraph::new("Sessions\n42")
1344 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1345 .alignment(Alignment::Center),
1346 stats_layout[0],
1347 );
1348
1349 f.render_widget(
1350 Paragraph::new("Time\n24h 15m")
1351 .style(Style::default().fg(ColorScheme::WHITE_TEXT))
1352 .alignment(Alignment::Center),
1353 stats_layout[1],
1354 );
1355
1356 let status = if project.is_archived {
1358 (" Archived ", Color::Red)
1359 } else {
1360 (" Active ", ColorScheme::CLEAN_GREEN)
1361 };
1362
1363 f.render_widget(
1364 Paragraph::new(status.0)
1365 .style(Style::default().fg(status.1))
1366 .alignment(Alignment::Center),
1367 card_layout[4],
1368 );
1369 }
1370 }
1371
1372 fn render_project_stats_summary(&self, f: &mut Frame, area: Rect) {
1373 let block = Block::default()
1374 .borders(Borders::ALL)
1375 .title(" Summary ")
1376 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
1377
1378 f.render_widget(block.clone(), area);
1379
1380 let inner = block.inner(area);
1381 let stats_layout = Layout::default()
1382 .direction(Direction::Horizontal)
1383 .constraints([
1384 Constraint::Percentage(25),
1385 Constraint::Percentage(25),
1386 Constraint::Percentage(25),
1387 Constraint::Percentage(25),
1388 ])
1389 .split(inner);
1390
1391 let total_projects = self.available_projects.len();
1392 let active_projects = self.available_projects.iter().filter(|p| !p.is_archived).count();
1393 let archived_projects = total_projects - active_projects;
1394
1395 let stats = [
1396 ("Total Projects", total_projects.to_string()),
1397 ("Active", active_projects.to_string()),
1398 ("Archived", archived_projects.to_string()),
1399 ("Selected", format!("{}/{}",
1400 self.selected_project_row * self.projects_per_row + self.selected_project_col + 1,
1401 total_projects)),
1402 ];
1403
1404 for (i, (label, value)) in stats.iter().enumerate() {
1405 let content = Paragraph::new(vec![
1406 Line::from(Span::styled(*label, Style::default().fg(ColorScheme::GRAY_TEXT))),
1407 Line::from(Span::styled(
1408 value.as_str(),
1409 Style::default().fg(ColorScheme::WHITE_TEXT).add_modifier(Modifier::BOLD),
1410 )),
1411 ])
1412 .alignment(Alignment::Center);
1413
1414 f.render_widget(content, stats_layout[i]);
1415 }
1416 }
1417
1418 fn render_active_session_panel(
1419 &self,
1420 f: &mut Frame,
1421 area: Rect,
1422 session: &Option<Session>,
1423 project: &Option<Project>,
1424 ) {
1425 let block = Block::default().style(Style::default().bg(ColorScheme::CLEAN_BG));
1426
1427 f.render_widget(block, area);
1428
1429 let layout = Layout::default()
1430 .direction(Direction::Vertical)
1431 .constraints([
1432 Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(3), ])
1437 .margin(1)
1438 .split(area);
1439
1440 f.render_widget(
1442 Paragraph::new("Active Session").style(
1443 Style::default()
1444 .fg(ColorScheme::GRAY_TEXT)
1445 .add_modifier(Modifier::BOLD),
1446 ),
1447 layout[0],
1448 );
1449
1450 if let Some(session) = session {
1451 let project_name = project
1452 .as_ref()
1453 .map(|p| p.name.as_str())
1454 .unwrap_or("Unknown Project");
1455
1456 let info_layout = Layout::default()
1458 .direction(Direction::Horizontal)
1459 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
1460 .split(layout[1]);
1461
1462 f.render_widget(
1463 Paragraph::new(project_name).style(
1464 Style::default()
1465 .fg(ColorScheme::GRAY_TEXT)
1466 .add_modifier(Modifier::BOLD),
1467 ),
1468 info_layout[0],
1469 );
1470
1471 f.render_widget(
1472 Paragraph::new("State: ACTIVE")
1473 .alignment(Alignment::Right)
1474 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1475 info_layout[1],
1476 );
1477
1478 let now = Local::now();
1480 let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
1481 - session.paused_duration.num_seconds();
1482 let duration_str = Formatter::format_duration_clock(elapsed_seconds);
1483
1484 f.render_widget(
1485 Paragraph::new(duration_str)
1486 .alignment(Alignment::Center)
1487 .style(
1488 Style::default()
1489 .fg(ColorScheme::WHITE_TEXT)
1490 .add_modifier(Modifier::BOLD),
1491 ),
1492 layout[3],
1493 );
1494 } else {
1495 f.render_widget(
1497 Paragraph::new("No Active Session")
1498 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1499 layout[1],
1500 );
1501 f.render_widget(
1502 Paragraph::new("--:--:--")
1503 .alignment(Alignment::Center)
1504 .style(
1505 Style::default()
1506 .fg(ColorScheme::GRAY_TEXT)
1507 .add_modifier(Modifier::DIM),
1508 ),
1509 layout[3],
1510 );
1511 }
1512 }
1513
1514 fn render_quick_stats(
1515 &self,
1516 f: &mut Frame,
1517 header_area: Rect,
1518 grid_area: Rect,
1519 daily_stats: &(i64, i64, i64),
1520 ) {
1521 let (sessions_count, total_seconds, _avg_seconds) = *daily_stats;
1522
1523 f.render_widget(
1525 Paragraph::new("Quick Stats").style(
1526 Style::default()
1527 .fg(ColorScheme::WHITE_TEXT)
1528 .add_modifier(Modifier::BOLD),
1529 ),
1530 header_area,
1531 );
1532
1533 let cols = Layout::default()
1535 .direction(Direction::Horizontal)
1536 .constraints([
1537 Constraint::Percentage(25),
1538 Constraint::Percentage(25),
1539 Constraint::Percentage(25),
1540 Constraint::Percentage(25),
1541 ])
1542 .split(grid_area);
1543
1544 let stats = [
1545 ("Today", Formatter::format_duration(total_seconds)),
1546 ("This Week", Formatter::format_duration(self.weekly_stats)),
1547 ("Active", sessions_count.to_string()),
1548 ("Projects", self.available_projects.len().to_string()),
1549 ];
1550
1551 for (i, (label, value)) in stats.iter().enumerate() {
1552 let block = Block::default()
1553 .borders(Borders::ALL)
1554 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
1555 .style(Style::default().bg(ColorScheme::CLEAN_BG));
1556
1557 let content = Paragraph::new(vec![
1558 Line::from(Span::styled(
1559 *label,
1560 Style::default().fg(ColorScheme::GRAY_TEXT),
1561 )),
1562 Line::from(Span::styled(
1563 value.as_str(),
1564 Style::default()
1565 .fg(ColorScheme::WHITE_TEXT)
1566 .add_modifier(Modifier::BOLD),
1567 )),
1568 ])
1569 .block(block)
1570 .alignment(Alignment::Center);
1571
1572 f.render_widget(content, cols[i]);
1573 }
1574 }
1575
1576 fn render_bottom_bar(&self, f: &mut Frame, area: Rect) {
1577 let help_text = if self.show_project_switcher {
1578 vec![
1579 Span::styled(
1580 "[Q]",
1581 Style::default()
1582 .fg(ColorScheme::CLEAN_ACCENT)
1583 .add_modifier(Modifier::BOLD),
1584 ),
1585 Span::raw(" Close "),
1586 Span::raw("[↑/↓] Navigate "),
1587 Span::raw("[Enter] Select"),
1588 ]
1589 } else {
1590 vec![
1591 Span::styled(
1592 "[Q]",
1593 Style::default()
1594 .fg(ColorScheme::CLEAN_ACCENT)
1595 .add_modifier(Modifier::BOLD),
1596 ),
1597 Span::raw(" Quit "),
1598 Span::raw("[P] Projects "),
1599 Span::raw("[R] Refresh"),
1600 ]
1601 };
1602
1603 let help_paragraph = Paragraph::new(Line::from(help_text))
1604 .alignment(Alignment::Center)
1605 .style(Style::default().fg(ColorScheme::GRAY_TEXT));
1606
1607 f.render_widget(help_paragraph, area);
1608 }
1609
1610 fn render_projects_and_timeline(&self, f: &mut Frame, area: Rect) {
1611 let chunks = Layout::default()
1612 .direction(Direction::Vertical)
1613 .constraints([
1614 Constraint::Length(2), Constraint::Min(5), Constraint::Length(2), Constraint::Length(3), ])
1619 .split(area);
1620
1621 f.render_widget(
1623 Paragraph::new("Recent Projects").style(
1624 Style::default()
1625 .fg(ColorScheme::WHITE_TEXT)
1626 .add_modifier(Modifier::BOLD),
1627 ),
1628 chunks[0],
1629 );
1630
1631 let list_area = chunks[1];
1632 let items_area = Rect::new(list_area.x, list_area.y, list_area.width, list_area.height);
1633
1634 let header = Row::new(vec![
1636 Cell::from("Project").style(Style::default().add_modifier(Modifier::BOLD)),
1637 Cell::from("Today").style(Style::default().add_modifier(Modifier::BOLD)),
1638 Cell::from("Total").style(Style::default().add_modifier(Modifier::BOLD)),
1639 Cell::from("Last Active").style(Style::default().add_modifier(Modifier::BOLD)),
1640 ])
1641 .style(Style::default().fg(ColorScheme::CLEAN_BLUE))
1642 .bottom_margin(1);
1643
1644 let items: Vec<Row> = self
1645 .recent_projects
1646 .iter()
1647 .map(|p| {
1648 let last_active = if let Some(date) = p.last_active {
1649 let now = chrono::Utc::now();
1650 let diff = now - date;
1651 if diff.num_days() > 0 {
1652 format!("{}d ago", diff.num_days())
1653 } else if diff.num_hours() > 0 {
1654 format!("{}h ago", diff.num_hours())
1655 } else {
1656 format!("{}m ago", diff.num_minutes())
1657 }
1658 } else {
1659 "-".to_string()
1660 };
1661
1662 Row::new(vec![
1663 Cell::from(p.project.name.clone()),
1664 Cell::from(Formatter::format_duration(p.today_seconds)),
1665 Cell::from(Formatter::format_duration(p.total_seconds)),
1666 Cell::from(last_active),
1667 ])
1668 })
1669 .collect();
1670
1671 let table = Table::new(items)
1672 .header(header)
1673 .block(Block::default().borders(Borders::NONE))
1674 .widths(&[
1675 Constraint::Percentage(40),
1676 Constraint::Percentage(20),
1677 Constraint::Percentage(20),
1678 Constraint::Percentage(20),
1679 ]);
1680
1681 f.render_widget(table, items_area);
1682
1683 f.render_widget(
1685 Paragraph::new("Activity Timeline").style(
1686 Style::default()
1687 .fg(ColorScheme::WHITE_TEXT)
1688 .add_modifier(Modifier::BOLD),
1689 ),
1690 chunks[2],
1691 );
1692
1693 let timeline_area = chunks[3];
1695 let bar_area = Rect::new(timeline_area.x, timeline_area.y, timeline_area.width, 1);
1696
1697 f.render_widget(
1699 Block::default().style(Style::default().bg(ColorScheme::GRAY_TEXT)),
1700 bar_area,
1701 );
1702
1703 let width = bar_area.width as f64;
1705 for session in &self.today_sessions {
1706 let start = session.start_time.with_timezone(&Local).time();
1707 let start_seconds = start.num_seconds_from_midnight() as f64;
1708
1709 let duration = if let Some(end) = session.end_time {
1710 (end - session.start_time).num_seconds() as f64
1711 } else {
1712 (Local::now().signed_duration_since(session.start_time.with_timezone(&Local)))
1713 .num_seconds() as f64
1714 };
1715
1716 let duration = duration - session.paused_duration.num_seconds() as f64;
1718
1719 let x_offset = (start_seconds / 86400.0) * width;
1720 let bar_width = (duration / 86400.0) * width;
1721
1722 if bar_width > 0.0 {
1723 let x_pos = bar_area.x + x_offset as u16;
1724 if x_pos < bar_area.x + bar_area.width {
1726 let w = (bar_width.max(1.0) as u16).min(bar_area.width - (x_pos - bar_area.x));
1727 let session_rect = Rect::new(x_pos, bar_area.y, w, 1);
1728 f.render_widget(
1729 Block::default().style(Style::default().bg(ColorScheme::CLEAN_BLUE)),
1730 session_rect,
1731 );
1732 }
1733 }
1734 }
1735
1736 let label_y = timeline_area.y + 1;
1738 f.render_widget(
1739 Paragraph::new("00:00").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
1740 Rect::new(timeline_area.x, label_y, 5, 1),
1741 );
1742 f.render_widget(
1743 Paragraph::new("12:00")
1744 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
1745 .alignment(Alignment::Center),
1746 Rect::new(timeline_area.x, label_y, timeline_area.width, 1),
1747 );
1748 f.render_widget(
1749 Paragraph::new("24:00")
1750 .style(Style::default().fg(ColorScheme::GRAY_TEXT))
1751 .alignment(Alignment::Right),
1752 Rect::new(timeline_area.x, label_y, timeline_area.width, 1),
1753 );
1754 }
1755
1756 fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
1757 let popup_area = self.centered_rect(60, 50, area);
1758
1759 let block = Block::default()
1760 .borders(Borders::ALL)
1761 .border_style(Style::default().fg(ColorScheme::CLEAN_BLUE))
1762 .title(" Select Project ")
1763 .title_alignment(Alignment::Center)
1764 .style(Style::default().bg(ColorScheme::CLEAN_BG));
1765
1766 f.render_widget(block.clone(), popup_area);
1767
1768 let list_area = block.inner(popup_area);
1769
1770 if self.available_projects.is_empty() {
1771 let no_projects = Paragraph::new("No projects found")
1772 .alignment(Alignment::Center)
1773 .style(Style::default().fg(ColorScheme::GRAY_TEXT));
1774 f.render_widget(no_projects, list_area);
1775 } else {
1776 let items: Vec<ListItem> = self
1777 .available_projects
1778 .iter()
1779 .enumerate()
1780 .map(|(i, p)| {
1781 let style = if i == self.selected_project_index {
1782 Style::default()
1783 .fg(ColorScheme::CLEAN_BG)
1784 .bg(ColorScheme::CLEAN_BLUE)
1785 } else {
1786 Style::default().fg(ColorScheme::WHITE_TEXT)
1787 };
1788 ListItem::new(format!(" {} ", p.name)).style(style)
1789 })
1790 .collect();
1791
1792 let list = List::new(items);
1793 f.render_widget(list, list_area);
1794 }
1795 }
1796
1797 fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1798 let popup_layout = Layout::default()
1799 .direction(Direction::Vertical)
1800 .constraints([
1801 Constraint::Percentage((100 - percent_y) / 2),
1802 Constraint::Percentage(percent_y),
1803 Constraint::Percentage((100 - percent_y) / 2),
1804 ])
1805 .split(r);
1806
1807 Layout::default()
1808 .direction(Direction::Horizontal)
1809 .constraints([
1810 Constraint::Percentage((100 - percent_x) / 2),
1811 Constraint::Percentage(percent_x),
1812 Constraint::Percentage((100 - percent_x) / 2),
1813 ])
1814 .split(popup_layout[1])[1]
1815 }
1816
1817 async fn get_current_session(&mut self) -> Result<Option<Session>> {
1818 if !is_daemon_running() {
1819 return Ok(None);
1820 }
1821
1822 self.ensure_connected().await?;
1823
1824 let response = self
1825 .client
1826 .send_message(&IpcMessage::GetActiveSession)
1827 .await?;
1828 match response {
1829 IpcResponse::ActiveSession(session) => Ok(session),
1830 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
1831 _ => Ok(None),
1832 }
1833 }
1834
1835 async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
1836 if !is_daemon_running() {
1837 return Ok(None);
1838 }
1839
1840 self.ensure_connected().await?;
1841
1842 let response = self
1843 .client
1844 .send_message(&IpcMessage::GetProject(session.project_id))
1845 .await?;
1846 match response {
1847 IpcResponse::Project(project) => Ok(project),
1848 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
1849 _ => Ok(None),
1850 }
1851 }
1852
1853 async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> {
1854 if !is_daemon_running() {
1856 return Ok((0, 0, 0));
1857 }
1858
1859 self.ensure_connected().await?;
1860
1861 let today = chrono::Local::now().date_naive();
1862 let response = self
1863 .client
1864 .send_message(&IpcMessage::GetDailyStats(today))
1865 .await?;
1866 match response {
1867 IpcResponse::DailyStats {
1868 sessions_count,
1869 total_seconds,
1870 avg_seconds,
1871 } => Ok((sessions_count, total_seconds, avg_seconds)),
1872 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
1873 _ => Ok((0, 0, 0)),
1874 }
1875 }
1876
1877 async fn get_today_sessions(&mut self) -> Result<Vec<Session>> {
1878 if !is_daemon_running() {
1879 return Ok(Vec::new());
1880 }
1881
1882 self.ensure_connected().await?;
1883
1884 let today = chrono::Local::now().date_naive();
1885 let response = self
1886 .client
1887 .send_message(&IpcMessage::GetSessionsForDate(today))
1888 .await?;
1889 match response {
1890 IpcResponse::SessionList(sessions) => Ok(sessions),
1891 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get sessions: {}", e)),
1892 _ => Ok(Vec::new()),
1893 }
1894 }
1895
1896 async fn get_history_sessions(&mut self) -> Result<Vec<Session>> {
1897 if !is_daemon_running() {
1898 return Ok(Vec::new());
1899 }
1900
1901 self.ensure_connected().await?;
1902
1903 let end_date = chrono::Local::now().date_naive();
1905 let _start_date = end_date - chrono::Duration::days(30);
1906
1907 let mut all_sessions = Vec::new();
1910 for days_ago in 0..30 {
1911 let date = end_date - chrono::Duration::days(days_ago);
1912 if let Ok(IpcResponse::SessionList(sessions)) = self
1913 .client
1914 .send_message(&IpcMessage::GetSessionsForDate(date))
1915 .await
1916 {
1917 all_sessions.extend(sessions);
1918 }
1919 }
1920
1921 let filtered_sessions: Vec<Session> = all_sessions
1923 .into_iter()
1924 .filter(|session| {
1925 if !self.session_filter.search_text.is_empty() {
1927 if let Some(notes) = &session.notes {
1928 if !notes.to_lowercase().contains(&self.session_filter.search_text.to_lowercase()) {
1929 return false;
1930 }
1931 } else {
1932 return false;
1933 }
1934 }
1935 true
1936 })
1937 .collect();
1938
1939 Ok(filtered_sessions)
1940 }
1941
1942 async fn send_activity_heartbeat(&mut self) -> Result<()> {
1943 if !is_daemon_running() {
1944 return Ok(());
1945 }
1946
1947 self.ensure_connected().await?;
1948
1949 let _response = self
1950 .client
1951 .send_message(&IpcMessage::ActivityHeartbeat)
1952 .await?;
1953 Ok(())
1954 }
1955
1956 fn navigate_projects(&mut self, direction: i32) {
1959 if self.available_projects.is_empty() {
1960 return;
1961 }
1962
1963 let new_index = self.selected_project_index as i32 + direction;
1964 if new_index >= 0 && new_index < self.available_projects.len() as i32 {
1965 self.selected_project_index = new_index as usize;
1966 }
1967 }
1968
1969 async fn refresh_projects(&mut self) -> Result<()> {
1970 if !is_daemon_running() {
1971 return Ok(());
1972 }
1973
1974 self.ensure_connected().await?;
1975
1976 let response = self.client.send_message(&IpcMessage::ListProjects).await?;
1977 if let IpcResponse::ProjectList(projects) = response {
1978 self.available_projects = projects;
1979 self.selected_project_index = 0;
1980 }
1981 Ok(())
1982 }
1983
1984 async fn switch_to_selected_project(&mut self) -> Result<()> {
1985 if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
1986 let project_id = selected_project.id.unwrap_or(0);
1987
1988 self.ensure_connected().await?;
1989
1990 let response = self
1992 .client
1993 .send_message(&IpcMessage::SwitchProject(project_id))
1994 .await?;
1995 match response {
1996 IpcResponse::Success => {
1997 self.show_project_switcher = false;
1998 }
1999 IpcResponse::Error(e) => {
2000 return Err(anyhow::anyhow!("Failed to switch project: {}", e))
2001 }
2002 _ => return Err(anyhow::anyhow!("Unexpected response")),
2003 }
2004 }
2005 Ok(())
2006 }
2007
2008 fn render_header(&self, f: &mut Frame, area: Rect) {
2009 let time_str = Local::now().format("%H:%M").to_string();
2010 let date_str = Local::now().format("%A, %B %d").to_string();
2011
2012 let header_layout = Layout::default()
2013 .direction(Direction::Horizontal)
2014 .constraints([
2015 Constraint::Percentage(50), Constraint::Percentage(50), ])
2018 .split(area);
2019
2020 f.render_widget(
2021 Paragraph::new("TEMPO").style(
2022 Style::default()
2023 .fg(ColorScheme::CLEAN_GOLD)
2024 .add_modifier(Modifier::BOLD),
2025 ),
2026 header_layout[0],
2027 );
2028
2029 f.render_widget(
2030 Paragraph::new(format!("{} {}", date_str, time_str))
2031 .alignment(Alignment::Right)
2032 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
2033 header_layout[1],
2034 );
2035 }
2036
2037 fn get_daily_stats(&self) -> &(i64, i64, i64) {
2038 &self.daily_stats
2039 }
2040}