use anyhow::Result; use serde::{Serialize, Serializer}; use shellexpand; use std::collections::HashMap; use std::io::{stdout, Write}; use std::rc::Rc; use std::time::{Duration, Instant}; // <--- Add this mod constants; mod window; use ratatui::{ crossterm::{ event::{self, Event, KeyCode}, execute, terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, }, ExecutableCommand, }, prelude::*, style::Color, widgets::*, }; #[derive(PartialEq)] enum State { InputIntention, InputDuration, InProgress, Paused, End, ShouldQuit, } fn serialize_duration(duration: &Duration, serializer: S) -> Result where S: Serializer, { serializer.serialize_u64(duration.as_secs()) } #[derive(Serialize)] struct SessionResult { intention: String, #[serde(serialize_with = "serialize_duration")] duration: Duration, session_ratings: Vec, rating: u8, rating_f64: f64, } impl SessionResult { fn rate(&mut self) { let mut rating = 0_f64; let total_duration = self .session_ratings .iter() .map(|r| r.duration) .fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur)); for session_rating in &self.session_ratings { let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64(); rating += (session_rating.rating as f64) * ratio; } self.rating_f64 = rating; if rating > 2.5 { self.rating = 3; } else if rating > 2.0 { self.rating = 2; } else if rating > 1.0 { self.rating = 1; } else { self.rating = 0; } } } #[derive(Clone, Serialize)] struct SessionRating { window_title: Rc, duration: Duration, rating: u8, } struct App { state: State, user_intention: String, user_duration_str: String, user_duration: Duration, current_window_title: Rc, current_window_time: Instant, session_start: Instant, session_stats: HashMap, Duration>, session_remaining: Duration, session_ratings: Vec, session_ratings_index: usize, session_results: Vec, last_tick_50ms: Instant, last_tick_1s: Instant, } fn save_session(session: &SessionResult) { let path = shellexpand::tilde(constants::HISTORY_FILE).to_string(); // 1. Open file in append mode, create if missing let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(path); if let Ok(mut f) = file { // 2. Serialize to JSON if let Ok(json) = serde_json::to_string(session) { // 3. Write newline appended let _ = writeln!(f, "{}", json); } } } impl App { fn new() -> Self { let window_title = window::get_title_clean(); App { state: State::InputIntention, user_intention: String::new(), user_duration_str: constants::DEFAULT_DURATION.to_string(), user_duration: Duration::ZERO, current_window_title: window_title.into(), current_window_time: Instant::now(), session_start: Instant::now(), session_stats: HashMap::new(), session_remaining: Duration::ZERO, session_ratings: Vec::new(), session_ratings_index: 0, session_results: Vec::new(), last_tick_50ms: Instant::now(), last_tick_1s: Instant::now(), } } fn handle_ticks(&mut self) { if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) { self.tick_50ms(); } if self.last_tick_1s.elapsed() >= Duration::from_secs(1) { self.tick_1s(); } } fn to_in_progress(&mut self) { if self.user_intention.len() == 0 || self.user_duration == Duration::ZERO { return; } self.state = State::InProgress; self.current_window_time = Instant::now(); self.session_start = self.current_window_time; self.session_stats = HashMap::new(); self.session_ratings_index = 0; } fn to_end(&mut self) { self.state = State::End; self.session_ratings = session_stats_as_vec(&self.session_stats); } fn cleanup(&self) { let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); let _ = std::fs::remove_file(path); } fn write_status(&self) { let status = match self.state { State::InputIntention | State::InputDuration => constants::STATUS_CONFIGURE.to_string(), State::InProgress => format!( "🎯 {} - {}", self.user_intention, duration_as_str(&self.session_remaining) ), State::Paused => format!( "{} - {}", constants::STATUS_PAUSED, duration_as_str(&self.session_remaining) ), State::End => constants::STATUS_RATE_SESSION.to_string(), State::ShouldQuit => constants::STATUS_QUIT.to_string(), }; let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); if let Ok(mut file) = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&path) { let _ = file.write_all(status.as_bytes()); } } fn tick_50ms(&mut self) { match self.state { State::InputIntention | State::InputDuration => { if let Ok(user_duration_mins) = self.user_duration_str.parse::() { self.user_duration = Duration::from_secs(user_duration_mins * 60); } else { self.user_duration = Duration::ZERO; } window::minimize_other(&constants::APP_TITLE); } State::InProgress => { let elapsed = self.session_start.elapsed(); self.session_remaining = self.user_duration.saturating_sub(elapsed); update_session_stats(self); if self.session_remaining.is_zero() { self.to_end(); } } State::ShouldQuit => {} State::Paused => { window::minimize_other(&constants::APP_TITLE); update_session_stats(self); } State::End => { window::minimize_other(&constants::APP_TITLE); } } self.last_tick_50ms = Instant::now(); } fn tick_1s(&mut self) { self.last_tick_1s = Instant::now(); self.write_status(); if self.state == State::Paused { self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1)); } } fn timeout(&self) -> Duration { Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) } fn get_session_results(&self) -> Vec> { self.session_results .iter() .map(|r| { Line::from(Span::styled( format!("{} {}", duration_as_str(&r.duration), r.intention,), match r.rating { 2 => Style::new().fg(Color::LightYellow), 3 => Style::new().fg(Color::LightGreen), _ => Style::new().fg(Color::LightRed), }, )) }) .collect() } fn get_session_stats(&self) -> Vec> { let mut zero_encountered = if self.state != State::End { true } else { false }; self.session_ratings .iter() .map(|s| { Line::from(Span::styled( format!("{}: {}", duration_as_str(&s.duration), s.window_title), match s.rating { 0 if !zero_encountered => { zero_encountered = true; Style::new().fg(Color::LightBlue) } 0 if zero_encountered => Style::new(), 1 => Style::new().fg(Color::LightRed), 2 => Style::new().fg(Color::LightYellow), 3 => Style::new().fg(Color::LightGreen), _ => Style::new().fg(Color::LightBlue), }, )) }) .collect() } } fn duration_as_str(duration: &Duration) -> String { format!( "{:3}:{:02}", duration.as_secs() / 60, duration.as_secs() % 60 ) } fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec { let mut stats: Vec<_> = session_stats .iter() .filter(|(_, duration)| duration.as_secs() > 30) .map(|(title, duration)| SessionRating { window_title: title.clone(), duration: duration.clone(), rating: 0, }) .collect(); stats.sort_by(|a, b| b.duration.cmp(&a.duration)); stats } fn main() -> Result<()> { enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; execute!(stdout(), SetTitle(constants::APP_TITLE))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let mut app = App::new(); while app.state != State::ShouldQuit { terminal.draw(|frame| ui(frame, &app))?; handle_events(&mut app)?; app.handle_ticks(); } app.cleanup(); disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; Ok(()) } fn handle_events(app: &mut App) -> Result<()> { if !event::poll(app.timeout())? { return Ok(()); } let Event::Key(key) = event::read()? else { return Ok(()); }; if key.kind != event::KeyEventKind::Press { return Ok(()); } if key.code == KeyCode::Esc { app.state = State::ShouldQuit; } match app.state { State::InputIntention => match key.code { KeyCode::Enter => app.state = State::InputDuration, KeyCode::Tab => app.state = State::InputDuration, KeyCode::Backspace => { let _ = app.user_intention.pop(); } KeyCode::Char(c) => { app.user_intention.push(c); } _ => {} }, State::InputDuration => match key.code { KeyCode::Enter => app.to_in_progress(), KeyCode::Tab => app.state = State::InputIntention, KeyCode::Backspace => { let _ = app.user_duration_str.pop(); } KeyCode::Char(c) if c.is_ascii_digit() => { app.user_duration_str.push(c); } _ => {} }, State::InProgress => match key.code { KeyCode::Char('q') => { app.to_end(); } KeyCode::Char('p') => { app.state = State::Paused; } KeyCode::Char('a') => { app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60)); } KeyCode::Char('x') => { app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60)); } _ => {} }, State::Paused => { if key.code == KeyCode::Char('p') { app.state = State::InProgress; } } State::ShouldQuit => (), State::End => { let code = match key.code { KeyCode::Char('1') => 1, KeyCode::Char('2') => 2, KeyCode::Char('3') => 3, _ => 0, }; if app.session_ratings_index < app.session_ratings.len() && code != 0 { app.session_ratings[app.session_ratings_index].rating = code; app.session_ratings_index += 1; } if app.session_ratings_index >= app.session_ratings.len() { app.state = State::InputIntention; let mut session_result = SessionResult { intention: app.user_intention.clone(), duration: app.session_start.elapsed(), session_ratings: std::mem::take(&mut app.session_ratings), rating: 0, rating_f64: 0.0, }; session_result.rate(); save_session(&session_result); app.session_results.push(session_result); } } } Ok(()) } fn update_session_stats(app: &mut App) { let window_title = if app.state == State::Paused { constants::PAUSED.to_string().into() } else { window::get_title_clean().into() }; let delta = app.current_window_time.elapsed(); if app.current_window_title != window_title || (delta > Duration::from_secs(1)) { let entry = app .session_stats .entry(app.current_window_title.clone()) .or_insert_with(|| Duration::default()); *entry += app.current_window_time.elapsed(); app.current_window_time = Instant::now(); app.current_window_title = window_title; app.session_ratings = session_stats_as_vec(&app.session_stats); } } fn ui(frame: &mut Frame, app: &App) { let layout = Layout::vertical([ Constraint::Min(3), Constraint::Min(3), Constraint::Percentage(100), Constraint::Min(3), ]); let [layout_intention, layout_duration, layout_titles, layout_status] = layout.areas(frame.size()); let border_type_intention = if app.state == State::InputIntention { BorderType::Thick } else { BorderType::Plain }; let input_intention = Line::from(Span::raw(&app.user_intention)); frame.render_widget( Paragraph::new(input_intention).block( Block::bordered() .border_type(border_type_intention) .title(constants::INTENTION_TITLE), ), layout_intention, ); let input_duration: Vec = match app.state { State::InProgress | State::Paused => { let s = duration_as_str(&app.session_remaining); vec![Line::from(Span::raw(s))] } _ => { vec![Line::from(Span::raw(&app.user_duration_str))] } }; let border_type_duration = if app.state == State::InputDuration { BorderType::Thick } else { BorderType::Plain }; frame.render_widget( Paragraph::new(input_duration).block( Block::bordered() .border_type(border_type_duration) .title(constants::DURATION_TITLE), ), layout_duration, ); if app.state == State::InputIntention || app.state == State::InputDuration { let results = app.get_session_results(); frame.render_widget( Paragraph::new(results) .block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)), layout_titles, ); } else { let stats = app.get_session_stats(); frame.render_widget( Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)), layout_titles, ); } let mut spans: Vec = Vec::new(); if app.user_intention.len() == 0 { let span = Span::styled( constants::PROVIDE_INTENTION, Style::new().fg(Color::LightRed), ); spans.push(span); } if app.user_duration.is_zero() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, Style::new().fg(Color::LightRed), ); spans.push(span); } match app.state { State::InputIntention | State::InputDuration => { if spans.len() == 0 { let span = Span::styled( constants::READY_TO_START, Style::new().fg(Color::LightGreen), ); spans.push(span); } } State::InProgress => { let span = Span::styled( constants::SESSION_IN_PROGRESS, Style::new().fg(Color::LightGreen), ); spans.push(span); } State::Paused => { let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue)); spans.push(span); } State::ShouldQuit => {} State::End => { let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue)); spans.push(span); } } let input_status: Vec = vec![Line::from(spans)]; frame.render_widget( Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)), layout_status, ); }