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
6 changes: 3 additions & 3 deletions toki-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ task_filter = "+work"

## Standard key bindings

| Key | Action |
| ---------------- | ------------------ |
| Key | Action |
| -------------- | ------------------ |
| `Space` | Start / stop timer |
| `Ctrl+S` | Save (options) |
| `Ctrl+X` | Clear |
Expand All @@ -82,6 +82,6 @@ task_filter = "+work"
| `P` | Project |
| `N` | Note |
| `T` | Toggle timer size |
| `S` | Statistics |
| `S` | Stats |
| `Esc` | Exit / cancel |
| `Q` | Quit |
26 changes: 26 additions & 0 deletions toki-tui/src/app/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,32 @@ impl App {
View::Timer
}
}

/// Copy project, activity, and note from a history entry into the timer fields.
/// Does not set a status message — callers are responsible for that.
pub fn copy_entry_fields(&mut self, entry: &crate::types::TimeEntry) {
self.selected_project = Some(crate::types::Project {
id: entry.project_id.clone(),
name: entry.project_name.clone(),
});
self.selected_activity = Some(crate::types::Activity {
id: entry.activity_id.clone(),
name: entry.activity_name.clone(),
project_id: entry.project_id.clone(),
});
let note = entry.note.clone().unwrap_or_default();
self.description_input = TextInput::from_str(&note);
self.description_is_default = false;
}

/// Copy project, activity, and note from a history entry into the running timer.
pub fn yank_entry_to_timer(&mut self, entry: &crate::types::TimeEntry) {
self.copy_entry_fields(entry);
self.set_status(format!(
"Copied: {}: {}",
entry.project_name, entry.activity_name
));
}
}

/// Given an entry's optional start/end times, date string (YYYY-MM-DD), and hours,
Expand Down
3 changes: 3 additions & 0 deletions toki-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ impl App {

pub fn clear_timer(&mut self) {
self.timer_state = TimerState::Stopped;
self.timer_size = TimerSize::Normal;
self.absolute_start = None;
self.local_start = None;
self.selected_project = None;
Expand Down Expand Up @@ -276,6 +277,7 @@ impl App {
self.timer_state = TimerState::Running;
self.absolute_start = Some(OffsetDateTime::now_utc());
self.local_start = Some(Instant::now());
self.timer_size = TimerSize::Large;
// Shift focus: running timer row is inserted at index 0, pushing DB entries up by 1
if let Some(idx) = self.focused_this_week_index {
self.focused_this_week_index = Some(idx + 1);
Expand All @@ -286,6 +288,7 @@ impl App {
#[allow(dead_code)]
pub fn stop_timer(&mut self) {
self.timer_state = TimerState::Stopped;
self.timer_size = TimerSize::Normal;
self.absolute_start = None;
self.local_start = None;
}
Expand Down
4 changes: 3 additions & 1 deletion toki-tui/src/runtime/action_queue.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::types::{Activity, Project};
use crate::types::{Activity, Project, TimeEntry};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -28,6 +28,8 @@ pub(super) enum Action {
ConfirmDelete,
StopServerTimerAndClear,
RefreshHistoryBackground,
YankEntryToTimer(TimeEntry),
ResumeEntry(TimeEntry),
}

pub(super) type ActionTx = UnboundedSender<Action>;
Expand Down
76 changes: 76 additions & 0 deletions toki-tui/src/runtime/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub(crate) fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTim
app.absolute_start = Some(timer.start_time);
app.local_start = Some(Instant::now() - Duration::from_secs(elapsed_secs));
app.timer_state = app::TimerState::Running;
app.timer_size = app::TimerSize::Large;
if let (Some(id), Some(name)) = (timer.project_id, timer.project_name) {
app.selected_project = Some(crate::types::Project { id, name });
}
Expand Down Expand Up @@ -115,6 +116,12 @@ pub(super) async fn run_action(
Action::RefreshHistoryBackground => {
refresh_history_background(app, client).await;
}
Action::YankEntryToTimer(entry) => {
yank_entry_to_timer(entry, app, client).await;
}
Action::ResumeEntry(entry) => {
resume_entry(entry, app, client).await;
}
}
Ok(())
}
Expand Down Expand Up @@ -329,6 +336,74 @@ async fn refresh_history_background(app: &mut App, client: &mut ApiClient) {
}
}

async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) {
// Apply locally first so the UI updates immediately
app.yank_entry_to_timer(&entry);

// Sync the new project/activity/note to the server so save works correctly
if app.timer_state == app::TimerState::Running {
let project_id = app.selected_project.as_ref().map(|p| p.id.clone());
let project_name = app.selected_project.as_ref().map(|p| p.name.clone());
let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone());
let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone());
let note = if app.description_input.value.is_empty() {
None
} else {
Some(app.description_input.value.clone())
};
if let Err(e) = client
.update_active_timer(
project_id,
project_name,
activity_id,
activity_name,
note,
None,
)
.await
{
app.set_status(format!("Warning: Could not sync copied entry to server: {}", e));
}
}
}

