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)]
pub(crate) struct WatsonFrame {
id: String,
project: String,
start: String,
stop: String,
tags: Vec<String>,
pub id: String,
pub project: String,
pub start: String,
pub stop: String,
pub tags: Vec<String>,
}
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<DateTime<Local>> = 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<Local> = start_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 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

View file

@ -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(),
}
}
}

273
src/ui.rs
View file

@ -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];
let mut constraints = Vec::new();
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()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
// Render enabled sections
let mut chunk_idx = 0;
// 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];
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 chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(section_constraints)
.split(layout[0]);
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;
}
// Render enabled sections
let mut chunk_idx = 0;
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;
}
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;
}
// Render bottom bar if needed
if bottom_height > 0 {
let bottom_area = chunks[chunk_idx];
render_bottom_bar(frame, bottom_area, app);
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(&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
.iter()
.enumerate()
.map(|(idx, line)| {
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()
.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",