//! Day planning and time blocking commands. //! //! Provides functionality for viewing and managing a daily timeline //! of scheduled tasks and events, including conflict detection. use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence_in_tz}; use chrono::Datelike; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound, task::TaskResponse}; // ============ Types ============ #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DayPlanningResponse { pub date: String, pub timeline_items: Vec, pub unscheduled_tasks: Vec, pub conflicts: Vec, /// Whether this day is marked as a vacation day in the weekly review pub is_vacation_day: bool, /// Total minutes tracked today across all tasks pub time_tracked_today: i32, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduleTaskInput { pub start_time: DateTime, pub duration: Option, } // ============ Helpers ============ /// Map a user-local civil date to the half-open UTC instant window `[start, end)` /// that the date spans in timezone `tz`. /// /// The frontend sends `date` as a *local* calendar day (`utils.js` derives it /// from local `getFullYear/getMonth/getDate`), but events and time sessions are /// stored as UTC instants. Interpreting the date as UTC midnight misattributes /// anything near local midnight for any non-UTC user. In production `tz` is /// `Local` — correct here because this is a single-user desktop app whose /// process runs on the user's machine (same convention as `event.rs`). /// /// Generic over `TimeZone` so tests can pin a fixed offset instead of depending /// on the host's timezone. The end bound is computed from the *next* local /// midnight (not `start + 24h`) so it stays correct across DST-length days. On /// the rare DST spring-forward gap at midnight, falls back to treating the /// civil time as UTC so the call never panics. fn local_day_to_utc_window(date: NaiveDate, tz: &Tz) -> (DateTime, DateTime) { let to_utc = |civil: NaiveDateTime| -> DateTime { tz.from_local_datetime(&civil) .earliest() .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|| DateTime::::from_naive_utc_and_offset(civil, Utc)) }; let start = date.and_hms_opt(0, 0, 0).expect("00:00:00 is a valid time"); let end = (date + Duration::days(1)) .and_hms_opt(0, 0, 0) .expect("00:00:00 is a valid time"); (to_utc(start), to_utc(end)) } // ============ Commands ============ /// Retrieves the day planning view for a specific date. /// /// Returns a timeline of scheduled events and tasks, unscheduled tasks due /// on that date, and any detected scheduling conflicts. /// /// # Arguments /// /// * `date` - Date in YYYY-MM-DD format /// /// # Errors /// /// Returns `PARSE_ERROR` if date format is invalid. /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn get_day_planning( state: State<'_, Arc>, date: String, ) -> Result { let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") .map_err(|e| ApiError::parse(format!("Invalid date format: {}. Expected YYYY-MM-DD", e)))?; // Look up vacation status from the weekly review for the date's week let days_from_monday = parsed_date.weekday().num_days_from_monday(); let week_start = parsed_date - chrono::Duration::days(days_from_monday as i64); let is_vacation_day = match state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start).await? { Some(review) => review.vacation_days.contains(&(days_from_monday as u8)), None => false, }; // Resolve the requested local day to a UTC instant window. `day_end` is the // inclusive (second-granular) upper bound used by the overlap/`due` queries, // mirroring the prior end-of-day-minus-one-second behavior; `day_end_excl` // is the half-open bound for `get_time_summary`. let (day_start, day_end_excl) = local_day_to_utc_window(parsed_date, &Local); let day_end = day_end_excl - Duration::seconds(1); let (date_events, recurring) = tokio::join!( state.events.list_between(DESKTOP_USER_ID, day_start, day_end), state.events.list_recurring(DESKTOP_USER_ID), ); let mut events = date_events?; let recurring = recurring?; let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); for r in recurring { if !existing_ids.contains(&r.id) { let expanded = expand_recurrence_in_tz(&r, day_start, day_end, crate::tz::system_tz()); events.extend(expanded); // Check if the original also falls on this day let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1)); if effective_end >= day_start && r.start_time <= day_end && !existing_ids.contains(&r.id) { events.push(r); } } } events.sort_by_key(|e| e.start_time); let unscheduled_tasks = state.tasks .list_unscheduled_due_between(DESKTOP_USER_ID, day_start, day_end) .await?; let mut timeline_items: Vec = events.iter().map(|event| { let duration = event.end_time.map(|end| { (end - event.start_time).num_minutes() as i32 }); let (item_type, block_type) = if event.block_type.is_some() { ("block".to_string(), event.block_type.as_ref().map(|b| b.db_value().to_string())) } else if event.linked_task_id.is_some() { ("task".to_string(), None) } else { ("event".to_string(), None) }; TimelineItem { id: event.id.into(), item_type, title: event.title.clone(), start_time: event.start_time, end_time: event.end_time, duration, project_id: event.project_id.map(Into::into), project_name: event.project_name.clone(), priority: None, status: None, block_type, } }).collect(); timeline_items.sort_by_key(|item| item.start_time); let conflicts = detect_conflicts(&timeline_items); // Time tracked for the requested local day. `get_time_summary` filters on a // half-open `[start, end)` window, so use the exclusive upper bound. let summaries = state.tasks .get_time_summary(DESKTOP_USER_ID, day_start, day_end_excl) .await?; let time_tracked_today: i32 = summaries.iter().map(|s| s.total_minutes).sum(); Ok(DayPlanningResponse { date, timeline_items, unscheduled_tasks: unscheduled_tasks.into_iter().map(TaskResponse::from).collect(), conflicts, is_vacation_day, time_tracked_today, }) } /// Schedules a task to a specific time slot. /// /// Creates or updates a linked calendar event for the task. The event /// inherits the task's project and uses the task description as its title. /// /// # Arguments /// /// * `id` - Task UUID /// * `input` - Scheduling parameters: /// - `start_time`: When the task is scheduled /// - `duration`: Optional duration in minutes (default: 30) /// /// # Errors /// /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn schedule_task( state: State<'_, Arc>, id: TaskId, input: ScheduleTaskInput, ) -> Result { let duration = input.duration.unwrap_or(30).max(1); let end_time = input.start_time + chrono::Duration::minutes(duration as i64); let task = state.tasks .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("task", id)?; let updated_task = state.tasks .update_schedule(id, DESKTOP_USER_ID, Some(input.start_time), Some(duration)) .await? .or_not_found("task", id)?; let existing_event = state.events .get_by_linked_task(DESKTOP_USER_ID, id) .await?; // Snapshot the prior schedule so a failed linked-event write can be rolled // back — otherwise the task is left scheduled with no calendar event (GO-10). // This is a compensating undo, not a DB transaction (tasks and events are // separate repos): it covers every in-process failure, but a crash between // the two writes can still leave them inconsistent. let prior_start = task.scheduled_start; let prior_duration = task.scheduled_duration; let event_result = if let Some(existing) = existing_event { let update_event = UpdateEvent { project_id: task.project_id, title: task.description.clone(), description: String::new(), start_time: input.start_time, end_time: Some(end_time), location: None, linked_task_id: Some(id), recurrence: Recurrence::None, recurrence_rule: None, contact_id: task.contact_id, block_type: None, reminder_offsets_seconds: Vec::new(), }; state.events .update(existing.id, DESKTOP_USER_ID, update_event) .await .map(|_| ()) } else { let new_event = NewEvent { user_id: Some(DESKTOP_USER_ID), project_id: task.project_id, title: task.description.clone(), description: String::new(), start_time: input.start_time, end_time: Some(end_time), location: None, linked_task_id: Some(id), recurrence: Recurrence::None, recurrence_rule: None, contact_id: task.contact_id, block_type: None, reminder_offsets_seconds: Vec::new(), }; state.events .create(DESKTOP_USER_ID, new_event) .await .map(|_| ()) }; if let Err(e) = event_result { // Best-effort restore of the previous schedule; surface the original // event-write error regardless of whether the undo itself succeeds. let _ = state.tasks .update_schedule(id, DESKTOP_USER_ID, prior_start, prior_duration) .await; return Err(e.into()); } Ok(TaskResponse::from(updated_task)) } /// Removes a task from the schedule. /// /// Deletes the linked calendar event and clears the task's scheduled time. /// /// # Errors /// /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn unschedule_task( state: State<'_, Arc>, id: TaskId, ) -> Result { state.events .delete_by_linked_task(DESKTOP_USER_ID, id) .await?; state.tasks .update_schedule(id, DESKTOP_USER_ID, None, None) .await? .map(TaskResponse::from) .or_not_found("task", id) } // Tests for detect_conflicts live in crates/core/src/day_planning.rs #[cfg(test)] mod tests { use super::*; use chrono::FixedOffset; fn utc(s: &str) -> DateTime { s.parse::>().expect("valid RFC3339 instant") } fn ymd(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).expect("valid date") } // UTC-5 (US Eastern, standard): a local civil day maps to a window shifted // +5h into UTC. #[test] fn local_day_window_offsets_into_utc() { let est = FixedOffset::west_opt(5 * 3600).unwrap(); let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &est); assert_eq!(start, utc("2026-06-11T05:00:00Z")); assert_eq!(end, utc("2026-06-12T05:00:00Z")); } // The GO-6 case: an instant stored at 02:00Z on the 12th is 21:00 local on // the 11th in UTC-5, so it must land in the 11th's window, not the 12th's. #[test] fn instant_near_local_midnight_lands_on_correct_local_day() { let est = FixedOffset::west_opt(5 * 3600).unwrap(); let instant = utc("2026-06-12T02:00:00Z"); let (s11, e11) = local_day_to_utc_window(ymd(2026, 6, 11), &est); let (s12, e12) = local_day_to_utc_window(ymd(2026, 6, 12), &est); assert!(instant >= s11 && instant < e11, "belongs to the 11th's local day"); assert!(!(instant >= s12 && instant < e12), "must not be the 12th's local day"); } // UTC users see no shift (regression guard for the common case). #[test] fn utc_day_window_is_plain_midnight() { let utc_tz = FixedOffset::east_opt(0).unwrap(); let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &utc_tz); assert_eq!(start, utc("2026-06-11T00:00:00Z")); assert_eq!(end, utc("2026-06-12T00:00:00Z")); } // Positive offset (UTC+9, Tokyo): the window shifts the other way, into the // prior UTC day. #[test] fn east_of_utc_shifts_window_back() { let jst = FixedOffset::east_opt(9 * 3600).unwrap(); let (start, end) = local_day_to_utc_window(ymd(2026, 6, 11), &jst); assert_eq!(start, utc("2026-06-10T15:00:00Z")); assert_eq!(end, utc("2026-06-11T15:00:00Z")); } }