use crate::config::Config; use crate::state::{AppState, TimeItem}; use chrono::Utc; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use serde::Deserialize; use std::process::Command; #[derive(Debug, Deserialize, Clone)] pub(crate) struct WatsonFrame { id: String, project: String, start: String, stop: String, tags: Vec, } pub enum Screen { Main, Help, ConfigHelp, NewEntry, ReassignProject, LogView, } pub enum LogViewPeriod { Day, Week, Month, } pub struct App { pub state: AppState, pub config: Config, pub current_screen: Screen, pub needs_clear: bool, pub new_entry_buffer: String, pub new_entry_project: String, pub new_entry_cursor: usize, pub new_entry_mode: NewEntryMode, // Task or Project pub reassign_project_buffer: String, pub reassign_project_cursor: usize, pub log_view_period: LogViewPeriod, pub log_view_frames: Vec, pub log_view_content: Vec, pub log_view_scroll: usize, pub log_view_selected: usize, pub status_message: Option<(String, std::time::Instant)>, } pub enum NewEntryMode { Task, Project, } impl App { pub fn new() -> anyhow::Result { Ok(Self { state: AppState::load()?, config: Config::load()?, current_screen: Screen::Main, needs_clear: false, new_entry_buffer: String::new(), new_entry_project: String::new(), new_entry_cursor: 0, new_entry_mode: NewEntryMode::Task, reassign_project_buffer: String::new(), reassign_project_cursor: 0, log_view_period: LogViewPeriod::Day, log_view_frames: Vec::new(), log_view_content: Vec::new(), log_view_scroll: 0, log_view_selected: 0, status_message: None, }) } pub fn handle_event(&mut self, event: Event) -> anyhow::Result { // Update status message self.update_status_message(); let previous_screen = std::mem::discriminant(&self.current_screen); let result = match self.current_screen { Screen::Main => self.handle_main_event(event), Screen::Help => self.handle_help_event(event), Screen::ConfigHelp => self.handle_config_help_event(event), Screen::NewEntry => self.handle_new_entry_event(event), Screen::ReassignProject => self.handle_reassign_project_event(event), Screen::LogView => self.handle_log_view_event(event), }; // If we switched screens, signal that we need to clear let current_screen = std::mem::discriminant(&self.current_screen); if previous_screen != current_screen { self.needs_clear = true; } result } fn handle_main_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => match (code, modifiers) { (KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1), (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1), (KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1), (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1), (KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1), (KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1), (KeyCode::Enter, _) => self.toggle_current_item()?, (KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?, (KeyCode::Char('?'), _) => self.current_screen = Screen::Help, (KeyCode::Char('c'), _) => self.edit_app_config()?, (KeyCode::Char('n'), _) => self.start_new_entry(), (KeyCode::Char('p'), _) => self.start_reassign_project(), (KeyCode::Char('v'), _) => self.start_log_view()?, (KeyCode::Char('d'), _) => self.delete_current_item()?, _ => {} }, _ => {} } Ok(false) } fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => { match (code, modifiers) { (KeyCode::Esc, _) => { self.current_screen = Screen::Main; self.new_entry_buffer.clear(); self.new_entry_project.clear(); self.new_entry_mode = NewEntryMode::Task; self.new_entry_cursor = 0; } (KeyCode::Enter, _) => { match self.new_entry_mode { NewEntryMode::Task => { if !self.new_entry_buffer.is_empty() { self.new_entry_mode = NewEntryMode::Project; self.new_entry_cursor = self.new_entry_project.len(); } } NewEntryMode::Project => { if self.new_entry_project.is_empty() || self.config.is_valid_project(&self.new_entry_project) { // Create new time item let item = TimeItem { name: self.new_entry_buffer.clone(), tags: if self.new_entry_project.is_empty() { vec![] } else { vec![self.new_entry_project.clone()] }, last_used: Some(Utc::now()), }; // Add to current pane (or recurring if in recent) match self.state.current_pane { 0 => self.state.permanent_items.push(item), // Permanent Items 1 => self.state.recurring_items.push(item), // Recurring Items 2 => self.state.recent_items.push(item), // Ad-Hoc Items _ => unreachable!(), } self.state.save()?; // Clear and return to main screen self.current_screen = Screen::Main; self.new_entry_buffer.clear(); self.new_entry_project.clear(); self.new_entry_mode = NewEntryMode::Task; } } } } (KeyCode::Backspace, _) => match self.new_entry_mode { NewEntryMode::Task => { if self.new_entry_cursor > 0 { self.new_entry_buffer.remove(self.new_entry_cursor - 1); self.new_entry_cursor -= 1; } } NewEntryMode::Project => { if self.new_entry_cursor > 0 { let idx = self.new_entry_cursor - 1; if idx < self.new_entry_project.len() { self.new_entry_project.remove(idx); self.new_entry_cursor -= 1; } } } }, (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode { NewEntryMode::Task => { self.new_entry_buffer.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } NewEntryMode::Project => { if self.new_entry_cursor <= self.new_entry_project.len() { self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } } }, _ => {} } } _ => {} } Ok(false) } fn handle_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char('c') => self.current_screen = Screen::ConfigHelp, KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Main, _ => {} }, _ => {} } Ok(false) } fn handle_config_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Help, _ => {} }, _ => {} } Ok(false) } fn handle_reassign_project_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => { match (code, modifiers) { (KeyCode::Esc, _) => { self.current_screen = Screen::Main; self.reassign_project_buffer.clear(); self.reassign_project_cursor = 0; } (KeyCode::Enter, _) => { if self.reassign_project_buffer.is_empty() || self.config.is_valid_project(&self.reassign_project_buffer) { self.reassign_project_for_current_item()?; self.current_screen = Screen::Main; self.reassign_project_buffer.clear(); self.reassign_project_cursor = 0; } } (KeyCode::Backspace, _) => { if self.reassign_project_cursor > 0 { let idx = self.reassign_project_cursor - 1; if idx < self.reassign_project_buffer.len() { self.reassign_project_buffer.remove(idx); self.reassign_project_cursor -= 1; } } } (KeyCode::Char(c), m) if m.is_empty() => { if self.reassign_project_cursor <= self.reassign_project_buffer.len() { self.reassign_project_buffer.insert(self.reassign_project_cursor, c); self.reassign_project_cursor += 1; } } _ => {} } } _ => {} } Ok(false) } fn handle_log_view_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Esc | KeyCode::Char('q') => { self.current_screen = Screen::Main; self.log_view_scroll = 0; self.log_view_selected = 0; self.needs_clear = true; } KeyCode::Char('d') => { self.log_view_period = LogViewPeriod::Day; self.log_view_scroll = 0; self.log_view_selected = 0; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('w') => { self.log_view_period = LogViewPeriod::Week; self.log_view_scroll = 0; self.log_view_selected = 0; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('m') => { self.log_view_period = LogViewPeriod::Month; self.log_view_scroll = 0; self.log_view_selected = 0; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('j') | KeyCode::Down => { if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) { self.log_view_selected += 1; } } KeyCode::Char('k') | KeyCode::Up => { if self.log_view_selected > 0 { self.log_view_selected -= 1; } } KeyCode::Char('e') => { self.edit_selected_frame()?; } KeyCode::Char('x') => { self.delete_selected_frame()?; } KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frames.len().saturating_sub(1)); } KeyCode::PageUp => { self.log_view_selected = self.log_view_selected.saturating_sub(10); } _ => {} }, _ => {} } Ok(false) } fn format_log_entries(&mut self) { use chrono::{DateTime, Local, Timelike}; use std::collections::BTreeMap; if self.log_view_frames.is_empty() { self.log_view_content = vec!["No log entries for this period.".to_string()]; return; } // Group frames by date let mut by_date: BTreeMap> = BTreeMap::new(); for frame in &self.log_view_frames { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); let date_key = local_dt.format("%A %d %B %Y").to_string(); by_date.entry(date_key).or_insert_with(Vec::new).push(frame); } } let mut lines = Vec::new(); for (date, frames) in by_date.iter().rev() { lines.push(date.clone()); for frame in frames { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), DateTime::parse_from_rfc3339(&frame.stop), ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); let tags_str = if frame.tags.is_empty() { String::new() } else { format!(" [{}]", frame.tags.join(", ")) }; lines.push(format!( "\t{} to {} {}{}", start_time, stop_time, frame.project, tags_str )); } } lines.push(String::new()); // Empty line between dates } self.log_view_content = lines; } fn edit_selected_frame(&mut self) -> anyhow::Result<()> { if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() { return Ok(()); } let frame_id = &self.log_view_frames[self.log_view_selected].id; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run watson edit let status = Command::new("watson").arg("edit").arg(frame_id).status()?; // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; if status.success() { // Reload log content self.load_log_content()?; } self.needs_clear = true; Ok(()) } fn delete_selected_frame(&mut self) -> anyhow::Result<()> { if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() { return Ok(()); } let frame_id = self.log_view_frames[self.log_view_selected].id.clone(); // Run watson remove with --force flag (no confirmation) let output = Command::new("watson") .arg("remove") .arg("--force") .arg(&frame_id) .output()?; if output.status.success() { // Reload log content self.load_log_content()?; // Adjust selection if we deleted the last item if self.log_view_selected >= self.log_view_frames.len() && !self.log_view_frames.is_empty() { self.log_view_selected = self.log_view_frames.len() - 1; } } self.needs_clear = true; Ok(()) } fn move_selection(&mut self, delta: i32) { let items_len = match self.state.current_pane { 0 => self.state.permanent_items.len(), 1 => self.state.recurring_items.len(), 2 => self.state.recent_items.len(), _ => return, }; if items_len == 0 { return; } let current = self.state.selected_indices[self.state.current_pane] as i32; let new_index = (current + delta).rem_euclid(items_len as i32) as usize; self.state.selected_indices[self.state.current_pane] = new_index; } fn change_pane(&mut self, delta: i32) { self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize; } fn get_current_item(&self) -> Option { let items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return None, }; let index = self.state.selected_indices[self.state.current_pane]; items.get(index).cloned() } fn toggle_current_item(&mut self) -> anyhow::Result<()> { if let Some(item) = self.get_current_item() { if self .state .active_timer .as_ref() .map(|(active, _)| active.name == item.name) .unwrap_or(false) { self.state.stop_timer()?; } else { self.state.start_timer(item)?; } self.state.save()?; } Ok(()) } fn delete_current_item(&mut self) -> anyhow::Result<()> { // Check if this is the active timer let should_stop = { let items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return Ok(()), }; let index = self.state.selected_indices[self.state.current_pane]; if !items.is_empty() && index < items.len() { if let Some((ref active, _)) = self.state.active_timer { items[index].name == active.name } else { false } } else { return Ok(()); } }; // Stop timer if needed if should_stop { self.state.stop_timer()?; } // Now delete the item let items = match self.state.current_pane { 0 => &mut self.state.permanent_items, 1 => &mut self.state.recurring_items, 2 => &mut self.state.recent_items, _ => return Ok(()), }; let index = self.state.selected_indices[self.state.current_pane]; if !items.is_empty() && index < items.len() { // Remove the item items.remove(index); // Adjust index if we're at the end if !items.is_empty() && index == items.len() { self.state.selected_indices[self.state.current_pane] = items.len() - 1; } // Save changes self.state.save()?; } Ok(()) } fn set_status_message>(&mut self, message: S) { self.status_message = Some((message.into(), std::time::Instant::now())); } fn update_status_message(&mut self) { // Clear status message after 3 seconds if let Some((_, instant)) = self.status_message { if instant.elapsed().as_secs() >= 3 { self.status_message = None; } } } fn start_new_entry(&mut self) { self.current_screen = Screen::NewEntry; self.new_entry_buffer.clear(); self.new_entry_project.clear(); self.new_entry_cursor = 0; self.new_entry_mode = NewEntryMode::Task; } fn start_reassign_project(&mut self) { if let Some(item) = self.get_current_item() { self.current_screen = Screen::ReassignProject; // Pre-fill with current project if it exists self.reassign_project_buffer = item.tags.first().cloned().unwrap_or_default(); self.reassign_project_cursor = self.reassign_project_buffer.len(); } } fn start_log_view(&mut self) -> anyhow::Result<()> { self.current_screen = Screen::LogView; self.log_view_period = LogViewPeriod::Day; self.log_view_scroll = 0; self.load_log_content()?; Ok(()) } fn load_log_content(&mut self) -> anyhow::Result<()> { let flag = match self.log_view_period { LogViewPeriod::Day => "--day", LogViewPeriod::Week => "--week", LogViewPeriod::Month => "--month", }; let output = Command::new("watson") .arg("log") .arg(flag) .arg("--json") .output()?; if output.status.success() { let json_str = String::from_utf8_lossy(&output.stdout); // Parse JSON frames match serde_json::from_str::>(&json_str) { Ok(frames) => { self.log_view_frames = frames; self.format_log_entries(); } Err(e) => { self.log_view_frames.clear(); self.log_view_content = vec![ "Failed to parse Watson log JSON:".to_string(), e.to_string(), ]; } } } else { let error = String::from_utf8_lossy(&output.stderr); self.log_view_frames.clear(); self.log_view_content = vec![ "Failed to load Watson log:".to_string(), error.to_string(), ]; } Ok(()) } fn reassign_project_for_current_item(&mut self) -> anyhow::Result<()> { let items = match self.state.current_pane { 0 => &mut self.state.permanent_items, 1 => &mut self.state.recurring_items, 2 => &mut self.state.recent_items, _ => return Ok(()), }; let index = self.state.selected_indices[self.state.current_pane]; let needs_restart = if let Some(item) = items.get_mut(index) { // Update the tags if self.reassign_project_buffer.is_empty() { item.tags.clear(); } else { item.tags = vec![self.reassign_project_buffer.clone()]; } // Check if this is the active timer self.state .active_timer .as_ref() .map(|(active, _)| active.name == item.name) .unwrap_or(false) } else { false }; // If this was the active timer, restart it with new tags if needs_restart { let item = items[index].clone(); self.state.stop_timer()?; self.state.start_timer(item)?; } self.state.save()?; Ok(()) } fn edit_app_config(&mut self) -> anyhow::Result<()> { use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = Config::config_path()?; // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run editor let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state self.config = Config::load()?; self.current_screen = Screen::Main; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; self.needs_clear = true; Ok(()) } fn edit_config(&mut self) -> anyhow::Result<()> { use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = AppState::config_file()?; if !config_path.exists() { std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?; } // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run editor let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state self.state = AppState::load()?; self.current_screen = Screen::Main; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; self.needs_clear = true; Ok(()) } }