Skip to main content

max / goingson

13.4 KB · 366 lines History Blame Raw
1 //! Day planning and time blocking commands.
2 //!
3 //! Provides functionality for viewing and managing a daily timeline
4 //! of scheduled tasks and events, including conflict detection.
5
6 use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence_in_tz};
13 use chrono::Datelike;
14
15 use crate::state::{AppState, DESKTOP_USER_ID};
16 use super::{ApiError, OptionNotFound, task::TaskResponse};
17
18 // ============ Types ============
19
20 #[derive(Debug, Serialize)]
21 #[serde(rename_all = "camelCase")]
22 pub struct DayPlanningResponse {
23 pub date: String,
24 pub timeline_items: Vec<TimelineItem>,
25 pub unscheduled_tasks: Vec<TaskResponse>,
26 pub conflicts: Vec<Conflict>,
27 /// Whether this day is marked as a vacation day in the weekly review
28 pub is_vacation_day: bool,
29 /// Total minutes tracked today across all tasks
30 pub time_tracked_today: i32,
31 }
32
33 #[derive(Debug, Deserialize)]
34 #[serde(rename_all = "camelCase")]
35 pub struct ScheduleTaskInput {
36 pub start_time: DateTime<Utc>,
37 pub duration: Option<i32>,
38 }
39
40 // ============ Helpers ============
41
42 /// Map a user-local civil date to the half-open UTC instant window `[start, end)`
43 /// that the date spans in timezone `tz`.
44 ///
45 /// The frontend sends `date` as a *local* calendar day (`utils.js` derives it
46 /// from local `getFullYear/getMonth/getDate`), but events and time sessions are
47 /// stored as UTC instants. Interpreting the date as UTC midnight misattributes
48 /// anything near local midnight for any non-UTC user. In production `tz` is
49 /// `Local` — correct here because this is a single-user desktop app whose
50 /// process runs on the user's machine (same convention as `event.rs`).
51 ///
52 /// Generic over `TimeZone` so tests can pin a fixed offset instead of depending
53 /// on the host's timezone. The end bound is computed from the *next* local
54 /// midnight (not `start + 24h`) so it stays correct across DST-length days. On
55 /// the rare DST spring-forward gap at midnight, falls back to treating the
56 /// civil time as UTC so the call never panics.
57 fn local_day_to_utc_window<Tz: TimeZone>(date: NaiveDate, tz: &Tz) -> (DateTime<Utc>, DateTime<Utc>) {
58 let to_utc = |civil: NaiveDateTime| -> DateTime<Utc> {
59 tz.from_local_datetime(&civil)
60 .earliest()
61 .map(|dt| dt.with_timezone(&Utc))
62 .unwrap_or_else(|| DateTime::<Utc>::from_naive_utc_and_offset(civil, Utc))
63 };
64 let start = date.and_hms_opt(0, 0, 0).expect("00:00:00 is a valid time");
65 let end = (date + Duration::days(1))
66 .and_hms_opt(0, 0, 0)
67 .expect("00:00:00 is a valid time");
68 (to_utc(start), to_utc(end))
69 }
70
71 // ============ Commands ============
72
73 /// Retrieves the day planning view for a specific date.
74 ///
75 /// Returns a timeline of scheduled events and tasks, unscheduled tasks due
76 /// on that date, and any detected scheduling conflicts.
77 ///
78 /// # Arguments
79 ///
80 /// * `date` - Date in YYYY-MM-DD format
81 ///
82 /// # Errors
83 ///
84 /// Returns `PARSE_ERROR` if date format is invalid.
85 /// Returns `DATABASE_ERROR` if the query fails.
86 #[tauri::command]
87 #[instrument(skip_all)]
88 pub async fn get_day_planning(
89 state: State<'_, Arc<AppState>>,
90 date: String,
91 ) -> Result<DayPlanningResponse, ApiError> {
92 let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
93 .map_err(|e| ApiError::parse(format!("Invalid date format: {}. Expected YYYY-MM-DD", e)))?;
94
95 // Look up vacation status from the weekly review for the date's week
96 let days_from_monday = parsed_date.weekday().num_days_from_monday();
97 let week_start = parsed_date - chrono::Duration::days(days_from_monday as i64);
98 let is_vacation_day = match state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start).await? {
99 Some(review) => review.vacation_days.contains(&(days_from_monday as u8)),
100 None => false,
101 };
102
103 // Resolve the requested local day to a UTC instant window. `day_end` is the
104 // inclusive (second-granular) upper bound used by the overlap/`due` queries,
105 // mirroring the prior end-of-day-minus-one-second behavior; `day_end_excl`
106 // is the half-open bound for `get_time_summary`.
107 let (day_start, day_end_excl) = local_day_to_utc_window(parsed_date, &Local);
108 let day_end = day_end_excl - Duration::seconds(1);
109
110 let (date_events, recurring) = tokio::join!(
111 state.events.list_between(DESKTOP_USER_ID, day_start, day_end),
112 state.events.list_recurring(DESKTOP_USER_ID),
113 );
114 let mut events = date_events?;
115 let recurring = recurring?;
116 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
117 for r in recurring {
118 if !existing_ids.contains(&r.id) {
119 let expanded = expand_recurrence_in_tz(&r, day_start, day_end, crate::tz::system_tz());
120 events.extend(expanded);
121 // Check if the original also falls on this day
122 let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1));
123 if effective_end >= day_start && r.start_time <= day_end
124 && !existing_ids.contains(&r.id) {
125 events.push(r);
126 }
127 }
128 }
129 events.sort_by_key(|e| e.start_time);
130
131 let unscheduled_tasks = state.tasks
132 .list_unscheduled_due_between(DESKTOP_USER_ID, day_start, day_end)
133 .await?;
134
135 let mut timeline_items: Vec<TimelineItem> = events.iter().map(|event| {
136 let duration = event.end_time.map(|end| {
137 (end - event.start_time).num_minutes() as i32
138 });
139 let (item_type, block_type) = if event.block_type.is_some() {
140 ("block".to_string(), event.block_type.as_ref().map(|b| b.db_value().to_string()))
141 } else if event.linked_task_id.is_some() {
142 ("task".to_string(), None)
143 } else {
144 ("event".to_string(), None)
145 };
146 TimelineItem {
147 id: event.id.into(),
148 item_type,
149 title: event.title.clone(),
150 start_time: event.start_time,
151 end_time: event.end_time,
152 duration,
153 project_id: event.project_id.map(Into::into),
154 project_name: event.project_name.clone(),
155 priority: None,
156 status: None,
157 block_type,
158 }
159 }).collect();
160
161 timeline_items.sort_by_key(|item| item.start_time);
162
163 let conflicts = detect_conflicts(&timeline_items);
164
165 // Time tracked for the requested local day. `get_time_summary` filters on a
166 // half-open `[start, end)` window, so use the exclusive upper bound.
167 let summaries = state.tasks
168 .get_time_summary(DESKTOP_USER_ID, day_start, day_end_excl)
169 .await?;
170 let time_tracked_today: i32 = summaries.iter().map(|s| s.total_minutes).sum();
171
172 Ok(DayPlanningResponse {
173 date,
174 timeline_items,
175 unscheduled_tasks: unscheduled_tasks.into_iter().map(TaskResponse::from).collect(),
176 conflicts,
177 is_vacation_day,
178 time_tracked_today,
179 })
180 }
181
182 /// Schedules a task to a specific time slot.
183 ///
184 /// Creates or updates a linked calendar event for the task. The event
185 /// inherits the task's project and uses the task description as its title.
186 ///
187 /// # Arguments
188 ///
189 /// * `id` - Task UUID
190 /// * `input` - Scheduling parameters:
191 /// - `start_time`: When the task is scheduled
192 /// - `duration`: Optional duration in minutes (default: 30)
193 ///
194 /// # Errors
195 ///
196 /// Returns `NOT_FOUND` if the task doesn't exist.
197 /// Returns `DATABASE_ERROR` if the update fails.
198 #[tauri::command]
199 #[instrument(skip_all)]
200 pub async fn schedule_task(
201 state: State<'_, Arc<AppState>>,
202 id: TaskId,
203 input: ScheduleTaskInput,
204 ) -> Result<TaskResponse, ApiError> {
205 let duration = input.duration.unwrap_or(30).max(1);
206 let end_time = input.start_time + chrono::Duration::minutes(duration as i64);
207
208 let task = state.tasks
209 .get_by_id(id, DESKTOP_USER_ID)
210 .await?
211 .or_not_found("task", id)?;
212
213 let updated_task = state.tasks
214 .update_schedule(id, DESKTOP_USER_ID, Some(input.start_time), Some(duration))
215 .await?
216 .or_not_found("task", id)?;
217
218 let existing_event = state.events
219 .get_by_linked_task(DESKTOP_USER_ID, id)
220 .await?;
221
222 // Snapshot the prior schedule so a failed linked-event write can be rolled
223 // back — otherwise the task is left scheduled with no calendar event (GO-10).
224 // This is a compensating undo, not a DB transaction (tasks and events are
225 // separate repos): it covers every in-process failure, but a crash between
226 // the two writes can still leave them inconsistent.
227 let prior_start = task.scheduled_start;
228 let prior_duration = task.scheduled_duration;
229
230 let event_result = if let Some(existing) = existing_event {
231 let update_event = UpdateEvent {
232 project_id: task.project_id,
233 title: task.description.clone(),
234 description: String::new(),
235 start_time: input.start_time,
236 end_time: Some(end_time),
237 location: None,
238 linked_task_id: Some(id),
239 recurrence: Recurrence::None,
240 recurrence_rule: None,
241 contact_id: task.contact_id,
242 block_type: None,
243 reminder_offsets_seconds: Vec::new(),
244 };
245 state.events
246 .update(existing.id, DESKTOP_USER_ID, update_event)
247 .await
248 .map(|_| ())
249 } else {
250 let new_event = NewEvent {
251 user_id: Some(DESKTOP_USER_ID),
252 project_id: task.project_id,
253 title: task.description.clone(),
254 description: String::new(),
255 start_time: input.start_time,
256 end_time: Some(end_time),
257 location: None,
258 linked_task_id: Some(id),
259 recurrence: Recurrence::None,
260 recurrence_rule: None,
261 contact_id: task.contact_id,
262 block_type: None,
263 reminder_offsets_seconds: Vec::new(),
264 };
265 state.events
266 .create(DESKTOP_USER_ID, new_event)
267 .await
268 .map(|_| ())
269 };
270
271 if let Err(e) = event_result {
272 // Best-effort restore of the previous schedule; surface the original
273 // event-write error regardless of whether the undo itself succeeds.
274 let _ = state.tasks
275 .update_schedule(id, DESKTOP_USER_ID, prior_start, prior_duration)
276 .await;
277 return Err(e.into());
278 }
279
280 Ok(TaskResponse::from(updated_task))
281 }
282
283 /// Removes a task from the schedule.
284 ///
285 /// Deletes the linked calendar event and clears the task's scheduled time.
286 ///
287 /// # Errors
288 ///
289 /// Returns `NOT_FOUND` if the task doesn't exist.
290 /// Returns `DATABASE_ERROR` if the update fails.
291 #[tauri::command]
292 #[instrument(skip_all)]
293 pub async fn unschedule_task(
294 state: State<'_, Arc<AppState>>,
295 id: TaskId,
296 ) -> Result<TaskResponse, ApiError> {
297 state.events
298 .delete_by_linked_task(DESKTOP_USER_ID, id)
299 .await?;
300
301 state.tasks
302 .update_schedule(id, DESKTOP_USER_ID, None, None)
303 .await?
304 .map(TaskResponse::from)
305 .or_not_found("task", id)
306 }
307
308 // Tests for detect_conflicts live in crates/core/src/day_planning.rs
309
310 #[cfg(test)]
311 mod tests {
312 use super::*;
313 use chrono::FixedOffset;
314
315 fn utc(s: &str) -> DateTime<Utc> {
316 s.parse::<DateTime<Utc>>().expect("valid RFC3339 instant")
317 }
318
319 fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
320 NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
321 }
322
323 // UTC-5 (US Eastern, standard): a local civil day maps to a window shifted
324 // +5h into UTC.
325 #[test]
326 fn local_day_window_offsets_into_utc() {
327 let est = FixedOffset::west_opt(5 * 3600).unwrap();
328 let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &est);
329 assert_eq!(start, utc("2026-06-11T05:00:00Z"));
330 assert_eq!(end, utc("2026-06-12T05:00:00Z"));
331 }
332
333 // The GO-6 case: an instant stored at 02:00Z on the 12th is 21:00 local on
334 // the 11th in UTC-5, so it must land in the 11th's window, not the 12th's.
335 #[test]
336 fn instant_near_local_midnight_lands_on_correct_local_day() {
337 let est = FixedOffset::west_opt(5 * 3600).unwrap();
338 let instant = utc("2026-06-12T02:00:00Z");
339
340 let (s11, e11) = local_day_to_utc_window(ymd(2026, 6, 11), &est);
341 let (s12, e12) = local_day_to_utc_window(ymd(2026, 6, 12), &est);
342
343 assert!(instant >= s11 && instant < e11, "belongs to the 11th's local day");
344 assert!(!(instant >= s12 && instant < e12), "must not be the 12th's local day");
345 }
346
347 // UTC users see no shift (regression guard for the common case).
348 #[test]
349 fn utc_day_window_is_plain_midnight() {
350 let utc_tz = FixedOffset::east_opt(0).unwrap();
351 let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &utc_tz);
352 assert_eq!(start, utc("2026-06-11T00:00:00Z"));
353 assert_eq!(end, utc("2026-06-12T00:00:00Z"));
354 }
355
356 // Positive offset (UTC+9, Tokyo): the window shifts the other way, into the
357 // prior UTC day.
358 #[test]
359 fn east_of_utc_shifts_window_back() {
360 let jst = FixedOffset::east_opt(9 * 3600).unwrap();
361 let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &jst);
362 assert_eq!(start, utc("2026-06-10T15:00:00Z"));
363 assert_eq!(end, utc("2026-06-11T15:00:00Z"));
364 }
365 }
366