diff --git a/README.md b/README.md index c7a037f..67f9cbc 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer. - **New entries**: `n` launches a modal (task name + optional project tag). Item is added to the current pane. - **Project reassignment**: `p` opens a modal to change the project tag for the selected item. +- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `j`/`k` to scroll. - **Deletion**: `d` removes the selected entry; no noisy status message. - **Config editing**: - `Ctrl+e` edits task config (`state.yaml`). @@ -55,6 +56,7 @@ Requires `watson` on your `PATH` with a configured workspace. | `Enter` | Start/stop timer | | `n` | New entry (task + optional project) | | `p` | Reassign project for selected item | +| `v` | View Watson log (d/w/m for day/week/month) | | `d` | Delete entry | | `Ctrl+e` | Edit task config (`state.yaml`) | | `c` | Edit app config (`config.yaml`) or show config help | diff --git a/src/app.rs b/src/app.rs index 17683c3..9b20884 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,19 +10,29 @@ pub enum Screen { 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_redraw: bool, + 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_content: Vec, + pub log_view_scroll: usize, pub status_message: Option<(String, std::time::Instant)>, } @@ -37,13 +47,16 @@ impl App { state: AppState::load()?, config: Config::load()?, current_screen: Screen::Main, - needs_redraw: true, + 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_content: Vec::new(), + log_view_scroll: 0, status_message: None, }) } @@ -52,13 +65,24 @@ impl App { // Update status message self.update_status_message(); - match self.current_screen { + 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 { @@ -79,6 +103,7 @@ impl App { (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()?, _ => {} }, @@ -244,6 +269,56 @@ impl App { 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.needs_clear = true; + } + KeyCode::Char('d') => { + self.log_view_period = LogViewPeriod::Day; + self.log_view_scroll = 0; + self.load_log_content()?; + self.needs_clear = true; + } + KeyCode::Char('w') => { + self.log_view_period = LogViewPeriod::Week; + self.log_view_scroll = 0; + self.load_log_content()?; + self.needs_clear = true; + } + KeyCode::Char('m') => { + self.log_view_period = LogViewPeriod::Month; + self.log_view_scroll = 0; + self.load_log_content()?; + self.needs_clear = true; + } + KeyCode::Char('j') | KeyCode::Down => { + if self.log_view_scroll < self.log_view_content.len().saturating_sub(1) { + self.log_view_scroll += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if self.log_view_scroll > 0 { + self.log_view_scroll -= 1; + } + } + KeyCode::PageDown => { + self.log_view_scroll = (self.log_view_scroll + 10) + .min(self.log_view_content.len().saturating_sub(1)); + } + KeyCode::PageUp => { + self.log_view_scroll = self.log_view_scroll.saturating_sub(10); + } + _ => {} + }, + _ => {} + } + Ok(false) + } + fn move_selection(&mut self, delta: i32) { let items_len = match self.state.current_pane { 0 => self.state.permanent_items.len(), @@ -379,6 +454,37 @@ impl App { } } + 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).output()?; + + if output.status.success() { + let content = String::from_utf8_lossy(&output.stdout); + self.log_view_content = content.lines().map(|s| s.to_string()).collect(); + } else { + let error = String::from_utf8_lossy(&output.stderr); + 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, @@ -439,14 +545,14 @@ impl App { if status.success() { // Reload entire application state self.config = Config::load()?; - // Signal for complete reload self.current_screen = Screen::Main; - self.needs_redraw = true; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; + + self.needs_clear = true; Ok(()) } @@ -476,14 +582,14 @@ impl App { if status.success() { // Reload entire application state self.state = AppState::load()?; - // Signal for complete reload self.current_screen = Screen::Main; - self.needs_redraw = true; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; + + self.needs_clear = true; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 71adce6..4dea70b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,19 +42,18 @@ fn main() -> Result<()> { fn run_app(terminal: &mut Terminal, app: &mut app::App) -> Result<()> { loop { - // Force a redraw if needed - if app.needs_redraw { - terminal.clear()?; // Clear the entire screen - terminal.draw(|f| ui::render(f, app))?; - app.needs_redraw = false; + // Clear terminal if we switched screens + if app.needs_clear { + terminal.clear()?; + app.needs_clear = false; } + terminal.draw(|f| ui::render(f, app))?; + if event::poll(std::time::Duration::from_millis(50))? { if app.handle_event(event::read()?)? { return Ok(()); } - // Always redraw after any event - terminal.draw(|f| ui::render(f, app))?; } } } diff --git a/src/ui.rs b/src/ui.rs index 95b9d38..1367581 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - app::{App, NewEntryMode, Screen}, + app::{App, LogViewPeriod, NewEntryMode, Screen}, state::{AppState, TimeItem}, }; @@ -21,6 +21,7 @@ pub fn render(frame: &mut Frame, app: &App) { Screen::ConfigHelp => render_config_help(frame, app), Screen::NewEntry => render_new_entry(frame, app), Screen::ReassignProject => render_reassign_project(frame, app), + Screen::LogView => render_log_view(frame, app), } } @@ -211,6 +212,7 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { ("c", "config"), ("n", "new"), ("p", "project"), + ("v", "log"), ("d", "delete"), ("q", "quit"), ]; @@ -314,6 +316,7 @@ fn render_help(frame: &mut Frame, _app: &App) { "Enter - Start/stop timer", "d - Delete task", "p - Reassign project", + "v - View Watson log", "Ctrl+e - Edit tasks config", "c - Edit app config", "n - New task", @@ -503,3 +506,72 @@ fn render_reassign_project(frame: &mut Frame, app: &App) { frame.render_widget(help_text, chunks[1]); } + +fn render_log_view(frame: &mut Frame, app: &App) { + let area = frame.size(); + + // Create the main content area (leave space for command bar at bottom) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(area); + + let period_str = match app.log_view_period { + LogViewPeriod::Day => "Day", + LogViewPeriod::Week => "Week", + LogViewPeriod::Month => "Month", + }; + + let title = format!("Watson Log - {} View", period_str); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(ACTIVE_COLOR).bg(Color::Reset)); + + // Calculate visible area for content + let inner_area = block.inner(chunks[0]); + let visible_lines = inner_area.height as usize; + + // Get the visible slice of content + let start = app.log_view_scroll; + let end = (start + visible_lines).min(app.log_view_content.len()); + let visible_content: Vec = app.log_view_content[start..end].to_vec(); + + let text = if visible_content.is_empty() { + "No log entries for this period.".to_string() + } else { + visible_content.join("\n") + }; + + let paragraph = Paragraph::new(text) + .block(block) + .style(Style::default().fg(Color::White)) + .wrap(ratatui::widgets::Wrap { trim: false }); + + frame.render_widget(paragraph, chunks[0]); + + // Render command bar + let commands = vec![ + ("d", "day"), + ("w", "week"), + ("m", "month"), + ("j/k", "scroll"), + ("q/ESC", "back"), + ]; + + let command_text = format!( + " {}", + commands + .iter() + .map(|(key, desc)| format!("{} ({})", key, desc)) + .collect::>() + .join(" ยท ") + ); + + let command_bar = Paragraph::new(command_text) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left); + + frame.render_widget(command_bar, chunks[1]); +}