Compare commits
10 commits
054a72e4a6
...
f3dcd5acbc
| Author | SHA1 | Date | |
|---|---|---|---|
| f3dcd5acbc | |||
| 7b6d24f955 | |||
| 599cc22463 | |||
| 89d1c02690 | |||
| 9ef6143dbd | |||
| e158378a13 | |||
| 2abc99ff3b | |||
| c61e66b1f6 | |||
| 31049a53dd | |||
| 8c1e72b1ef |
3 changed files with 720 additions and 123 deletions
393
src/app.rs
393
src/app.rs
|
|
@ -7,11 +7,11 @@ use std::process::Command;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub(crate) struct WatsonFrame {
|
pub(crate) struct WatsonFrame {
|
||||||
id: String,
|
pub id: String,
|
||||||
project: String,
|
pub project: String,
|
||||||
start: String,
|
pub start: String,
|
||||||
stop: String,
|
pub stop: String,
|
||||||
tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
|
|
@ -22,6 +22,7 @@ pub enum Screen {
|
||||||
ReassignProject,
|
ReassignProject,
|
||||||
LogView,
|
LogView,
|
||||||
LogViewHelp,
|
LogViewHelp,
|
||||||
|
AddAnnotation,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum LogViewPeriod {
|
pub enum LogViewPeriod {
|
||||||
|
|
@ -67,9 +68,12 @@ 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 {
|
||||||
|
|
@ -78,18 +82,56 @@ 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("\t\t"),
|
LogViewGrouping::ByProject => line.starts_with(" "),
|
||||||
LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"),
|
LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: AppState::load()?,
|
state,
|
||||||
config: Config::load()?,
|
config,
|
||||||
current_screen: Screen::Main,
|
current_screen: Screen::Main,
|
||||||
needs_clear: false,
|
needs_clear: false,
|
||||||
new_entry_buffer: String::new(),
|
new_entry_buffer: String::new(),
|
||||||
|
|
@ -107,9 +149,12 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +172,7 @@ 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
|
||||||
|
|
@ -160,7 +206,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('d'), _) => self.delete_current_item()?,
|
(KeyCode::Char('x'), _) => self.delete_current_item()?,
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -236,7 +282,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
|
(KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => 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;
|
||||||
|
|
@ -368,7 +414,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(KeyCode::Char(c), m) if m.is_empty() => {
|
(KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => {
|
||||||
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;
|
||||||
|
|
@ -467,12 +513,29 @@ 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) {
|
||||||
|
|
@ -517,6 +580,12 @@ 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));
|
||||||
|
|
@ -591,7 +660,9 @@ 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());
|
||||||
|
|
||||||
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)) = (
|
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),
|
||||||
|
|
@ -599,20 +670,52 @@ 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();
|
||||||
|
|
||||||
let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
|
// Apply rounding if enabled
|
||||||
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
|
let (display_start, display_stop) = if self.log_view_rounded {
|
||||||
|
(Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local))
|
||||||
let tags_str = if frame.tags.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
} else {
|
||||||
format!(" [{}]", frame.tags.join(", "))
|
(start_local, stop_local)
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.push(format!(
|
// Check for time gap and add separator line if enabled
|
||||||
"\t{} to {} {}{}",
|
if self.config.show_time_gaps && frame_idx > 0 {
|
||||||
start_time, stop_time, frame.project, tags_str
|
if let Some(prev) = prev_stop {
|
||||||
));
|
let gap = start_local.signed_duration_since(prev);
|
||||||
frame_indices.push(*idx);
|
if gap.num_minutes() >= 5 {
|
||||||
|
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
|
||||||
|
|
@ -685,8 +788,25 @@ 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();
|
||||||
|
|
||||||
let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
|
// Apply rounding if enabled
|
||||||
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
|
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();
|
||||||
|
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()
|
||||||
|
|
@ -695,8 +815,8 @@ impl App {
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"\t\t{} to {}{}",
|
" {} to {} {:>9}{}",
|
||||||
start_time, stop_time, tags_str
|
start_time, stop_time, duration_str, tags_str
|
||||||
));
|
));
|
||||||
frame_indices.push(*idx);
|
frame_indices.push(*idx);
|
||||||
}
|
}
|
||||||
|
|
@ -787,6 +907,88 @@ 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(¤t.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(());
|
||||||
|
|
@ -1213,7 +1415,29 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change_pane(&mut self, delta: i32) {
|
fn change_pane(&mut self, delta: i32) {
|
||||||
self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize;
|
// Find next enabled pane
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1486,4 +1710,115 @@ 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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ 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 {
|
||||||
|
|
@ -25,6 +33,10 @@ 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 {
|
||||||
|
|
@ -32,6 +44,10 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
434
src/ui.rs
434
src/ui.rs
|
|
@ -2,10 +2,12 @@ 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, List, ListItem, Paragraph, Row, Table, TableState},
|
widgets::{Block, Borders, Clear, 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,
|
||||||
|
|
@ -22,96 +24,175 @@ 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 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 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
|
2 // Need extra line for status
|
||||||
} else {
|
} else {
|
||||||
1
|
1 // Just tracking or help hint
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if has_status {
|
0
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let constraints = if bottom_height > 0 {
|
// Count enabled sections
|
||||||
vec![
|
let enabled_sections = [
|
||||||
Constraint::Min(3), // At least 3 lines for each section
|
app.config.show_permanent,
|
||||||
Constraint::Min(3),
|
app.config.show_recurring,
|
||||||
Constraint::Min(3),
|
app.config.show_recent,
|
||||||
Constraint::Length(bottom_height), // Command bar + optional status
|
]
|
||||||
]
|
.iter()
|
||||||
} else {
|
.filter(|&&x| x)
|
||||||
vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
|
.count();
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
if enabled_sections == 0 {
|
||||||
.direction(Direction::Vertical)
|
// No sections enabled - show a message
|
||||||
.constraints(constraints)
|
let block = Block::default()
|
||||||
.split(frame.size());
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
let main_height = if bottom_height > 0 {
|
// Build constraints for enabled sections
|
||||||
chunks[0].height + chunks[1].height + chunks[2].height
|
let mut constraints = Vec::new();
|
||||||
} else {
|
|
||||||
frame.size().height
|
|
||||||
};
|
|
||||||
|
|
||||||
let section_height = main_height / 3;
|
|
||||||
|
|
||||||
// Create sections with equal height
|
|
||||||
let sections = vec![
|
|
||||||
Rect::new(0, 0, frame.size().width, section_height),
|
|
||||||
Rect::new(0, section_height, frame.size().width, section_height),
|
|
||||||
Rect::new(0, section_height * 2, frame.size().width, section_height),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Render main sections
|
|
||||||
render_section(
|
|
||||||
frame,
|
|
||||||
sections[0],
|
|
||||||
"Permanent Items",
|
|
||||||
&app.state.permanent_items,
|
|
||||||
app.state.current_pane == 0,
|
|
||||||
app.state.selected_indices[0],
|
|
||||||
app.state.current_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 {
|
if bottom_height > 0 {
|
||||||
let bottom_area = chunks[3];
|
// Reserve space for bottom bar first, then split remainder among sections
|
||||||
render_bottom_bar(frame, bottom_area, app);
|
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 {
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +342,7 @@ fn render_help(frame: &mut Frame, app: &App) {
|
||||||
"",
|
"",
|
||||||
"Main Commands:",
|
"Main Commands:",
|
||||||
"Enter - Start/stop timer",
|
"Enter - Start/stop timer",
|
||||||
"d - Delete task from list",
|
"x - 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",
|
||||||
|
|
@ -328,10 +409,36 @@ 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",
|
||||||
|
|
@ -585,15 +692,16 @@ fn render_log_view(frame: &mut Frame, app: &App) {
|
||||||
LogViewDayOrder::ReverseChronological => "↓",
|
LogViewDayOrder::ReverseChronological => "↓",
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = format!("Watson Log - {} View ({}) [{}]", period_str, grouping_str, order_str);
|
let rounded_indicator = if app.log_view_rounded { " [ROUNDED]" } else { "" };
|
||||||
|
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 list items with selection highlighting based on selection level
|
// Build text lines with selection highlighting based on selection level
|
||||||
let items: Vec<ListItem> = {
|
let text_lines: Vec<Line> = {
|
||||||
// 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();
|
||||||
|
|
@ -609,17 +717,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('\t') && !line.is_empty() && !line.starts_with(" ") {
|
if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") {
|
||||||
current_date = line.clone();
|
current_date = line.clone();
|
||||||
} else if line.starts_with(" ") && !line.starts_with("\t") {
|
} else if line.starts_with(" ") && !line.starts_with(" ") {
|
||||||
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("\t\t") // Double tab for ByProject
|
line.starts_with(" ") // 8 spaces for ByProject
|
||||||
} else {
|
} else {
|
||||||
line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate
|
line.starts_with(" ") && !line.starts_with(" ") // 4 spaces for ByDate
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_entry {
|
if is_entry {
|
||||||
|
|
@ -638,10 +746,11 @@ 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("\t\t")
|
line.starts_with(" ")
|
||||||
} else {
|
} else {
|
||||||
line.starts_with('\t') && !line.starts_with("\t\t")
|
line.starts_with(" ") && !line.starts_with(" ") && line.trim() != "---"
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_entry {
|
if is_entry {
|
||||||
|
|
@ -660,19 +769,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('\t') && !line.is_empty() && !line.starts_with(" ") {
|
if !line.starts_with(" ") && !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("\t") {
|
} else if line.starts_with(" ") && !line.starts_with(" ") {
|
||||||
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('\t') {
|
} else if in_target_project && line.starts_with(" ") {
|
||||||
selected_line_end = idx;
|
selected_line_end = idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -682,7 +791,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('\t') && !line.is_empty() && !line.starts_with(" ") {
|
if !line.starts_with(" ") && !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;
|
||||||
|
|
@ -701,28 +810,102 @@ 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(" ") && !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(¤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
|
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 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_selected {
|
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)
|
||||||
};
|
};
|
||||||
|
|
||||||
ListItem::new(Line::from(vec![Span::styled(line.clone(), style)]))
|
Line::from(vec![Span::styled(line.clone(), style)])
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let list = List::new(items).block(block);
|
let paragraph = Paragraph::new(text_lines)
|
||||||
frame.render_widget(list, chunks[0]);
|
.block(block)
|
||||||
|
.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 {
|
||||||
|
|
@ -757,6 +940,12 @@ 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",
|
||||||
|
|
@ -776,10 +965,19 @@ 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",
|
||||||
|
|
@ -816,3 +1014,51 @@ 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]);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue