From b61db909c9baadf9dd6632744712234d61dee1db Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 25 May 2026 21:45:01 -0400 Subject: [PATCH] Keep transition prompt time active --- src/main.rs | 196 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 134 insertions(+), 62 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6c88c80..9365cc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -214,11 +214,8 @@ impl App { } fn to_in_progress(&mut self) -> Result<()> { - self.user_duration = self - .user_duration_str - .parse::() - .map(|minutes| Duration::from_secs(minutes * 60)) - .unwrap_or(Duration::ZERO); + self.user_duration = + parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); if self.user_intention.trim().is_empty() { self.state = State::InputIntention; @@ -266,11 +263,8 @@ impl App { return Ok(()); } - let expected_duration = self - .transition_duration_str - .parse::() - .map(|minutes| Duration::from_secs(minutes * 60)) - .unwrap_or(Duration::ZERO); + let expected_duration = + parse_duration_minutes(&self.transition_duration_str).unwrap_or(Duration::ZERO); if expected_duration.is_zero() { self.state = State::InputTransitionDuration; @@ -316,31 +310,7 @@ impl App { } fn write_status(&self) { - let status = match self.state { - State::InputIntention - | State::InputSuccessCondition - | State::InputDuration - | State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration => match self.session_controller.runtime_state() { - RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), - RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), - RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), - _ => constants::STATUS_CONFIGURE.to_string(), - }, - State::InProgress => format!( - "🎯 {} - {}", - self.user_intention, - duration_as_str(&self.session_remaining) - ), - State::Paused => format!( - "{} - {}", - constants::STATUS_PAUSED, - duration_as_str(&self.session_remaining) - ), - State::End => constants::STATUS_RATE_SESSION.to_string(), - State::ShouldQuit => constants::STATUS_QUIT.to_string(), - }; + let status = self.status_text(); let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); if let Ok(mut file) = std::fs::OpenOptions::new() @@ -353,31 +323,62 @@ impl App { } } + fn status_text(&self) -> String { + match self.state { + State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration + if self.session_controller.runtime_state() == RuntimeState::Active => + { + self.active_status_text() + } + State::InputIntention | State::InputSuccessCondition | State::InputDuration => { + match self.session_controller.runtime_state() { + RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), + RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), + RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), + _ => constants::STATUS_CONFIGURE.to_string(), + } + } + State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration => match self.session_controller.runtime_state() { + RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), + RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), + RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), + _ => constants::STATUS_CONFIGURE.to_string(), + }, + State::InProgress => self.active_status_text(), + State::Paused => format!( + "{} - {}", + constants::STATUS_PAUSED, + duration_as_str(&self.session_remaining) + ), + State::End => constants::STATUS_RATE_SESSION.to_string(), + State::ShouldQuit => constants::STATUS_QUIT.to_string(), + } + } + + fn active_status_text(&self) -> String { + format!( + "🎯 {} - {}", + self.user_intention, + duration_as_str(&self.session_remaining) + ) + } + fn tick_50ms(&mut self) -> Result<()> { match self.state { - State::InputIntention - | State::InputSuccessCondition - | State::InputDuration - | State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration => { - if let Ok(user_duration_mins) = self.user_duration_str.parse::() { - self.user_duration = Duration::from_secs(user_duration_mins * 60); - } else { - self.user_duration = Duration::ZERO; - } + State::InputIntention | State::InputSuccessCondition | State::InputDuration => { + self.user_duration = + parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); window::minimize_other(&constants::APP_TITLE); } - State::InProgress => { - let elapsed = self.session_start.elapsed(); - self.session_remaining = self.user_duration.saturating_sub(elapsed); - update_session_stats(self); - - if self.session_remaining.is_zero() { - self.to_end()?; - } - } + State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration + | State::InProgress => self.tick_active_session()?, State::ShouldQuit => {} State::Paused => { window::minimize_other(&constants::APP_TITLE); @@ -392,6 +393,18 @@ impl App { Ok(()) } + fn tick_active_session(&mut self) -> Result<()> { + let elapsed = self.session_start.elapsed(); + self.session_remaining = self.user_duration.saturating_sub(elapsed); + update_session_stats(self); + + if self.session_remaining.is_zero() { + self.to_end()?; + } + + Ok(()) + } + fn tick_1s(&mut self) { self.last_tick_1s = Instant::now(); self.write_status(); @@ -476,6 +489,15 @@ fn duration_as_str(duration: &Duration) -> String { ) } +fn parse_duration_minutes(input: &str) -> Option { + let minutes = input.parse::().ok()?; + if minutes == 0 { + return None; + } + + minutes.checked_mul(60).map(Duration::from_secs) +} + fn is_transition_input_state(state: &State) -> bool { matches!( state, @@ -813,12 +835,7 @@ fn ui(frame: &mut Frame, app: &App) { spans.push(span); } - let valid_transition_duration = app - .transition_duration_str - .parse::() - .map(|minutes| minutes > 0) - .unwrap_or(false); - if !valid_transition_duration { + if parse_duration_minutes(&app.transition_duration_str).is_none() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, Style::new().fg(Color::LightRed), @@ -841,7 +858,7 @@ fn ui(frame: &mut Frame, app: &App) { spans.push(span); } - if app.user_duration.is_zero() { + if !transition_input && parse_duration_minutes(&app.user_duration_str).is_none() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, Style::new().fg(Color::LightRed), @@ -1162,4 +1179,59 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn parse_duration_minutes_rejects_invalid_and_overflowing_values() { + assert_eq!(parse_duration_minutes(""), None); + assert_eq!(parse_duration_minutes("0"), None); + assert_eq!(parse_duration_minutes("abc"), None); + assert_eq!( + parse_duration_minutes("25"), + Some(Duration::from_secs(25 * 60)) + ); + assert_eq!(parse_duration_minutes(&u64::MAX.to_string()), None); + } + + #[test] + fn transition_prompt_tick_keeps_active_time_and_can_end_session() { + let path = unique_event_log_path("transition-prompt-active-time"); + let mut app = App::new_with_event_log_path(&path).unwrap(); + + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + app.pause().unwrap(); + app.session_start = Instant::now().checked_sub(Duration::from_secs(61)).unwrap(); + + app.tick_50ms().unwrap(); + + assert_eq!(app.state, State::End); + assert_eq!(app.session_remaining, Duration::ZERO); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); + + let _ = fs::remove_file(path); + } + + #[test] + fn transition_prompt_status_shows_active_countdown() { + let path = unique_event_log_path("transition-prompt-status"); + let mut app = App::new_with_event_log_path(&path).unwrap(); + + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + app.pause().unwrap(); + + assert_eq!( + app.status_text(), + format!( + "🎯 write tests - {}", + duration_as_str(&app.session_remaining) + ) + ); + + let _ = fs::remove_file(path); + } }