Visual indicators for skips and overlaps, fix bottom bar
This commit is contained in:
parent
31049a53dd
commit
c61e66b1f6
3 changed files with 253 additions and 83 deletions
50
src/app.rs
50
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<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
|
||||
|
|
|
|||
|
|
@ -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
273
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];
|
||||
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(¤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;
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue