Check for missing directories

This commit is contained in:
Ian Keane 2025-12-01 15:09:08 -05:00
parent f3dcd5acbc
commit e62832fde6

View file

@ -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::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
}
@ -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<Self> {
// 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<String, (String, Vec<(usize, &WatsonFrame)>)> = 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<Local> = 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<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),
@ -669,14 +692,14 @@ impl App {
) {
let start_local: DateTime<Local> = start_dt.into();
let stop_local: DateTime<Local> = 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<String, (String, BTreeMap<String, Vec<(usize, &WatsonFrame)>>)> = 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<Local> = 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<Local> = start_dt.into();
let stop_local: DateTime<Local> = 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::<Vec<WatsonFrame>>(&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(())