Compare commits

..

No commits in common. "f3dcd5acbc414fdb774351a9fc9113f5ab55eff0" and "054a72e4a6ab51a26560b3da57334df54b71977e" have entirely different histories.

3 changed files with 122 additions and 719 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 {
pub id: String, id: String,
pub project: String, project: String,
pub start: String, start: String,
pub stop: String, stop: String,
pub tags: Vec<String>, tags: Vec<String>,
} }
pub enum Screen { pub enum Screen {
@ -22,7 +22,6 @@ pub enum Screen {
ReassignProject, ReassignProject,
LogView, LogView,
LogViewHelp, LogViewHelp,
AddAnnotation,
} }
pub enum LogViewPeriod { pub enum LogViewPeriod {
@ -68,12 +67,9 @@ pub struct App {
pub log_view_frame_indices: Vec<usize>, // Maps display order to frame index pub log_view_frame_indices: Vec<usize>, // Maps display order to frame index
pub log_view_scroll: usize, pub log_view_scroll: usize,
pub log_view_selected: usize, pub log_view_selected: usize,
pub log_view_rounded: bool, // Toggle for 15-minute rounding display
pub help_scroll: usize, pub help_scroll: usize,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub status_message: Option<(String, std::time::Instant)>, pub status_message: Option<(String, std::time::Instant)>,
pub annotation_buffer: String,
pub annotation_cursor: usize,
} }
pub enum NewEntryMode { pub enum NewEntryMode {
@ -82,56 +78,18 @@ pub enum NewEntryMode {
} }
impl App { impl App {
// Helper to round a time to the nearest 15-minute interval
fn round_to_15min(dt: &chrono::DateTime<chrono::Local>) -> chrono::DateTime<chrono::Local> {
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 // 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(" "), LogViewGrouping::ByProject => line.starts_with("\t\t"),
LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "), LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"),
} }
} }
pub fn new() -> anyhow::Result<Self> { pub fn new() -> anyhow::Result<Self> {
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 { Ok(Self {
state, state: AppState::load()?,
config, config: Config::load()?,
current_screen: Screen::Main, current_screen: Screen::Main,
needs_clear: false, needs_clear: false,
new_entry_buffer: String::new(), new_entry_buffer: String::new(),
@ -149,12 +107,9 @@ impl App {
log_view_frame_indices: Vec::new(), log_view_frame_indices: Vec::new(),
log_view_scroll: 0, log_view_scroll: 0,
log_view_selected: 0, log_view_selected: 0,
log_view_rounded: false,
help_scroll: 0, help_scroll: 0,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),
status_message: None, status_message: None,
annotation_buffer: String::new(),
annotation_cursor: 0,
}) })
} }
@ -172,7 +127,6 @@ impl App {
Screen::ReassignProject => self.handle_reassign_project_event(event), Screen::ReassignProject => self.handle_reassign_project_event(event),
Screen::LogView => self.handle_log_view_event(event), Screen::LogView => self.handle_log_view_event(event),
Screen::LogViewHelp => self.handle_log_view_help_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 // If we switched screens, signal that we need to clear
@ -206,7 +160,7 @@ impl App {
(KeyCode::Char('n'), _) => self.start_new_entry(), (KeyCode::Char('n'), _) => self.start_new_entry(),
(KeyCode::Char('p'), _) => self.start_reassign_project(), (KeyCode::Char('p'), _) => self.start_reassign_project(),
(KeyCode::Char('v'), _) => self.start_log_view()?, (KeyCode::Char('v'), _) => self.start_log_view()?,
(KeyCode::Char('x'), _) => self.delete_current_item()?, (KeyCode::Char('d'), _) => self.delete_current_item()?,
_ => {} _ => {}
}, },
_ => {} _ => {}
@ -282,7 +236,7 @@ impl App {
} }
} }
}, },
(KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => match self.new_entry_mode { (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
NewEntryMode::Task => { NewEntryMode::Task => {
self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_project.insert(self.new_entry_cursor, c);
self.new_entry_cursor += 1; self.new_entry_cursor += 1;
@ -414,7 +368,7 @@ impl App {
} }
} }
} }
(KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => { (KeyCode::Char(c), m) if m.is_empty() => {
if self.reassign_project_cursor <= self.reassign_project_buffer.len() { if self.reassign_project_cursor <= self.reassign_project_buffer.len() {
self.reassign_project_buffer.insert(self.reassign_project_cursor, c); self.reassign_project_buffer.insert(self.reassign_project_cursor, c);
self.reassign_project_cursor += 1; self.reassign_project_cursor += 1;
@ -513,29 +467,12 @@ impl App {
self.edit_selected_frame()?; 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') => { KeyCode::Char('x') => {
// Only allow delete when selecting individual entry // Only allow delete when selecting individual entry
if matches!(self.log_view_selection_level, LogViewSelection::Entry) { if matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.delete_selected_frame()?; 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 => { KeyCode::Char('h') | KeyCode::Left => {
// Zoom out selection level // Zoom out selection level
self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) {
@ -580,12 +517,6 @@ impl App {
self.format_log_entries(); self.format_log_entries();
self.needs_clear = true; 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 => { KeyCode::PageDown => {
self.log_view_selected = (self.log_view_selected + 10) self.log_view_selected = (self.log_view_selected + 10)
.min(self.log_view_frame_indices.len().saturating_sub(1)); .min(self.log_view_frame_indices.len().saturating_sub(1));
@ -660,9 +591,7 @@ 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());
let mut prev_stop: Option<DateTime<Local>> = None; for (idx, frame) in frames {
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),
@ -670,52 +599,20 @@ 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();
// Apply rounding if enabled let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
let (display_start, display_stop) = if self.log_view_rounded { let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
(Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local))
let tags_str = if frame.tags.is_empty() {
String::new()
} else { } else {
(start_local, stop_local) format!(" [{}]", frame.tags.join(", "))
}; };
// Check for time gap and add separator line if enabled lines.push(format!(
if self.config.show_time_gaps && frame_idx > 0 { "\t{} to {} {}{}",
if let Some(prev) = prev_stop { start_time, stop_time, frame.project, tags_str
let gap = start_local.signed_duration_since(prev); ));
if gap.num_minutes() >= 5 { frame_indices.push(*idx);
lines.push(" ---".to_string());
// Note: don't add to frame_indices - separator is not an entry
}
}
}
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 {
format!("({}h {:02}m)", hours, minutes)
} else {
format!("({}m)", minutes)
};
let display_text = if frame.tags.is_empty() {
frame.project.clone()
} else {
format!("{} [{}]", frame.tags.join(", "), frame.project)
};
let line_text = format!(
" {} to {} {:>9} {}",
start_time, stop_time, duration_str, 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 lines.push(String::new()); // Empty line between dates
@ -788,25 +685,8 @@ 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();
// Apply rounding if enabled let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
let (display_start, display_stop) = if self.log_view_rounded { let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
(Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local))
} else {
(start_local, stop_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 {
format!("({}h {:02}m)", hours, minutes)
} else {
format!("({}m)", minutes)
};
let tags_str = if frame.tags.is_empty() { let tags_str = if frame.tags.is_empty() {
String::new() String::new()
@ -815,8 +695,8 @@ impl App {
}; };
lines.push(format!( lines.push(format!(
" {} to {} {:>9}{}", "\t\t{} to {}{}",
start_time, stop_time, duration_str, tags_str start_time, stop_time, tags_str
)); ));
frame_indices.push(*idx); frame_indices.push(*idx);
} }
@ -907,88 +787,6 @@ impl App {
Ok(()) 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(&current.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<()> { fn copy_log_to_clipboard(&mut self) -> anyhow::Result<()> {
if self.log_view_content.is_empty() { if self.log_view_content.is_empty() {
return Ok(()); return Ok(());
@ -1415,29 +1213,7 @@ impl App {
} }
fn change_pane(&mut self, delta: i32) { fn change_pane(&mut self, delta: i32) {
// Find next enabled pane self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize;
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; self.state.current_column = 0;
} }
@ -1710,115 +1486,4 @@ impl App {
Ok(()) 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<bool> {
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() || m == KeyModifiers::SHIFT => {
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(())
}
} }

View file

@ -11,14 +11,6 @@ pub struct Config {
pub strict_projects: bool, pub strict_projects: bool,
#[serde(default = "default_multi_column")] #[serde(default = "default_multi_column")]
pub multi_column: bool, 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,
#[serde(default = "default_true")]
pub show_time_gaps: bool,
} }
fn default_show_help_hint() -> bool { fn default_show_help_hint() -> bool {
@ -33,10 +25,6 @@ fn default_multi_column() -> bool {
true true
} }
fn default_true() -> bool {
true
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -44,10 +32,6 @@ impl Default for Config {
projects: Vec::new(), projects: Vec::new(),
strict_projects: default_strict_projects(), strict_projects: default_strict_projects(),
multi_column: default_multi_column(), multi_column: default_multi_column(),
show_permanent: default_true(),
show_recurring: default_true(),
show_recent: default_true(),
show_time_gaps: default_true(),
} }
} }
} }

428
src/ui.rs
View file

@ -2,12 +2,10 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table, TableState},
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,
@ -24,175 +22,96 @@ pub fn render(frame: &mut Frame, app: &App) {
Screen::ReassignProject => render_reassign_project(frame, app), Screen::ReassignProject => render_reassign_project(frame, app),
Screen::LogView => render_log_view(frame, app), Screen::LogView => render_log_view(frame, app),
Screen::LogViewHelp => render_log_view_help(frame, app), Screen::LogViewHelp => render_log_view_help(frame, app),
Screen::AddAnnotation => render_add_annotation(frame, 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 has_active_timer = app.state.active_timer.is_some(); let show_bottom_bar = app.config.show_help_hint;
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 // Need extra line for status 2
} else { } else {
1 // Just tracking or help hint 1
} }
} else { } else {
0 if has_status {
1
} else {
0
}
}; };
// Count enabled sections let constraints = if bottom_height > 0 {
let enabled_sections = [ vec![
app.config.show_permanent, Constraint::Min(3), // At least 3 lines for each section
app.config.show_recurring, Constraint::Min(3),
app.config.show_recent, Constraint::Min(3),
] Constraint::Length(bottom_height), // Command bar + optional status
.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 mut constraints = Vec::new();
if bottom_height > 0 {
// 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 { } else {
// No bottom bar - just render sections vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
let section_percentage = 100 / enabled_sections as u16; };
let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
.split(frame.size()); .split(frame.size());
let mut chunk_idx = 0; let main_height = if bottom_height > 0 {
chunks[0].height + chunks[1].height + chunks[2].height
} else {
frame.size().height
};
if app.config.show_permanent { let section_height = main_height / 3;
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 { // Create sections with equal height
render_section( let sections = vec![
frame, Rect::new(0, 0, frame.size().width, section_height),
chunks[chunk_idx], Rect::new(0, section_height, frame.size().width, section_height),
"Recurring Items", Rect::new(0, section_height * 2, frame.size().width, section_height),
&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 main sections
render_section( render_section(
frame, frame,
chunks[chunk_idx], sections[0],
"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,
); );
}
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,
);
// Render bottom bar if needed
if bottom_height > 0 {
let bottom_area = chunks[3];
render_bottom_bar(frame, bottom_area, app);
} }
} }
@ -342,7 +261,7 @@ fn render_help(frame: &mut Frame, app: &App) {
"", "",
"Main Commands:", "Main Commands:",
"Enter - Start/stop timer", "Enter - Start/stop timer",
"x - Delete task from list", "d - Delete task from list",
"p - Reassign project name", "p - Reassign project name",
"v - View Watson log", "v - View Watson log",
"Ctrl+e - Edit tasks config file", "Ctrl+e - Edit tasks config file",
@ -409,36 +328,10 @@ fn render_config_help(frame: &mut Frame, app: &App) {
" When true, only allows projects from the 'projects' list", " When true, only allows projects from the 'projects' list",
" When false, any project name can be used", " 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",
"",
"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",
"strict_projects: false", "strict_projects: false",
"multi_column: true",
"show_permanent: true",
"show_recurring: true",
"show_recent: false",
"show_time_gaps: true",
"projects:", "projects:",
" - work", " - work",
" - personal", " - personal",
@ -692,16 +585,15 @@ fn render_log_view(frame: &mut Frame, app: &App) {
LogViewDayOrder::ReverseChronological => "", LogViewDayOrder::ReverseChronological => "",
}; };
let rounded_indicator = if app.log_view_rounded { " [ROUNDED]" } else { "" }; let title = format!("Watson Log - {} View ({}) [{}]", period_str, grouping_str, order_str);
let title = format!("Watson Log - {} View ({}) [{}]{}", period_str, grouping_str, order_str, rounded_indicator);
let block = Block::default() let block = Block::default()
.title(title) .title(title)
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(ACTIVE_COLOR)); .style(Style::default().fg(ACTIVE_COLOR));
// Build text lines with selection highlighting based on selection level // Build list items with selection highlighting based on selection level
let text_lines: Vec<Line> = { let items: Vec<ListItem> = {
// Pre-calculate which line indices should be highlighted // Pre-calculate which line indices should be highlighted
let mut selected_date = String::new(); let mut selected_date = String::new();
let mut selected_project = String::new(); let mut selected_project = String::new();
@ -717,17 +609,17 @@ fn render_log_view(frame: &mut Frame, app: &App) {
let is_by_project = matches!(app.log_view_grouping, LogViewGrouping::ByProject); let is_by_project = matches!(app.log_view_grouping, LogViewGrouping::ByProject);
for (_idx, line) in app.log_view_content.iter().enumerate() { for (_idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone(); current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with(" ") { } else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone(); current_project = line.clone();
} }
// Only count actual entries (not all tab-indented lines) // Only count actual entries (not all tab-indented lines)
let is_entry = if is_by_project { let is_entry = if is_by_project {
line.starts_with(" ") // 8 spaces for ByProject line.starts_with("\t\t") // Double tab for ByProject
} else { } else {
line.starts_with(" ") && !line.starts_with(" ") // 4 spaces for ByDate line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate
}; };
if is_entry { if is_entry {
@ -746,11 +638,10 @@ 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(" ") line.starts_with("\t\t")
} else { } else {
line.starts_with(" ") && !line.starts_with(" ") && line.trim() != "---" line.starts_with('\t') && !line.starts_with("\t\t")
}; };
if is_entry { if is_entry {
@ -769,19 +660,19 @@ fn render_log_view(frame: &mut Frame, app: &App) {
current_date = String::new(); current_date = String::new();
for (idx, line) in app.log_view_content.iter().enumerate() { for (idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone(); current_date = line.clone();
if in_target_project { if in_target_project {
break; // End of project group break; // End of project group
} }
} else if line.starts_with(" ") && !line.starts_with(" ") { } else if line.starts_with(" ") && !line.starts_with("\t") {
if current_date == selected_date && line == &selected_project { if current_date == selected_date && line == &selected_project {
selected_line_start = idx; selected_line_start = idx;
in_target_project = true; in_target_project = true;
} else if in_target_project { } else if in_target_project {
break; // Different project break; // Different project
} }
} else if in_target_project && line.starts_with(" ") { } else if in_target_project && line.starts_with('\t') {
selected_line_end = idx; selected_line_end = idx;
} }
} }
@ -791,7 +682,7 @@ fn render_log_view(frame: &mut Frame, app: &App) {
let mut in_target_day = false; let mut in_target_day = false;
for (idx, line) in app.log_view_content.iter().enumerate() { for (idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if line == &selected_date { if line == &selected_date {
selected_line_start = idx; selected_line_start = idx;
in_target_day = true; in_target_day = true;
@ -810,102 +701,28 @@ fn render_log_view(frame: &mut Frame, app: &App) {
} }
} }
// Third pass: render with highlighting and overlap detection // Third pass: render with highlighting
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(" ") && !line.is_empty() && !line.starts_with(" ") {
current_day_start_entry = app.log_view_content[..line_idx]
.iter()
.filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---")
.count();
in_day = true;
} 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(" ") && !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;
// Skip styling for separator lines let style = if is_selected {
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(" ") && !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(" ") && !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)
}; };
Line::from(vec![Span::styled(line.clone(), style)]) ListItem::new(Line::from(vec![Span::styled(line.clone(), style)]))
}) })
.collect() .collect()
}; };
let paragraph = Paragraph::new(text_lines) let list = List::new(items).block(block);
.block(block) frame.render_widget(list, chunks[0]);
.wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(paragraph, chunks[0]);
// Render help hint at bottom if enabled // Render help hint at bottom if enabled
if app.config.show_help_hint { if app.config.show_help_hint {
@ -940,12 +757,6 @@ fn render_log_view_help(frame: &mut Frame, app: &App) {
" - Days are shown in the chosen order", " - Days are shown in the chosen order",
" - Entries within each day are always chronological (earliest to latest)", " - 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:", "Navigation:",
"- j/k or ↑/↓: Navigate selection", "- j/k or ↑/↓: Navigate selection",
" - At Entry level: Move to next/previous entry", " - At Entry level: Move to next/previous entry",
@ -965,19 +776,10 @@ fn render_log_view_help(frame: &mut Frame, app: &App) {
"", "",
"Actions:", "Actions:",
"- e: Edit the selected entry (Entry level only)", "- 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)", "- 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)", "- 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",
@ -1014,51 +816,3 @@ fn render_log_view_help(frame: &mut Frame, app: &App) {
frame.render_widget(command_bar, bar_area); 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]);
}