diff --git a/toki-api/src/routes/time_tracking/timer.rs b/toki-api/src/routes/time_tracking/timer.rs index 1ffa35dc..7389498e 100644 --- a/toki-api/src/routes/time_tracking/timer.rs +++ b/toki-api/src/routes/time_tracking/timer.rs @@ -14,6 +14,46 @@ use tracing::instrument; use super::CookieJarResult; +const SAVE_TIMER_PARTIAL_UPDATE_ERROR: &str = "Project/activity update must be atomic: provide projectId, projectName, activityId, and activityName together."; + +#[derive(Debug, PartialEq, Eq)] +struct AtomicSaveTimerProjectActivityUpdate { + project_id: String, + project_name: String, + activity_id: String, + activity_name: String, +} + +fn parse_save_timer_project_activity_update( + body: &SaveTimerPayload, +) -> Result, ApiError> { + let has_any_update = body.project_id.is_some() + || body.project_name.is_some() + || body.activity_id.is_some() + || body.activity_name.is_some(); + + if !has_any_update { + return Ok(None); + } + + match ( + body.project_id.clone(), + body.project_name.clone(), + body.activity_id.clone(), + body.activity_name.clone(), + ) { + (Some(project_id), Some(project_name), Some(activity_id), Some(activity_name)) => { + Ok(Some(AtomicSaveTimerProjectActivityUpdate { + project_id, + project_name, + activity_id, + activity_name, + })) + } + _ => Err(ApiError::bad_request(SAVE_TIMER_PARTIAL_UPDATE_ERROR)), + } +} + // ============================================================================ // Get Timer // ============================================================================ @@ -109,6 +149,10 @@ pub async fn stop_timer( #[serde(rename_all = "camelCase")] pub struct SaveTimerPayload { user_note: Option, + project_id: Option, + project_name: Option, + activity_id: Option, + activity_name: Option, } #[instrument(name = "save_timer", skip(jar))] @@ -123,7 +167,31 @@ pub async fn save_timer( .create_service(jar, &app_state.cookie_domain) .await?; - service.save_timer(&user.id, body.user_note).await?; + let parsed_update = parse_save_timer_project_activity_update(&body)?; + let user_note = body.user_note; + + // Allow clients to include latest project/activity values in the save call. + // This makes save robust if a prior timer-edit sync call was missed. + if let Some(AtomicSaveTimerProjectActivityUpdate { + project_id, + project_name, + activity_id, + activity_name, + }) = parsed_update + { + let current_timer = service + .get_active_timer(&user.id) + .await? + .ok_or_else(|| ApiError::not_found("no active timer found"))?; + + let updated_timer = ActiveTimer::new(current_timer.started_at) + .with_project(project_id, project_name) + .with_activity(activity_id, activity_name) + .with_note(current_timer.note); + service.edit_timer(&user.id, &updated_timer).await?; + } + + service.save_timer(&user.id, user_note).await?; Ok((jar, StatusCode::OK)) } @@ -227,3 +295,102 @@ pub async fn get_timer_history( Ok((jar, Json(response))) } + +#[cfg(test)] +mod tests { + use axum::response::IntoResponse; + + use super::*; + + fn save_payload( + project_id: Option<&str>, + project_name: Option<&str>, + activity_id: Option<&str>, + activity_name: Option<&str>, + ) -> SaveTimerPayload { + SaveTimerPayload { + user_note: None, + project_id: project_id.map(ToString::to_string), + project_name: project_name.map(ToString::to_string), + activity_id: activity_id.map(ToString::to_string), + activity_name: activity_name.map(ToString::to_string), + } + } + + #[test] + fn parse_save_timer_update_accepts_no_project_or_activity_fields() { + let body = save_payload(None, None, None, None); + let parsed = if let Ok(parsed) = parse_save_timer_project_activity_update(&body) { + parsed + } else { + panic!("expected parse success"); + }; + assert_eq!(parsed, None); + } + + #[test] + fn parse_save_timer_update_accepts_atomic_project_and_activity_fields() { + let body = save_payload(Some("p1"), Some("Project"), Some("a1"), Some("Activity")); + let parsed = if let Ok(parsed) = parse_save_timer_project_activity_update(&body) { + parsed + } else { + panic!("expected parse success"); + }; + assert_eq!( + parsed, + Some(AtomicSaveTimerProjectActivityUpdate { + project_id: "p1".to_string(), + project_name: "Project".to_string(), + activity_id: "a1".to_string(), + activity_name: "Activity".to_string() + }) + ); + } + + fn assert_bad_request_for_partial_update(body: SaveTimerPayload) { + let err = + parse_save_timer_project_activity_update(&body).expect_err("expected bad request"); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn parse_save_timer_update_rejects_project_only_pair() { + let body = save_payload(Some("p1"), Some("Project"), None, None); + assert_bad_request_for_partial_update(body); + } + + #[test] + fn parse_save_timer_update_rejects_activity_only_pair() { + let body = save_payload(None, None, Some("a1"), Some("Activity")); + assert_bad_request_for_partial_update(body); + } + + #[test] + fn parse_save_timer_update_rejects_single_field_partials() { + let cases = [ + save_payload(Some("p1"), None, None, None), + save_payload(None, Some("Project"), None, None), + save_payload(None, None, Some("a1"), None), + save_payload(None, None, None, Some("Activity")), + ]; + + for body in cases { + assert_bad_request_for_partial_update(body); + } + } + + #[test] + fn parse_save_timer_update_rejects_mixed_partial_pairs() { + let cases = [ + save_payload(Some("p1"), Some("Project"), Some("a1"), None), + save_payload(Some("p1"), Some("Project"), None, Some("Activity")), + save_payload(Some("p1"), None, Some("a1"), Some("Activity")), + save_payload(None, Some("Project"), Some("a1"), Some("Activity")), + ]; + + for body in cases { + assert_bad_request_for_partial_update(body); + } + } +} diff --git a/toki-tui/src/api/client.rs b/toki-tui/src/api/client.rs index 625b6783..8418b27c 100644 --- a/toki-tui/src/api/client.rs +++ b/toki-tui/src/api/client.rs @@ -9,8 +9,9 @@ use std::sync::Arc; use crate::api::dev_backend::DevBackend; use crate::api::dto::{ ActivityDto, AuthenticateRequest, DeleteEntryRequest, EditEntryRequest, ProjectDto, - SaveTimerRequest, StartTimerRequest, UpdateActiveTimerRequest, + StartTimerRequest, UpdateActiveTimerRequest, }; +use crate::api::SaveTimerRequest; use crate::session_store; use crate::types::{ ActiveTimerState, Activity, GetTimerResponse, Me, Project, TimeEntry, TimeInfo, @@ -288,7 +289,7 @@ impl ApiClient { .await } - pub async fn save_timer(&mut self, note: Option) -> Result<()> { + pub async fn save_timer(&mut self, request: SaveTimerRequest) -> Result<()> { if self.dev_backend.is_some() { return Ok(()); } @@ -296,7 +297,7 @@ impl ApiClient { self.send_without_body( self.client .put(self.endpoint("/time-tracking/timer")?) - .json(&SaveTimerRequest { user_note: note }), + .json(&request), "PUT /time-tracking/timer", UNAUTH_RELOGIN, ) diff --git a/toki-tui/src/api/dto.rs b/toki-tui/src/api/dto.rs index a69778f3..00f50a24 100644 --- a/toki-tui/src/api/dto.rs +++ b/toki-tui/src/api/dto.rs @@ -28,6 +28,10 @@ pub struct StartTimerRequest { #[serde(rename_all = "camelCase")] pub struct SaveTimerRequest { pub user_note: Option, + pub project_id: Option, + pub project_name: Option, + pub activity_id: Option, + pub activity_name: Option, } #[derive(Serialize)] diff --git a/toki-tui/src/api/mod.rs b/toki-tui/src/api/mod.rs index 9f3aac43..70f89802 100644 --- a/toki-tui/src/api/mod.rs +++ b/toki-tui/src/api/mod.rs @@ -3,3 +3,4 @@ mod dev_backend; mod dto; pub use client::ApiClient; +pub(crate) use dto::SaveTimerRequest; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index daf54be0..298e82a7 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::api::{ApiClient, SaveTimerRequest}; use crate::app::{self, App, TextInput}; use crate::types; use anyhow::{Context, Result}; @@ -317,9 +317,16 @@ pub(super) async fn handle_save_timer_with_action( let project_display = app.current_project_name(); let activity_display = app.current_activity_name(); + let save_request = SaveTimerRequest { + user_note: note, + project_id: app.selected_project.as_ref().map(|p| p.id.clone()), + project_name: app.selected_project.as_ref().map(|p| p.name.clone()), + activity_id: app.selected_activity.as_ref().map(|a| a.id.clone()), + activity_name: app.selected_activity.as_ref().map(|a| a.name.clone()), + }; // Save the active timer to Milltime - match client.save_timer(note.clone()).await { + match client.save_timer(save_request).await { Ok(()) => { let hours = duration.as_secs() / 3600; let minutes = (duration.as_secs() % 3600) / 60;