use anyhow::Result;
use chrono::{Local, Timelike};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use log::debug;
use ratatui::{
backend::Backend,
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
Frame, Terminal,
};
use std::time::{Duration, Instant};
use crate::{
models::{Project, Session},
ui::animations::{AnimatedSpinner, PulsingIndicator, ViewTransition, TransitionDirection},
ui::formatter::Formatter,
ui::widgets::{ColorScheme, Spinner},
utils::ipc::{
get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse, ProjectWithStats,
},
};
#[derive(Clone, PartialEq)]
pub enum DashboardView {
FocusedSession,
Overview,
History,
Projects,
}
#[derive(Clone)]
pub struct SessionFilter {
pub start_date: Option<chrono::NaiveDate>,
pub end_date: Option<chrono::NaiveDate>,
pub project_filter: Option<String>,
pub duration_filter: Option<(i64, i64)>, pub search_text: String,
}
impl Default for SessionFilter {
fn default() -> Self {
Self {
start_date: None,
end_date: None,
project_filter: None,
duration_filter: None,
search_text: String::new(),
}
}
}
pub struct Dashboard {
client: IpcClient,
current_session: Option<Session>,
current_project: Option<Project>,
daily_stats: (i64, i64, i64),
weekly_stats: i64,
today_sessions: Vec<Session>,
recent_projects: Vec<ProjectWithStats>,
available_projects: Vec<Project>,
selected_project_index: usize,
show_project_switcher: bool,
current_view: DashboardView,
history_sessions: Vec<Session>,
selected_session_index: usize,
session_filter: SessionFilter,
filter_input_mode: bool,
selected_project_row: usize,
selected_project_col: usize,
projects_per_row: usize,
spinner: Spinner,
last_update: Instant,
animated_spinner: AnimatedSpinner,
pulsing_indicator: PulsingIndicator,
view_transition: Option<ViewTransition>,
previous_view: DashboardView,
frame_count: u64,
}
impl Dashboard {
pub async fn new() -> Result<Self> {
let socket_path = get_socket_path()?;
let client = if socket_path.exists() && is_daemon_running() {
IpcClient::connect(&socket_path)
.await
.unwrap_or_else(|_| IpcClient::new().unwrap())
} else {
IpcClient::new()?
};
Ok(Self {
client,
current_session: None,
current_project: None,
daily_stats: (0, 0, 0),
weekly_stats: 0,
today_sessions: Vec::new(),
recent_projects: Vec::new(),
available_projects: Vec::new(),
selected_project_index: 0,
show_project_switcher: false,
current_view: DashboardView::FocusedSession,
history_sessions: Vec::new(),
selected_session_index: 0,
session_filter: SessionFilter::default(),
filter_input_mode: false,
selected_project_row: 0,
selected_project_col: 0,
projects_per_row: 3,
spinner: Spinner::new(),
last_update: Instant::now(),
animated_spinner: AnimatedSpinner::braille(),
pulsing_indicator: PulsingIndicator::new(),
view_transition: None,
previous_view: DashboardView::FocusedSession,
frame_count: 0,
})
}
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
use tokio::time::{interval, Duration as TokioDuration};
let mut frame_interval = interval(TokioDuration::from_millis(16));
loop {
frame_interval.tick().await;
self.update_animations();
self.update_state().await?;
terminal.draw(|f| self.render_dashboard_sync(f))?;
if event::poll(Duration::from_millis(0))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if self.show_project_switcher {
self.handle_project_switcher_input(key).await?;
} else {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Esc => {
if self.current_view == DashboardView::FocusedSession {
self.transition_to_view(DashboardView::Overview);
} else {
break;
}
}
_ => self.handle_dashboard_input(key).await?,
}
}
}
_ => {}
}
}
}
Ok(())
}
fn update_animations(&mut self) {
self.frame_count += 1;
self.animated_spinner.tick();
if let Some(transition) = &self.view_transition {
if transition.is_complete() {
self.view_transition = None;
}
}
}
async fn update_state(&mut self) -> Result<()> {
if self.last_update.elapsed() >= Duration::from_secs(3) {
if let Err(e) = self.send_activity_heartbeat().await {
debug!("Heartbeat error: {}", e);
}
self.last_update = Instant::now();
}
self.spinner.next();
self.current_session = self.get_current_session().await?;
let session_clone = self.current_session.clone();
if let Some(session) = session_clone {
self.current_project = self.get_project_by_session(&session).await?;
} else {
self.current_project = None;
}
self.daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
self.weekly_stats = self.get_weekly_stats().await.unwrap_or(0);
self.today_sessions = self.get_today_sessions().await.unwrap_or_default();
self.recent_projects = self.get_recent_projects().await.unwrap_or_default();
if self.current_view == DashboardView::History {
self.history_sessions = self.get_history_sessions().await.unwrap_or_default();
}
if self.current_view == DashboardView::Projects && self.available_projects.is_empty() {
if let Err(_) = self.refresh_projects().await {
}
}
Ok(())
}
async fn get_weekly_stats(&mut self) -> Result<i64> {
match self.client.send_message(&IpcMessage::GetWeeklyStats).await {
Ok(IpcResponse::WeeklyStats { total_seconds }) => Ok(total_seconds),
Ok(response) => {
debug!("Unexpected response for GetWeeklyStats: {:?}", response);
Err(anyhow::anyhow!("Unexpected response"))
}
Err(e) => {
debug!("Failed to receive GetWeeklyStats response: {}", e);
Err(anyhow::anyhow!("Failed to receive response"))
}
}
}
async fn get_recent_projects(&mut self) -> Result<Vec<ProjectWithStats>> {
match self
.client
.send_message(&IpcMessage::GetRecentProjects)
.await
{
Ok(IpcResponse::RecentProjects(projects)) => Ok(projects),
Ok(response) => {
debug!("Unexpected response for GetRecentProjects: {:?}", response);
Err(anyhow::anyhow!("Unexpected response"))
}
Err(e) => {
debug!("Failed to receive GetRecentProjects response: {}", e);
Err(anyhow::anyhow!("Failed to receive response"))
}
}
}
fn transition_to_view(&mut self, new_view: DashboardView) {
if self.current_view == new_view {
return;
}
let direction = match (&self.current_view, &new_view) {
(DashboardView::FocusedSession, _) => TransitionDirection::SlideLeft,
(_, DashboardView::FocusedSession) => TransitionDirection::SlideRight,
_ => TransitionDirection::FadeIn,
};
self.view_transition = Some(ViewTransition::new(direction, Duration::from_millis(200)));
self.previous_view = self.current_view.clone();
self.current_view = new_view;
}
async fn handle_dashboard_input(&mut self, key: KeyEvent) -> Result<()> {
match self.current_view {
DashboardView::History => {
return self.handle_history_input(key).await;
}
DashboardView::Projects => {
return self.handle_project_grid_input(key).await;
}
_ => {}
}
match key.code {
KeyCode::Char('1') => self.transition_to_view(DashboardView::FocusedSession),
KeyCode::Char('2') => self.transition_to_view(DashboardView::Overview),
KeyCode::Char('3') => self.transition_to_view(DashboardView::History),
KeyCode::Char('4') => self.transition_to_view(DashboardView::Projects),
KeyCode::Char('f') => self.transition_to_view(DashboardView::FocusedSession),
KeyCode::Tab => {
let next_view = match self.current_view {
DashboardView::FocusedSession => DashboardView::Overview,
DashboardView::Overview => DashboardView::History,
DashboardView::History => DashboardView::Projects,
DashboardView::Projects => DashboardView::FocusedSession,
};
self.transition_to_view(next_view);
}
KeyCode::Char('p') if self.current_view != DashboardView::Projects => {
self.refresh_projects().await?;
self.show_project_switcher = true;
}
_ => {}
}
Ok(())
}
async fn handle_history_input(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if !self.history_sessions.is_empty() && self.selected_session_index > 0 {
self.selected_session_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_session_index < self.history_sessions.len().saturating_sub(1) {
self.selected_session_index += 1;
}
}
KeyCode::Char('/') => {
self.filter_input_mode = true;
}
KeyCode::Enter if self.filter_input_mode => {
self.filter_input_mode = false;
self.history_sessions = self.get_history_sessions().await.unwrap_or_default();
}
KeyCode::Char(c) if self.filter_input_mode => {
self.session_filter.search_text.push(c);
}
KeyCode::Backspace if self.filter_input_mode => {
self.session_filter.search_text.pop();
}
KeyCode::Esc if self.filter_input_mode => {
self.filter_input_mode = false;
self.session_filter.search_text.clear();
}
_ => {}
}
Ok(())
}
async fn handle_project_grid_input(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_project_row > 0 {
self.selected_project_row -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
let total_projects = self.available_projects.len();
let total_rows =
(total_projects + self.projects_per_row - 1) / self.projects_per_row;
if self.selected_project_row < total_rows.saturating_sub(1) {
let next_row_first_index =
(self.selected_project_row + 1) * self.projects_per_row;
if next_row_first_index < total_projects {
self.selected_project_row += 1;
}
}
}
KeyCode::Left | KeyCode::Char('h') => {
if self.selected_project_col > 0 {
self.selected_project_col -= 1;
}
}
KeyCode::Right | KeyCode::Char('l') => {
let row_start = self.selected_project_row * self.projects_per_row;
let row_end =
(row_start + self.projects_per_row).min(self.available_projects.len());
let max_col = (row_end - row_start).saturating_sub(1);
if self.selected_project_col < max_col {
self.selected_project_col += 1;
}
}
KeyCode::Enter => {
self.switch_to_grid_selected_project().await?;
}
_ => {}
}
Ok(())
}
async fn handle_project_switcher_input(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.show_project_switcher = false;
}
KeyCode::Up | KeyCode::Char('k') => {
self.navigate_projects(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.navigate_projects(1);
}
KeyCode::Enter => {
self.switch_to_selected_project().await?;
}
_ => {}
}
Ok(())
}
async fn ensure_connected(&mut self) -> Result<()> {
if !is_daemon_running() {
return Err(anyhow::anyhow!("Daemon is not running"));
}
if self.client.stream.is_some() {
return Ok(());
}
let socket_path = get_socket_path()?;
if socket_path.exists() {
self.client = IpcClient::connect(&socket_path).await?;
}
Ok(())
}
async fn switch_to_grid_selected_project(&mut self) -> Result<()> {
let selected_index =
self.selected_project_row * self.projects_per_row + self.selected_project_col;
if let Some(selected_project) = self.available_projects.get(selected_index) {
let project_id = selected_project.id.unwrap_or(0);
self.ensure_connected().await?;
let response = self
.client
.send_message(&IpcMessage::SwitchProject(project_id))
.await?;
match response {
IpcResponse::Success => {
self.transition_to_view(DashboardView::FocusedSession);
}
IpcResponse::Error(e) => {
return Err(anyhow::anyhow!("Failed to switch project: {}", e))
}
_ => return Err(anyhow::anyhow!("Unexpected response")),
}
}
Ok(())
}
fn render_keyboard_hints(&self, area: Rect, buf: &mut Buffer) {
let hints = match self.current_view {
DashboardView::FocusedSession => vec![
("Esc", "Exit Focus"),
("Tab", "Next View"),
("p", "Projects"),
],
DashboardView::History => vec![
("↑/↓", "Navigate"),
("/", "Search"),
("Tab", "Next View"),
("q", "Quit"),
],
DashboardView::Projects => vec![
("↑/↓/←/→", "Navigate"),
("Enter", "Select"),
("Tab", "Next View"),
("q", "Quit"),
],
_ => vec![
("q", "Quit"),
("f", "Focus"),
("Tab", "Next View"),
("1-4", "View"),
("p", "Projects"),
],
};
let spans: Vec<Span> = hints
.iter()
.flat_map(|(key, desc)| {
vec![
Span::styled(
format!(" {} ", key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
]
})
.collect();
let line = Line::from(spans);
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
Paragraph::new(line).block(block).render(area, buf);
}
fn render_dashboard_sync(&mut self, f: &mut Frame) {
match self.current_view {
DashboardView::FocusedSession => self.render_focused_session_view(f),
DashboardView::Overview => self.render_overview_dashboard(f),
DashboardView::History => self.render_history_browser(f),
DashboardView::Projects => self.render_project_grid(f),
}
if self.show_project_switcher {
self.render_project_switcher(f, f.size());
}
}
fn render_focused_session_view(&mut self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
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), ])
.split(f.size());
let header_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(chunks[0]);
f.render_widget(
Paragraph::new("Press ESC to exit focused mode.")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
header_layout[0],
);
if let (Some(session), Some(project)) = (&self.current_session, &self.current_project) {
let project_area = self.centered_rect(60, 20, chunks[2]);
let project_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
.style(Style::default().bg(ColorScheme::CLEAN_BG));
let project_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.margin(1)
.split(project_area);
f.render_widget(project_block, project_area);
f.render_widget(
Paragraph::new(project.name.clone())
.alignment(Alignment::Center)
.style(
Style::default()
.fg(ColorScheme::WHITE_TEXT)
.add_modifier(Modifier::BOLD),
),
project_layout[0],
);
let default_description = "Refactor authentication module".to_string();
let description = project.description.as_ref().unwrap_or(&default_description);
f.render_widget(
Paragraph::new(description.clone())
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
project_layout[1],
);
let timer_area = self.centered_rect(40, 20, chunks[4]);
use crate::ui::animations::pulse_color;
let pulse_start = ColorScheme::CLEAN_GREEN;
let pulse_end = ColorScheme::PRIMARY_FOCUS;
let border_color = pulse_color(
pulse_start,
pulse_end,
self.pulsing_indicator.start_time.elapsed(),
Duration::from_millis(2000),
);
let timer_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(Color::Black));
let timer_inner = timer_block.inner(timer_area);
f.render_widget(timer_block, timer_area);
let now = Local::now();
let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
- session.paused_duration.num_seconds();
let duration_str = Formatter::format_duration_clock(elapsed_seconds);
f.render_widget(
Paragraph::new(duration_str)
.alignment(Alignment::Center)
.style(
Style::default()
.fg(ColorScheme::CLEAN_GREEN)
.add_modifier(Modifier::BOLD),
),
timer_inner,
);
let details_area = self.centered_rect(60, 25, chunks[6]);
let details_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
.style(Style::default().bg(ColorScheme::CLEAN_BG));
let details_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), ])
.margin(1)
.split(details_area);
f.render_widget(details_block, details_area);
let start_time_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(details_layout[0]);
f.render_widget(
Paragraph::new("Start Time").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
start_time_layout[0],
);
f.render_widget(
Paragraph::new(
session
.start_time
.with_timezone(&Local)
.format("%H:%M")
.to_string(),
)
.alignment(Alignment::Right)
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
start_time_layout[1],
);
let session_type_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(details_layout[1]);
f.render_widget(
Paragraph::new("Session Type").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
session_type_layout[0],
);
f.render_widget(
Paragraph::new("Deep Work")
.alignment(Alignment::Right)
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
session_type_layout[1],
);
let tags_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(details_layout[2]);
f.render_widget(
Paragraph::new("Tags").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
tags_layout[0],
);
let tag_spans = vec![
Span::styled(
" Backend ",
Style::default()
.fg(ColorScheme::CLEAN_BG)
.bg(ColorScheme::GRAY_TEXT),
),
Span::raw(" "),
Span::styled(
" Refactor ",
Style::default()
.fg(ColorScheme::CLEAN_BG)
.bg(ColorScheme::GRAY_TEXT),
),
Span::raw(" "),
Span::styled(
" Security ",
Style::default()
.fg(ColorScheme::CLEAN_BG)
.bg(ColorScheme::GRAY_TEXT),
),
];
f.render_widget(
Paragraph::new(Line::from(tag_spans)).alignment(Alignment::Right),
tags_layout[1],
);
} else {
let idle_area = self.centered_rect(50, 20, chunks[4]);
let idle_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
.style(Style::default().bg(ColorScheme::CLEAN_BG));
f.render_widget(idle_block.clone(), idle_area);
let idle_inner = idle_block.inner(idle_area);
f.render_widget(
Paragraph::new("No Active Session\n\nPress 's' to start tracking")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
idle_inner,
);
}
}
fn render_overview_dashboard(&mut self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
.split(f.size());
self.render_header(f, chunks[0]);
let grid_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(66), Constraint::Percentage(34), ])
.split(chunks[1]);
let left_col = grid_chunks[0];
let right_col = grid_chunks[1];
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12), Constraint::Min(10), ])
.split(left_col);
let current_session = &self.current_session;
let current_project = &self.current_project;
self.render_active_session_panel(f, left_chunks[0], current_session, current_project);
self.render_projects_table(f, left_chunks[1]);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), Constraint::Min(10), ])
.split(right_col);
let daily_stats = self.get_daily_stats();
self.render_quick_stats(f, right_chunks[0], daily_stats);
self.render_activity_timeline(f, right_chunks[1]);
self.render_keyboard_hints(chunks[2], f.buffer_mut());
}
fn render_history_browser(&mut self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(f.size());
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(8), Constraint::Min(10), ])
.split(chunks[0]);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(60), Constraint::Length(4), Constraint::Min(0), ])
.split(chunks[1]);
f.render_widget(
Paragraph::new("Tempo TUI :: History Browser")
.style(
Style::default()
.fg(ColorScheme::CLEAN_BLUE)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
),
left_chunks[0],
);
self.render_history_filters(f, left_chunks[1]);
self.render_session_list(f, left_chunks[2]);
self.render_session_details(f, right_chunks[0]);
self.render_session_actions(f, right_chunks[1]);
self.render_history_summary(f, right_chunks[2]);
}
fn render_history_filters(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Filters ")
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
let inner_area = block.inner(area);
f.render_widget(block, area);
let filter_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(inner_area);
let date_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30),
Constraint::Percentage(35),
Constraint::Percentage(35),
])
.split(filter_chunks[0]);
f.render_widget(
Paragraph::new("Start Date\nEnd Date")
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
date_layout[0],
);
f.render_widget(
Paragraph::new("2023-10-01\n2023-10-31")
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
date_layout[1],
);
f.render_widget(
Paragraph::new("Project\nDuration Filter")
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
date_layout[2],
);
let project_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(15), Constraint::Min(0)])
.split(filter_chunks[1]);
f.render_widget(
Paragraph::new("Project").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
project_layout[0],
);
f.render_widget(
Paragraph::new("Filter by project ▼")
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
project_layout[1],
);
let duration_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(15), Constraint::Min(0)])
.split(filter_chunks[2]);
f.render_widget(
Paragraph::new("Duration Filter").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
duration_layout[0],
);
f.render_widget(
Paragraph::new(">1h, <30m").style(Style::default().fg(ColorScheme::WHITE_TEXT)),
duration_layout[1],
);
let search_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(15), Constraint::Min(0)])
.split(filter_chunks[3]);
f.render_widget(
Paragraph::new("Free-text Search").style(Style::default().fg(ColorScheme::GRAY_TEXT)),
search_layout[0],
);
let search_style = if self.filter_input_mode {
Style::default().fg(ColorScheme::CLEAN_BLUE)
} else {
Style::default().fg(ColorScheme::WHITE_TEXT)
};
let search_text = if self.session_filter.search_text.is_empty() {
"Search session notes and context..."
} else {
&self.session_filter.search_text
};
f.render_widget(
Paragraph::new(search_text).style(search_style),
search_layout[1],
);
}
fn render_session_list(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
let inner_area = block.inner(area);
f.render_widget(block, area);
let header_row = Row::new(vec![
Cell::from("DATE").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("PROJECT").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("DURATION").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("START").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("END").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("STATUS").style(Style::default().add_modifier(Modifier::BOLD)),
])
.style(Style::default().fg(ColorScheme::GRAY_TEXT))
.bottom_margin(1);
let rows: Vec<Row> = self
.history_sessions
.iter()
.enumerate()
.map(|(i, session)| {
let is_selected = i == self.selected_session_index;
let style = if is_selected {
Style::default()
.bg(ColorScheme::CLEAN_BLUE)
.fg(Color::Black)
} else {
Style::default().fg(ColorScheme::WHITE_TEXT)
};
let status = if session.end_time.is_some() {
"[✓] Completed"
} else {
"[▶] Running"
};
let start_time = session
.start_time
.with_timezone(&Local)
.format("%H:%M")
.to_string();
let end_time = if let Some(end) = session.end_time {
end.with_timezone(&Local).format("%H:%M").to_string()
} else {
"--:--".to_string()
};
let duration = if let Some(_) = session.end_time {
let duration_secs =
(session.start_time.timestamp() - session.start_time.timestamp()).abs();
Formatter::format_duration(duration_secs)
} else {
"0h 0m".to_string()
};
Row::new(vec![
Cell::from(
session
.start_time
.with_timezone(&Local)
.format("%Y-%m-%d")
.to_string(),
),
Cell::from("Project Phoenix"), Cell::from(duration),
Cell::from(start_time),
Cell::from(end_time),
Cell::from(status),
])
.style(style)
})
.collect();
if rows.is_empty() {
let sample_rows = vec![
Row::new(vec![
Cell::from("2023-10-26"),
Cell::from("Project Phoenix"),
Cell::from("2h 15m"),
Cell::from("09:03"),
Cell::from("11:18"),
Cell::from("[✓] Completed"),
])
.style(
Style::default()
.bg(ColorScheme::CLEAN_BLUE)
.fg(Color::Black),
),
Row::new(vec![
Cell::from("2023-10-26"),
Cell::from("Internal Tools"),
Cell::from("0h 45m"),
Cell::from("11:30"),
Cell::from("12:15"),
Cell::from("[✓] Completed"),
]),
Row::new(vec![
Cell::from("2023-10-25"),
Cell::from("Project Phoenix"),
Cell::from("4h 05m"),
Cell::from("13:00"),
Cell::from("17:05"),
Cell::from("[✓] Completed"),
]),
Row::new(vec![
Cell::from("2023-10-25"),
Cell::from("Client Support"),
Cell::from("1h 00m"),
Cell::from("10:00"),
Cell::from("11:00"),
Cell::from("[✓] Completed"),
]),
Row::new(vec![
Cell::from("2023-10-24"),
Cell::from("Project Phoenix"),
Cell::from("8h 00m"),
Cell::from("09:00"),
Cell::from("17:00"),
Cell::from("[✓] Completed"),
]),
Row::new(vec![
Cell::from("2023-10-27"),
Cell::from("Project Nova"),
Cell::from("0h 22m"),
Cell::from("14:00"),
Cell::from("--:--"),
Cell::from("[▶] Running"),
]),
];
let table = Table::new(sample_rows).header(header_row).widths(&[
Constraint::Length(12),
Constraint::Min(15),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Min(12),
]);
f.render_widget(table, inner_area);
}
}
fn render_session_details(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Session Details ")
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
let inner_area = block.inner(area);
f.render_widget(block, area);
let details_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(2), Constraint::Length(3), ])
.split(inner_area);
f.render_widget(
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.")
.style(Style::default().fg(ColorScheme::WHITE_TEXT))
.wrap(ratatui::widgets::Wrap { trim: true }),
details_chunks[0],
);
let tag_spans = vec![
Span::styled(
" #backend ",
Style::default()
.fg(Color::Black)
.bg(ColorScheme::CLEAN_BLUE),
),
Span::raw(" "),
Span::styled(
" #auth ",
Style::default()
.fg(Color::Black)
.bg(ColorScheme::CLEAN_BLUE),
),
Span::raw(" "),
Span::styled(
" #bugfix ",
Style::default()
.fg(Color::Black)
.bg(ColorScheme::CLEAN_BLUE),
),
];
f.render_widget(
Paragraph::new(vec![Line::from("TAGS"), Line::from(tag_spans)])
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
details_chunks[1],
);
let context_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(details_chunks[2]);
f.render_widget(
Paragraph::new("CONTEXT").style(Style::default().fg(ColorScheme::WHITE_TEXT)),
context_chunks[0],
);
let context_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(context_chunks[1]);
f.render_widget(
Paragraph::new("Git\nBranch:\nIssue ID:\nCommit:")
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
context_layout[0],
);
f.render_widget(
Paragraph::new("feature/PHX-123-auth\nPHX-123\na1b2c3d")
.style(Style::default().fg(ColorScheme::WHITE_TEXT)),
context_layout[1],
);
}
fn render_session_actions(&self, f: &mut Frame, area: Rect) {
let button_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(area);
let buttons = [
("[ Edit ]", ColorScheme::GRAY_TEXT),
("[ Duplicate ]", ColorScheme::GRAY_TEXT),
("[ Delete ]", Color::Red),
("", ColorScheme::GRAY_TEXT),
];
for (i, (text, color)) in buttons.iter().enumerate() {
if !text.is_empty() {
f.render_widget(
Paragraph::new(*text)
.alignment(Alignment::Center)
.style(Style::default().fg(*color)),
button_layout[i],
);
}
}
}
fn render_history_summary(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Summary ")
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
let inner_area = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new("Showing 7 of 128 sessions. Total Duration: 17h 40m")
.style(Style::default().fg(ColorScheme::WHITE_TEXT))
.alignment(Alignment::Center),
inner_area,
);
}
fn render_project_grid(&mut self, f: &mut Frame) {
let area = f.size();
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(1), ])
.split(area);
f.render_widget(
Paragraph::new("Project Dashboard")
.style(
Style::default()
.fg(ColorScheme::CLEAN_BLUE)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT)),
),
main_layout[0],
);
self.render_project_cards(f, main_layout[1]);
self.render_project_stats_summary(f, main_layout[2]);
let hints = vec![
("↑/↓/←/→", "Navigate"),
("Enter", "Select"),
("Tab", "Next View"),
("q", "Quit"),
];
let spans: Vec<Span> = hints
.iter()
.flat_map(|(key, desc)| {
vec![
Span::styled(
format!(" {} ", key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{} ", desc), Style::default().fg(Color::DarkGray)),
]
})
.collect();
let line = Line::from(spans);
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
Paragraph::new(line)
.block(block)
.render(main_layout[3], f.buffer_mut());
}
fn render_project_cards(&mut self, f: &mut Frame, area: Rect) {
if self.available_projects.is_empty() {
let empty_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
.title(" No Projects Found ");
let empty_area = self.centered_rect(50, 30, area);
f.render_widget(empty_block.clone(), empty_area);
let inner = empty_block.inner(empty_area);
f.render_widget(
Paragraph::new("No projects available.\n\nStart a session to create a project.")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::GRAY_TEXT)),
inner,
);
return;
}
let margin = 2;
let card_height = 8;
let card_spacing = 1;
let available_height = area.height.saturating_sub(margin * 2);
let total_rows =
(self.available_projects.len() + self.projects_per_row - 1) / self.projects_per_row;
let visible_rows =
(available_height / (card_height + card_spacing)).min(total_rows as u16) as usize;
for row in 0..visible_rows {
let y_offset = margin + row as u16 * (card_height + card_spacing);
let row_area = Rect::new(area.x, area.y + y_offset, area.width, card_height);
let card_constraints = vec![
Constraint::Percentage(100 / self.projects_per_row as u16);
self.projects_per_row
];
let row_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(card_constraints)
.margin(1)
.split(row_area);
for col in 0..self.projects_per_row {
let project_index = row * self.projects_per_row + col;
if project_index >= self.available_projects.len() {
break;
}
let is_selected =
row == self.selected_project_row && col == self.selected_project_col;
self.render_project_card(f, row_layout[col], project_index, is_selected);
}
}
}
fn render_project_card(
&self,
f: &mut Frame,
area: Rect,
project_index: usize,
is_selected: bool,
) {
if let Some(project) = self.available_projects.get(project_index) {
let border_style = if is_selected {
Style::default().fg(ColorScheme::CLEAN_BLUE)
} else {
Style::default().fg(ColorScheme::GRAY_TEXT)
};
let bg_color = if is_selected {
ColorScheme::CLEAN_BG
} else {
Color::Black
};
let card_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(bg_color));
f.render_widget(card_block.clone(), area);
let inner_area = card_block.inner(area);
let card_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), ])
.split(inner_area);
let name = if project.name.len() > 20 {
format!("{}...", &project.name[..17])
} else {
project.name.clone()
};
f.render_widget(
Paragraph::new(name)
.style(
Style::default()
.fg(ColorScheme::WHITE_TEXT)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center),
card_layout[0],
);
let path_str = project.path.to_string_lossy();
let short_path = if path_str.len() > 25 {
format!("...{}", &path_str[path_str.len() - 22..])
} else {
path_str.to_string()
};
f.render_widget(
Paragraph::new(short_path)
.style(Style::default().fg(ColorScheme::GRAY_TEXT))
.alignment(Alignment::Center),
card_layout[1],
);
let stats_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(card_layout[3]);
f.render_widget(
Paragraph::new("Sessions\n42")
.style(Style::default().fg(ColorScheme::WHITE_TEXT))
.alignment(Alignment::Center),
stats_layout[0],
);
f.render_widget(
Paragraph::new("Time\n24h 15m")
.style(Style::default().fg(ColorScheme::WHITE_TEXT))
.alignment(Alignment::Center),
stats_layout[1],
);
let status = if project.is_archived {
(" Archived ", Color::Red)
} else {
(" Active ", ColorScheme::CLEAN_GREEN)
};
f.render_widget(
Paragraph::new(status.0)
.style(Style::default().fg(status.1))
.alignment(Alignment::Center),
card_layout[4],
);
}
}
fn render_project_stats_summary(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Summary ")
.border_style(Style::default().fg(ColorScheme::GRAY_TEXT));
f.render_widget(block.clone(), area);
let inner = block.inner(area);
let stats_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(inner);
let total_projects = self.available_projects.len();
let active_projects = self
.available_projects
.iter()
.filter(|p| !p.is_archived)
.count();
let archived_projects = total_projects - active_projects;
let stats = [
("Total Projects", total_projects.to_string()),
("Active", active_projects.to_string()),
("Archived", archived_projects.to_string()),
(
"Selected",
format!(
"{}/{}",
self.selected_project_row * self.projects_per_row
+ self.selected_project_col
+ 1,
total_projects
),
),
];
for (i, (label, value)) in stats.iter().enumerate() {
let content = Paragraph::new(vec![
Line::from(Span::styled(
*label,
Style::default().fg(ColorScheme::GRAY_TEXT),
)),
Line::from(Span::styled(
value.as_str(),
Style::default()
.fg(ColorScheme::WHITE_TEXT)
.add_modifier(Modifier::BOLD),
)),
])
.alignment(Alignment::Center);
f.render_widget(content, stats_layout[i]);
}
}
fn render_active_session_panel(
&self,
f: &mut Frame,
area: Rect,
session: &Option<Session>,
project: &Option<Project>,
) {
use crate::ui::animations::pulse_color;
let border_color = if session.is_some() {
pulse_color(
ColorScheme::PRIMARY_DASHBOARD,
ColorScheme::PRIMARY_FOCUS,
self.pulsing_indicator.start_time.elapsed(),
Duration::from_millis(2000),
)
} else {
ColorScheme::BORDER_DARK
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(ColorScheme::BG_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(4), ])
.margin(1)
.split(inner_area);
f.render_widget(
Paragraph::new("Active Session")
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
),
layout[0],
);
if let Some(session) = session {
let project_name = project
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or("Unknown Project");
let content_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(3), ])
.split(layout[2]);
f.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
project_name,
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED), )),
Line::from(Span::styled(
"▶ RUNNING",
Style::default().fg(ColorScheme::SUCCESS),
)),
])
.alignment(Alignment::Center),
content_layout[0],
);
let now = Local::now();
let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
- session.paused_duration.num_seconds();
let hours = elapsed_seconds / 3600;
let minutes = (elapsed_seconds % 3600) / 60;
let seconds = elapsed_seconds % 60;
let timer_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(content_layout[1]);
self.render_timer_digit(f, timer_layout[0], hours, "Hours");
self.render_timer_digit(f, timer_layout[1], minutes, "Minutes");
self.render_timer_digit(f, timer_layout[2], seconds, "Seconds");
} else {
f.render_widget(
Paragraph::new("No Active Session\n\nPress 's' to start tracking")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
layout[2],
);
}
}
fn render_timer_digit(&self, f: &mut Frame, area: Rect, value: i64, label: &str) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK))
.style(Style::default().bg(ColorScheme::PANEL_DARK));
let inner = block.inner(area);
f.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
f.render_widget(
Paragraph::new(format!("{:02}", value))
.alignment(Alignment::Center)
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
layout[0],
);
f.render_widget(
Paragraph::new(label)
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
layout[1],
);
}
fn render_quick_stats(&self, f: &mut Frame, area: Rect, daily_stats: &(i64, i64, i64)) {
let (_sessions_count, total_seconds, _avg_seconds) = *daily_stats;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), ])
.split(inner_area);
f.render_widget(
Paragraph::new("Quick Stats")
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
),
layout[0],
);
let stats_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), ])
.margin(1)
.split(layout[1]);
let stats = [
(
"Today's Total",
Formatter::format_duration(total_seconds),
stats_layout[0],
),
(
"This Week's Total",
Formatter::format_duration(self.weekly_stats),
stats_layout[1],
),
(
"Active Projects",
self.available_projects
.iter()
.filter(|p| !p.is_archived)
.count()
.to_string(),
stats_layout[2],
),
];
for (label, value, chunk) in stats.iter() {
let item_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK))
.style(Style::default().bg(ColorScheme::PANEL_DARK));
f.render_widget(item_block.clone(), *chunk);
let item_inner = item_block.inner(*chunk);
let item_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), ])
.split(item_inner);
f.render_widget(
Paragraph::new(*label).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
item_layout[0],
);
f.render_widget(
Paragraph::new(value.as_str()).style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
item_layout[1],
);
}
}
fn render_projects_table(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), ])
.split(inner_area);
f.render_widget(
Paragraph::new("Project List")
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
),
layout[0],
);
let header_row = Row::new(vec![
Cell::from("PROJECT NAME").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from("TIME TODAY").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from("TOTAL TIME").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from("LAST ACTIVITY").style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
])
.bottom_margin(1);
let rows: Vec<Row> = self
.recent_projects
.iter()
.map(|p| {
let time_today = Formatter::format_duration(p.today_seconds);
let total_time = Formatter::format_duration(p.total_seconds);
let last_activity = if let Some(last) = p.last_active {
let now = chrono::Utc::now();
let diff = now - last;
if diff.num_days() == 0 {
format!("Today, {}", last.with_timezone(&Local).format("%H:%M"))
} else if diff.num_days() == 1 {
"Yesterday".to_string()
} else {
format!("{} days ago", diff.num_days())
}
} else {
"Never".to_string()
};
Row::new(vec![
Cell::from(p.project.name.clone()).style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
Cell::from(time_today).style(Style::default().fg(if p.today_seconds > 0 {
ColorScheme::SUCCESS
} else {
ColorScheme::TEXT_SECONDARY
})),
Cell::from(total_time).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from(last_activity)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
])
})
.collect();
let table = Table::new(rows)
.header(header_row)
.widths(&[
Constraint::Percentage(30),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(30),
])
.column_spacing(1);
f.render_widget(table, layout[1]);
}
fn render_activity_timeline(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), ])
.split(inner_area);
f.render_widget(
Paragraph::new("Activity Timeline")
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
),
layout[0],
);
let timeline_area = layout[1];
let bar_area = Rect::new(
timeline_area.x,
timeline_area.y + 1,
timeline_area.width,
2, );
f.render_widget(
Block::default().style(Style::default().bg(ColorScheme::PANEL_DARK)),
bar_area,
);
let total_width = bar_area.width as f64;
let seconds_in_day = 86400.0;
for session in &self.today_sessions {
let start_seconds = session
.start_time
.with_timezone(&Local)
.num_seconds_from_midnight() as f64;
let end_seconds = if let Some(end) = session.end_time {
end.with_timezone(&Local).num_seconds_from_midnight() as f64
} else {
Local::now().num_seconds_from_midnight() as f64
};
let start_x = (start_seconds / seconds_in_day * total_width).floor() as u16;
let width =
((end_seconds - start_seconds) / seconds_in_day * total_width).ceil() as u16;
let draw_width = width.min(bar_area.width.saturating_sub(start_x));
if draw_width > 0 {
let segment_area = Rect::new(
bar_area.x + start_x,
bar_area.y,
draw_width,
bar_area.height,
);
let color = if session.end_time.is_none() {
ColorScheme::SUCCESS
} else {
ColorScheme::SUCCESS };
f.render_widget(
Block::default().style(Style::default().bg(color)),
segment_area,
);
}
}
let labels_y = bar_area.y + bar_area.height + 1;
let labels = ["00:00", "06:00", "12:00", "18:00", "24:00"];
let positions = [0.0, 0.25, 0.5, 0.75, 1.0];
for (label, pos) in labels.iter().zip(positions.iter()) {
let x = (timeline_area.x as f64 + (timeline_area.width as f64 * pos)
- (label.len() as f64 / 2.0)) as u16;
let x = x
.max(timeline_area.x)
.min(timeline_area.x + timeline_area.width - label.len() as u16);
f.render_widget(
Paragraph::new(*label).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Rect::new(x, labels_y, label.len() as u16, 1),
);
}
}
fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
let popup_area = self.centered_rect(60, 50, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ColorScheme::CLEAN_BLUE))
.title(" Select Project ")
.title_alignment(Alignment::Center)
.style(Style::default().bg(ColorScheme::CLEAN_BG));
f.render_widget(block.clone(), popup_area);
let list_area = block.inner(popup_area);
if self.available_projects.is_empty() {
let no_projects = Paragraph::new("No projects found")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::GRAY_TEXT));
f.render_widget(no_projects, list_area);
} else {
let items: Vec<ListItem> = self
.available_projects
.iter()
.enumerate()
.map(|(i, p)| {
let style = if i == self.selected_project_index {
Style::default()
.fg(ColorScheme::CLEAN_BG)
.bg(ColorScheme::CLEAN_BLUE)
} else {
Style::default().fg(ColorScheme::WHITE_TEXT)
};
ListItem::new(format!(" {} ", p.name)).style(style)
})
.collect();
let list = List::new(items);
f.render_widget(list, list_area);
}
}
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
async fn get_current_session(&mut self) -> Result<Option<Session>> {
if !is_daemon_running() {
return Ok(None);
}
self.ensure_connected().await?;
let response = self
.client
.send_message(&IpcMessage::GetActiveSession)
.await?;
match response {
IpcResponse::ActiveSession(session) => Ok(session),
IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
_ => Ok(None),
}
}
async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
if !is_daemon_running() {
return Ok(None);
}
self.ensure_connected().await?;
let response = self
.client
.send_message(&IpcMessage::GetProject(session.project_id))
.await?;
match response {
IpcResponse::Project(project) => Ok(project),
IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
_ => Ok(None),
}
}
async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> {
if !is_daemon_running() {
return Ok((0, 0, 0));
}
self.ensure_connected().await?;
let today = chrono::Local::now().date_naive();
let response = self
.client
.send_message(&IpcMessage::GetDailyStats(today))
.await?;
match response {
IpcResponse::DailyStats {
sessions_count,
total_seconds,
avg_seconds,
} => Ok((sessions_count, total_seconds, avg_seconds)),
IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
_ => Ok((0, 0, 0)),
}
}
async fn get_today_sessions(&mut self) -> Result<Vec<Session>> {
if !is_daemon_running() {
return Ok(Vec::new());
}
self.ensure_connected().await?;
let today = chrono::Local::now().date_naive();
let response = self
.client
.send_message(&IpcMessage::GetSessionsForDate(today))
.await?;
match response {
IpcResponse::SessionList(sessions) => Ok(sessions),
IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get sessions: {}", e)),
_ => Ok(Vec::new()),
}
}
async fn get_history_sessions(&mut self) -> Result<Vec<Session>> {
if !is_daemon_running() {
return Ok(Vec::new());
}
self.ensure_connected().await?;
let end_date = chrono::Local::now().date_naive();
let _start_date = end_date - chrono::Duration::days(30);
let mut all_sessions = Vec::new();
for days_ago in 0..30 {
let date = end_date - chrono::Duration::days(days_ago);
if let Ok(IpcResponse::SessionList(sessions)) = self
.client
.send_message(&IpcMessage::GetSessionsForDate(date))
.await
{
all_sessions.extend(sessions);
}
}
let filtered_sessions: Vec<Session> = all_sessions
.into_iter()
.filter(|session| {
if !self.session_filter.search_text.is_empty() {
if let Some(notes) = &session.notes {
if !notes
.to_lowercase()
.contains(&self.session_filter.search_text.to_lowercase())
{
return false;
}
} else {
return false;
}
}
true
})
.collect();
Ok(filtered_sessions)
}
async fn send_activity_heartbeat(&mut self) -> Result<()> {
if !is_daemon_running() {
return Ok(());
}
self.ensure_connected().await?;
let _response = self
.client
.send_message(&IpcMessage::ActivityHeartbeat)
.await?;
Ok(())
}
fn navigate_projects(&mut self, direction: i32) {
if self.available_projects.is_empty() {
return;
}
let new_index = self.selected_project_index as i32 + direction;
if new_index >= 0 && new_index < self.available_projects.len() as i32 {
self.selected_project_index = new_index as usize;
}
}
async fn refresh_projects(&mut self) -> Result<()> {
if !is_daemon_running() {
return Ok(());
}
self.ensure_connected().await?;
let response = self.client.send_message(&IpcMessage::ListProjects).await?;
if let IpcResponse::ProjectList(projects) = response {
self.available_projects = projects;
self.selected_project_index = 0;
}
Ok(())
}
async fn switch_to_selected_project(&mut self) -> Result<()> {
if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
let project_id = selected_project.id.unwrap_or(0);
self.ensure_connected().await?;
let response = self
.client
.send_message(&IpcMessage::SwitchProject(project_id))
.await?;
match response {
IpcResponse::Success => {
self.show_project_switcher = false;
}
IpcResponse::Error(e) => {
return Err(anyhow::anyhow!("Failed to switch project: {}", e))
}
_ => return Err(anyhow::anyhow!("Unexpected response")),
}
}
Ok(())
}
fn render_header(&self, f: &mut Frame, area: Rect) {
let time_str = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let header_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(20), Constraint::Min(30), ])
.split(area);
let title_block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
let title_inner = title_block.inner(header_layout[0]);
f.render_widget(title_block, header_layout[0]);
f.render_widget(
Paragraph::new("Tempo TUI").style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
title_inner,
);
let status_block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
let status_inner = status_block.inner(header_layout[1]);
f.render_widget(status_block, header_layout[1]);
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(20), Constraint::Length(15), ])
.split(status_inner);
f.render_widget(
Paragraph::new(time_str)
.alignment(Alignment::Right)
.style(Style::default().fg(ColorScheme::TEXT_MAIN)),
status_layout[0],
);
let daemon_status_line = if is_daemon_running() {
let spinner_frame = self.animated_spinner.current();
Line::from(vec![
Span::raw("Daemon: "),
Span::styled(
spinner_frame,
Style::default().fg(ColorScheme::SUCCESS),
),
Span::raw(" "),
Span::styled(
"Running",
Style::default()
.fg(ColorScheme::SUCCESS)
.add_modifier(Modifier::BOLD),
),
])
} else {
Line::from(vec![
Span::raw("Daemon: "),
Span::styled(
"Offline",
Style::default()
.fg(ColorScheme::ERROR)
.add_modifier(Modifier::BOLD),
),
])
};
f.render_widget(
Paragraph::new(daemon_status_line)
.alignment(Alignment::Right),
status_layout[1],
);
}
fn get_daily_stats(&self) -> &(i64, i64, i64) {
&self.daily_stats
}
}