From c61e66b1f662c87e6d1c73b655ce94a17e5847d8 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 19:32:28 -0500 Subject: [PATCH] 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",