//! Time tracking commands: start/stop/discard timer, get active, list sessions, summary. use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{TaskId, TimeSession, TimeSummaryPanel, roll_up_time_summary}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::ApiError; // ============ Types ============ #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ActiveTimerResponse { pub session: TimeSession, pub task_id: TaskId, pub task_description: String, pub elapsed_minutes: i32, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LogManualTimeInput { pub task_id: TaskId, pub minutes: i32, pub date: DateTime, } // ============ Commands ============ /// Starts a timer on a task. /// /// Fails if the user already has an active timer on any task. #[tauri::command] #[instrument(skip_all)] pub async fn start_timer( state: State<'_, Arc>, task_id: TaskId, ) -> Result { Ok(state.tasks.start_timer(task_id, DESKTOP_USER_ID).await?) } /// Stops the active timer on a task. /// /// Sets ended_at, calculates duration, and updates the task's actual_minutes cache. #[tauri::command] #[instrument(skip_all)] pub async fn stop_timer( state: State<'_, Arc>, task_id: TaskId, ) -> Result, ApiError> { Ok(state.tasks.stop_timer(task_id, DESKTOP_USER_ID).await?) } /// Discards the active timer without recording time. #[tauri::command] #[instrument(skip_all)] pub async fn discard_timer( state: State<'_, Arc>, task_id: TaskId, ) -> Result { Ok(state.tasks.discard_timer(task_id, DESKTOP_USER_ID).await?) } /// Gets the currently active timer for the user (at most one). /// /// Returns the session with task description for display. #[tauri::command] #[instrument(skip_all)] pub async fn get_active_timer( state: State<'_, Arc>, ) -> Result, ApiError> { match state.tasks.get_active_timer(DESKTOP_USER_ID).await? { Some((session, description)) => { let elapsed_minutes = session.elapsed_minutes(); Ok(Some(ActiveTimerResponse { task_id: session.task_id, session, task_description: description, elapsed_minutes, })) } None => Ok(None), } } /// Lists all time sessions for a task. #[tauri::command] #[instrument(skip_all)] pub async fn list_time_sessions( state: State<'_, Arc>, task_id: TaskId, ) -> Result, ApiError> { Ok(state.tasks.list_time_sessions(task_id, DESKTOP_USER_ID).await?) } /// Logs a manual time entry (retroactive, no live timer). #[tauri::command] #[instrument(skip_all)] pub async fn log_manual_time( state: State<'_, Arc>, input: LogManualTimeInput, ) -> Result { // Validate the duration via the newtype: anything below 1 is rejected here // (a negative value would make ended_at < started_at and drive the task's // actual_minutes cache negative; zero records an empty session). let minutes = goingson_core::PositiveMinutes::try_new(input.minutes)?; // Ownership of `task_id` is verified inside the repo method, within the same // transaction as the writes; a foreign/bogus id surfaces as NOT_FOUND. Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, minutes, input.date).await?) } /// Pre-computed time-summary panel for the Day view: today's tracked total plus /// the current (Monday-started) week's per-project breakdown, already aggregated /// and sorted. The frontend only renders the returned rows. /// /// The week window is computed here from the local clock so the JS never does /// date math: Monday 00:00 local through the following Monday, converted to UTC /// for the query. "Today" is matched against the UTC date the query buckets by. #[tauri::command] #[instrument(skip_all)] pub async fn get_time_summary_panel( state: State<'_, Arc>, ) -> Result { let now = Local::now(); let days_from_monday = i64::from(now.weekday().num_days_from_monday()); let week_start = now.date_naive() - Duration::days(days_from_monday); let start = local_midnight_utc(week_start); let end = local_midnight_utc(week_start + Duration::days(7)); let rows = state.tasks.get_time_summary(DESKTOP_USER_ID, start, end).await?; let today = Utc::now().format("%Y-%m-%d").to_string(); Ok(roll_up_time_summary(&rows, &today)) } /// Instant of local midnight on `date`, as UTC. On the rare DST-transition day /// where local midnight is skipped/ambiguous, falls back to the earliest valid /// instant (or the naive value as UTC) rather than panicking. fn local_midnight_utc(date: NaiveDate) -> DateTime { let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid"); Local .from_local_datetime(&naive) .earliest() .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|| Utc.from_utc_datetime(&naive)) }