From 353d422730e42eaa39ffe705cbb6229df4433942 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Sat, 22 Nov 2025 11:53:43 -0500 Subject: [PATCH] View filtering, task name refactor Weird issues where projects were mapping to tags. Also group everything by project in view with 'g' --- README.md | 12 +++--- src/app.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++++------- src/ui.rs | 46 +++++++++++----------- 3 files changed, 123 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ae56a27..60b2500 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ ## Key Features - **Navigation**: `j/k` or arrows move within a pane; `h/l`, arrows, or `Ctrl+n/p` switch panes; `q` quits. - **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 select entries, `e` to edit, `x` to delete, `c` to copy to clipboard. +- **New entries**: `n` launches a modal (project + optional tag). Item is added to the current pane. +- **Project reassignment**: `p` opens a modal to change the tags for the selected item. +- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `g` to toggle grouping by date/project, `j`/`k` to select entries, `e` to edit, `x` to delete, `c` to copy to clipboard. - **Deletion**: `d` removes the selected entry; no noisy status message. - **Config editing**: - `Ctrl+e` edits task config (`state.yaml`). @@ -54,9 +54,9 @@ Requires `watson` on your `PATH` with a configured workspace. | `j` / `k`, arrows | Move selection | | `h` / `l`, arrows, `Ctrl+n` / `Ctrl+p` | Switch panes | | `Enter` | Start/stop timer | -| `n` | New entry (task + optional project) | -| `p` | Reassign project for selected item | -| `v` | View Watson log (d/w/m day/week/month, e edit, x delete, c copy) | +| `n` | New entry (project + optional tag) | +| `p` | Reassign tag for selected item | +| `v` | View Watson log (d/w/m period, g group, e edit, x delete, c copy) | | `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 1a77c09..88a42b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,6 +29,11 @@ pub enum LogViewPeriod { Month, } +pub enum LogViewGrouping { + ByDate, + ByProject, +} + pub struct App { pub state: AppState, pub config: Config, @@ -41,6 +46,7 @@ pub struct App { pub reassign_project_buffer: String, pub reassign_project_cursor: usize, pub log_view_period: LogViewPeriod, + pub log_view_grouping: LogViewGrouping, pub log_view_frames: Vec, pub log_view_content: Vec, pub log_view_scroll: usize, @@ -68,6 +74,7 @@ impl App { reassign_project_buffer: String::new(), reassign_project_cursor: 0, log_view_period: LogViewPeriod::Day, + log_view_grouping: LogViewGrouping::ByDate, log_view_frames: Vec::new(), log_view_content: Vec::new(), log_view_scroll: 0, @@ -144,15 +151,13 @@ impl App { (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(); - } + // Move from Tag to Project + self.new_entry_mode = NewEntryMode::Project; + self.new_entry_cursor = self.new_entry_buffer.len(); } NewEntryMode::Project => { - if self.new_entry_project.is_empty() - || self.config.is_valid_project(&self.new_entry_project) - { + // Project is required, tag is optional + if !self.new_entry_buffer.is_empty() { // Create new time item let item = TimeItem { name: self.new_entry_buffer.clone(), @@ -184,15 +189,15 @@ impl App { (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_project.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); + if idx < self.new_entry_buffer.len() { + self.new_entry_buffer.remove(idx); self.new_entry_cursor -= 1; } } @@ -200,12 +205,12 @@ impl App { }, (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_project.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); + if self.new_entry_cursor <= self.new_entry_buffer.len() { + self.new_entry_buffer.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } } @@ -334,6 +339,16 @@ impl App { KeyCode::Char('c') => { self.copy_log_to_clipboard()?; } + KeyCode::Char('g') => { + // Toggle grouping mode + self.log_view_grouping = match self.log_view_grouping { + LogViewGrouping::ByDate => LogViewGrouping::ByProject, + LogViewGrouping::ByProject => LogViewGrouping::ByDate, + }; + self.log_view_selected = 0; + self.format_log_entries(); + self.needs_clear = true; + } KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frames.len().saturating_sub(1)); @@ -357,6 +372,16 @@ impl App { return; } + match self.log_view_grouping { + LogViewGrouping::ByDate => self.format_by_date(), + LogViewGrouping::ByProject => self.format_by_project(), + } + } + + fn format_by_date(&mut self) { + use chrono::{DateTime, Local, Timelike}; + use std::collections::BTreeMap; + // Group frames by date let mut by_date: BTreeMap> = BTreeMap::new(); @@ -402,6 +427,64 @@ impl App { self.log_view_content = lines; } + fn format_by_project(&mut self) { + use chrono::{DateTime, Local, Timelike}; + use std::collections::BTreeMap; + + // Group frames by date, then by project within each 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(BTreeMap::new) + .entry(frame.project.clone()) + .or_insert_with(Vec::new) + .push(frame); + } + } + + let mut lines = Vec::new(); + + for (date, projects) in by_date.iter().rev() { + lines.push(date.clone()); + + for (project, frames) in projects.iter() { + lines.push(format!(" {}", project)); // Project header with indent + + 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\t{} to {}{}", + start_time, stop_time, 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(()); diff --git a/src/ui.rs b/src/ui.rs index 6f33a47..904f4f8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - app::{App, LogViewPeriod, NewEntryMode, Screen}, + app::{App, LogViewGrouping, LogViewPeriod, NewEntryMode, Screen}, state::{AppState, TimeItem}, }; @@ -121,9 +121,9 @@ fn render_new_entry(frame: &mut Frame, app: &App) { .constraints([Constraint::Length(3), Constraint::Length(3)]) .split(area); - // Task name input - let task_block = Block::default() - .title("Task Name") + // Tag input (first) + let tag_block = Block::default() + .title("Tag") .borders(Borders::ALL) .style( Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) { @@ -133,13 +133,13 @@ fn render_new_entry(frame: &mut Frame, app: &App) { }), ); - let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block); + let tag_text = Paragraph::new(app.new_entry_project.as_str()).block(tag_block); - frame.render_widget(task_text, chunks[0]); + frame.render_widget(tag_text, chunks[0]); - // Project input + // Project input (second) let project_block = Block::default() - .title("Project (optional)") + .title("Project") .borders(Borders::ALL) .style( Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) { @@ -149,17 +149,7 @@ fn render_new_entry(frame: &mut Frame, app: &App) { }), ); - let project_text = if !app.config.projects.is_empty() { - format!( - "{} (available: {})", - app.new_entry_project, - app.config.projects.join(", ") - ) - } else { - app.new_entry_project.clone() - }; - - let project_text = Paragraph::new(project_text).block(project_block); + let project_text = Paragraph::new(app.new_entry_buffer.as_str()).block(project_block); frame.render_widget(project_text, chunks[1]); @@ -167,8 +157,8 @@ fn render_new_entry(frame: &mut Frame, app: &App) { let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1); let command_text = match app.new_entry_mode { - NewEntryMode::Task => "Enter task name, press Enter to continue", - NewEntryMode::Project => "Enter project name (optional), press Enter to save", + NewEntryMode::Task => "Enter tag, press Enter to continue", + NewEntryMode::Project => "Enter project, press Enter to save", }; let command_bar = Paragraph::new(command_text) @@ -316,7 +306,7 @@ fn render_help(frame: &mut Frame, _app: &App) { "Enter - Start/stop timer", "d - Delete task", "p - Reassign project", - "v - View Watson log (e to edit entries, x to delete, c to copy)", + "v - View Watson log (g to group, e to edit, x to delete, c to copy)", "Ctrl+e - Edit tasks config", "c - Edit app config", "n - New task", @@ -481,7 +471,7 @@ fn render_reassign_project(frame: &mut Frame, app: &App) { // Project input let project_block = Block::default() - .title(format!("Reassign Project for: {}", current_item_name)) + .title(format!("Reassign Tag for: {}", current_item_name)) .borders(Borders::ALL) .style(Style::default().fg(ACTIVE_COLOR)); @@ -500,7 +490,7 @@ fn render_reassign_project(frame: &mut Frame, app: &App) { frame.render_widget(project_paragraph, chunks[0]); // Help text - let help_text = Paragraph::new("Enter new project (leave empty to remove), press Enter to save, Esc to cancel") + let help_text = Paragraph::new("Enter new tag (leave empty to remove), press Enter to save, Esc to cancel") .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); @@ -522,7 +512,12 @@ fn render_log_view(frame: &mut Frame, app: &App) { LogViewPeriod::Month => "Month", }; - let title = format!("Watson Log - {} View", period_str); + let grouping_str = match app.log_view_grouping { + LogViewGrouping::ByDate => "by Date", + LogViewGrouping::ByProject => "by Project", + }; + + let title = format!("Watson Log - {} View ({})", period_str, grouping_str); let block = Block::default() .title(title) @@ -568,6 +563,7 @@ fn render_log_view(frame: &mut Frame, app: &App) { ("d", "day"), ("w", "week"), ("m", "month"), + ("g", "group"), ("j/k", "select"), ("e", "edit"), ("x", "delete"),