//! 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, 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}; 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, } // ============ 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, }; // Fetch events for the date + expand recurring events let day_start = parsed_date.and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let day_end = day_start + Duration::days(1) - Duration::seconds(1); let (date_events, recurring) = tokio::join!( state.events.list_for_date(DESKTOP_USER_ID, parsed_date), 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(&r, day_start, day_end); 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 { if !existing_ids.contains(&r.id) { events.push(r); } } } } events.sort_by_key(|e| e.start_time); let unscheduled_tasks = state.tasks .list_unscheduled_due_on_date(DESKTOP_USER_ID, parsed_date) .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); // Calculate time tracked for the requested date let day_start = parsed_date.and_hms_opt(0, 0, 0).expect("midnight is valid"); let day_end = parsed_date.succ_opt().unwrap_or(parsed_date).and_hms_opt(0, 0, 0).expect("midnight is valid"); let day_start_utc = chrono::DateTime::::from_naive_utc_and_offset(day_start, Utc); let day_end_utc = chrono::DateTime::::from_naive_utc_and_offset(day_end, Utc); let summaries = state.tasks.get_time_summary(DESKTOP_USER_ID, day_start_utc, day_end_utc).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?; 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?; } 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?; } 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