From 8c1e72b1ef969ed459aef5c79429994172cfc039 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 18:06:24 -0500 Subject: [PATCH 01/10] Optional sections --- src/app.rs | 43 ++++++++++++++-- src/config.rs | 13 +++++ src/ui.rs | 132 ++++++++++++++++++++++++++++---------------------- 3 files changed, 128 insertions(+), 60 deletions(-) diff --git a/src/app.rs b/src/app.rs index deaa35f..5533434 100644 --- a/src/app.rs +++ b/src/app.rs @@ -87,9 +87,24 @@ impl App { } pub fn new() -> anyhow::Result { + 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: AppState::load()?, - config: Config::load()?, + state, + config, current_screen: Screen::Main, needs_clear: false, new_entry_buffer: String::new(), @@ -1213,7 +1228,29 @@ impl App { } fn change_pane(&mut self, delta: i32) { - self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize; + // Find next enabled pane + let mut next_pane = self.state.current_pane; + let enabled = [ + self.config.show_permanent, + 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; + if enabled[next_pane] { + break; + } + } + + self.state.current_pane = next_pane; self.state.current_column = 0; } diff --git a/src/config.rs b/src/config.rs index 1ddb538..240617f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,12 @@ pub struct Config { pub strict_projects: bool, #[serde(default = "default_multi_column")] pub multi_column: bool, + #[serde(default = "default_true")] + pub show_permanent: bool, + #[serde(default = "default_true")] + pub show_recurring: bool, + #[serde(default = "default_true")] + pub show_recent: bool, } fn default_show_help_hint() -> bool { @@ -25,6 +31,10 @@ fn default_multi_column() -> bool { true } +fn default_true() -> bool { + true +} + impl Default for Config { fn default() -> Self { Self { @@ -32,6 +42,9 @@ impl Default for Config { projects: Vec::new(), strict_projects: default_strict_projects(), multi_column: default_multi_column(), + show_permanent: default_true(), + show_recurring: default_true(), + show_recent: default_true(), } } } diff --git a/src/ui.rs b/src/ui.rs index d7ab0f5..df721af 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -43,74 +43,92 @@ fn render_main(frame: &mut Frame, app: &App) { } }; - let constraints = if bottom_height > 0 { - vec![ - Constraint::Min(3), // At least 3 lines for each section - Constraint::Min(3), - Constraint::Min(3), - Constraint::Length(bottom_height), // Command bar + optional status - ] - } else { - vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)] - }; + // Count enabled sections + let enabled_sections = [ + app.config.show_permanent, + app.config.show_recurring, + app.config.show_recent, + ] + .iter() + .filter(|&&x| x) + .count(); + + if enabled_sections == 0 { + // No sections enabled - show a message + let block = Block::default() + .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 section_percentage = 100 / enabled_sections as u16; + let mut constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; + if bottom_height > 0 { + constraints.push(Constraint::Length(bottom_height)); + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(frame.size()); - let main_height = if bottom_height > 0 { - chunks[0].height + chunks[1].height + chunks[2].height - } else { - frame.size().height - }; + // Render enabled sections + let mut chunk_idx = 0; + + if app.config.show_permanent { + render_section( + frame, + chunks[chunk_idx], + "Permanent Items", + &app.state.permanent_items, + app.state.current_pane == 0, + app.state.selected_indices[0], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } - let section_height = main_height / 3; + if app.config.show_recurring { + render_section( + frame, + chunks[chunk_idx], + "Recurring Items", + &app.state.recurring_items, + app.state.current_pane == 1, + app.state.selected_indices[1], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } - // Create sections with equal height - let sections = vec![ - Rect::new(0, 0, frame.size().width, section_height), - Rect::new(0, section_height, frame.size().width, section_height), - Rect::new(0, section_height * 2, frame.size().width, section_height), - ]; - - // Render main sections - render_section( - frame, - sections[0], - "Permanent Items", - &app.state.permanent_items, - app.state.current_pane == 0, - app.state.selected_indices[0], - app.state.current_column, - app.config.multi_column, - ); - - render_section( - frame, - sections[1], - "Recurring Items", - &app.state.recurring_items, - app.state.current_pane == 1, - app.state.selected_indices[1], - app.state.current_column, - app.config.multi_column, - ); - - render_section( - frame, - sections[2], - "Ad-Hoc Items", - &app.state.recent_items, - app.state.current_pane == 2, - app.state.selected_indices[2], - app.state.current_column, - app.config.multi_column, - ); + if app.config.show_recent { + render_section( + frame, + chunks[chunk_idx], + "Ad-Hoc Items", + &app.state.recent_items, + app.state.current_pane == 2, + app.state.selected_indices[2], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } // Render bottom bar if needed if bottom_height > 0 { - let bottom_area = chunks[3]; + let bottom_area = chunks[chunk_idx]; render_bottom_bar(frame, bottom_area, app); } } From 31049a53ddc2856ca3232164c092928efa1e6297 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 18:07:23 -0500 Subject: [PATCH 02/10] Update help doc --- src/ui.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index df721af..c60e754 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -346,10 +346,31 @@ fn render_config_help(frame: &mut Frame, app: &App) { " When true, only allows projects from the 'projects' list", " When false, any project name can be used", "", + "multi_column: true/false", + " Default: true", + " When true, sections with many items display in two columns", + " When false, always use single column with scrolling", + "", + "show_permanent: true/false", + " Default: true", + " Show the 'Permanent Items' section", + "", + "show_recurring: true/false", + " Default: true", + " Show the 'Recurring Items' section", + "", + "show_recent: true/false", + " Default: true", + " Show the 'Ad-Hoc Items' section", + "", "Example configuration:", "---", "show_help_hint: true", "strict_projects: false", + "multi_column: true", + "show_permanent: true", + "show_recurring: true", + "show_recent: false", "projects:", " - work", " - personal", From c61e66b1f662c87e6d1c73b655ce94a17e5847d8 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 19:32:28 -0500 Subject: [PATCH 03/10] Visual indicators for skips and overlaps, fix bottom bar --- src/app.rs | 50 ++++++--- src/config.rs | 3 + src/ui.rs | 283 ++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 253 insertions(+), 83 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5533434..e49f131 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,11 +7,11 @@ use std::process::Command; #[derive(Debug, Deserialize, Clone)] pub(crate) struct WatsonFrame { - id: String, - project: String, - start: String, - stop: String, - tags: Vec, + pub id: String, + pub project: String, + pub start: String, + pub stop: String, + pub tags: Vec, } pub enum Screen { @@ -80,6 +80,11 @@ pub enum NewEntryMode { impl App { // Helper to determine if a line is an entry line based on grouping mode 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("\t\t"), LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"), @@ -606,7 +611,9 @@ impl App { for (_sort_key, (display_date, frames)) in dates { lines.push(display_date.clone()); - for (idx, frame) in frames { + 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), DateTime::parse_from_rfc3339(&frame.stop), @@ -614,20 +621,35 @@ impl App { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); + // 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 { + let gap = start_local.signed_duration_since(prev); + if gap.num_minutes() >= 5 { + lines.push("\t ---".to_string()); + // Note: don't add to frame_indices - separator is not an entry + } + } + } + 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() + let display_text = if frame.tags.is_empty() { + frame.project.clone() } else { - format!(" [{}]", frame.tags.join(", ")) + format!("{} [{}]", frame.tags.join(", "), frame.project) }; - lines.push(format!( - "\t{} to {} {}{}", - start_time, stop_time, frame.project, tags_str - )); - frame_indices.push(*idx); + let line_text = format!( + "\t{} to {} {}", + start_time, stop_time, display_text + ); + + lines.push(line_text); + frame_indices.push(*idx); // Only add actual entries to frame_indices + + prev_stop = Some(stop_local); } } lines.push(String::new()); // Empty line between dates diff --git a/src/config.rs b/src/config.rs index 240617f..fb91af3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,8 @@ pub struct Config { pub show_recurring: bool, #[serde(default = "default_true")] pub show_recent: bool, + #[serde(default = "default_true")] + pub show_time_gaps: bool, } fn default_show_help_hint() -> bool { @@ -45,6 +47,7 @@ impl Default for Config { show_permanent: default_true(), show_recurring: default_true(), show_recent: default_true(), + show_time_gaps: default_true(), } } } diff --git a/src/ui.rs b/src/ui.rs index c60e754..ee97530 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,6 +6,8 @@ use ratatui::{ Frame, }; +use chrono::DateTime; + use crate::{ app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, state::TimeItem, @@ -27,20 +29,21 @@ pub fn render(frame: &mut Frame, app: &App) { fn render_main(frame: &mut Frame, app: &App) { // Calculate layout - accounting for bottom bar if needed - let show_bottom_bar = app.config.show_help_hint; + 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 + 2 // Need extra line for status } else { - 1 + 1 // Just tracking or help hint } } else { - if has_status { - 1 - } else { - 0 - } + 0 }; // Count enabled sections @@ -70,66 +73,125 @@ fn render_main(frame: &mut Frame, app: &App) { } // Build constraints for enabled sections - let section_percentage = 100 / enabled_sections as u16; - let mut constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; - if bottom_height > 0 { - constraints.push(Constraint::Length(bottom_height)); - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(frame.size()); - - // Render enabled sections - let mut chunk_idx = 0; + let mut constraints = Vec::new(); - if app.config.show_permanent { - render_section( - frame, - chunks[chunk_idx], - "Permanent Items", - &app.state.permanent_items, - app.state.current_pane == 0, - app.state.selected_indices[0], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } - - if app.config.show_recurring { - render_section( - frame, - chunks[chunk_idx], - "Recurring Items", - &app.state.recurring_items, - app.state.current_pane == 1, - app.state.selected_indices[1], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } - - if app.config.show_recent { - render_section( - frame, - chunks[chunk_idx], - "Ad-Hoc Items", - &app.state.recent_items, - app.state.current_pane == 2, - app.state.selected_indices[2], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } - - // Render bottom bar if needed if bottom_height > 0 { - let bottom_area = chunks[chunk_idx]; - render_bottom_bar(frame, bottom_area, app); + // 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, + chunks[chunk_idx], + "Permanent Items", + &app.state.permanent_items, + app.state.current_pane == 0, + app.state.selected_indices[0], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } + + if app.config.show_recurring { + render_section( + frame, + chunks[chunk_idx], + "Recurring Items", + &app.state.recurring_items, + app.state.current_pane == 1, + app.state.selected_indices[1], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } + + if app.config.show_recent { + render_section( + frame, + chunks[chunk_idx], + "Ad-Hoc Items", + &app.state.recent_items, + app.state.current_pane == 2, + app.state.selected_indices[2], + app.state.current_column, + app.config.multi_column, + ); + } + + // Render bottom bar + render_bottom_bar(frame, layout[1], app); + } else { + // 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, + chunks[chunk_idx], + "Permanent Items", + &app.state.permanent_items, + app.state.current_pane == 0, + app.state.selected_indices[0], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } + + if app.config.show_recurring { + render_section( + frame, + chunks[chunk_idx], + "Recurring Items", + &app.state.recurring_items, + app.state.current_pane == 1, + app.state.selected_indices[1], + app.state.current_column, + app.config.multi_column, + ); + chunk_idx += 1; + } + + if app.config.show_recent { + render_section( + frame, + chunks[chunk_idx], + "Ad-Hoc Items", + &app.state.recent_items, + app.state.current_pane == 2, + app.state.selected_indices[2], + app.state.current_column, + app.config.multi_column, + ); + } } } @@ -363,6 +425,10 @@ fn render_config_help(frame: &mut Frame, app: &App) { " Default: true", " Show the 'Ad-Hoc Items' section", "", + "show_time_gaps: true/false", + " Default: true", + " In log view (by date), show '---' separator for 5+ minute gaps", + "", "Example configuration:", "---", "show_help_hint: true", @@ -371,6 +437,7 @@ fn render_config_help(frame: &mut Frame, app: &App) { "show_permanent: true", "show_recurring: true", "show_recent: false", + "show_time_gaps: true", "projects:", " - work", " - personal", @@ -677,10 +744,11 @@ fn render_log_view(frame: &mut Frame, app: &App) { // Just find the specific entry line frame_count = 0; for (idx, line) in app.log_view_content.iter().enumerate() { + // Use is_entry_line logic which excludes separator lines let is_entry = if is_by_project { line.starts_with("\t\t") } else { - line.starts_with('\t') && !line.starts_with("\t\t") + line.starts_with('\t') && !line.starts_with("\t\t") && line.trim() != "---" }; if is_entry { @@ -740,17 +808,88 @@ fn render_log_view(frame: &mut Frame, app: &App) { } } - // Third pass: render with highlighting + // 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('\t') && !line.is_empty() && !line.starts_with(" ") { + current_day_start_entry = app.log_view_content[..line_idx] + .iter() + .filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---") + .count(); + in_day = true; + } else if in_day && line.starts_with('\t') && !line.is_empty() && line.trim() != "---" { + // This is an entry line + let entry_idx = app.log_view_content[..=line_idx] + .iter() + .filter(|l| l.starts_with('\t') && !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), + ) { + if let (Ok(curr_start), Ok(prev_stop)) = ( + DateTime::parse_from_rfc3339(¤t.start), + DateTime::parse_from_rfc3339(&prev.stop), + ) { + // Overlap if current starts before previous ends + if curr_start < prev_stop { + overlap_entry_indices.insert(entry_idx); + } + } + } + } + } + } + } + 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('\t') && !line.is_empty() { + // Count which entry this is (0-based in display order) + // Only count actual entry lines, not separator lines + let entry_idx = app.log_view_content[..=idx] + .iter() + .filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---") + .count() + .saturating_sub(1); + + overlap_entry_indices.contains(&entry_idx) + } else { + false + }; - let style = if is_selected { + let style = if is_separator { + Style::default().fg(Color::DarkGray) // Dim the separator + } else if is_selected { Style::default() .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED) + } else if has_overlap { + Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) }; @@ -819,6 +958,12 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { "- c: Copy selection to clipboard (works at all levels)", " Copies based on current selection level", "", + "Visual Indicators (By Date view only):", + "- Yellow highlight: Entry overlaps with previous entry's time", + " (starts before the previous entry ended)", + "- '---' separator: 5+ minute gap between entries", + " (can be disabled with show_time_gaps: false in config)", + "", "Other:", "- ?: Show/hide this help", "- q or ESC: Return to main screen", From 2abc99ff3b61091d65ee1eaff28c88cf84b6f360 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 19:33:54 -0500 Subject: [PATCH 04/10] Remap delete key --- src/app.rs | 2 +- src/ui.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index e49f131..7eb0623 100644 --- a/src/app.rs +++ b/src/app.rs @@ -180,7 +180,7 @@ impl App { (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()?, + (KeyCode::Char('x'), _) => self.delete_current_item()?, _ => {} }, _ => {} diff --git a/src/ui.rs b/src/ui.rs index ee97530..29c4a47 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -341,7 +341,7 @@ fn render_help(frame: &mut Frame, app: &App) { "", "Main Commands:", "Enter - Start/stop timer", - "d - Delete task from list", + "x - Delete task from list", "p - Reassign project name", "v - View Watson log", "Ctrl+e - Edit tasks config file", From e158378a1390d85e2a89e3900909bdeea3360721 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 19:49:58 -0500 Subject: [PATCH 05/10] Command to backfill to last entry --- src/app.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 2 ++ 2 files changed, 95 insertions(+) diff --git a/src/app.rs b/src/app.rs index 7eb0623..1170897 100644 --- a/src/app.rs +++ b/src/app.rs @@ -493,6 +493,17 @@ impl App { self.delete_selected_frame()?; } } + KeyCode::Char('b') => { + // Backfill - adjust start time to match previous entry's end time + // Only works in ByDate mode at Entry level + if !matches!(self.log_view_selection_level, LogViewSelection::Entry) { + self.set_status_message("Backfill only works at Entry level (use h/l)"); + } else if !matches!(self.log_view_grouping, LogViewGrouping::ByDate) { + self.set_status_message("Backfill only works in By Date view (press g)"); + } else { + self.fix_entry_gap()?; + } + } KeyCode::Char('h') | KeyCode::Left => { // Zoom out selection level self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { @@ -824,6 +835,88 @@ impl App { Ok(()) } + 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), + ) { + // Parse timestamps + if let (Ok(curr_start), Ok(prev_stop)) = ( + DateTime::parse_from_rfc3339(¤t.start), + DateTime::parse_from_rfc3339(&prev.stop), + ) { + // 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 { + if let Some(frame_array) = frame.as_array_mut() { + // Frame format: [start, stop, project, id, tags, ...] + if frame_array.len() > 3 { + if let Some(id) = frame_array[3].as_str() { + if id == current.id { + // Update start timestamp (index 0) + let new_start_timestamp = prev_stop.timestamp(); + frame_array[0] = Value::Number(new_start_timestamp.into()); + break; + } + } + } + } + } + } + + // 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(()) + } + fn copy_log_to_clipboard(&mut self) -> anyhow::Result<()> { if self.log_view_content.is_empty() { return Ok(()); diff --git a/src/ui.rs b/src/ui.rs index 29c4a47..65065af 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -955,6 +955,8 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { "Actions:", "- e: Edit the selected entry (Entry level only)", "- x: Delete the selected entry (Entry level only)", + "- b: Backfill - set entry start time to previous entry's end time", + " (Entry level, By Date view only)", "- c: Copy selection to clipboard (works at all levels)", " Copies based on current selection level", "", From 9ef6143dbdee68f7ee349aefa229c9d0a5f8db77 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 19:56:59 -0500 Subject: [PATCH 06/10] Add total time to view pages --- src/app.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1170897..07f1804 100644 --- a/src/app.rs +++ b/src/app.rs @@ -646,6 +646,16 @@ impl App { let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); + // Calculate duration + let duration = stop_local.signed_duration_since(start_local); + let hours = duration.num_hours(); + let minutes = duration.num_minutes() % 60; + let duration_str = if hours > 0 { + format!("({}h {:02}m)", hours, minutes) + } else { + format!("({}m)", minutes) + }; + let display_text = if frame.tags.is_empty() { frame.project.clone() } else { @@ -653,8 +663,8 @@ impl App { }; let line_text = format!( - "\t{} to {} {}", - start_time, stop_time, display_text + "\t{} to {} {:>9} {}", + start_time, stop_time, duration_str, display_text ); lines.push(line_text); @@ -736,6 +746,16 @@ impl App { let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); + // Calculate duration + let duration = stop_local.signed_duration_since(start_local); + let hours = duration.num_hours(); + let minutes = duration.num_minutes() % 60; + let duration_str = if hours > 0 { + format!("({}h {:02}m)", hours, minutes) + } else { + format!("({}m)", minutes) + }; + let tags_str = if frame.tags.is_empty() { String::new() } else { @@ -743,8 +763,8 @@ impl App { }; lines.push(format!( - "\t\t{} to {}{}", - start_time, stop_time, tags_str + "\t\t{} to {} {:>9}{}", + start_time, stop_time, duration_str, tags_str )); frame_indices.push(*idx); } From 89d1c026904dc9dde4c1caffc34d155166a65ebf Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 20:21:08 -0500 Subject: [PATCH 07/10] Fix border wrapping issue --- src/app.rs | 10 +++++----- src/ui.rs | 47 +++++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index 07f1804..bab4b65 100644 --- a/src/app.rs +++ b/src/app.rs @@ -86,8 +86,8 @@ impl App { } match self.log_view_grouping { - LogViewGrouping::ByProject => line.starts_with("\t\t"), - LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"), + LogViewGrouping::ByProject => line.starts_with(" "), + LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "), } } @@ -637,7 +637,7 @@ impl App { if let Some(prev) = prev_stop { let gap = start_local.signed_duration_since(prev); if gap.num_minutes() >= 5 { - lines.push("\t ---".to_string()); + lines.push(" ---".to_string()); // Note: don't add to frame_indices - separator is not an entry } } @@ -663,7 +663,7 @@ impl App { }; let line_text = format!( - "\t{} to {} {:>9} {}", + " {} to {} {:>9} {}", start_time, stop_time, duration_str, display_text ); @@ -763,7 +763,7 @@ impl App { }; lines.push(format!( - "\t\t{} to {} {:>9}{}", + " {} to {} {:>9}{}", start_time, stop_time, duration_str, tags_str )); frame_indices.push(*idx); diff --git a/src/ui.rs b/src/ui.rs index 65065af..d2a1a1f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState}, Frame, }; @@ -698,8 +698,8 @@ fn render_log_view(frame: &mut Frame, app: &App) { .borders(Borders::ALL) .style(Style::default().fg(ACTIVE_COLOR)); - // Build list items with selection highlighting based on selection level - let items: Vec = { + // Build text lines with selection highlighting based on selection level + let text_lines: Vec = { // Pre-calculate which line indices should be highlighted let mut selected_date = String::new(); let mut selected_project = String::new(); @@ -715,17 +715,17 @@ fn render_log_view(frame: &mut Frame, app: &App) { let is_by_project = matches!(app.log_view_grouping, LogViewGrouping::ByProject); for (_idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); - } else if line.starts_with(" ") && !line.starts_with("\t") { + } else if line.starts_with(" ") && !line.starts_with(" ") { current_project = line.clone(); } // Only count actual entries (not all tab-indented lines) let is_entry = if is_by_project { - line.starts_with("\t\t") // Double tab for ByProject + line.starts_with(" ") // 8 spaces for ByProject } else { - line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate + line.starts_with(" ") && !line.starts_with(" ") // 4 spaces for ByDate }; if is_entry { @@ -746,9 +746,9 @@ fn render_log_view(frame: &mut Frame, app: &App) { for (idx, line) in app.log_view_content.iter().enumerate() { // Use is_entry_line logic which excludes separator lines let is_entry = if is_by_project { - line.starts_with("\t\t") + line.starts_with(" ") } else { - line.starts_with('\t') && !line.starts_with("\t\t") && line.trim() != "---" + line.starts_with(" ") && !line.starts_with(" ") && line.trim() != "---" }; if is_entry { @@ -767,19 +767,19 @@ fn render_log_view(frame: &mut Frame, app: &App) { current_date = String::new(); for (idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); if in_target_project { break; // End of project group } - } else if line.starts_with(" ") && !line.starts_with("\t") { + } else if line.starts_with(" ") && !line.starts_with(" ") { if current_date == selected_date && line == &selected_project { selected_line_start = idx; in_target_project = true; } else if in_target_project { break; // Different project } - } else if in_target_project && line.starts_with('\t') { + } else if in_target_project && line.starts_with(" ") { selected_line_end = idx; } } @@ -789,7 +789,7 @@ fn render_log_view(frame: &mut Frame, app: &App) { let mut in_target_day = false; for (idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { if line == &selected_date { selected_line_start = idx; in_target_day = true; @@ -820,17 +820,17 @@ fn render_log_view(frame: &mut Frame, app: &App) { for (line_idx, line) in app.log_view_content.iter().enumerate() { // New day header - if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { current_day_start_entry = app.log_view_content[..line_idx] .iter() - .filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---") + .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .count(); in_day = true; - } else if in_day && line.starts_with('\t') && !line.is_empty() && line.trim() != "---" { + } else if in_day && line.starts_with(" ") && !line.is_empty() && line.trim() != "---" { // This is an entry line let entry_idx = app.log_view_content[..=line_idx] .iter() - .filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---") + .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .count() .saturating_sub(1); @@ -868,12 +868,12 @@ fn render_log_view(frame: &mut Frame, app: &App) { 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('\t') && !line.is_empty() { + 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) // Only count actual entry lines, not separator lines let entry_idx = app.log_view_content[..=idx] .iter() - .filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---") + .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .count() .saturating_sub(1); @@ -894,13 +894,16 @@ fn render_log_view(frame: &mut Frame, app: &App) { Style::default().fg(Color::White) }; - ListItem::new(Line::from(vec![Span::styled(line.clone(), style)])) + Line::from(vec![Span::styled(line.clone(), style)]) }) .collect() }; - let list = List::new(items).block(block); - frame.render_widget(list, chunks[0]); + let paragraph = Paragraph::new(text_lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); + + frame.render_widget(paragraph, chunks[0]); // Render help hint at bottom if enabled if app.config.show_help_hint { From 599cc22463d53852bb617519126159c183440490 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 20:25:29 -0500 Subject: [PATCH 08/10] Add annotations --- src/app.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 50 ++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/app.rs b/src/app.rs index bab4b65..9bb4581 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,7 @@ pub enum Screen { ReassignProject, LogView, LogViewHelp, + AddAnnotation, } pub enum LogViewPeriod { @@ -70,6 +71,8 @@ pub struct App { pub help_scroll: usize, pub clipboard: Option, pub status_message: Option<(String, std::time::Instant)>, + pub annotation_buffer: String, + pub annotation_cursor: usize, } pub enum NewEntryMode { @@ -130,6 +133,8 @@ impl App { help_scroll: 0, clipboard: arboard::Clipboard::new().ok(), status_message: None, + annotation_buffer: String::new(), + annotation_cursor: 0, }) } @@ -147,6 +152,7 @@ impl App { Screen::ReassignProject => self.handle_reassign_project_event(event), Screen::LogView => self.handle_log_view_event(event), Screen::LogViewHelp => self.handle_log_view_help_event(event), + Screen::AddAnnotation => self.handle_add_annotation_event(event), }; // If we switched screens, signal that we need to clear @@ -487,6 +493,12 @@ impl App { self.edit_selected_frame()?; } } + KeyCode::Char('a') => { + // Only allow annotation when selecting individual entry + if matches!(self.log_view_selection_level, LogViewSelection::Entry) { + self.start_add_annotation(); + } + } KeyCode::Char('x') => { // Only allow delete when selecting individual entry if matches!(self.log_view_selection_level, LogViewSelection::Entry) { @@ -1658,4 +1670,115 @@ impl App { Ok(()) } + + fn start_add_annotation(&mut self) { + self.current_screen = Screen::AddAnnotation; + self.annotation_buffer.clear(); + self.annotation_cursor = 0; + } + + fn handle_add_annotation_event(&mut self, event: Event) -> anyhow::Result { + match event { + Event::Key(KeyEvent { + code, modifiers, .. + }) => { + match (code, modifiers) { + (KeyCode::Esc, _) => { + self.current_screen = Screen::LogView; + self.annotation_buffer.clear(); + self.annotation_cursor = 0; + } + (KeyCode::Enter, _) => { + if !self.annotation_buffer.is_empty() { + self.add_annotation_to_frame()?; + } + self.current_screen = Screen::LogView; + self.annotation_buffer.clear(); + self.annotation_cursor = 0; + } + (KeyCode::Backspace, _) => { + if self.annotation_cursor > 0 { + let idx = self.annotation_cursor - 1; + if idx < self.annotation_buffer.len() { + self.annotation_buffer.remove(idx); + self.annotation_cursor -= 1; + } + } + } + (KeyCode::Char(c), m) if m.is_empty() => { + if self.annotation_cursor <= self.annotation_buffer.len() { + self.annotation_buffer.insert(self.annotation_cursor, c); + self.annotation_cursor += 1; + } + } + _ => {} + } + } + _ => {} + } + Ok(false) + } + + fn add_annotation_to_frame(&mut self) -> anyhow::Result<()> { + use serde_json::Value; + + // Check if selection is valid + if self.log_view_selected >= self.log_view_frame_indices.len() { + self.set_status_message("Invalid selection"); + 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() { + return Ok(()); + } + + let frame_id = self.log_view_frames[frame_idx].id.clone(); + + // 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 { + if let Some(frame_array) = frame.as_array_mut() { + // Frame format: [start, stop, project, id, tags, updated] + if frame_array.len() > 4 { + if let Some(id) = frame_array[3].as_str() { + if id == frame_id { + // Update tags array (index 4) + if let Some(tags) = frame_array[4].as_array_mut() { + // Add the annotation tag + tags.push(Value::String(self.annotation_buffer.clone())); + } + // Update timestamp (index 5) + if frame_array.len() > 5 { + frame_array[5] = Value::Number(chrono::Utc::now().timestamp().into()); + } + break; + } + } + } + } + } + } + + // 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("Annotation added"); + + self.needs_clear = true; + Ok(()) + } } diff --git a/src/ui.rs b/src/ui.rs index d2a1a1f..9dcab36 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -24,6 +24,7 @@ pub fn render(frame: &mut Frame, app: &App) { Screen::ReassignProject => render_reassign_project(frame, app), Screen::LogView => render_log_view(frame, app), Screen::LogViewHelp => render_log_view_help(frame, app), + Screen::AddAnnotation => render_add_annotation(frame, app), } } @@ -957,6 +958,7 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { "", "Actions:", "- e: Edit the selected entry (Entry level only)", + "- a: Add annotation tag to the selected entry (Entry level only)", "- x: Delete the selected entry (Entry level only)", "- b: Backfill - set entry start time to previous entry's end time", " (Entry level, By Date view only)", @@ -1005,3 +1007,51 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { frame.render_widget(command_bar, bar_area); } + +fn render_add_annotation(frame: &mut Frame, app: &App) { + let area = centered_rect(60, 5, frame.size()); + frame.render_widget(Clear, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(2)]) + .split(area); + + // Get current entry to show in title + let current_entry = if app.log_view_selected < app.log_view_frame_indices.len() { + let frame_idx = app.log_view_frame_indices[app.log_view_selected]; + app.log_view_frames.get(frame_idx).map(|frame| { + if frame.tags.is_empty() { + frame.project.clone() + } else { + format!("{} [{}]", frame.tags.join(", "), frame.project) + } + }) + } else { + None + }; + + let title = if let Some(entry) = current_entry { + format!("Add Annotation to: {}", entry) + } else { + "Add Annotation".to_string() + }; + + // Annotation input + let annotation_block = Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(ACTIVE_COLOR)); + + let annotation_text = Paragraph::new(app.annotation_buffer.as_str()) + .block(annotation_block); + + frame.render_widget(annotation_text, chunks[0]); + + // Help text + let help_text = Paragraph::new("Enter annotation text, press Enter to save, Esc to cancel") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + frame.render_widget(help_text, chunks[1]); +} From 7b6d24f955fa796f6c76551cc6c4a9c305526410 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 20:27:06 -0500 Subject: [PATCH 09/10] Fix issue with capital letters --- src/app.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9bb4581..ddf686d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -262,7 +262,7 @@ impl App { } } }, - (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode { + (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => match self.new_entry_mode { NewEntryMode::Task => { self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; @@ -394,7 +394,7 @@ impl App { } } } - (KeyCode::Char(c), m) if m.is_empty() => { + (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => { 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; @@ -1705,7 +1705,7 @@ impl App { } } } - (KeyCode::Char(c), m) if m.is_empty() => { + (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => { if self.annotation_cursor <= self.annotation_buffer.len() { self.annotation_buffer.insert(self.annotation_cursor, c); self.annotation_cursor += 1; From f3dcd5acbc414fdb774351a9fc9113f5ab55eff0 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Mon, 1 Dec 2025 13:31:53 -0500 Subject: [PATCH 10/10] Fifteen-minute rounding --- src/app.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++-------- src/ui.rs | 9 ++++++++- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index ddf686d..732b94a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,6 +68,7 @@ pub struct App { pub log_view_frame_indices: Vec, // Maps display order to frame index pub log_view_scroll: usize, pub log_view_selected: usize, + pub log_view_rounded: bool, // Toggle for 15-minute rounding display pub help_scroll: usize, pub clipboard: Option, pub status_message: Option<(String, std::time::Instant)>, @@ -81,6 +82,24 @@ pub enum NewEntryMode { } 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 { // Exclude separator lines (time gap markers) @@ -130,6 +149,7 @@ impl App { log_view_frame_indices: Vec::new(), log_view_scroll: 0, log_view_selected: 0, + log_view_rounded: false, help_scroll: 0, clipboard: arboard::Clipboard::new().ok(), status_message: None, @@ -560,6 +580,12 @@ impl App { self.format_log_entries(); self.needs_clear = true; } + KeyCode::Char('R') => { + // Toggle 15-minute rounding for display + self.log_view_rounded = !self.log_view_rounded; + self.format_log_entries(); + self.needs_clear = true; + } KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frame_indices.len().saturating_sub(1)); @@ -644,6 +670,13 @@ 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 { @@ -655,11 +688,11 @@ impl App { } } - 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 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 - let duration = stop_local.signed_duration_since(start_local); + // Calculate duration (use rounded times if enabled) + let duration = display_stop.signed_duration_since(display_start); let hours = duration.num_hours(); let minutes = duration.num_minutes() % 60; let duration_str = if hours > 0 { @@ -755,11 +788,18 @@ impl App { 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()); + // 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) + }; - // Calculate duration - let duration = stop_local.signed_duration_since(start_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(); let minutes = duration.num_minutes() % 60; let duration_str = if hours > 0 { diff --git a/src/ui.rs b/src/ui.rs index 9dcab36..1f71251 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -692,7 +692,8 @@ fn render_log_view(frame: &mut Frame, app: &App) { LogViewDayOrder::ReverseChronological => "↓", }; - let title = format!("Watson Log - {} View ({}) [{}]", period_str, grouping_str, order_str); + let rounded_indicator = if app.log_view_rounded { " [ROUNDED]" } else { "" }; + let title = format!("Watson Log - {} View ({}) [{}]{}", period_str, grouping_str, order_str, rounded_indicator); let block = Block::default() .title(title) @@ -939,6 +940,12 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { " - Days are shown in the chosen order", " - Entries within each day are always chronological (earliest to latest)", "", + "Display Mode:", + "- R: Toggle 15-minute rounding (for estimates/reports)", + " - When enabled, all times are rounded to nearest 15-minute interval", + " - Shows [ROUNDED] indicator in title bar", + " - Affects display and clipboard copy, does not modify Watson data", + "", "Navigation:", "- j/k or ↑/↓: Navigate selection", " - At Entry level: Move to next/previous entry",