diff --git a/src/app.rs b/src/app.rs index 732b94a..e40b8eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -85,18 +85,18 @@ impl App { // Helper to round a time to the nearest 15-minute interval fn round_to_15min(dt: &chrono::DateTime) -> chrono::DateTime { 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 } @@ -106,29 +106,52 @@ impl App { if line.trim() == "---" { return false; } - + match self.log_view_grouping { LogViewGrouping::ByProject => line.starts_with(" "), LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "), } } + fn ensure_watson_directories() -> anyhow::Result<()> { + // Create watson config directory if it doesn't exist + let watson_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? + .join("watson"); + + if !watson_dir.exists() { + std::fs::create_dir_all(&watson_dir)?; + } + + // Ensure frames file exists + let frames_path = watson_dir.join("frames"); + if !frames_path.exists() { + // Create an empty frames file (empty JSON array) + std::fs::write(&frames_path, "[]")?; + } + + Ok(()) + } + pub fn new() -> anyhow::Result { + // Ensure watson directories and files exist + Self::ensure_watson_directories()?; + 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 { state, config, @@ -619,13 +642,13 @@ impl App { // Group frames by date, tracking their original indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap)> = BTreeMap::new(); - + // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; - + for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); @@ -641,12 +664,12 @@ impl App { let mut lines = Vec::new(); let mut frame_indices = Vec::new(); - + // Sort each day's frames chronologically for (_, frames) in by_date.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } - + // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { @@ -656,12 +679,12 @@ impl App { by_date.iter().collect() } }; - + for (_sort_key, (display_date, frames)) in dates { lines.push(display_date.clone()); - + 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), @@ -669,14 +692,14 @@ impl App { ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - + // Apply rounding if enabled let (display_start, display_stop) = if self.log_view_rounded { (Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local)) } else { (start_local, stop_local) }; - + // 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 { @@ -687,10 +710,10 @@ impl App { } } } - + 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(); @@ -700,21 +723,21 @@ impl App { } 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); } } @@ -732,13 +755,13 @@ impl App { // Group frames by date, then by project within each date, tracking indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap>)> = BTreeMap::new(); - + // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; - + for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); @@ -756,14 +779,14 @@ impl App { let mut lines = Vec::new(); let mut frame_indices = Vec::new(); - + // Sort frames within each project chronologically for (_, projects) in by_date.values_mut() { for frames in projects.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } } - + // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { @@ -773,13 +796,13 @@ impl App { by_date.iter().collect() } }; - + for (_sort_key, (display_date, projects)) in dates { lines.push(display_date.clone()); - + for (project, frames) in projects.iter() { lines.push(format!(" {}", project)); // Project header with indent - + for (idx, frame) in frames { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), @@ -787,17 +810,17 @@ impl App { ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - + // Apply rounding if enabled let (display_start, display_stop) = if self.log_view_rounded { (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(); @@ -807,13 +830,13 @@ impl App { } else { format!("({}m)", minutes) }; - + let tags_str = if frame.tags.is_empty() { String::new() } else { format!(" [{}]", frame.tags.join(", ")) }; - + lines.push(format!( " {} to {} {:>9}{}", start_time, stop_time, duration_str, tags_str @@ -834,7 +857,7 @@ impl App { if self.log_view_selected >= self.log_view_frame_indices.len() { 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() { @@ -876,7 +899,7 @@ impl App { if self.log_view_selected >= self.log_view_frame_indices.len() { 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() { @@ -895,7 +918,7 @@ impl App { if output.status.success() { // Reload log content self.load_log_content()?; - + // Adjust selection if we deleted the last item if self.log_view_selected >= self.log_view_frame_indices.len() && !self.log_view_frame_indices.is_empty() { self.log_view_selected = self.log_view_frame_indices.len() - 1; @@ -910,25 +933,25 @@ impl App { 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), @@ -941,21 +964,21 @@ impl App { // 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 { @@ -974,17 +997,17 @@ impl App { } } } - + // 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(()) } @@ -1044,7 +1067,7 @@ impl App { .iter() .filter(|l| self.is_entry_line(l)) .collect(); - + if self.log_view_selected < entry_lines.len() { entry_lines[self.log_view_selected].to_string() } else { @@ -1096,7 +1119,7 @@ impl App { } else if line.starts_with('\t') || line.starts_with(" ") { // Add all lines within the day (project headers and entries) current_day_lines.push(line.clone()); - + // Count only actual entries if self.is_entry_line(line) { if frame_count == self.log_view_selected { @@ -1118,7 +1141,7 @@ impl App { let mut selected_date = String::new(); let mut frame_count = 0; let mut found_current = false; - + // Find which project we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { @@ -1126,7 +1149,7 @@ impl App { } else if line.starts_with(" ") && !line.starts_with("\t") { current_project = line.clone(); } - + if self.is_entry_line(line) { if frame_count == self.log_view_selected { selected_project = current_project.clone(); @@ -1137,17 +1160,17 @@ impl App { frame_count += 1; } } - + if !found_current { return; } - + // Now find the next project within the same day first, then next day current_project = String::new(); current_date = String::new(); let mut past_selected = false; frame_count = 0; - + for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); @@ -1159,7 +1182,7 @@ impl App { return; } } - + if self.is_entry_line(line) { if current_project == selected_project && current_date == selected_date { past_selected = true; @@ -1176,7 +1199,7 @@ impl App { let mut frame_count = 0; let mut previous_project_first_entry = None; let mut last_different_project_entry = None; - + // Find which project we're currently in for line in &self.log_view_content.clone() { if line.starts_with(" ") && !line.starts_with("\t") { @@ -1186,7 +1209,7 @@ impl App { current_project = line.clone(); previous_project_first_entry = Some(frame_count); } - + if self.is_entry_line(line) { if frame_count == self.log_view_selected { _selected_project = current_project.clone(); @@ -1207,13 +1230,13 @@ impl App { let mut selected_date = String::new(); let mut frame_count = 0; let mut found_current = false; - + // Find which day we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); } - + if self.is_entry_line(line) { if frame_count == self.log_view_selected { selected_date = current_date.clone(); @@ -1223,16 +1246,16 @@ impl App { frame_count += 1; } } - + if !found_current { return; } - + // Now find the next day and jump to its first entry current_date = String::new(); let mut past_selected = false; frame_count = 0; - + for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { if past_selected && line != &selected_date { @@ -1242,7 +1265,7 @@ impl App { past_selected = true; } } - + if self.is_entry_line(line) { if past_selected && !current_date.is_empty() && current_date != selected_date { // First entry of next day @@ -1261,7 +1284,7 @@ impl App { let mut frame_count = 0; let mut last_different_day_entry = None; let mut previous_day_first_entry = None; - + // Find which day we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { @@ -1271,7 +1294,7 @@ impl App { current_date = line.clone(); previous_day_first_entry = Some(frame_count); } - + if self.is_entry_line(line) { if frame_count == self.log_view_selected { _selected_date = current_date.clone(); @@ -1300,10 +1323,10 @@ impl App { let current = self.state.selected_indices[self.state.current_pane] as i32; let items_len = items.len() as i32; - + // Check if we're using two-column mode let use_two_columns = self.config.multi_column && items.len() > 6; - + if use_two_columns { let mid_point = (items.len() + 1) / 2; let (col_start, col_end) = if self.state.current_column == 0 { @@ -1311,9 +1334,9 @@ impl App { } else { (mid_point as i32, items_len - 1) }; - + let new_index = current + delta; - + if new_index < col_start && delta < 0 { // Moving up beyond column - go to previous pane self.change_pane(-1); @@ -1339,13 +1362,13 @@ impl App { self.state.current_column = 0; } else { // Normal movement within column - self.state.selected_indices[self.state.current_pane] = + self.state.selected_indices[self.state.current_pane] = new_index.clamp(col_start, col_end) as usize; } } else { // Single column mode let new_index = current + delta; - + if new_index < 0 && delta < 0 { // At top, move to previous pane self.change_pane(-1); @@ -1364,45 +1387,45 @@ impl App { self.state.selected_indices[self.state.current_pane] = 0; } else { // Normal movement within pane - self.state.selected_indices[self.state.current_pane] = + self.state.selected_indices[self.state.current_pane] = new_index.clamp(0, items_len - 1) as usize; } } } - + fn move_column(&mut self, delta: i32) { if !self.config.multi_column { return; } - + let items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; - + // Only switch columns if we have enough items for two columns if items.len() <= 6 { return; } - + let mid_point = (items.len() + 1) / 2; let new_column = if delta > 0 { 1 } else { 0 }; - + if new_column != self.state.current_column { let current_selected = self.state.selected_indices[self.state.current_pane]; - + // Calculate which row we're on in the current column let current_row = if self.state.current_column == 0 { current_selected // In left column } else { current_selected.saturating_sub(mid_point) // In right column }; - + // Switch column and jump to the same row in the new column self.state.current_column = new_column; - + if new_column == 0 { // Moving to left column - jump to same row self.state.selected_indices[self.state.current_pane] = current_row.min(mid_point - 1); @@ -1413,7 +1436,7 @@ impl App { } } } - + fn change_pane(&mut self, delta: i32) { // Find next enabled pane let mut next_pane = self.state.current_pane; @@ -1422,13 +1445,13 @@ impl App { 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; @@ -1436,7 +1459,7 @@ impl App { break; } } - + self.state.current_pane = next_pane; self.state.current_column = 0; } @@ -1578,7 +1601,7 @@ impl App { if output.status.success() { let json_str = String::from_utf8_lossy(&output.stdout); - + // Parse JSON frames match serde_json::from_str::>(&json_str) { Ok(frames) => { @@ -1668,7 +1691,7 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + self.needs_clear = true; Ok(()) @@ -1705,7 +1728,7 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + self.needs_clear = true; Ok(())