Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions toki-tui/src/api/dev_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
48 changes: 48 additions & 0 deletions toki-tui/src/app/edit.rs
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 10 additions & 2 deletions toki-tui/src/runtime/views/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
_ => {}
}
Expand Down
18 changes: 15 additions & 3 deletions toki-tui/src/runtime/views/timer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(),
_ => {}
Expand Down Expand Up @@ -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;
}

Expand Down
28 changes: 28 additions & 0 deletions toki-tui/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> 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)]
Expand All @@ -36,6 +62,8 @@ pub struct TimeEntry {
pub end_time: Option<OffsetDateTime>,
#[allow(dead_code)]
pub week_number: u8,
#[serde(default)]
pub attest_level: AttestLevel,
}

/// The current user, as returned by GET /me.
Expand Down
22 changes: 11 additions & 11 deletions toki-tui/src/ui/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
Loading