diff --git a/toki-tui/src/api/dev_backend.rs b/toki-tui/src/api/dev_backend.rs index 1a586aa3..28a90a57 100644 --- a/toki-tui/src/api/dev_backend.rs +++ b/toki-tui/src/api/dev_backend.rs @@ -49,6 +49,7 @@ impl DevBackend { start_time: Some(e.start_time), end_time: e.end_time, week_number: e.start_time.iso_week(), + attest_level: Default::default(), }) .collect() } diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index 93b5f9fd..2733bef6 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -1,5 +1,8 @@ use super::*; +const LOCKED_ENTRY_MSG: &str = "Entry is locked and cannot be edited"; +const LOCKED_DELETE_MSG: &str = "Entry is locked and cannot be deleted"; + impl App { /// Enter edit mode for the currently focused This Week entry pub fn enter_this_week_edit_mode(&mut self) { @@ -27,6 +30,13 @@ impl App { } else { idx }; + // Guard: locked entries cannot be edited + if let Some(entry) = self.this_week_history().get(db_idx) { + if entry.attest_level.is_locked() { + self.set_status(LOCKED_ENTRY_MSG.to_string()); + return; + } + } let entry_data = self.this_week_history().get(db_idx).map(|e| { let (start_time, end_time) = derive_start_end(e.start_time, e.end_time, &e.date, e.hours); @@ -72,6 +82,13 @@ impl App { pub fn enter_history_edit_mode(&mut self) { if let Some(list_idx) = self.focused_history_index { if let Some(&history_idx) = self.history_list_entries.get(list_idx) { + // Guard: locked entries cannot be edited + if let Some(entry) = self.time_entries.get(history_idx) { + if entry.attest_level.is_locked() { + self.set_status(LOCKED_ENTRY_MSG.to_string()); + return; + } + } let entry_data = self.time_entries.get(history_idx).map(|e| { let (start_time, end_time) = derive_start_end(e.start_time, e.end_time, &e.date, e.hours); @@ -566,6 +583,37 @@ impl App { } } + /// Returns true if the currently focused history entry is locked. + pub fn focused_history_entry_is_locked(&self) -> bool { + self.focused_history_index + .and_then(|list_idx| self.history_list_entries.get(list_idx)) + .and_then(|&history_idx| self.time_entries.get(history_idx)) + .map(|e| e.attest_level.is_locked()) + .unwrap_or(false) + } + + /// Returns true if the currently focused This Week entry is locked. + pub fn focused_this_week_entry_is_locked(&self) -> bool { + self.focused_this_week_index + .map(|idx| { + let db_idx = if self.timer_state == TimerState::Running { + idx.saturating_sub(1) + } else { + idx + }; + self.this_week_history() + .get(db_idx) + .map(|e| e.attest_level.is_locked()) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + /// Set the status message for a locked-entry delete attempt. + pub fn set_locked_delete_status(&mut self) { + self.set_status(LOCKED_DELETE_MSG.to_string()); + } + /// Check if we're in any edit mode pub fn is_in_edit_mode(&self) -> bool { self.this_week_edit_state.is_some() || self.history_edit_state.is_some() diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index ffa0b4a6..5f49f955 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -110,13 +110,21 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio } KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), KeyCode::Delete | KeyCode::Backspace if app.focused_history_index.is_some() => { - app.enter_delete_confirm(app::DeleteOrigin::History); + if app.focused_history_entry_is_locked() { + app.set_locked_delete_status(); + } else { + app.enter_delete_confirm(app::DeleteOrigin::History); + } } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) && app.focused_history_index.is_some() => { - app.enter_delete_confirm(app::DeleteOrigin::History); + if app.focused_history_entry_is_locked() { + app.set_locked_delete_status(); + } else { + app.enter_delete_confirm(app::DeleteOrigin::History); + } } _ => {} } diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 6b0186ae..9e9ebd04 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -95,7 +95,11 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT app.entry_edit_backspace(); } } else if is_persisted_today_row_selected(app) { - app.enter_delete_confirm(app::DeleteOrigin::Timer); + if app.focused_this_week_entry_is_locked() { + app.set_locked_delete_status(); + } else { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + } } } KeyCode::Esc => { @@ -123,7 +127,11 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT handle_ctrl_x_key(app, action_tx); } KeyCode::Delete if !is_editing_this_week(app) && is_persisted_today_row_selected(app) => { - app.enter_delete_confirm(app::DeleteOrigin::Timer); + if app.focused_this_week_entry_is_locked() { + app.set_locked_delete_status(); + } else { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + } } KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), _ => {} @@ -233,7 +241,11 @@ fn handle_ctrl_x_key(app: &mut App, action_tx: &ActionTx) { } if is_persisted_today_row_selected(app) { - app.enter_delete_confirm(app::DeleteOrigin::Timer); + if app.focused_this_week_entry_is_locked() { + app.set_locked_delete_status(); + } else { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + } return; } diff --git a/toki-tui/src/types.rs b/toki-tui/src/types.rs index f9cd15e0..b075f008 100644 --- a/toki-tui/src/types.rs +++ b/toki-tui/src/types.rs @@ -16,6 +16,32 @@ pub struct Activity { pub project_id: String, } +/// Attestation / lock level for a time entry, matching the Milltime API value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(from = "u8")] +pub enum AttestLevel { + #[default] + None = 0, + Week = 1, + Month = 2, +} + +impl From for AttestLevel { + fn from(v: u8) -> Self { + match v { + 1 => AttestLevel::Week, + 2 => AttestLevel::Month, + _ => AttestLevel::None, + } + } +} + +impl AttestLevel { + pub fn is_locked(self) -> bool { + self != AttestLevel::None + } +} + /// A completed time entry from Milltime (via GET /time-tracking/time-entries). /// start_time / end_time are optional — present only if a local timer history record exists. #[derive(Debug, Clone, Deserialize)] @@ -36,6 +62,8 @@ pub struct TimeEntry { pub end_time: Option, #[allow(dead_code)] pub week_number: u8, + #[serde(default)] + pub attest_level: AttestLevel, } /// The current user, as returned by GET /me. diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index f1b33374..d6d61a4c 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -73,10 +73,9 @@ pub fn build_display_row( is_overlapping: bool, available_width: u16, ) -> Line<'_> { - // Warning emoji for overlapping entries - let warning_prefix = if is_overlapping { "⚠ " } else { "" }; + let is_locked = entry.attest_level.is_locked(); - // Base colors - red for overlapping, normal for non-overlapping + // Base colors - red for overlapping, normal otherwise (locked entries keep normal colors) let time_color = if is_overlapping { Color::Red } else { @@ -132,8 +131,9 @@ pub fn build_display_row( // Responsive truncation: compute remaining width after fixed prefix. // Non-overlapping: "HH:MM - HH:MM " (14) + "[DDh:DDm]" (9) + " | " (3) = 26 - // Overlapping adds "⚠ " (2 chars: symbol + space) = 28 - let prefix_len: usize = if is_overlapping { 28 } else { 26 }; + // Both ⊘ and ⚠ are 2 chars (symbol + space), so same budget = 28 + let has_prefix = is_locked || is_overlapping; + let prefix_len: usize = if has_prefix { 28 } else { 26 }; let remaining = (available_width as usize).saturating_sub(prefix_len); let proj_act = format!("{}: {}", project, activity); @@ -142,12 +142,12 @@ pub fn build_display_row( // Build styled line with colors let mut spans = vec![]; - // Warning prefix for overlapping entries - if is_overlapping { - spans.push(Span::styled( - warning_prefix, - Style::default().fg(Color::Red), - )); + // Locked takes visual precedence over overlap — attested entries cannot be + // edited regardless of overlap, so the lock indicator is more actionable. + if is_locked { + spans.push(Span::styled("⊘ ", Style::default().fg(Color::Red))); + } else if is_overlapping { + spans.push(Span::styled("⚠ ", Style::default().fg(Color::Red))); } // Show time range — use real times if available, otherwise a dimmed placeholder