Visual indicators for skips and overlaps, fix bottom bar

This commit is contained in:
Ian Keane 2025-11-25 19:32:28 -05:00
parent 31049a53dd
commit c61e66b1f6
3 changed files with 253 additions and 83 deletions

View file

@ -7,11 +7,11 @@ use std::process::Command;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub(crate) struct WatsonFrame { pub(crate) struct WatsonFrame {
id: String, pub id: String,
project: String, pub project: String,
start: String, pub start: String,
stop: String, pub stop: String,
tags: Vec<String>, pub tags: Vec<String>,
} }
pub enum Screen { pub enum Screen {
@ -80,6 +80,11 @@ pub enum NewEntryMode {
impl App { impl App {
// Helper to determine if a line is an entry line based on grouping mode // Helper to determine if a line is an entry line based on grouping mode
fn is_entry_line(&self, line: &str) -> bool { fn is_entry_line(&self, line: &str) -> bool {
// Exclude separator lines (time gap markers)
if line.trim() == "---" {
return false;
}
match self.log_view_grouping { match self.log_view_grouping {
LogViewGrouping::ByProject => line.starts_with("\t\t"), LogViewGrouping::ByProject => line.starts_with("\t\t"),
LogViewGrouping::ByDate => line.starts_with('\t') && !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 { for (_sort_key, (display_date, frames)) in dates {
lines.push(display_date.clone()); lines.push(display_date.clone());
for (idx, frame) in frames { let mut prev_stop: Option<DateTime<Local>> = None;
for (frame_idx, (idx, frame)) in frames.iter().enumerate() {
if let (Ok(start_dt), Ok(stop_dt)) = ( if let (Ok(start_dt), Ok(stop_dt)) = (
DateTime::parse_from_rfc3339(&frame.start), DateTime::parse_from_rfc3339(&frame.start),
DateTime::parse_from_rfc3339(&frame.stop), DateTime::parse_from_rfc3339(&frame.stop),
@ -614,20 +621,35 @@ impl App {
let start_local: DateTime<Local> = start_dt.into(); let start_local: DateTime<Local> = start_dt.into();
let stop_local: DateTime<Local> = stop_dt.into(); let stop_local: DateTime<Local> = 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 start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
let tags_str = if frame.tags.is_empty() { let display_text = if frame.tags.is_empty() {
String::new() frame.project.clone()
} else { } else {
format!(" [{}]", frame.tags.join(", ")) format!("{} [{}]", frame.tags.join(", "), frame.project)
}; };
lines.push(format!( let line_text = format!(
"\t{} to {} {}{}", "\t{} to {} {}",
start_time, stop_time, frame.project, tags_str start_time, stop_time, display_text
)); );
frame_indices.push(*idx);
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 lines.push(String::new()); // Empty line between dates

View file

@ -17,6 +17,8 @@ pub struct Config {
pub show_recurring: bool, pub show_recurring: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_recent: bool, pub show_recent: bool,
#[serde(default = "default_true")]
pub show_time_gaps: bool,
} }
fn default_show_help_hint() -> bool { fn default_show_help_hint() -> bool {
@ -45,6 +47,7 @@ impl Default for Config {
show_permanent: default_true(), show_permanent: default_true(),
show_recurring: default_true(), show_recurring: default_true(),
show_recent: default_true(), show_recent: default_true(),
show_time_gaps: default_true(),
} }
} }
} }

273
src/ui.rs
View file

@ -6,6 +6,8 @@ use ratatui::{
Frame, Frame,
}; };
use chrono::DateTime;
use crate::{ use crate::{
app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen},
state::TimeItem, state::TimeItem,
@ -27,20 +29,21 @@ pub fn render(frame: &mut Frame, app: &App) {
fn render_main(frame: &mut Frame, app: &App) { fn render_main(frame: &mut Frame, app: &App) {
// Calculate layout - accounting for bottom bar if needed // 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 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 { let bottom_height = if show_bottom_bar {
if has_status { if has_status {
2 2 // Need extra line for status
} else { } else {
1 1 // Just tracking or help hint
} }
} else { } else {
if has_status { 0
1
} else {
0
}
}; };
// Count enabled sections // Count enabled sections
@ -70,66 +73,125 @@ fn render_main(frame: &mut Frame, app: &App) {
} }
// Build constraints for enabled sections // Build constraints for enabled sections
let section_percentage = 100 / enabled_sections as u16; let mut constraints = Vec::new();
let mut constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
if bottom_height > 0 { if bottom_height > 0 {
constraints.push(Constraint::Length(bottom_height)); // 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 chunks = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
.split(frame.size()); .split(frame.size());
// Render enabled sections // Split the top area among enabled sections
let mut chunk_idx = 0; let section_percentage = 100 / enabled_sections as u16;
let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
if app.config.show_permanent { let chunks = Layout::default()
render_section( .direction(Direction::Vertical)
frame, .constraints(section_constraints)
chunks[chunk_idx], .split(layout[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,
);
chunk_idx += 1;
}
if app.config.show_recurring { // Render enabled sections
render_section( let mut chunk_idx = 0;
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 { if app.config.show_permanent {
render_section( render_section(
frame, frame,
chunks[chunk_idx], chunks[chunk_idx],
"Ad-Hoc Items", "Permanent Items",
&app.state.recent_items, &app.state.permanent_items,
app.state.current_pane == 2, app.state.current_pane == 0,
app.state.selected_indices[2], app.state.selected_indices[0],
app.state.current_column, app.state.current_column,
app.config.multi_column, app.config.multi_column,
); );
chunk_idx += 1; chunk_idx += 1;
} }
// Render bottom bar if needed if app.config.show_recurring {
if bottom_height > 0 { render_section(
let bottom_area = chunks[chunk_idx]; frame,
render_bottom_bar(frame, bottom_area, app); 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", " Default: true",
" Show the 'Ad-Hoc Items' section", " 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:", "Example configuration:",
"---", "---",
"show_help_hint: true", "show_help_hint: true",
@ -371,6 +437,7 @@ fn render_config_help(frame: &mut Frame, app: &App) {
"show_permanent: true", "show_permanent: true",
"show_recurring: true", "show_recurring: true",
"show_recent: false", "show_recent: false",
"show_time_gaps: true",
"projects:", "projects:",
" - work", " - work",
" - personal", " - personal",
@ -677,10 +744,11 @@ fn render_log_view(frame: &mut Frame, app: &App) {
// Just find the specific entry line // Just find the specific entry line
frame_count = 0; frame_count = 0;
for (idx, line) in app.log_view_content.iter().enumerate() { 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 { let is_entry = if is_by_project {
line.starts_with("\t\t") line.starts_with("\t\t")
} else { } else {
line.starts_with('\t') && !line.starts_with("\t\t") line.starts_with('\t') && !line.starts_with("\t\t") && line.trim() != "---"
}; };
if is_entry { 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(&current.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 app.log_view_content
.iter() .iter()
.enumerate() .enumerate()
.map(|(idx, line)| { .map(|(idx, line)| {
let is_selected = idx >= selected_line_start && idx <= selected_line_end; let is_selected = idx >= selected_line_start && idx <= selected_line_end;
let style = if is_selected { // 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_separator {
Style::default().fg(Color::DarkGray) // Dim the separator
} else if is_selected {
Style::default() Style::default()
.fg(ACTIVE_COLOR) .fg(ACTIVE_COLOR)
.add_modifier(Modifier::REVERSED) .add_modifier(Modifier::REVERSED)
} else if has_overlap {
Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::White) 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)", "- c: Copy selection to clipboard (works at all levels)",
" Copies based on current selection level", " 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:", "Other:",
"- ?: Show/hide this help", "- ?: Show/hide this help",
"- q or ESC: Return to main screen", "- q or ESC: Return to main screen",