diff --git a/Cargo.lock b/Cargo.lock index 504c742..5ebd0a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "mio" version = "0.8.11" @@ -484,6 +490,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -715,6 +734,7 @@ dependencies = [ "dirs", "ratatui", "serde", + "serde_json", "serde_yaml", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 8bd79be..201ec16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ ratatui = "0.24.0" crossterm = "0.27.0" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" +serde_json = "1.0" tokio = { version = "1.34", features = ["full"] } anyhow = "1.0" dirs = "5.0" diff --git a/README.md b/README.md index 67f9cbc..a2484fe 100644 --- a/README.md +++ b/README.md @@ -20,7 +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. +- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `j`/`k` to select entries, `e` to edit, `x` to delete. - **Deletion**: `d` removes the selected entry; no noisy status message. - **Config editing**: - `Ctrl+e` edits task config (`state.yaml`). @@ -56,7 +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) | +| `v` | View Watson log (d/w/m for day/week/month, e to edit, x to delete) | | `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 9b20884..ba2cf36 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,8 +2,18 @@ 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, @@ -31,8 +41,10 @@ pub struct App { 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)>, } @@ -55,8 +67,10 @@ impl App { 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, }) } @@ -275,42 +289,52 @@ impl App { 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_scroll < self.log_view_content.len().saturating_sub(1) { - self.log_view_scroll += 1; + 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_scroll > 0 { - self.log_view_scroll -= 1; + 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_scroll = (self.log_view_scroll + 10) - .min(self.log_view_content.len().saturating_sub(1)); + self.log_view_selected = (self.log_view_selected + 10) + .min(self.log_view_frames.len().saturating_sub(1)); } KeyCode::PageUp => { - self.log_view_scroll = self.log_view_scroll.saturating_sub(10); + self.log_view_selected = self.log_view_selected.saturating_sub(10); } _ => {} }, @@ -319,6 +343,124 @@ impl App { 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(), @@ -469,13 +611,32 @@ impl App { LogViewPeriod::Month => "--month", }; - let output = Command::new("watson").arg("log").arg(flag).output()?; + let output = Command::new("watson") + .arg("log") + .arg(flag) + .arg("--json") + .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(); + 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(), diff --git a/src/ui.rs b/src/ui.rs index 1367581..c78f3da 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -316,7 +316,7 @@ fn render_help(frame: &mut Frame, _app: &App) { "Enter - Start/stop timer", "d - Delete task", "p - Reassign project", - "v - View Watson log", + "v - View Watson log (e to edit entries, x to delete)", "Ctrl+e - Edit tasks config", "c - Edit app config", "n - New task", @@ -527,36 +527,50 @@ fn render_log_view(frame: &mut Frame, app: &App) { let block = Block::default() .title(title) .borders(Borders::ALL) - .style(Style::default().fg(ACTIVE_COLOR).bg(Color::Reset)); + .style(Style::default().fg(ACTIVE_COLOR)); - // Calculate visible area for content - let inner_area = block.inner(chunks[0]); - let visible_lines = inner_area.height as usize; + // Build list items with selection highlighting + let items: Vec = app + .log_view_content + .iter() + .enumerate() + .map(|(idx, line)| { + // Check if this is a frame line (starts with tab) and matches selected frame + let is_selected = if line.starts_with('\t') { + // Count how many frame lines we've seen so far + let frame_idx = app.log_view_content[..=idx] + .iter() + .filter(|l| l.starts_with('\t')) + .count() + .saturating_sub(1); + frame_idx == app.log_view_selected + } else { + false + }; - // 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 style = if is_selected { + Style::default() + .fg(ACTIVE_COLOR) + .add_modifier(Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + }; - let text = if visible_content.is_empty() { - "No log entries for this period.".to_string() - } else { - visible_content.join("\n") - }; + ListItem::new(Line::from(vec![Span::styled(line.clone(), style)])) + }) + .collect(); - 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]); + let list = List::new(items).block(block); + frame.render_widget(list, chunks[0]); // Render command bar let commands = vec![ ("d", "day"), ("w", "week"), ("m", "month"), - ("j/k", "scroll"), + ("j/k", "select"), + ("e", "edit"), + ("x", "delete"), ("q/ESC", "back"), ];