From 7c44b54930cca7e0b5b16b86b63bcc04d418f373 Mon Sep 17 00:00:00 2001 From: Alessandro La Conca Date: Sun, 1 Feb 2026 22:37:36 +0100 Subject: [PATCH] feat: support for quantities added in success-lib 0.4.0 --- Cargo.toml | 2 +- src/main.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 232 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc561f6..867c8da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "success-cli" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "CLI for achieving goals" diff --git a/src/main.rs b/src/main.rs index 15b002f..98fa037 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,16 @@ enum Mode { is_reward: bool, input: String, }, + QuantityNameInput { + goal_name: String, + is_reward: bool, + commands: Vec, + input: String, + }, + QuantityDoneInput { + goal_name: String, + quantity_name: Option, + }, DurationInput { is_reward: bool, goal_name: String, @@ -119,6 +129,8 @@ fn format_mode(mode: &Mode) -> &'static str { Mode::AddSession => "add-session", Mode::AddReward => "add-reward", Mode::CommandInput { .. } => "commands", + Mode::QuantityNameInput { .. } => "quantity-name", + Mode::QuantityDoneInput { .. } => "quantity-done", Mode::DurationInput { .. } => "duration", Mode::Timer => "timer", Mode::NotesEdit => "notes-edit", @@ -136,7 +148,9 @@ struct AppState { search_input: String, search_selected: usize, duration_input: String, + quantity_input: String, timer: Option, + pending_session: Option, notes: String, notes_cursor: usize, status: Option, @@ -154,6 +168,15 @@ struct TimerState { started_at: DateTime, } +#[derive(Debug, Clone)] +struct PendingSession { + label: String, + goal_id: u64, + total: u64, + is_reward: bool, + started_at: DateTime, +} + #[derive(Debug)] struct SpawnedCommand { command: String, @@ -174,7 +197,9 @@ fn main() -> Result<()> { search_input: String::new(), search_selected: 0, duration_input: String::new(), + quantity_input: String::new(), timer: None, + pending_session: None, notes: String::new(), notes_cursor: 0, status: None, @@ -414,6 +439,29 @@ fn ui(f: &mut ratatui::Frame, state: &AppState) { let para = Paragraph::new(input.clone()).block(block); f.render_widget(para, footer_area); } + Mode::QuantityNameInput { + ref goal_name, + ref input, + .. + } => { + let title = format!("Quantity name for {goal_name} (e.g., pages, reps). Blank to skip"); + let block = Block::default().borders(Borders::ALL).title(title); + let para = Paragraph::new(input.clone()).block(block); + f.render_widget(para, footer_area); + } + Mode::QuantityDoneInput { + ref goal_name, + ref quantity_name, + } => { + let title = if let Some(name) = quantity_name { + format!("{name} done for goal {goal_name} (blank to skip)") + } else { + format!("Quantity done for goal {goal_name} (blank to skip)") + }; + let block = Block::default().borders(Borders::ALL).title(title); + let para = Paragraph::new(state.quantity_input.clone()).block(block); + f.render_widget(para, footer_area); + } Mode::Timer => { let help = Paragraph::new( "Timer running • Up/Down/Left/Right navigate • 'e' edit notes • Finish before starting another", @@ -460,8 +508,15 @@ fn build_view_items(state: &AppState) -> Vec { }; let duration = (n.end_at - n.start_at).num_minutes(); let times = get_formatted_session_time_range(n); + let unit = goal_quantity_name(state, n.goal_id) + .map(|u| format!(" {u}")) + .unwrap_or_default(); + let qty_label = n + .quantity + .map(|q| format!("{q}{unit} in ")) + .unwrap_or_default(); items.push(ViewItem { - label: format!("{prefix} {} ({duration}m) [{times}]", n.name), + label: format!("{prefix} {} ({qty_label}{duration}m) [{times}]", n.name), kind: ViewItemKind::Existing(n.kind, idx), }); } @@ -491,8 +546,18 @@ fn build_view_items(state: &AppState) -> Vec { kind: ViewItemKind::AddReward, }); } else { + let add_label = if let Mode::QuantityDoneInput { + ref goal_name, + ref quantity_name, + } = state.mode + { + let quantity_name = quantity_name.as_deref().unwrap_or("quantity"); + format!("[+] Insert {quantity_name} for {goal_name}") + } else { + "[+] Work on new goal".to_string() + }; items.push(ViewItem { - label: "[+] Work on new goal".to_string(), + label: add_label, kind: ViewItemKind::AddSession, }); } @@ -508,6 +573,8 @@ fn handle_key(state: &mut AppState, key: KeyEvent) -> Result { Mode::View => handle_view_key(state, key), Mode::AddSession | Mode::AddReward => handle_search_key(state, key), Mode::CommandInput { .. } => handle_command_key(state, key), + Mode::QuantityNameInput { .. } => handle_quantity_name_key(state, key), + Mode::QuantityDoneInput { .. } => handle_quantity_done_key(state, key), Mode::DurationInput { .. } => handle_duration_key(state, key), Mode::Timer => handle_timer_key(state, key), Mode::NotesEdit => handle_notes_key(state, key), @@ -723,7 +790,57 @@ fn handle_command_key(state: &mut AppState, key: KeyEvent) -> Result { } KeyCode::Enter => { let commands = parse_commands_input(input); - let created = add_goal(&state.archive, goal_name, is_reward, commands)?; + state.mode = Mode::QuantityNameInput { + goal_name: goal_name.clone(), + is_reward, + commands, + input: String::new(), + }; + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + if key.modifiers != KeyModifiers::CONTROL { + input.push(c); + } + } + _ => {} + } + + Ok(false) +} + +fn handle_quantity_name_key(state: &mut AppState, key: KeyEvent) -> Result { + let Mode::QuantityNameInput { + ref goal_name, + is_reward, + ref commands, + ref mut input, + } = state.mode + else { + return Ok(false); + }; + + match key.code { + KeyCode::Esc => { + input.clear(); + state.mode = Mode::View; + } + KeyCode::Enter => { + let quantity_name = input.trim(); + let quantity_name = if quantity_name.is_empty() { + None + } else { + Some(quantity_name.to_string()) + }; + let created = add_goal( + &state.archive, + goal_name, + is_reward, + commands.clone(), + quantity_name, + )?; state.goals.push(created.clone()); state.duration_input = "25m".to_string(); @@ -749,6 +866,41 @@ fn handle_command_key(state: &mut AppState, key: KeyEvent) -> Result { Ok(false) } +fn handle_quantity_done_key(state: &mut AppState, key: KeyEvent) -> Result { + let Mode::QuantityDoneInput { .. } = state.mode else { + return Ok(false); + }; + + match key.code { + KeyCode::Esc => { + state.quantity_input.clear(); + if let Some(pending) = state.pending_session.take() { + finalize_session(state, pending, None); + } else { + state.mode = Mode::View; + } + } + KeyCode::Enter => { + let qty = parse_optional_u32(&state.quantity_input); + if let Some(pending) = state.pending_session.take() { + finalize_session(state, pending, qty); + } + state.quantity_input.clear(); + } + KeyCode::Backspace => { + state.quantity_input.pop(); + } + KeyCode::Char(c) => { + if key.modifiers != KeyModifiers::CONTROL { + state.quantity_input.push(c); + } + } + _ => {} + } + + Ok(false) +} + fn handle_duration_key(state: &mut AppState, key: KeyEvent) -> Result { match key.code { KeyCode::Esc => { @@ -872,44 +1024,28 @@ fn finish_timer(state: &mut AppState) { if timer.is_reward { kill_spawned(&mut timer.spawned); } - state.mode = Mode::View; - - let duration_secs = timer.total.min(u32::MAX as u64) as u32; - let created = match add_session( - &state.archive, - timer.goal_id, - &timer.label, - timer.started_at, - duration_secs, - timer.is_reward, - ) { - Ok(session) => session, - Err(e) => { - state.status = Some(format!("Failed to record session: {e}")); - return; - } + + let pending = PendingSession { + label: timer.label.clone(), + goal_id: timer.goal_id, + total: timer.total, + is_reward: timer.is_reward, + started_at: timer.started_at, }; - let timer_day = created.start_at.with_timezone(&Local).date_naive(); - if state.current_day == timer_day { - match list_day_sessions(&state.archive, timer_day) { - Ok(nodes) => { - state.nodes = nodes; - let items = build_view_items(state); - state.selected = items.len().saturating_sub(1); - if let Err(e) = refresh_notes_for_selection(state) { - eprintln!("Failed to load notes: {e}"); - } - } - Err(e) => eprintln!("Failed to load day graph: {e}"), + if timer.is_reward { + finalize_session(state, pending, None); + } else { + state.pending_session = Some(pending); + state.quantity_input.clear(); + state.mode = Mode::QuantityDoneInput { + goal_name: timer.label, + quantity_name: goal_quantity_name(state, timer.goal_id), + }; + if let Some(unit) = goal_quantity_name(state, timer.goal_id) { + state.status = Some(format!("Enter quantity done ({unit})")); } } - let kind = if timer.is_reward { - SessionKind::Reward - } else { - SessionKind::Goal - }; - state.status = Some(format!("Finished {}", kind_label(kind))); } } @@ -958,6 +1094,64 @@ fn start_timer( Ok(()) } +fn finalize_session(state: &mut AppState, pending: PendingSession, quantity: Option) { + state.mode = Mode::View; + let duration_secs = pending.total.min(u32::MAX as u64) as u32; + let created = match add_session( + &state.archive, + pending.goal_id, + &pending.label, + pending.started_at, + duration_secs, + pending.is_reward, + quantity, + ) { + Ok(session) => session, + Err(e) => { + state.status = Some(format!("Failed to record session: {e}")); + return; + } + }; + + let timer_day = created.start_at.with_timezone(&Local).date_naive(); + if state.current_day == timer_day { + match list_day_sessions(&state.archive, timer_day) { + Ok(nodes) => { + state.nodes = nodes; + let items = build_view_items(state); + state.selected = items.len().saturating_sub(1); + if let Err(e) = refresh_notes_for_selection(state) { + eprintln!("Failed to load notes: {e}"); + } + } + Err(e) => eprintln!("Failed to load day graph: {e}"), + } + } + let kind = if pending.is_reward { + SessionKind::Reward + } else { + SessionKind::Goal + }; + state.status = Some(format!("Finished {}", kind_label(kind))); +} + +fn parse_optional_u32(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } +} + +fn goal_quantity_name(state: &AppState, goal_id: u64) -> Option { + state + .goals + .iter() + .find(|g| g.id == goal_id) + .and_then(|g| g.quantity_name.clone()) +} + fn search_results(state: &AppState) -> Vec<(String, SearchResult)> { let q = state.search_input.trim(); let is_reward = matches!(state.mode, Mode::AddReward);