Skip to main content

max / goingson

13.1 KB · 368 lines History Blame Raw
1 //! Task state-change and query methods delegated from SqliteTaskRepository.
2 //!
3 //! Covers snoozing, waiting-for-response, scheduling, focus mode, and
4 //! date-range reporting queries. Each function takes `&SqlitePool` directly,
5 //! following the same pattern as `annotation_repo` and `subtask_repo`.
6
7 use chrono::{DateTime, NaiveDate, Utc};
8 use sqlx::SqlitePool;
9
10 use goingson_core::{CoreError, Result, Task, TaskId, TaskStatus, UserId};
11
12 use crate::utils::{format_datetime, format_datetime_now, format_datetime_opt};
13
14 use super::task_repo::{get_task_by_id, query_tasks, TASK_SELECT_COLUMNS};
15
16 // ---- Snooze ----
17
18 /// Snooze a task until the given time. Completed/deleted tasks cannot be snoozed.
19 pub(crate) async fn snooze(
20 pool: &SqlitePool,
21 id: TaskId,
22 user_id: UserId,
23 until: DateTime<Utc>,
24 ) -> Result<Option<Task>> {
25 let until_str = format_datetime(&until);
26
27 // Atomically update only if task is not completed/deleted
28 let result = sqlx::query(
29 "UPDATE tasks SET snoozed_until = ? WHERE id = ? AND user_id = ? AND status NOT IN ('Completed', 'Deleted')"
30 )
31 .bind(&until_str)
32 .bind(id.to_string())
33 .bind(user_id.to_string())
34 .execute(pool)
35 .await
36 .map_err(CoreError::database)?;
37
38 if result.rows_affected() > 0 {
39 get_task_by_id(pool, id, user_id).await
40 } else {
41 // Distinguish "not found" from "wrong status"
42 if let Some(task) = get_task_by_id(pool, id, user_id).await? {
43 if task.status == TaskStatus::Completed {
44 return Err(CoreError::validation("status", "cannot snooze a completed task"));
45 }
46 if task.status == TaskStatus::Deleted {
47 return Err(CoreError::validation("status", "cannot snooze a deleted task"));
48 }
49 }
50 Ok(None)
51 }
52 }
53
54 /// Remove the snooze from a task.
55 pub(crate) async fn unsnooze(
56 pool: &SqlitePool,
57 id: TaskId,
58 user_id: UserId,
59 ) -> Result<Option<Task>> {
60 let result = sqlx::query(
61 "UPDATE tasks SET snoozed_until = NULL WHERE id = ? AND user_id = ?"
62 )
63 .bind(id.to_string())
64 .bind(user_id.to_string())
65 .execute(pool)
66 .await
67 .map_err(CoreError::database)?;
68
69 if result.rows_affected() > 0 {
70 get_task_by_id(pool, id, user_id).await
71 } else {
72 Ok(None)
73 }
74 }
75
76 /// List all currently snoozed tasks.
77 pub(crate) async fn list_snoozed(
78 pool: &SqlitePool,
79 user_id: UserId,
80 ) -> Result<Vec<Task>> {
81 let sql = format!(
82 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now') ORDER BY t.snoozed_until ASC",
83 TASK_SELECT_COLUMNS
84 );
85 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string()]).await
86 }
87
88 // ---- Waiting ----
89
90 /// Mark a task as waiting for response.
91 pub(crate) async fn mark_waiting(
92 pool: &SqlitePool,
93 id: TaskId,
94 user_id: UserId,
95 expected_response: Option<DateTime<Utc>>,
96 ) -> Result<Option<Task>> {
97 let now = format_datetime_now();
98 let expected = format_datetime_opt(expected_response);
99
100 let result = sqlx::query(
101 "UPDATE tasks SET waiting_for_response = 1, waiting_since = ?, expected_response_date = ? WHERE id = ? AND user_id = ?"
102 )
103 .bind(&now)
104 .bind(&expected)
105 .bind(id.to_string())
106 .bind(user_id.to_string())
107 .execute(pool)
108 .await
109 .map_err(CoreError::database)?;
110
111 if result.rows_affected() > 0 {
112 get_task_by_id(pool, id, user_id).await
113 } else {
114 Ok(None)
115 }
116 }
117
118 /// Clear the waiting-for-response state on a task.
119 pub(crate) async fn clear_waiting(
120 pool: &SqlitePool,
121 id: TaskId,
122 user_id: UserId,
123 ) -> Result<Option<Task>> {
124 let result = sqlx::query(
125 "UPDATE tasks SET waiting_for_response = 0, waiting_since = NULL, expected_response_date = NULL WHERE id = ? AND user_id = ?"
126 )
127 .bind(id.to_string())
128 .bind(user_id.to_string())
129 .execute(pool)
130 .await
131 .map_err(CoreError::database)?;
132
133 if result.rows_affected() > 0 {
134 get_task_by_id(pool, id, user_id).await
135 } else {
136 Ok(None)
137 }
138 }
139
140 /// List all tasks currently waiting for a response.
141 pub(crate) async fn list_waiting(
142 pool: &SqlitePool,
143 user_id: UserId,
144 ) -> Result<Vec<Task>> {
145 let sql = format!(
146 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.waiting_for_response = 1 ORDER BY t.expected_response_date ASC NULLS LAST, t.waiting_since ASC",
147 TASK_SELECT_COLUMNS
148 );
149 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string()]).await
150 }
151
152 // ---- Scheduling ----
153
154 /// List tasks scheduled for a specific date.
155 pub(crate) async fn list_scheduled_for_date(
156 pool: &SqlitePool,
157 user_id: UserId,
158 date: NaiveDate,
159 ) -> Result<Vec<Task>> {
160 let date_start = format!("{} 00:00:00", date);
161 let date_end = format!("{} 23:59:59", date);
162
163 let sql = format!(
164 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NOT NULL AND datetime(t.scheduled_start) >= datetime(?) AND datetime(t.scheduled_start) <= datetime(?) ORDER BY t.scheduled_start ASC",
165 TASK_SELECT_COLUMNS
166 );
167 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), date_start, date_end]).await
168 }
169
170 /// List unscheduled tasks due on a specific date.
171 pub(crate) async fn list_unscheduled_due_on_date(
172 pool: &SqlitePool,
173 user_id: UserId,
174 date: NaiveDate,
175 ) -> Result<Vec<Task>> {
176 let date_start = format!("{} 00:00:00", date);
177 let date_end = format!("{} 23:59:59", date);
178
179 let sql = format!(
180 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NULL AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.urgency DESC, t.due ASC",
181 TASK_SELECT_COLUMNS
182 );
183 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), date_start, date_end]).await
184 }
185
186 /// Update the scheduled start time and duration for a task.
187 pub(crate) async fn update_schedule(
188 pool: &SqlitePool,
189 id: TaskId,
190 user_id: UserId,
191 start: Option<DateTime<Utc>>,
192 duration: Option<i32>,
193 ) -> Result<Option<Task>> {
194 let start_str = format_datetime_opt(start);
195
196 let result = sqlx::query(
197 "UPDATE tasks SET scheduled_start = ?, scheduled_duration = ? WHERE id = ? AND user_id = ?"
198 )
199 .bind(&start_str)
200 .bind(duration)
201 .bind(id.to_string())
202 .bind(user_id.to_string())
203 .execute(pool)
204 .await
205 .map_err(CoreError::database)?;
206
207 if result.rows_affected() > 0 {
208 get_task_by_id(pool, id, user_id).await
209 } else {
210 Ok(None)
211 }
212 }
213
214 // ---- Focus ----
215
216 /// Set or clear focus on a task.
217 pub(crate) async fn set_focus(
218 pool: &SqlitePool,
219 id: TaskId,
220 user_id: UserId,
221 is_focus: bool,
222 ) -> Result<Option<Task>> {
223 let focus_set_at = if is_focus {
224 Some(format_datetime(&Utc::now()))
225 } else {
226 None
227 };
228
229 let result = sqlx::query(
230 "UPDATE tasks SET is_focus = ?, focus_set_at = ? WHERE id = ? AND user_id = ?"
231 )
232 .bind(is_focus as i32)
233 .bind(&focus_set_at)
234 .bind(id.to_string())
235 .bind(user_id.to_string())
236 .execute(pool)
237 .await
238 .map_err(CoreError::database)?;
239
240 if result.rows_affected() > 0 {
241 get_task_by_id(pool, id, user_id).await
242 } else {
243 Ok(None)
244 }
245 }
246
247 /// List all focused tasks.
248 pub(crate) async fn list_focused(
249 pool: &SqlitePool,
250 user_id: UserId,
251 ) -> Result<Vec<Task>> {
252 let sql = format!(
253 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.is_focus = 1 ORDER BY t.focus_set_at DESC",
254 TASK_SELECT_COLUMNS
255 );
256 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string()]).await
257 }
258
259 /// Clear focus from all tasks for a user.
260 pub(crate) async fn clear_all_focus(
261 pool: &SqlitePool,
262 user_id: UserId,
263 ) -> Result<u64> {
264 let result = sqlx::query(
265 "UPDATE tasks SET is_focus = 0, focus_set_at = NULL WHERE user_id = ? AND is_focus = 1"
266 )
267 .bind(user_id.to_string())
268 .execute(pool)
269 .await
270 .map_err(CoreError::database)?;
271
272 Ok(result.rows_affected())
273 }
274
275 // ---- Reporting ----
276
277 /// List tasks completed within a date range.
278 pub(crate) async fn list_completed_between(
279 pool: &SqlitePool,
280 user_id: UserId,
281 start: DateTime<Utc>,
282 end: DateTime<Utc>,
283 ) -> Result<Vec<Task>> {
284 let start_str = format_datetime(&start);
285 let end_str = format_datetime(&end);
286
287 let sql = format!(
288 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status = 'Completed' AND t.completed_at IS NOT NULL AND datetime(t.completed_at) >= datetime(?) AND datetime(t.completed_at) <= datetime(?) ORDER BY t.completed_at DESC",
289 TASK_SELECT_COLUMNS
290 );
291 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await
292 }
293
294 /// List tasks created within a date range (for monthly review stats).
295 pub(crate) async fn list_created_between(
296 pool: &SqlitePool,
297 user_id: UserId,
298 start: DateTime<Utc>,
299 end: DateTime<Utc>,
300 ) -> Result<Vec<Task>> {
301 let start_str = format_datetime(&start);
302 let end_str = format_datetime(&end);
303
304 let sql = format!(
305 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND datetime(t.created_at) >= datetime(?) AND datetime(t.created_at) <= datetime(?) ORDER BY t.created_at DESC",
306 TASK_SELECT_COLUMNS
307 );
308 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await
309 }
310
311 /// List tasks that became overdue within a date range.
312 pub(crate) async fn list_became_overdue_between(
313 pool: &SqlitePool,
314 user_id: UserId,
315 start: DateTime<Utc>,
316 end: DateTime<Utc>,
317 ) -> Result<Vec<Task>> {
318 let start_str = format_datetime(&start);
319 let end_str = format_datetime(&end);
320
321 // Tasks whose due date is in the given range and are still pending/started (overdue)
322 let sql = format!(
323 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.due ASC",
324 TASK_SELECT_COLUMNS
325 );
326 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await
327 }
328
329 /// List tasks due within a date range.
330 pub(crate) async fn list_due_between(
331 pool: &SqlitePool,
332 user_id: UserId,
333 start: DateTime<Utc>,
334 end: DateTime<Utc>,
335 ) -> Result<Vec<Task>> {
336 let start_str = format_datetime(&start);
337 let end_str = format_datetime(&end);
338
339 let sql = format!(
340 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.due ASC, t.urgency DESC",
341 TASK_SELECT_COLUMNS
342 );
343 query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await
344 }
345
346 /// List tasks available for focus (high priority, not snoozed, not waiting, not focused).
347 pub(crate) async fn list_available_for_focus(
348 pool: &SqlitePool,
349 user_id: UserId,
350 limit: i64,
351 ) -> Result<Vec<Task>> {
352 let sql = format!(
353 "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.is_focus = 0 AND (t.snoozed_until IS NULL OR datetime(t.snoozed_until) <= datetime('now')) AND t.waiting_for_response = 0 ORDER BY t.urgency DESC, t.priority DESC, t.due ASC NULLS LAST LIMIT ?",
354 TASK_SELECT_COLUMNS
355 );
356
357 // This query has an extra i64 bind, so we handle it directly
358 let rows = sqlx::query_as::<_, super::task_repo::TaskRowWithProject>(&sql)
359 .bind(user_id.to_string())
360 .bind(user_id.to_string())
361 .bind(limit)
362 .fetch_all(pool)
363 .await
364 .map_err(CoreError::database)?;
365
366 super::task_repo::rows_to_tasks(pool, rows).await
367 }
368