Skip to main content

max / goingson

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