tempo-cli 0.4.0

Automatic project time tracking CLI tool with beautiful terminal interface
Documentation
use anyhow::Result;
use chrono::{DateTime, Duration, Local, Utc};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
    backend::Backend,
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Frame, Terminal,
};
use std::time::Duration as StdDuration;

use crate::ui::widgets::{ColorScheme, Throbber};

pub struct InteractiveTimer {
    start_time: Option<DateTime<Utc>>,
    paused_at: Option<DateTime<Utc>>,
    total_paused: Duration,
    target_duration: i64, // in seconds
    show_milestones: bool,
    throbber: Throbber,
}

impl InteractiveTimer {
    pub async fn new() -> Result<Self> {
        Ok(Self {
            start_time: None,
            paused_at: None,
            total_paused: Duration::zero(),
            target_duration: 25 * 60, // Default 25 minutes (Pomodoro)
            show_milestones: true,
            throbber: Throbber::new(),
        })
    }

    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
        loop {
            // Update timer state
            self.update_timer_state().await?;

            terminal.draw(|f| {
                self.render_timer(f);
            })?;

            // Handle input
            if event::poll(StdDuration::from_millis(100))? {
                match event::read()? {
                    Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
                        KeyCode::Char('q') | KeyCode::Esc => break,
                        KeyCode::Char(' ') => self.toggle_timer().await?,
                        KeyCode::Char('r') => self.reset_timer().await?,
                        KeyCode::Char('s') => self.set_target().await?,
                        KeyCode::Char('m') => self.show_milestones = !self.show_milestones,
                        _ => {}
                    },
                    _ => {}
                }
            }
        }

        Ok(())
    }

    fn render_timer(&self, f: &mut Frame) {
        let area = f.size();

        // Center the content
        let vertical_center = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Percentage(15),
                Constraint::Percentage(70),
                Constraint::Percentage(15),
            ])
            .split(area);

        let horizontal_center = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(15),
                Constraint::Percentage(70),
                Constraint::Percentage(15),
            ])
            .split(vertical_center[1]);

        let main_area = horizontal_center[1];

        // Pulsing border effect (simulated by toggling color intensity or just using primary)
        // For now, static primary color
        let border_color = if self.paused_at.is_some() || self.start_time.is_none() {
            ColorScheme::BORDER_DARK
        } else {
            ColorScheme::PRIMARY_FOCUS
        };

        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(), main_area);
        let inner_area = block.inner(main_area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(4), // Project Info
                Constraint::Min(10),   // Timer Display
                Constraint::Length(6), // Metadata
                Constraint::Length(1), // Footer
            ])
            .margin(2)
            .split(inner_area);

        // 1. Project Info
        self.render_project_context(f, chunks[0]);

        // 2. Timer Display
        self.render_large_timer(f, chunks[1]);

        // 3. Metadata
        self.render_metadata(f, chunks[2]);

        // 4. Footer
        self.render_footer(f, chunks[3]);
    }

    fn render_project_context(&self, f: &mut Frame, area: Rect) {
        let project_name = "Current Project"; // Ideally fetched from state
        let description = "Deep Work Session";

        let text = vec![
            Line::from(Span::styled(
                project_name,
                Style::default()
                    .fg(ColorScheme::PRIMARY_FOCUS)
                    .add_modifier(Modifier::BOLD)
                    .add_modifier(Modifier::UNDERLINED),
            )),
            Line::from(""),
            Line::from(Span::styled(
                description,
                Style::default().fg(ColorScheme::TEXT_SECONDARY),
            )),
        ];

        f.render_widget(Paragraph::new(text).alignment(Alignment::Left), area);
    }

    fn render_large_timer(&self, f: &mut Frame, area: Rect) {
        let elapsed = self.get_elapsed_time();

        let hours = elapsed / 3600;
        let minutes = (elapsed % 3600) / 60;
        let seconds = elapsed % 60;

        let layout = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Ratio(1, 3),
                Constraint::Ratio(1, 3),
                Constraint::Ratio(1, 3),
            ])
            .split(area);

        self.render_timer_digit(f, layout[0], hours, "HOURS");
        self.render_timer_digit(f, layout[1], minutes, "MINUTES");
        self.render_timer_digit(f, layout[2], seconds, "SECONDS");
    }

    fn render_timer_digit(&self, f: &mut Frame, area: Rect, value: i64, label: &str) {
        // Center the digit box within the area
        let centered_area = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(1),
                Constraint::Length(8), // Box height
                Constraint::Min(1),
            ])
            .split(area)[1];

        let centered_area = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Min(1),
                Constraint::Length(14), // Box width
                Constraint::Min(1),
            ])
            .split(centered_area)[1];

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(
                if self.start_time.is_some() && self.paused_at.is_none() {
                    ColorScheme::PRIMARY_FOCUS
                } else {
                    ColorScheme::BORDER_DARK
                },
            ))
            .style(Style::default().bg(ColorScheme::PANEL_DARK));

        f.render_widget(block.clone(), centered_area);
        let inner = block.inner(centered_area);

        let content_layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(1),    // Digit
                Constraint::Length(1), // Label
            ])
            .margin(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),
                ), // In a real TUI we'd use big text here
            content_layout[0],
        );

        f.render_widget(
            Paragraph::new(label)
                .alignment(Alignment::Center)
                .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
            content_layout[1],
        );
    }

    fn render_metadata(&self, f: &mut Frame, area: Rect) {
        let start_time_str = if let Some(start) = self.start_time {
            start.with_timezone(&Local).format("%H:%M").to_string()
        } else {
            "--:--".to_string()
        };

        let layout = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(33),
                Constraint::Percentage(33),
                Constraint::Percentage(33),
            ])
            .split(area);

        let items = [
            ("START TIME", start_time_str),
            ("SESSION TYPE", "Focus".to_string()),
            ("TAGS", "coding, rust".to_string()),
        ];

        for (i, (label, value)) in items.iter().enumerate() {
            let text = vec![
                Line::from(Span::styled(
                    *label,
                    Style::default().fg(ColorScheme::TEXT_SECONDARY),
                )),
                Line::from(Span::styled(
                    value.as_str(),
                    Style::default()
                        .fg(ColorScheme::TEXT_MAIN)
                        .add_modifier(Modifier::BOLD),
                )),
            ];
            f.render_widget(Paragraph::new(text).alignment(Alignment::Center), layout[i]);
        }
    }

    fn render_footer(&self, f: &mut Frame, area: Rect) {
        let hints = vec![
            Span::styled(
                "[Space]",
                Style::default()
                    .fg(ColorScheme::PRIMARY_FOCUS)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" Toggle  "),
            Span::styled(
                "[R]",
                Style::default()
                    .fg(ColorScheme::PRIMARY_FOCUS)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" Reset  "),
            Span::styled(
                "[S]",
                Style::default()
                    .fg(ColorScheme::PRIMARY_FOCUS)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" Set Target  "),
            Span::styled(
                "[Q]",
                Style::default()
                    .fg(ColorScheme::ERROR)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" Quit"),
        ];

        f.render_widget(
            Paragraph::new(Line::from(hints)).alignment(Alignment::Center),
            area,
        );
    }

    async fn update_timer_state(&mut self) -> Result<()> {
        // This would sync with the actual session state from the daemon
        // For now, we'll keep local state
        if self.start_time.is_some() && self.paused_at.is_none() {
            self.throbber.next();
        }
        Ok(())
    }

    async fn toggle_timer(&mut self) -> Result<()> {
        if self.start_time.is_none() {
            // Start timer
            self.start_time = Some(Utc::now());
            self.paused_at = None;
        } else if self.paused_at.is_some() {
            // Resume timer
            if let Some(paused_at) = self.paused_at {
                self.total_paused += Utc::now() - paused_at;
            }
            self.paused_at = None;
        } else {
            // Pause timer
            self.paused_at = Some(Utc::now());
        }
        Ok(())
    }

    async fn reset_timer(&mut self) -> Result<()> {
        self.start_time = None;
        self.paused_at = None;
        self.total_paused = chrono::Duration::zero();
        Ok(())
    }

    async fn set_target(&mut self) -> Result<()> {
        // In a full implementation, this would show an input dialog
        // For now, cycle through common durations
        self.target_duration = match self.target_duration {
            1500 => 1800, // 25min -> 30min
            1800 => 2700, // 30min -> 45min
            2700 => 3600, // 45min -> 1hour
            3600 => 5400, // 1hour -> 1.5hour
            5400 => 7200, // 1.5hour -> 2hour
            _ => 1500,    // Default back to 25min (Pomodoro)
        };
        Ok(())
    }

    fn get_elapsed_time(&self) -> i64 {
        if let Some(start) = self.start_time {
            let end_time = if let Some(paused) = self.paused_at {
                paused
            } else {
                Utc::now()
            };

            (end_time - start - self.total_paused).num_seconds().max(0)
        } else {
            0
        }
    }
}