Skip to main content

max / goingson

5.2 KB · 152 lines History Blame Raw
1 //! Time tracking commands: start/stop/discard timer, get active, list sessions, summary.
2
3 use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
4 use serde::{Deserialize, Serialize};
5 use std::sync::Arc;
6 use tauri::State;
7 use tracing::instrument;
8
9 use goingson_core::{TaskId, TimeSession, TimeSummaryPanel, roll_up_time_summary};
10
11 use crate::state::{AppState, DESKTOP_USER_ID};
12 use super::ApiError;
13
14 // ============ Types ============
15
16 #[derive(Debug, Serialize)]
17 #[serde(rename_all = "camelCase")]
18 pub struct ActiveTimerResponse {
19 pub session: TimeSession,
20 pub task_id: TaskId,
21 pub task_description: String,
22 pub elapsed_minutes: i32,
23 }
24
25 #[derive(Debug, Deserialize)]
26 #[serde(rename_all = "camelCase")]
27 pub struct LogManualTimeInput {
28 pub task_id: TaskId,
29 pub minutes: i32,
30 pub date: DateTime<Utc>,
31 }
32
33 // ============ Commands ============
34
35 /// Starts a timer on a task.
36 ///
37 /// Fails if the user already has an active timer on any task.
38 #[tauri::command]
39 #[instrument(skip_all)]
40 pub async fn start_timer(
41 state: State<'_, Arc<AppState>>,
42 task_id: TaskId,
43 ) -> Result<TimeSession, ApiError> {
44 Ok(state.tasks.start_timer(task_id, DESKTOP_USER_ID).await?)
45 }
46
47 /// Stops the active timer on a task.
48 ///
49 /// Sets ended_at, calculates duration, and updates the task's actual_minutes cache.
50 #[tauri::command]
51 #[instrument(skip_all)]
52 pub async fn stop_timer(
53 state: State<'_, Arc<AppState>>,
54 task_id: TaskId,
55 ) -> Result<Option<TimeSession>, ApiError> {
56 Ok(state.tasks.stop_timer(task_id, DESKTOP_USER_ID).await?)
57 }
58
59 /// Discards the active timer without recording time.
60 #[tauri::command]
61 #[instrument(skip_all)]
62 pub async fn discard_timer(
63 state: State<'_, Arc<AppState>>,
64 task_id: TaskId,
65 ) -> Result<bool, ApiError> {
66 Ok(state.tasks.discard_timer(task_id, DESKTOP_USER_ID).await?)
67 }
68
69 /// Gets the currently active timer for the user (at most one).
70 ///
71 /// Returns the session with task description for display.
72 #[tauri::command]
73 #[instrument(skip_all)]
74 pub async fn get_active_timer(
75 state: State<'_, Arc<AppState>>,
76 ) -> Result<Option<ActiveTimerResponse>, ApiError> {
77 match state.tasks.get_active_timer(DESKTOP_USER_ID).await? {
78 Some((session, description)) => {
79 let elapsed_minutes = session.elapsed_minutes();
80 Ok(Some(ActiveTimerResponse {
81 task_id: session.task_id,
82 session,
83 task_description: description,
84 elapsed_minutes,
85 }))
86 }
87 None => Ok(None),
88 }
89 }
90
91 /// Lists all time sessions for a task.
92 #[tauri::command]
93 #[instrument(skip_all)]
94 pub async fn list_time_sessions(
95 state: State<'_, Arc<AppState>>,
96 task_id: TaskId,
97 ) -> Result<Vec<TimeSession>, ApiError> {
98 Ok(state.tasks.list_time_sessions(task_id, DESKTOP_USER_ID).await?)
99 }
100
101 /// Logs a manual time entry (retroactive, no live timer).
102 #[tauri::command]
103 #[instrument(skip_all)]
104 pub async fn log_manual_time(
105 state: State<'_, Arc<AppState>>,
106 input: LogManualTimeInput,
107 ) -> Result<TimeSession, ApiError> {
108 // Validate the duration via the newtype: anything below 1 is rejected here
109 // (a negative value would make ended_at < started_at and drive the task's
110 // actual_minutes cache negative; zero records an empty session).
111 let minutes = goingson_core::PositiveMinutes::try_new(input.minutes)?;
112 // Ownership of `task_id` is verified inside the repo method, within the same
113 // transaction as the writes; a foreign/bogus id surfaces as NOT_FOUND.
114 Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, minutes, input.date).await?)
115 }
116
117 /// Pre-computed time-summary panel for the Day view: today's tracked total plus
118 /// the current (Monday-started) week's per-project breakdown, already aggregated
119 /// and sorted. The frontend only renders the returned rows.
120 ///
121 /// The week window is computed here from the local clock so the JS never does
122 /// date math: Monday 00:00 local through the following Monday, converted to UTC
123 /// for the query. "Today" is matched against the UTC date the query buckets by.
124 #[tauri::command]
125 #[instrument(skip_all)]
126 pub async fn get_time_summary_panel(
127 state: State<'_, Arc<AppState>>,
128 ) -> Result<TimeSummaryPanel, ApiError> {
129 let now = Local::now();
130 let days_from_monday = i64::from(now.weekday().num_days_from_monday());
131 let week_start = now.date_naive() - Duration::days(days_from_monday);
132
133 let start = local_midnight_utc(week_start);
134 let end = local_midnight_utc(week_start + Duration::days(7));
135
136 let rows = state.tasks.get_time_summary(DESKTOP_USER_ID, start, end).await?;
137 let today = Utc::now().format("%Y-%m-%d").to_string();
138 Ok(roll_up_time_summary(&rows, &today))
139 }
140
141 /// Instant of local midnight on `date`, as UTC. On the rare DST-transition day
142 /// where local midnight is skipped/ambiguous, falls back to the earliest valid
143 /// instant (or the naive value as UTC) rather than panicking.
144 fn local_midnight_utc(date: NaiveDate) -> DateTime<Utc> {
145 let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
146 Local
147 .from_local_datetime(&naive)
148 .earliest()
149 .map(|dt| dt.with_timezone(&Utc))
150 .unwrap_or_else(|| Utc.from_utc_datetime(&naive))
151 }
152