Skip to main content

max / goingson

8.8 KB · 260 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, 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};
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 // ============ Commands ============
41
42 /// Retrieves the day planning view for a specific date.
43 ///
44 /// Returns a timeline of scheduled events and tasks, unscheduled tasks due
45 /// on that date, and any detected scheduling conflicts.
46 ///
47 /// # Arguments
48 ///
49 /// * `date` - Date in YYYY-MM-DD format
50 ///
51 /// # Errors
52 ///
53 /// Returns `PARSE_ERROR` if date format is invalid.
54 /// Returns `DATABASE_ERROR` if the query fails.
55 #[tauri::command]
56 #[instrument(skip_all)]
57 pub async fn get_day_planning(
58 state: State<'_, Arc<AppState>>,
59 date: String,
60 ) -> Result<DayPlanningResponse, ApiError> {
61 let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
62 .map_err(|e| ApiError::parse(format!("Invalid date format: {}. Expected YYYY-MM-DD", e)))?;
63
64 // Look up vacation status from the weekly review for the date's week
65 let days_from_monday = parsed_date.weekday().num_days_from_monday();
66 let week_start = parsed_date - chrono::Duration::days(days_from_monday as i64);
67 let is_vacation_day = match state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start).await? {
68 Some(review) => review.vacation_days.contains(&(days_from_monday as u8)),
69 None => false,
70 };
71
72 // Fetch events for the date + expand recurring events
73 let day_start = parsed_date.and_hms_opt(0, 0, 0)
74 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
75 .unwrap_or_else(Utc::now);
76 let day_end = day_start + Duration::days(1) - Duration::seconds(1);
77
78 let (date_events, recurring) = tokio::join!(
79 state.events.list_for_date(DESKTOP_USER_ID, parsed_date),
80 state.events.list_recurring(DESKTOP_USER_ID),
81 );
82 let mut events = date_events?;
83 let recurring = recurring?;
84 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
85 for r in recurring {
86 if !existing_ids.contains(&r.id) {
87 let expanded = expand_recurrence(&r, day_start, day_end);
88 events.extend(expanded);
89 // Check if the original also falls on this day
90 let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1));
91 if effective_end >= day_start && r.start_time <= day_end {
92 if !existing_ids.contains(&r.id) {
93 events.push(r);
94 }
95 }
96 }
97 }
98 events.sort_by_key(|e| e.start_time);
99
100 let unscheduled_tasks = state.tasks
101 .list_unscheduled_due_on_date(DESKTOP_USER_ID, parsed_date)
102 .await?;
103
104 let mut timeline_items: Vec<TimelineItem> = events.iter().map(|event| {
105 let duration = event.end_time.map(|end| {
106 (end - event.start_time).num_minutes() as i32
107 });
108 let (item_type, block_type) = if event.block_type.is_some() {
109 ("block".to_string(), event.block_type.as_ref().map(|b| b.db_value().to_string()))
110 } else if event.linked_task_id.is_some() {
111 ("task".to_string(), None)
112 } else {
113 ("event".to_string(), None)
114 };
115 TimelineItem {
116 id: event.id.into(),
117 item_type,
118 title: event.title.clone(),
119 start_time: event.start_time,
120 end_time: event.end_time,
121 duration,
122 project_id: event.project_id.map(Into::into),
123 project_name: event.project_name.clone(),
124 priority: None,
125 status: None,
126 block_type,
127 }
128 }).collect();
129
130 timeline_items.sort_by_key(|item| item.start_time);
131
132 let conflicts = detect_conflicts(&timeline_items);
133
134 // Calculate time tracked for the requested date
135 let day_start = parsed_date.and_hms_opt(0, 0, 0).expect("midnight is valid");
136 let day_end = parsed_date.succ_opt().unwrap_or(parsed_date).and_hms_opt(0, 0, 0).expect("midnight is valid");
137 let day_start_utc = chrono::DateTime::<Utc>::from_naive_utc_and_offset(day_start, Utc);
138 let day_end_utc = chrono::DateTime::<Utc>::from_naive_utc_and_offset(day_end, Utc);
139 let summaries = state.tasks.get_time_summary(DESKTOP_USER_ID, day_start_utc, day_end_utc).await?;
140 let time_tracked_today: i32 = summaries.iter().map(|s| s.total_minutes).sum();
141
142 Ok(DayPlanningResponse {
143 date,
144 timeline_items,
145 unscheduled_tasks: unscheduled_tasks.into_iter().map(TaskResponse::from).collect(),
146 conflicts,
147 is_vacation_day,
148 time_tracked_today,
149 })
150 }
151
152 /// Schedules a task to a specific time slot.
153 ///
154 /// Creates or updates a linked calendar event for the task. The event
155 /// inherits the task's project and uses the task description as its title.
156 ///
157 /// # Arguments
158 ///
159 /// * `id` - Task UUID
160 /// * `input` - Scheduling parameters:
161 /// - `start_time`: When the task is scheduled
162 /// - `duration`: Optional duration in minutes (default: 30)
163 ///
164 /// # Errors
165 ///
166 /// Returns `NOT_FOUND` if the task doesn't exist.
167 /// Returns `DATABASE_ERROR` if the update fails.
168 #[tauri::command]
169 #[instrument(skip_all)]
170 pub async fn schedule_task(
171 state: State<'_, Arc<AppState>>,
172 id: TaskId,
173 input: ScheduleTaskInput,
174 ) -> Result<TaskResponse, ApiError> {
175 let duration = input.duration.unwrap_or(30).max(1);
176 let end_time = input.start_time + chrono::Duration::minutes(duration as i64);
177
178 let task = state.tasks
179 .get_by_id(id, DESKTOP_USER_ID)
180 .await?
181 .or_not_found("task", id)?;
182
183 let updated_task = state.tasks
184 .update_schedule(id, DESKTOP_USER_ID, Some(input.start_time), Some(duration))
185 .await?
186 .or_not_found("task", id)?;
187
188 let existing_event = state.events
189 .get_by_linked_task(DESKTOP_USER_ID, id)
190 .await?;
191
192 if let Some(existing) = existing_event {
193 let update_event = UpdateEvent {
194 project_id: task.project_id,
195 title: task.description.clone(),
196 description: String::new(),
197 start_time: input.start_time,
198 end_time: Some(end_time),
199 location: None,
200 linked_task_id: Some(id),
201 recurrence: Recurrence::None,
202 recurrence_rule: None,
203 contact_id: task.contact_id,
204 block_type: None,
205 reminder_offsets_seconds: Vec::new(),
206 };
207 state.events
208 .update(existing.id, DESKTOP_USER_ID, update_event)
209 .await?;
210 } else {
211 let new_event = NewEvent {
212 user_id: Some(DESKTOP_USER_ID),
213 project_id: task.project_id,
214 title: task.description.clone(),
215 description: String::new(),
216 start_time: input.start_time,
217 end_time: Some(end_time),
218 location: None,
219 linked_task_id: Some(id),
220 recurrence: Recurrence::None,
221 recurrence_rule: None,
222 contact_id: task.contact_id,
223 block_type: None,
224 reminder_offsets_seconds: Vec::new(),
225 };
226 state.events
227 .create(DESKTOP_USER_ID, new_event)
228 .await?;
229 }
230
231 Ok(TaskResponse::from(updated_task))
232 }
233
234 /// Removes a task from the schedule.
235 ///
236 /// Deletes the linked calendar event and clears the task's scheduled time.
237 ///
238 /// # Errors
239 ///
240 /// Returns `NOT_FOUND` if the task doesn't exist.
241 /// Returns `DATABASE_ERROR` if the update fails.
242 #[tauri::command]
243 #[instrument(skip_all)]
244 pub async fn unschedule_task(
245 state: State<'_, Arc<AppState>>,
246 id: TaskId,
247 ) -> Result<TaskResponse, ApiError> {
248 state.events
249 .delete_by_linked_task(DESKTOP_USER_ID, id)
250 .await?;
251
252 state.tasks
253 .update_schedule(id, DESKTOP_USER_ID, None, None)
254 .await?
255 .map(TaskResponse::from)
256 .or_not_found("task", id)
257 }
258
259 // Tests for detect_conflicts live in crates/core/src/day_planning.rs
260