diff --git a/README.md b/README.md index 60b2500..0e01ff6 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,92 @@ # wat -`wat` is a terminal UI wrapper around [Watson](https://tailordev.github.io/Watson/) that lets you browse and launch timers via a keyboard-driven interface. +`wat` is a TUI interface for [Watson](https://tailordev.github.io/Watson/) + +![wat in tarnation](./assets/wat.png "wat") --- -## Goals & Workflow -- Never type `watson start ...` again—start/stop timers with `Enter`. -- Maintain three panes simultaneously: - 1. **Permanent Items** – curated recurring tasks defined in `state.yaml`. - 2. **Recurring Items** – semi-regular tasks you hop between. - 3. **Ad-Hoc Items** – last ~20 one-offs, automatically tracked. -- Highlight clearly when a timer is running (bold green) vs idle (yellow). -- Keep everything human-editable: YAML state/config under `~/.config/wat`. +## Overview and screenshots ---- +### Main Interface -## 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 (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`). - - `c` edits app config (`config.yaml`). -- **Help overlays**: `?` for help, `c` inside help for config instructions. +Wat's main screen consists of 3 groupings of tasks, each of which is optional and can be disabled via settings. The idea here is to provide an interface to toggle what task you are currently working on, with lots of options for editing, annotating, and reporting on that time spent later. When you track time on a task, it will show up in the `view` page (v), which is analogous to `watson log`. ---- +![Toggling a task in the wat interface](./assets/0-track-time.webm "time-tracking") -## Files & Persistence -``` -~/.config/wat/ -├─ state.yaml # serialized AppState (items, selections, active timer) -└─ config.yaml # UI/app options (help hints, allowed projects, etc.) -``` -- `state.yaml` and `config.yaml` use `serde_yaml` for readability. +The three available categories of task are `Permanent Items`, for tasks that will likely be part of your workflow for the forseeable future, `Recurring Items` for scheduled weekly meetings or tasks which come and go, and `Ad-Hoc Items` for things that will not recur. Eventually, I plan to make the `Ad-Hoc` section display items in reverse order, and remove ones beyond 10 items. This will not delete past time entries for those items. ---- +### The 'View Entries' Interface + +Pressing `v` on the main interface will take the user to the interface for viewing actual time entries, as created from the main interface. The default view is the 'day' view, which shows your entries for the current day. You can press `g` to `group` or `ungroup` your tasks by project. You can also navigate using `h,j,k,l` between entries, entries grouped by project, grouped by day, or all entries. You can press `y` to copy the selection to your clipboard. + +![Navigating the daily view](./assets/2-daily-view-and-proj-selection.webm "daily-view") + +### Weekly and Monthly View + +When in the `view` interface, you can switch to a weekly or monthly view using `w` or `m`. Grouping works the same way in these views as in the `daily` view. You can also press 'r' to reverse the order of the days in the display. + +![Navigating by week or month](./assets/3-weekly-view-and-day-selection.webm "week-and-month-view") + +### Individual entry editing + +When an entry is selected, you can press `e` to edit the full `watson edit` configuration for your selected entry in your chosen `$EDITOR` + +![Editing a time entry](./assets/4-individual-editing.webm "editing-an-entry") + +### Backfill gap and overlap correction + +The `ungrouped` view can also be used as an error resolution and quick edit page using the `b` (backfill) key. Here, `backfill` roughly translates to 'take the current entry and make its start time equal to the stop time of the previous entry'. The `b` key will do nothing if the entry is the first entry of the day. + +In the ungrouped view, entries are chronological. Entries with gaps betweent them are denoted by a blank line with three dashes (---). Entries which overlap with the previous entry (which is likely an error and should never happen) are colored yellow. In both cases, pressing `b` will resolve the issue. (You may want gaps in certain cases such as lunch breaks or short breaks, so don't do this without thinking about the context) + +![Backfilling Overlapped or Disjointed Entries](./assets/5-backfill-gap-and-overlap-correction.webm "backfilling") + +### Rounding + +The time tracking software I use at work only accepts logging for tasks and projects in 15m intervals, so I included a hotkey `R` to automatically round tasks to the nearest 15m. This reduces a little burden when reporting time, but keep in mind that if you get unlucky and many entries are rounded down you may end up under-reporting. Keep this in mind and always sanity check your time. + +![Rounding](./assets/6-rounding.webm "rounding") + +### Annotation + +Watson actually doesn't include any options for annotating time entries by default! However, it does allow you to technically assign multiple 'tags' to an entry. In `wat`, we've been treating the `tag` as the name of the time entry more-or-less. When an entry is completed, you can navigate to the view page and press `a` to add a second tag if you want to be more specific about that particular entry. In practice, it looks just fine in the final reporting. ## Building & Running ``` cargo build cargo run ``` -Requires `watson` on your `PATH` with a configured workspace. +Requires `watson` on your `PATH` with a configured workspace. The application will automatically create the necessary `~/.config/watson` directory structure on first run if it doesn't exist. + +Otherwise, just download the latest release for a relatively-stable binary. ---- ## Keybindings (Summary) -| Keys | Action | -|------|--------| -| `j` / `k`, arrows | Move selection | -| `h` / `l`, arrows, `Ctrl+n` / `Ctrl+p` | Switch panes | -| `Enter` | Start/stop timer | -| `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 | -| `?` | Help screen | -| `q` | Quit | +| Keys | Action | +|-------------------|-------------------------------------------------------------------| +| `j` / `k`, arrows | Move selection | +| `Enter` | Start/stop timer | +| `n` | New entry (project + optional tag) | +| `p` | Reassign project for selected item | +| `v` | View Watson log (d/w/m period, g group, e edit, x delete, y copy) | +| `x` | Delete entry | +| `Ctrl+e` | Edit task config (`state.yaml`) | +| `c` | Edit app config (`config.yaml`) or show config help | +| `?` | Help screen | +| `q` | Quit | ---- +## TODO -## TODO / Follow-ups -- Add inline editing of permanent/recurring items instead of dropping to `$EDITOR`. -- Consider status messaging for critical errors (currently unused after deleting the delete confirmation). -- Add tests/snapshots for UI components. +### Architecture +Eventually I need to refactor this whole thing, which was mostly written by LLMs. I want to make the underlying data structure for the view display much more solidly defined so that it is more extensible. It should be stored in some kind of nested structure which is aware of ordering as well as grouping and can be dynamically (and optimistically) updated without refreshing the UI when appropriate. Once that is resolved, I can focus on the following upgrades: + +- Adding a `/` command to narrow down entries by fuzzy searching +- Perhaps having a universal interface for entries going back more than a month +- An option for people who don't want to copy / paste their hours into another interface +- Actually ordering the ad-hoc section by most recently used one-offs, and implementing a good way to auto-prune that list +- Print the time totals at the project-level for each day +- Some kind of API for the real weirdos out there (sean) + +### Terminal interface +Another big reason I need to drill down and rewrite this with human hands is the difference between the mac and linux terminal handling. I need to learn to use ratatui well enough to understand how to develop for both platforms at once. I originally coded this on a linux machine, and I had a much smoother experience which was largely broken on Mac. This lead to a lot of spaghettification supporting mac, and some of the things I had working on linux are still not working on mac. diff --git a/assets/0-track-time.webm b/assets/0-track-time.webm new file mode 100644 index 0000000..879afe1 Binary files /dev/null and b/assets/0-track-time.webm differ diff --git a/assets/1-main-view.webm b/assets/1-main-view.webm new file mode 100644 index 0000000..486055e Binary files /dev/null and b/assets/1-main-view.webm differ diff --git a/assets/2-daily-view-and-proj-selection.webm b/assets/2-daily-view-and-proj-selection.webm new file mode 100644 index 0000000..4755e7c Binary files /dev/null and b/assets/2-daily-view-and-proj-selection.webm differ diff --git a/assets/3-weekly-view-and-day-selection.webm b/assets/3-weekly-view-and-day-selection.webm new file mode 100644 index 0000000..e6f9d2e Binary files /dev/null and b/assets/3-weekly-view-and-day-selection.webm differ diff --git a/assets/4-full-individual-editing.webm b/assets/4-full-individual-editing.webm new file mode 100644 index 0000000..d82df95 Binary files /dev/null and b/assets/4-full-individual-editing.webm differ diff --git a/assets/5-backfill-gap-and-overlap-correction.webm b/assets/5-backfill-gap-and-overlap-correction.webm new file mode 100644 index 0000000..1abba69 Binary files /dev/null and b/assets/5-backfill-gap-and-overlap-correction.webm differ diff --git a/assets/6-rounding.webm b/assets/6-rounding.webm new file mode 100644 index 0000000..ca34e9b Binary files /dev/null and b/assets/6-rounding.webm differ diff --git a/assets/7-annotation.webm b/assets/7-annotation.webm new file mode 100644 index 0000000..269655b Binary files /dev/null and b/assets/7-annotation.webm differ diff --git a/assets/add-task.webm b/assets/add-task.webm new file mode 100644 index 0000000..600379f Binary files /dev/null and b/assets/add-task.webm differ diff --git a/assets/sample-conversion.sh b/assets/sample-conversion.sh new file mode 100644 index 0000000..0e97b6c --- /dev/null +++ b/assets/sample-conversion.sh @@ -0,0 +1 @@ +ffmpeg -i add-task.mov -c:v libvpx-vp9 -b:v 0 -crf 30 -pix_fmt yuv420p add-task.webm diff --git a/assets/wat.png b/assets/wat.png new file mode 100644 index 0000000..800b624 Binary files /dev/null and b/assets/wat.png differ diff --git a/src/app.rs b/src/app.rs index 732b94a..71e6c63 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,7 +28,7 @@ pub enum Screen { pub enum LogViewPeriod { Day, Week, - Month, + Month, // Represents the last 31 days } pub enum LogViewDayOrder { @@ -36,11 +36,13 @@ pub enum LogViewDayOrder { ReverseChronological, // Newest first (default) } +#[derive(Debug, Copy, Clone)] pub enum LogViewGrouping { ByDate, ByProject, } +#[derive(Debug, Copy, Clone)] pub enum LogViewSelection { Entry, // Individual entry selected Project, // Whole project selected (ByProject mode only) @@ -85,50 +87,73 @@ impl App { // Helper to round a time to the nearest 15-minute interval fn round_to_15min(dt: &chrono::DateTime) -> chrono::DateTime { use chrono::{Timelike, Duration}; - + let minutes = dt.minute(); let rounded_minutes = ((minutes + 7) / 15) * 15; // Round to nearest 15 - + let mut rounded = dt.with_minute(0).unwrap().with_second(0).unwrap().with_nanosecond(0).unwrap(); rounded = rounded + Duration::minutes(rounded_minutes as i64); - + // Handle hour overflow (e.g., 50 minutes rounds to 60) if rounded_minutes >= 60 { rounded = rounded.with_minute(0).unwrap(); } - + rounded } // Helper to determine if a line is an entry line based on grouping mode - fn is_entry_line(&self, line: &str) -> bool { + pub fn is_entry_line(&self, line: &str) -> bool { // Exclude separator lines (time gap markers) if line.trim() == "---" { return false; } - + match self.log_view_grouping { LogViewGrouping::ByProject => line.starts_with(" "), LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "), } } + fn ensure_watson_directories() -> anyhow::Result<()> { + // Create watson config directory if it doesn't exist + let watson_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? + .join("watson"); + + if !watson_dir.exists() { + std::fs::create_dir_all(&watson_dir)?; + } + + // Ensure frames file exists + let frames_path = watson_dir.join("frames"); + if !frames_path.exists() { + // Create an empty frames file (empty JSON array) + std::fs::write(&frames_path, "[]")?; + } + + Ok(()) + } + pub fn new() -> anyhow::Result { + // Ensure watson directories and files exist + Self::ensure_watson_directories()?; + let config = Config::load()?; let mut state = AppState::load()?; - + // Initialize current_pane to first enabled section let enabled = [ config.show_permanent, config.show_recurring, config.show_recent, ]; - + // Find first enabled pane if let Some(first_enabled) = enabled.iter().position(|&x| x) { state.current_pane = first_enabled; } - + Ok(Self { state, config, @@ -466,6 +491,7 @@ impl App { self.needs_clear = true; } KeyCode::Char('j') | KeyCode::Down => { + let old_selected = self.log_view_selected; match self.log_view_selection_level { LogViewSelection::Entry => { // Move to next entry @@ -485,8 +511,13 @@ impl App { // No navigation at All level - already selecting everything } } + // Update scroll if selection changed + if self.log_view_selected != old_selected { + self.update_log_view_scroll(); + } } KeyCode::Char('k') | KeyCode::Up => { + let old_selected = self.log_view_selected; match self.log_view_selection_level { LogViewSelection::Entry => { // Move to previous entry @@ -506,6 +537,10 @@ impl App { // No navigation at All level - already selecting everything } } + // Update scroll if selection changed + if self.log_view_selected != old_selected { + self.update_log_view_scroll(); + } } KeyCode::Char('e') => { // Only allow edit when selecting individual entry @@ -538,6 +573,7 @@ impl App { } KeyCode::Char('h') | KeyCode::Left => { // Zoom out selection level + let old_level = self.log_view_selection_level; self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { (LogViewSelection::All, _) => LogViewSelection::All, // Already at highest (LogViewSelection::Day, _) => LogViewSelection::All, @@ -548,6 +584,7 @@ impl App { } KeyCode::Char('l') | KeyCode::Right => { // Zoom in selection level + let old_level = self.log_view_selection_level; self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { (LogViewSelection::Entry, _) => LogViewSelection::Entry, // Already at lowest (LogViewSelection::Project, _) => LogViewSelection::Entry, @@ -589,9 +626,11 @@ impl App { KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frame_indices.len().saturating_sub(1)); + self.update_log_view_scroll(); } KeyCode::PageUp => { self.log_view_selected = self.log_view_selected.saturating_sub(10); + self.update_log_view_scroll(); } _ => {} }, @@ -610,6 +649,9 @@ impl App { LogViewGrouping::ByDate => self.format_by_date(), LogViewGrouping::ByProject => self.format_by_project(), } + + // Update scroll position after formatting + self.update_log_view_scroll(); } fn format_by_date(&mut self) { @@ -619,13 +661,13 @@ impl App { // Group frames by date, tracking their original indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap)> = BTreeMap::new(); - + // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; - + for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); @@ -641,12 +683,12 @@ impl App { let mut lines = Vec::new(); let mut frame_indices = Vec::new(); - + // Sort each day's frames chronologically for (_, frames) in by_date.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } - + // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { @@ -656,12 +698,12 @@ impl App { by_date.iter().collect() } }; - + for (_sort_key, (display_date, frames)) in dates { lines.push(display_date.clone()); - + let mut prev_stop: Option> = None; - + for (frame_idx, (idx, frame)) in frames.iter().enumerate() { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), @@ -669,14 +711,14 @@ impl App { ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - + // Apply rounding if enabled let (display_start, display_stop) = if self.log_view_rounded { (Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local)) } else { (start_local, stop_local) }; - + // Check for time gap and add separator line if enabled if self.config.show_time_gaps && frame_idx > 0 { if let Some(prev) = prev_stop { @@ -687,10 +729,10 @@ impl App { } } } - + let start_time = format!("{:02}:{:02}", display_start.hour(), display_start.minute()); let stop_time = format!("{:02}:{:02}", display_stop.hour(), display_stop.minute()); - + // Calculate duration (use rounded times if enabled) let duration = display_stop.signed_duration_since(display_start); let hours = duration.num_hours(); @@ -700,21 +742,21 @@ impl App { } else { format!("({}m)", minutes) }; - + let display_text = if frame.tags.is_empty() { frame.project.clone() } else { format!("{} [{}]", frame.tags.join(", "), frame.project) }; - + let line_text = format!( " {} to {} {:>9} {}", start_time, stop_time, duration_str, display_text ); - + lines.push(line_text); frame_indices.push(*idx); // Only add actual entries to frame_indices - + prev_stop = Some(stop_local); } } @@ -732,13 +774,13 @@ impl App { // Group frames by date, then by project within each date, tracking indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap>)> = BTreeMap::new(); - + // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; - + for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); @@ -756,14 +798,14 @@ impl App { let mut lines = Vec::new(); let mut frame_indices = Vec::new(); - + // Sort frames within each project chronologically for (_, projects) in by_date.values_mut() { for frames in projects.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } } - + // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { @@ -773,13 +815,13 @@ impl App { by_date.iter().collect() } }; - + for (_sort_key, (display_date, projects)) in dates { lines.push(display_date.clone()); - + for (project, frames) in projects.iter() { lines.push(format!(" {}", project)); // Project header with indent - + for (idx, frame) in frames { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), @@ -787,17 +829,17 @@ impl App { ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - + // Apply rounding if enabled let (display_start, display_stop) = if self.log_view_rounded { (Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local)) } else { (start_local, stop_local) }; - + let start_time = format!("{:02}:{:02}", display_start.hour(), display_start.minute()); let stop_time = format!("{:02}:{:02}", display_stop.hour(), display_stop.minute()); - + // Calculate duration (use rounded times if enabled) let duration = display_stop.signed_duration_since(display_start); let hours = duration.num_hours(); @@ -807,13 +849,13 @@ impl App { } else { format!("({}m)", minutes) }; - + let tags_str = if frame.tags.is_empty() { String::new() } else { format!(" [{}]", frame.tags.join(", ")) }; - + lines.push(format!( " {} to {} {:>9}{}", start_time, stop_time, duration_str, tags_str @@ -834,7 +876,7 @@ impl App { if self.log_view_selected >= self.log_view_frame_indices.len() { return Ok(()); } - + // Get the actual frame index from the display index let frame_idx = self.log_view_frame_indices[self.log_view_selected]; if frame_idx >= self.log_view_frames.len() { @@ -876,7 +918,7 @@ impl App { if self.log_view_selected >= self.log_view_frame_indices.len() { return Ok(()); } - + // Get the actual frame index from the display index let frame_idx = self.log_view_frame_indices[self.log_view_selected]; if frame_idx >= self.log_view_frames.len() { @@ -895,7 +937,7 @@ impl App { 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_frame_indices.len() && !self.log_view_frame_indices.is_empty() { self.log_view_selected = self.log_view_frame_indices.len() - 1; @@ -910,25 +952,25 @@ impl App { fn fix_entry_gap(&mut self) -> anyhow::Result<()> { use chrono::DateTime; use serde_json::Value; - + self.set_status_message("Backfill function called"); - + // Check if selection is valid if self.log_view_selected >= self.log_view_frame_indices.len() { self.set_status_message("Invalid selection"); return Ok(()); } - + // Can't fix the first entry if self.log_view_selected == 0 { self.set_status_message("No previous entry!"); return Ok(()); } - + // Check if previous entry is on the same day let current_frame_idx = self.log_view_frame_indices[self.log_view_selected]; let prev_frame_idx = self.log_view_frame_indices[self.log_view_selected - 1]; - + if let (Some(current), Some(prev)) = ( self.log_view_frames.get(current_frame_idx), self.log_view_frames.get(prev_frame_idx), @@ -941,21 +983,21 @@ impl App { // Check if they're on the same day let curr_date = curr_start.format("%Y-%m-%d").to_string(); let prev_date = prev_stop.format("%Y-%m-%d").to_string(); - + if curr_date != prev_date { self.set_status_message("No previous entry on same day!"); return Ok(()); } - + // Read watson frames file let frames_path = dirs::config_dir() .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? .join("watson") .join("frames"); - + let frames_content = std::fs::read_to_string(&frames_path)?; let mut frames: Value = serde_json::from_str(&frames_content)?; - + // Find and update the frame with matching id if let Some(frames_array) = frames.as_array_mut() { for frame in frames_array { @@ -974,17 +1016,17 @@ impl App { } } } - + // Write back to frames file let updated_content = serde_json::to_string_pretty(&frames)?; std::fs::write(&frames_path, updated_content)?; - + // Reload log content self.load_log_content()?; self.set_status_message("Entry start time adjusted"); } } - + self.needs_clear = true; Ok(()) } @@ -1014,10 +1056,15 @@ impl App { } }; + // Debug what we're copying + std::fs::write("/tmp/wat_copy_debug.log", format!("Selection level: {:?}\nSelected index: {}\nText to copy:\n{}\n", self.log_view_selection_level, self.log_view_selected, text)).ok(); + // Copy to clipboard using the persistent clipboard instance if let Some(ref mut clipboard) = self.clipboard { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); + } else { + self.set_status_message(format!("Copied {} chars to clipboard", text.len())); } } else { // Try to create a new clipboard if we don't have one @@ -1025,6 +1072,8 @@ impl App { Ok(mut clipboard) => { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); + } else { + self.set_status_message(format!("Copied {} chars to clipboard", text.len())); } self.clipboard = Some(clipboard); } @@ -1044,7 +1093,7 @@ impl App { .iter() .filter(|l| self.is_entry_line(l)) .collect(); - + if self.log_view_selected < entry_lines.len() { entry_lines[self.log_view_selected].to_string() } else { @@ -1053,30 +1102,70 @@ impl App { } fn get_selected_project_text(&self) -> String { - // Find which project group the selected entry belongs to - let mut current_project_lines = Vec::new(); - let mut frame_count = 0; - let mut found = false; + // Find all project headers and their entry ranges + let mut projects = Vec::new(); // (first_entry_index, project_line_index) + let mut entry_count = 0; + let mut expecting_first_entry = false; + let mut current_project_line_index = 0; - for line in &self.log_view_content { - if line.starts_with(" ") && !line.starts_with("\t") { + for (line_index, line) in self.log_view_content.iter().enumerate() { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { // This is a project header - if found { - break; // We've collected the target project - } - current_project_lines.clear(); - current_project_lines.push(line.clone()); + current_project_line_index = line_index; + expecting_first_entry = true; } else if self.is_entry_line(line) { - // Entry within a project - current_project_lines.push(line.clone()); - if frame_count == self.log_view_selected { - found = true; + if expecting_first_entry { + // This is the first entry of a project + projects.push((entry_count, current_project_line_index)); + expecting_first_entry = false; } - frame_count += 1; + entry_count += 1; } } - current_project_lines.join("\n") + // Find which project contains our current selection + let mut target_project_line_index = None; + for i in 0..projects.len() { + let project_start = projects[i].0; + let project_end = if i + 1 < projects.len() { + projects[i + 1].0 + } else { + entry_count + }; + + if project_start <= self.log_view_selected && self.log_view_selected < project_end { + target_project_line_index = Some(projects[i].1); + break; + } + } + + if let Some(target_line_index) = target_project_line_index { + // Collect all lines for the target project by position + let mut project_lines = Vec::new(); + let mut in_target_project = false; + let mut current_project_line_index = 0; + + for (line_index, line) in self.log_view_content.iter().enumerate() { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header + current_project_line_index = line_index; + if line_index == target_line_index { + in_target_project = true; + project_lines.push(line.clone()); + } else if in_target_project { + // Hit a different project header, we're done + break; + } + } else if in_target_project && self.is_entry_line(line) { + // Entry within our target project + project_lines.push(line.clone()); + } + } + + project_lines.join("\n") + } else { + String::new() + } } fn get_selected_day_text(&self) -> String { @@ -1096,7 +1185,7 @@ impl App { } else if line.starts_with('\t') || line.starts_with(" ") { // Add all lines within the day (project headers and entries) current_day_lines.push(line.clone()); - + // Count only actual entries if self.is_entry_line(line) { if frame_count == self.log_view_selected { @@ -1111,177 +1200,179 @@ impl App { } fn jump_to_next_project(&mut self) { - // Find the current project, then jump to first entry of next project - let mut current_date = String::new(); - let mut current_project = String::new(); - let mut selected_project = String::new(); - let mut selected_date = String::new(); - let mut frame_count = 0; - let mut found_current = false; - - // Find which project we're currently in - for line in &self.log_view_content.clone() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { - current_date = line.clone(); - } else if line.starts_with(" ") && !line.starts_with("\t") { - current_project = line.clone(); - } - - if self.is_entry_line(line) { - if frame_count == self.log_view_selected { - selected_project = current_project.clone(); - selected_date = current_date.clone(); - found_current = true; - break; + // Find all project headers and their first entry positions + let mut projects = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut expecting_first_entry = false; + + for line in &self.log_view_content { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header - next entry will be first entry of this project + expecting_first_entry = true; + } else if self.is_entry_line(line) { + if expecting_first_entry { + // This is the first entry of a project + projects.push(entry_count); + expecting_first_entry = false; } - frame_count += 1; + entry_count += 1; } } - - if !found_current { - return; - } - - // Now find the next project within the same day first, then next day - current_project = String::new(); - current_date = String::new(); - let mut past_selected = false; - frame_count = 0; - - for line in &self.log_view_content.clone() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { - current_date = line.clone(); - } else if line.starts_with(" ") && !line.starts_with("\t") { - current_project = line.clone(); - if past_selected && current_project != selected_project { - // This is the next project, select its first entry - self.log_view_selected = frame_count; - return; + + // Find which project contains our current selection and jump to next + for i in 0..projects.len() { + let project_start = projects[i]; + let project_end = if i + 1 < projects.len() { + projects[i + 1] + } else { + entry_count + }; + + if project_start <= self.log_view_selected && self.log_view_selected < project_end { + // Found current project, jump to next if it exists + if i + 1 < projects.len() { + self.log_view_selected = projects[i + 1]; } - } - - if self.is_entry_line(line) { - if current_project == selected_project && current_date == selected_date { - past_selected = true; - } - frame_count += 1; + return; } } } fn jump_to_previous_project(&mut self) { - // Find the previous project and jump to its first entry - let mut current_project = String::new(); - let mut _selected_project = String::new(); - let mut frame_count = 0; - let mut previous_project_first_entry = None; - let mut last_different_project_entry = None; - - // Find which project we're currently in - for line in &self.log_view_content.clone() { - if line.starts_with(" ") && !line.starts_with("\t") { - if !current_project.is_empty() && current_project != _selected_project { - last_different_project_entry = previous_project_first_entry; + // Find all project headers and their first entry positions + let mut projects = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut expecting_first_entry = false; + + for line in &self.log_view_content { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header - next entry will be first entry of this project + expecting_first_entry = true; + } else if self.is_entry_line(line) { + if expecting_first_entry { + // This is the first entry of a project + projects.push(entry_count); + expecting_first_entry = false; } - current_project = line.clone(); - previous_project_first_entry = Some(frame_count); + entry_count += 1; } - - if self.is_entry_line(line) { - if frame_count == self.log_view_selected { - _selected_project = current_project.clone(); - // Jump to the last different project we saw - if let Some(entry) = last_different_project_entry { - self.log_view_selected = entry; - } - return; + } + + // Find which project contains our current selection and jump to previous + for i in 0..projects.len() { + let project_start = projects[i]; + let project_end = if i + 1 < projects.len() { + projects[i + 1] + } else { + entry_count + }; + + if project_start <= self.log_view_selected && self.log_view_selected < project_end { + // Found current project, jump to previous if it exists + if i > 0 { + self.log_view_selected = projects[i - 1]; } - frame_count += 1; + return; } } } - fn jump_to_next_day(&mut self) { - // Find the current day, then jump to first entry of next day - let mut current_date = String::new(); - let mut selected_date = String::new(); + fn update_log_view_scroll(&mut self) { + // Calculate which content line the selected entry is on + let mut content_line: usize = 0; let mut frame_count = 0; - let mut found_current = false; - - // Find which day we're currently in - for line in &self.log_view_content.clone() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { - current_date = line.clone(); - } - + for line in &self.log_view_content { if self.is_entry_line(line) { if frame_count == self.log_view_selected { - selected_date = current_date.clone(); - found_current = true; break; } frame_count += 1; } + content_line += 1; } - - if !found_current { - return; - } - - // Now find the next day and jump to its first entry - current_date = String::new(); - let mut past_selected = false; - frame_count = 0; - - for line in &self.log_view_content.clone() { + + // Center the selection in the viewport + // Assume a reasonable viewport height for centering calculation + let viewport_center_offset: usize = 10; // Lines from top to center selection + self.log_view_scroll = content_line.saturating_sub(viewport_center_offset); + } + + fn jump_to_next_day(&mut self) { + // Find all day headers and their first entry positions + let mut days = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut current_day_seen = false; + + for line in &self.log_view_content { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { - if past_selected && line != &selected_date { - // This is the next day, continue to find its first entry - current_date = line.clone(); - } else if line == &selected_date { - past_selected = true; - } + // This is a day header - mark that we'll record the next entry + current_day_seen = true; + } else if self.is_entry_line(line) && current_day_seen { + // This is the first entry of a day + days.push(entry_count); + current_day_seen = false; } - + if self.is_entry_line(line) { - if past_selected && !current_date.is_empty() && current_date != selected_date { - // First entry of next day - self.log_view_selected = frame_count; - return; + entry_count += 1; + } + } + + // Find which day contains our current selection and jump to next + for i in 0..days.len() { + let day_start = days[i]; + let day_end = if i + 1 < days.len() { + days[i + 1] + } else { + entry_count + }; + + if day_start <= self.log_view_selected && self.log_view_selected < day_end { + // Found current day, jump to next if it exists + if i + 1 < days.len() { + self.log_view_selected = days[i + 1]; } - frame_count += 1; + return; } } } fn jump_to_previous_day(&mut self) { - // Find the previous day and jump to its first entry - let mut current_date = String::new(); - let mut _selected_date = String::new(); - let mut frame_count = 0; - let mut last_different_day_entry = None; - let mut previous_day_first_entry = None; - - // Find which day we're currently in - for line in &self.log_view_content.clone() { + // Find all day headers and their first entry positions + let mut days = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut current_day_seen = false; + + for line in &self.log_view_content { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { - if !current_date.is_empty() && current_date != _selected_date { - last_different_day_entry = previous_day_first_entry; - } - current_date = line.clone(); - previous_day_first_entry = Some(frame_count); + // This is a day header - mark that we'll record the next entry + current_day_seen = true; + } else if self.is_entry_line(line) && current_day_seen { + // This is the first entry of a day + days.push(entry_count); + current_day_seen = false; } - + if self.is_entry_line(line) { - if frame_count == self.log_view_selected { - _selected_date = current_date.clone(); - // Jump to the last different day we saw - if let Some(entry) = last_different_day_entry { - self.log_view_selected = entry; - } - return; + entry_count += 1; + } + } + + // Find which day contains our current selection and jump to previous + for i in 0..days.len() { + let day_start = days[i]; + let day_end = if i + 1 < days.len() { + days[i + 1] + } else { + entry_count + }; + + if day_start <= self.log_view_selected && self.log_view_selected < day_end { + // Found current day, jump to previous if it exists + if i > 0 { + self.log_view_selected = days[i - 1]; } - frame_count += 1; + return; } } } @@ -1300,10 +1391,10 @@ impl App { let current = self.state.selected_indices[self.state.current_pane] as i32; let items_len = items.len() as i32; - + // Check if we're using two-column mode let use_two_columns = self.config.multi_column && items.len() > 6; - + if use_two_columns { let mid_point = (items.len() + 1) / 2; let (col_start, col_end) = if self.state.current_column == 0 { @@ -1311,9 +1402,9 @@ impl App { } else { (mid_point as i32, items_len - 1) }; - + let new_index = current + delta; - + if new_index < col_start && delta < 0 { // Moving up beyond column - go to previous pane self.change_pane(-1); @@ -1339,13 +1430,13 @@ impl App { self.state.current_column = 0; } else { // Normal movement within column - self.state.selected_indices[self.state.current_pane] = + self.state.selected_indices[self.state.current_pane] = new_index.clamp(col_start, col_end) as usize; } } else { // Single column mode let new_index = current + delta; - + if new_index < 0 && delta < 0 { // At top, move to previous pane self.change_pane(-1); @@ -1364,45 +1455,45 @@ impl App { self.state.selected_indices[self.state.current_pane] = 0; } else { // Normal movement within pane - self.state.selected_indices[self.state.current_pane] = + self.state.selected_indices[self.state.current_pane] = new_index.clamp(0, items_len - 1) as usize; } } } - + fn move_column(&mut self, delta: i32) { if !self.config.multi_column { return; } - + let items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; - + // Only switch columns if we have enough items for two columns if items.len() <= 6 { return; } - + let mid_point = (items.len() + 1) / 2; let new_column = if delta > 0 { 1 } else { 0 }; - + if new_column != self.state.current_column { let current_selected = self.state.selected_indices[self.state.current_pane]; - + // Calculate which row we're on in the current column let current_row = if self.state.current_column == 0 { current_selected // In left column } else { current_selected.saturating_sub(mid_point) // In right column }; - + // Switch column and jump to the same row in the new column self.state.current_column = new_column; - + if new_column == 0 { // Moving to left column - jump to same row self.state.selected_indices[self.state.current_pane] = current_row.min(mid_point - 1); @@ -1413,7 +1504,7 @@ impl App { } } } - + fn change_pane(&mut self, delta: i32) { // Find next enabled pane let mut next_pane = self.state.current_pane; @@ -1422,13 +1513,13 @@ impl App { self.config.show_recurring, self.config.show_recent, ]; - + // Count enabled panes let enabled_count = enabled.iter().filter(|&&x| x).count(); if enabled_count == 0 { return; // No panes to switch to } - + // Find next enabled pane for _ in 0..3 { next_pane = ((next_pane as i32 + delta).rem_euclid(3)) as usize; @@ -1436,7 +1527,7 @@ impl App { break; } } - + self.state.current_pane = next_pane; self.state.current_column = 0; } @@ -1564,21 +1655,33 @@ impl App { } 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 mut command = Command::new("watson"); + command.arg("log").arg("--json"); + + // Handle different period options + match self.log_view_period { + LogViewPeriod::Day => { + command.arg("--day"); + }, + LogViewPeriod::Week => { + command.arg("--week"); + }, + LogViewPeriod::Month => { + // Use --from with date 31 days ago for month view + let thirty_one_days_ago = chrono::Local::now() + .checked_sub_signed(chrono::Duration::days(31)) + .expect("Failed to calculate date from 31 days ago"); + + command.arg("--from"); + command.arg(thirty_one_days_ago.format("%Y-%m-%d").to_string()); + }, }; - let output = Command::new("watson") - .arg("log") - .arg(flag) - .arg("--json") - .output()?; + let output = command.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) => { @@ -1668,7 +1771,7 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + self.needs_clear = true; Ok(()) @@ -1705,7 +1808,7 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + self.needs_clear = true; Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 1f71251..4f28eaa 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -33,10 +33,10 @@ fn render_main(frame: &mut Frame, app: &App) { let has_active_timer = app.state.active_timer.is_some(); let has_status = app.status_message.is_some(); let show_help_hint = app.config.show_help_hint; - + // Show bottom bar if we have any of: timer, status, or help hint let show_bottom_bar = has_active_timer || has_status || show_help_hint; - + let bottom_height = if show_bottom_bar { if has_status { 2 // Need extra line for status @@ -63,41 +63,41 @@ fn render_main(frame: &mut Frame, app: &App) { .borders(Borders::ALL) .title("WAT") .style(Style::default().fg(ACTIVE_COLOR)); - + let text = Paragraph::new("No sections enabled. Edit config (press 'c') to enable sections.") .block(block) .style(Style::default().fg(Color::Yellow)) .alignment(Alignment::Center); - + frame.render_widget(text, frame.size()); return; } // Build constraints for enabled sections let mut constraints = Vec::new(); - + if bottom_height > 0 { // Reserve space for bottom bar first, then split remainder among sections constraints.push(Constraint::Min(0)); // Sections get remaining space constraints.push(Constraint::Length(bottom_height)); // Bottom bar gets fixed height - + let layout = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(frame.size()); - + // Split the top area among enabled sections let section_percentage = 100 / enabled_sections as u16; let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; - + let chunks = Layout::default() .direction(Direction::Vertical) .constraints(section_constraints) .split(layout[0]); - + // Render enabled sections let mut chunk_idx = 0; - + if app.config.show_permanent { render_section( frame, @@ -145,14 +145,14 @@ fn render_main(frame: &mut Frame, app: &App) { // No bottom bar - just render sections let section_percentage = 100 / enabled_sections as u16; let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; - + let chunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(frame.size()); let mut chunk_idx = 0; - + if app.config.show_permanent { render_section( frame, @@ -679,7 +679,7 @@ fn render_log_view(frame: &mut Frame, app: &App) { let period_str = match app.log_view_period { LogViewPeriod::Day => "Day", LogViewPeriod::Week => "Week", - LogViewPeriod::Month => "Month", + LogViewPeriod::Month => "31 Days", }; let grouping_str = match app.log_view_grouping { @@ -812,14 +812,14 @@ fn render_log_view(frame: &mut Frame, app: &App) { // Third pass: render with highlighting and overlap detection let is_by_date = matches!(app.log_view_grouping, LogViewGrouping::ByDate); - + // Pre-calculate overlaps for efficiency - check consecutive entries within same day let mut overlap_entry_indices = std::collections::HashSet::new(); if is_by_date && app.log_view_frame_indices.len() > 1 { // Track which day we're in to avoid comparing across days let mut current_day_start_entry = 0; let mut in_day = false; - + for (line_idx, line) in app.log_view_content.iter().enumerate() { // New day header if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { @@ -835,12 +835,12 @@ fn render_log_view(frame: &mut Frame, app: &App) { .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .count() .saturating_sub(1); - + // Only check overlap if not the first entry of the day if entry_idx > current_day_start_entry { let current_frame_idx = app.log_view_frame_indices[entry_idx]; let prev_frame_idx = app.log_view_frame_indices[entry_idx - 1]; - + if let (Some(current), Some(prev)) = ( app.log_view_frames.get(current_frame_idx), app.log_view_frames.get(prev_frame_idx), @@ -859,16 +859,16 @@ fn render_log_view(frame: &mut Frame, app: &App) { } } } - + app.log_view_content .iter() .enumerate() .map(|(idx, line)| { let is_selected = idx >= selected_line_start && idx <= selected_line_end; - + // Skip styling for separator lines let is_separator = line.trim() == "---"; - + // Check if this line corresponds to an overlapping entry let has_overlap = if !is_separator && is_by_date && line.starts_with(" ") && !line.is_empty() { // Count which entry this is (0-based in display order) @@ -878,7 +878,7 @@ fn render_log_view(frame: &mut Frame, app: &App) { .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .count() .saturating_sub(1); - + overlap_entry_indices.contains(&entry_idx) } else { false @@ -901,10 +901,12 @@ fn render_log_view(frame: &mut Frame, app: &App) { .collect() }; + // Use the scroll position calculated by the app let paragraph = Paragraph::new(text_lines) .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }); - + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((app.log_view_scroll as u16, 0)); + frame.render_widget(paragraph, chunks[0]); // Render help hint at bottom if enabled @@ -928,7 +930,7 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { "Time Periods:", "- d: Switch to Day view (current day)", "- w: Switch to Week view (current week)", - "- m: Switch to Month view (current month)", + "- m: Switch to Month view (last 31 days)", "", "Grouping:", "- g: Toggle between grouping by Date or by Project",