async fn resume_entry(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) {
// Guard: should not be called while running, but be safe
if app.timer_state == app::TimerState::Running {
app.set_status("Timer already running — stop it first (Space or Ctrl+X)".to_string());
return;
}

// Build server arguments from the entry directly (not from app state)
let project_id = Some(entry.project_id.clone());
let project_name = Some(entry.project_name.clone());
let activity_id = Some(entry.activity_id.clone());
let activity_name = Some(entry.activity_name.clone());
let note = entry.note.clone().filter(|n| !n.is_empty());

match client
.start_timer(project_id, project_name, activity_id, activity_name, note)
.await
{
Ok(()) => {
// Only mutate local state after confirmed server success
app.copy_entry_fields(&entry);
app.start_timer(); // sets TimerState::Running + TimerSize::Large + local_start
app.set_status(format!(
"Resumed: {}: {}",
entry.project_name, entry.activity_name
));
}
Err(e) => {
if is_milltime_auth_error(&e) {
app.open_milltime_reauth();
} else {
app.set_status(format!("Error resuming entry: {}", e));
}
}
}
}

pub(super) async fn handle_save_timer_with_action(
app: &mut App,
client: &mut ApiClient,
Expand Down Expand Up @@ -409,6 +484,7 @@ pub(super) async fn handle_save_timer_with_action(
}
app::SaveAction::SaveAndStop => {
app.timer_state = app::TimerState::Stopped;
app.timer_size = app::TimerSize::Normal;
app.absolute_start = None;
app.local_start = None;
if let Some(idx) = app.focused_this_week_index {
Expand Down
34 changes: 34 additions & 0 deletions toki-tui/src/runtime/views/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,40 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio
{
app.enter_delete_confirm(app::DeleteOrigin::History);
}
KeyCode::Char('y') | KeyCode::Char('Y') if app.focused_history_index.is_some() => {
if app.timer_state != app::TimerState::Running {
app.set_status(
"No running timer — use R to resume this entry instead".to_string(),
);
} else {
let entry = app
.focused_history_index
.and_then(|idx| app.history_list_entries.get(idx).copied())
.and_then(|te_idx| app.time_entries.get(te_idx).cloned());
if let Some(entry) = entry {
enqueue_action(action_tx, Action::YankEntryToTimer(entry));
} else {
app.set_status("Error: could not resolve selected entry".to_string());
}
}
}
KeyCode::Char('r') | KeyCode::Char('R') if app.focused_history_index.is_some() => {
if app.timer_state == app::TimerState::Running {
app.set_status(
"Timer already running — stop it first (Space or Ctrl+X)".to_string(),
);
} else {
let entry = app
.focused_history_index
.and_then(|idx| app.history_list_entries.get(idx).copied())
.and_then(|te_idx| app.time_entries.get(te_idx).cloned());
if let Some(entry) = entry {
enqueue_action(action_tx, Action::ResumeEntry(entry));
} else {
app.set_status("Error: could not resolve selected entry".to_string());
}
}
}
_ => {}
}
}
Expand Down
28 changes: 27 additions & 1 deletion toki-tui/src/runtime/views/timer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT
KeyCode::Char('t') | KeyCode::Char('T') => {
app.toggle_timer_size();
}
// S: Open Statistics view (unmodified only - Ctrl+S is save)
// S: Open Stats view (unmodified only - Ctrl+S is save)
KeyCode::Char('s') | KeyCode::Char('S')
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
Expand All @@ -134,6 +134,32 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT
app.enter_delete_confirm(app::DeleteOrigin::Timer);
}
KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(),
KeyCode::Char('y') | KeyCode::Char('Y') if !is_editing_this_week(app) => {
if app.timer_state != app::TimerState::Running {
app.set_status("No running timer — use R to resume this entry instead".to_string());
} else if is_persisted_today_row_selected(app) {
let idx = app.focused_this_week_index.unwrap();
let db_idx = idx.saturating_sub(1); // timer row at 0 shifts DB entries by 1
let entry = app.this_week_history().get(db_idx).cloned().cloned();
if let Some(entry) = entry {
enqueue_action(action_tx, Action::YankEntryToTimer(entry));
}
}
}
KeyCode::Char('r') | KeyCode::Char('R') if !is_editing_this_week(app) => {
if app.timer_state == app::TimerState::Running {
app.set_status(
"Timer already running — stop it first (Space or Ctrl+X)".to_string(),
);
} else if is_persisted_today_row_selected(app) {
let idx = app.focused_this_week_index.unwrap();
// Timer is stopped: no running-timer row at index 0, so idx is the DB index directly
let entry = app.this_week_history().get(idx).cloned().cloned();
if let Some(entry) = entry {
enqueue_action(action_tx, Action::ResumeEntry(entry));
}
}
}
_ => {}
}
}
Expand Down
4 changes: 4 additions & 0 deletions toki-tui/src/ui/history_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) {
Span::raw(": Navigate "),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::raw(": Edit "),
Span::styled("Y", Style::default().fg(Color::Yellow)),
Span::raw(": Copy to running timer "),
Span::styled("R", Style::default().fg(Color::Yellow)),
Span::raw(": Resume "),
Span::styled("H / Esc", Style::default().fg(Color::Yellow)),
Span::raw(": Back to timer "),
Span::styled("Q", Style::default().fg(Color::Yellow)),
Expand Down
4 changes: 2 additions & 2 deletions toki-tui/src/ui/statistics_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ pub fn render_statistics_view(frame: &mut Frame, app: &App, body: Rect) {
.constraints([Constraint::Min(10), Constraint::Length(3)])
.split(body);

// Outer "Statistics" box
// Outer "Stats" box
let stats_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White))
.title(Span::styled(
" Statistics ",
" Stats ",
Style::default().fg(Color::White),
));
let stats_inner = stats_block.inner(outer[0]);
Expand Down
23 changes: 15 additions & 8 deletions toki-tui/src/ui/timer_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fn render_timer(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) {
let border_style = if is_focused {
Style::default().fg(Color::Magenta)
} else if is_running {
Style::default().fg(Color::Green)
Style::default().fg(Color::White)
} else {
Style::default()
};
Expand Down Expand Up @@ -118,8 +118,8 @@ fn render_project(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) {
// Magenta border and white text when focused (takes priority)
(Style::default().fg(Color::Magenta), Color::White)
} else if !is_empty {
// Green border when project/activity selected and not focused
(Style::default().fg(Color::Green), Color::White)
// White border when project/activity selected and not focused
(Style::default().fg(Color::White), Color::White)
} else {
// Default border when empty and not focused
(Style::default(), Color::White)
Expand Down Expand Up @@ -154,8 +154,8 @@ fn render_description(frame: &mut Frame, area: ratatui::layout::Rect, app: &App)
let border_style = if is_focused {
Style::default().fg(Color::Magenta)
} else if !is_empty {
// Green border when annotation has content and not focused
Style::default().fg(Color::Green)
// White border when note has content and not focused
Style::default().fg(Color::White)
} else {
// Default when empty and not focused
Style::default()
Expand Down Expand Up @@ -190,6 +190,8 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App)
let is_error = status_lower.contains("error")
|| status_lower.contains("warning")
|| status_lower.contains("no active timer")
|| status_lower.contains("no running timer")
|| status_lower.contains("timer already running")
|| status_lower.contains("cannot save")
|| status_lower.contains("please select")
|| status_lower.contains("cancelled");
Expand All @@ -200,7 +202,8 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App)
|| status_lower.contains("started")
|| status_lower.contains("stopped")
|| status_lower.contains("cleared")
|| status_lower.contains("loaded");
|| status_lower.contains("loaded")
|| status_lower.contains("resumed");

let (border_style, text_color) = if is_error {
(Style::default().fg(Color::Red), Color::Red)
Expand Down Expand Up @@ -246,9 +249,13 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) {
Span::styled("H", Style::default().fg(Color::Yellow)),
Span::raw(": History "),
Span::styled("S", Style::default().fg(Color::Yellow)),
Span::raw(": Statistics "),
Span::raw(": Stats "),
Span::styled("T", Style::default().fg(Color::Yellow)),
Span::raw(": Toggle timer size "),
Span::raw(": Toggle size "),
Span::styled("Y", Style::default().fg(Color::Yellow)),
Span::raw(": Copy to running timer "),
Span::styled("R", Style::default().fg(Color::Yellow)),
Span::raw(": Resume "),
Span::styled("Z", Style::default().fg(Color::Yellow)),
Span::raw(": Zen mode "),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Expand Down
Loading