Skip to main content

things3_core/database/queries/
tasks.rs

1#[cfg(feature = "advanced-queries")]
2use crate::database::conversions::naive_date_to_things_timestamp;
3#[cfg(feature = "advanced-queries")]
4use crate::models::TaskFilters;
5use crate::{
6    database::{mappers::map_task_row, ThingsDatabase},
7    error::{Result as ThingsResult, ThingsError},
8    models::{Task, TaskStatus, TaskType, ThingsId},
9};
10use chrono::{DateTime, NaiveDate, Utc};
11use sqlx::Row;
12use tracing::{debug, instrument};
13#[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
14use uuid::Uuid;
15
16impl ThingsDatabase {
17    /// Get all tasks from the database
18    ///
19    /// # Examples
20    ///
21    /// ```no_run
22    /// use things3_core::{ThingsDatabase, ThingsError};
23    /// use std::path::Path;
24    ///
25    /// # async fn example() -> Result<(), ThingsError> {
26    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
27    ///
28    /// // Get all tasks
29    /// let tasks = db.get_all_tasks().await?;
30    /// println!("Found {} total tasks", tasks.len());
31    ///
32    /// // Filter tasks by status
33    /// let incomplete: Vec<_> = tasks.iter()
34    ///     .filter(|t| t.status == things3_core::TaskStatus::Incomplete)
35    ///     .collect();
36    /// println!("Found {} incomplete tasks", incomplete.len());
37    /// # Ok(())
38    /// # }
39    /// ```
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if the database query fails or if task data is invalid
44    #[instrument]
45    pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
46        let rows = sqlx::query(
47            r"
48            SELECT 
49                uuid, title, status, type, 
50                start_date, due_date, 
51                project_uuid, area_uuid, 
52                notes, tags, 
53                created, modified
54            FROM TMTask
55            ORDER BY created DESC
56            ",
57        )
58        .fetch_all(&self.pool)
59        .await
60        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
61
62        let mut tasks = Vec::new();
63        for row in rows {
64            let task = Task {
65                uuid: ThingsId::from_trusted(row.get::<String, _>("uuid")),
66                title: row.get("title"),
67                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
68                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
69                start_date: row
70                    .get::<Option<String>, _>("start_date")
71                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
72                deadline: row
73                    .get::<Option<String>, _>("due_date")
74                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
75                project_uuid: row
76                    .get::<Option<String>, _>("project_uuid")
77                    .map(ThingsId::from_trusted),
78                area_uuid: row
79                    .get::<Option<String>, _>("area_uuid")
80                    .map(ThingsId::from_trusted),
81                parent_uuid: None, // Not available in this query
82                notes: row.get("notes"),
83                tags: row
84                    .get::<Option<String>, _>("tags")
85                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
86                    .unwrap_or_default(),
87                children: Vec::new(), // Not available in this query
88                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
89                    .ok()
90                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
91                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
92                    .ok()
93                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
94                stop_date: None, // Not available in this query context
95            };
96            tasks.push(task);
97        }
98
99        debug!("Fetched {} tasks", tasks.len());
100        Ok(tasks)
101    }
102
103    /// Get tasks by status
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the database query fails or if task data is invalid
108    #[instrument]
109    pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
110        let status_value = status as i32;
111        let rows = sqlx::query(
112            r"
113            SELECT 
114                uuid, title, status, type, 
115                start_date, due_date, 
116                project_uuid, area_uuid, 
117                notes, tags, 
118                created, modified
119             FROM TMTask 
120            WHERE status = ?
121            ORDER BY created DESC
122            ",
123        )
124        .bind(status_value)
125        .fetch_all(&self.pool)
126        .await
127        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
128
129        let mut tasks = Vec::new();
130        for row in rows {
131            let task = Task {
132                uuid: ThingsId::from_trusted(row.get::<String, _>("uuid")),
133                title: row.get("title"),
134                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
135                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
136                start_date: row
137                    .get::<Option<String>, _>("start_date")
138                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
139                deadline: row
140                    .get::<Option<String>, _>("due_date")
141                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
142                project_uuid: row
143                    .get::<Option<String>, _>("project_uuid")
144                    .map(ThingsId::from_trusted),
145                area_uuid: row
146                    .get::<Option<String>, _>("area_uuid")
147                    .map(ThingsId::from_trusted),
148                parent_uuid: None, // Not available in this query
149                notes: row.get("notes"),
150                tags: row
151                    .get::<Option<String>, _>("tags")
152                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
153                    .unwrap_or_default(),
154                children: Vec::new(), // Not available in this query
155                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
156                    .ok()
157                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
158                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
159                    .ok()
160                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
161                stop_date: None, // Not available in this query context
162            };
163            tasks.push(task);
164        }
165
166        debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
167        Ok(tasks)
168    }
169
170    /// Search tasks by title or notes
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the database query fails or if task data is invalid
175    #[instrument]
176    pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
177        let search_pattern = format!("%{query}%");
178        let rows = sqlx::query(
179            r"
180            SELECT
181                uuid, title, status, type,
182                startDate, deadline, stopDate,
183                project, area, heading,
184                notes,
185                (SELECT GROUP_CONCAT(tg.title, char(31))
186                   FROM TMTaskTag tt
187                   JOIN TMTag tg ON tg.uuid = tt.tags
188                  WHERE tt.tasks = TMTask.uuid) AS tags_csv,
189                creationDate, userModificationDate
190            FROM TMTask
191            WHERE (title LIKE ? OR notes LIKE ?) AND type IN (0, 2) AND trashed = 0
192            ORDER BY creationDate DESC
193            ",
194        )
195        .bind(&search_pattern)
196        .bind(&search_pattern)
197        .fetch_all(&self.pool)
198        .await
199        .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
200
201        let tasks = rows
202            .iter()
203            .map(map_task_row)
204            .collect::<ThingsResult<Vec<Task>>>()?;
205
206        debug!("Found {} tasks matching query: {}", tasks.len(), query);
207        Ok(tasks)
208    }
209
210    /// Query tasks using a [`TaskFilters`] struct produced by [`crate::query::TaskQueryBuilder`].
211    ///
212    /// All filter fields are optional and combined with AND semantics in SQL.
213    /// Tag and search-query filters are applied in Rust after the SQL query returns
214    /// (Things 3 stores tags as a BLOB). When those post-filters are active,
215    /// `LIMIT`/`OFFSET` is also applied in Rust so pagination counts only
216    /// matching rows; without post-filters it is applied in SQL for efficiency.
217    ///
218    /// Tag matching via `filters.tags` is case-sensitive.
219    ///
220    /// Filtering by [`TaskStatus::Trashed`] queries rows where `trashed = 1`
221    /// rather than adding a `status` condition, matching Things 3's soft-delete
222    /// semantics (trashed rows keep their original status value).
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the database query fails or task data cannot be mapped.
227    #[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
228    pub async fn query_tasks(&self, filters: &TaskFilters) -> ThingsResult<Vec<Task>> {
229        self.query_tasks_inner(filters, None).await
230    }
231
232    /// Internal query path that optionally applies a cursor WHERE clause.
233    ///
234    /// `after` is `(seconds_since_unix_epoch, uuid)` of the last-returned
235    /// task. When `Some`, an additional `WHERE` clause restricts results to
236    /// rows strictly older than that anchor in the canonical
237    /// `CAST(creationDate AS INTEGER) DESC, uuid DESC` ordering.
238    ///
239    /// Gated on either `advanced-queries` or `batch-operations` because both
240    /// public surfaces (`query_tasks` and `execute_paged`) share this engine.
241    #[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
242    pub(crate) async fn query_tasks_inner(
243        &self,
244        filters: &TaskFilters,
245        after: Option<(i64, Uuid)>,
246    ) -> ThingsResult<Vec<Task>> {
247        const COLS: &str = "uuid, title, type, status, notes, startDate, deadline, stopDate, \
248                            creationDate, userModificationDate, project, area, heading, \
249                            (SELECT GROUP_CONCAT(tg.title, char(31)) \
250                               FROM TMTaskTag tt \
251                               JOIN TMTag tg ON tg.uuid = tt.tags \
252                              WHERE tt.tasks = TMTask.uuid) AS tags_csv";
253
254        // Things 3 soft-deletes by setting trashed = 1; the status column is unchanged.
255        // Requesting Trashed means "show trashed rows", not a status = 3 predicate.
256        let trashed_val = i32::from(matches!(filters.status, Some(TaskStatus::Trashed)));
257        let mut conditions: Vec<String> = vec![format!("trashed = {trashed_val}")];
258
259        if let Some(status) = filters.status {
260            let n = match status {
261                TaskStatus::Incomplete => Some(0),
262                TaskStatus::Canceled => Some(2),
263                TaskStatus::Completed => Some(3),
264                TaskStatus::Trashed => None, // handled via trashed = 1 above
265            };
266            if let Some(n) = n {
267                conditions.push(format!("status = {n}"));
268            }
269        }
270
271        if let Some(task_type) = filters.task_type {
272            let n = match task_type {
273                TaskType::Todo => 0,
274                TaskType::Project => 1,
275                TaskType::Heading => 2,
276                TaskType::Area => 3,
277            };
278            conditions.push(format!("type = {n}"));
279        }
280
281        if let Some(ref uuid) = filters.project_uuid {
282            conditions.push(format!("project = '{uuid}'"));
283        }
284
285        if let Some(ref uuid) = filters.area_uuid {
286            conditions.push(format!("area = '{uuid}'"));
287        }
288
289        if let Some(from) = filters.start_date_from {
290            conditions.push(format!(
291                "startDate >= {}",
292                naive_date_to_things_timestamp(from)
293            ));
294        }
295        if let Some(to) = filters.start_date_to {
296            conditions.push(format!(
297                "startDate <= {}",
298                naive_date_to_things_timestamp(to)
299            ));
300        }
301
302        if let Some(from) = filters.deadline_from {
303            conditions.push(format!(
304                "deadline >= {}",
305                naive_date_to_things_timestamp(from)
306            ));
307        }
308        if let Some(to) = filters.deadline_to {
309            conditions.push(format!(
310                "deadline <= {}",
311                naive_date_to_things_timestamp(to)
312            ));
313        }
314
315        if let Some((after_seconds, _)) = after {
316            // Strictly less than the cursor in (truncated_seconds DESC, uuid DESC)
317            // ordering — i.e. older second, or same second with smaller uuid.
318            // Casting to INTEGER matches the precision of `Task::created`,
319            // which is reconstructed at second precision when reading rows.
320            // UUID is bound as a parameter (?) rather than interpolated for
321            // consistency with the rest of the codebase's query practices.
322            conditions.push(format!(
323                "(CAST(creationDate AS INTEGER) < {after_seconds} \
324                 OR (CAST(creationDate AS INTEGER) = {after_seconds} AND uuid < ?))"
325            ));
326        }
327
328        let where_clause = conditions.join(" AND ");
329        // ORDER BY uses the truncated-second value so it agrees with the
330        // cursor pagination logic (which compares at second precision because
331        // `Task::created` is reconstructed at second precision). `uuid DESC` is
332        // a deterministic tiebreak within the same second.
333        let mut sql = format!(
334            "SELECT {COLS} FROM TMTask WHERE {where_clause} \
335             ORDER BY CAST(creationDate AS INTEGER) DESC, uuid DESC"
336        );
337
338        // When tags or search_query are active, LIMIT/OFFSET must be applied in Rust
339        // after post-filtering, because SQL LIMIT would count non-matching rows.
340        let has_post_filters =
341            filters.tags.as_ref().is_some_and(|t| !t.is_empty()) || filters.search_query.is_some();
342
343        if !has_post_filters {
344            match (filters.limit, filters.offset) {
345                (Some(limit), Some(offset)) => {
346                    sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
347                }
348                (Some(limit), None) => {
349                    sql.push_str(&format!(" LIMIT {limit}"));
350                }
351                (None, Some(offset)) => {
352                    // SQLite requires LIMIT when OFFSET is used; -1 means unlimited
353                    sql.push_str(&format!(" LIMIT -1 OFFSET {offset}"));
354                }
355                (None, None) => {}
356            }
357        }
358
359        let rows = if let Some((_, after_uuid)) = after {
360            sqlx::query(&sql)
361                .bind(after_uuid.to_string())
362                .fetch_all(&self.pool)
363                .await
364        } else {
365            sqlx::query(&sql).fetch_all(&self.pool).await
366        }
367        .map_err(|e| ThingsError::unknown(format!("Failed to query tasks: {e}")))?;
368
369        let mut tasks = rows
370            .iter()
371            .map(map_task_row)
372            .collect::<ThingsResult<Vec<Task>>>()?;
373
374        if let Some(ref filter_tags) = filters.tags {
375            if !filter_tags.is_empty() {
376                tasks.retain(|task| filter_tags.iter().all(|f| task.tags.contains(f)));
377            }
378        }
379
380        if let Some(ref q) = filters.search_query {
381            let q_lower = q.to_lowercase();
382            tasks.retain(|task| {
383                task.title.to_lowercase().contains(&q_lower)
384                    || task
385                        .notes
386                        .as_deref()
387                        .unwrap_or("")
388                        .to_lowercase()
389                        .contains(&q_lower)
390            });
391        }
392
393        if has_post_filters {
394            let offset = filters.offset.unwrap_or(0);
395            tasks = tasks.into_iter().skip(offset).collect();
396            if let Some(limit) = filters.limit {
397                tasks.truncate(limit);
398            }
399        }
400
401        Ok(tasks)
402    }
403
404    /// Search completed tasks in the logbook
405    ///
406    /// Returns completed tasks matching the provided filters.
407    /// All filters are optional and can be combined.
408    ///
409    /// # Parameters
410    ///
411    /// - `search_text`: Search in task titles and notes (case-insensitive)
412    /// - `from_date`: Start date for completion date range
413    /// - `to_date`: End date for completion date range
414    /// - `project_uuid`: Filter by project UUID
415    /// - `area_uuid`: Filter by area UUID
416    /// - `tags`: Filter by tags (all tags must match)
417    /// - `limit`: Maximum number of results (default: 50)
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if the database query fails or if task data is invalid
422    #[allow(clippy::too_many_arguments)]
423    #[instrument(skip(self))]
424    pub async fn search_logbook(
425        &self,
426        search_text: Option<String>,
427        from_date: Option<NaiveDate>,
428        to_date: Option<NaiveDate>,
429        project_uuid: Option<ThingsId>,
430        area_uuid: Option<ThingsId>,
431        tags: Option<Vec<String>>,
432        limit: Option<u32>,
433        offset: Option<u32>,
434    ) -> ThingsResult<Vec<Task>> {
435        // Apply limit and offset
436        let result_limit = limit.unwrap_or(50).min(500);
437        let result_offset = offset.unwrap_or(0);
438
439        // Build and execute query based on filters
440        // type = 0 (Todo) is intentional here: headings (type=2) have no stopDate and
441        // cannot appear in a stop-date-ordered logbook.
442        let rows = if let Some(ref text) = search_text {
443            let pattern = format!("%{text}%");
444            let mut q = String::from(
445                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv, creationDate, userModificationDate FROM TMTask WHERE status = 3 AND trashed = 0 AND type = 0",
446            );
447            q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
448
449            if let Some(date) = from_date {
450                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
451                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
452                let timestamp = date_time.timestamp() as f64;
453                q.push_str(&format!(" AND stopDate >= {}", timestamp));
454            }
455
456            if let Some(date) = to_date {
457                // Include tasks completed on to_date by adding 1 day
458                let end_date = date + chrono::Duration::days(1);
459                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
460                let timestamp = date_time.timestamp() as f64;
461                q.push_str(&format!(" AND stopDate < {}", timestamp));
462            }
463
464            if let Some(ref id) = project_uuid {
465                q.push_str(&format!(" AND project = '{}'", id));
466            }
467
468            if let Some(ref id) = area_uuid {
469                q.push_str(&format!(" AND area = '{}'", id));
470            }
471
472            q.push_str(&format!(
473                " ORDER BY stopDate DESC LIMIT {result_limit} OFFSET {result_offset}"
474            ));
475
476            sqlx::query(&q)
477                .bind(&pattern)
478                .bind(&pattern)
479                .fetch_all(&self.pool)
480                .await
481                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
482        } else {
483            let mut q = String::from(
484                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv, creationDate, userModificationDate FROM TMTask WHERE status = 3 AND trashed = 0 AND type = 0",
485            );
486
487            if let Some(date) = from_date {
488                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
489                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
490                let timestamp = date_time.timestamp() as f64;
491                q.push_str(&format!(" AND stopDate >= {}", timestamp));
492            }
493
494            if let Some(date) = to_date {
495                // Include tasks completed on to_date by adding 1 day
496                let end_date = date + chrono::Duration::days(1);
497                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
498                let timestamp = date_time.timestamp() as f64;
499                q.push_str(&format!(" AND stopDate < {}", timestamp));
500            }
501
502            if let Some(ref id) = project_uuid {
503                q.push_str(&format!(" AND project = '{}'", id));
504            }
505
506            if let Some(ref id) = area_uuid {
507                q.push_str(&format!(" AND area = '{}'", id));
508            }
509
510            q.push_str(&format!(
511                " ORDER BY stopDate DESC LIMIT {result_limit} OFFSET {result_offset}"
512            ));
513
514            sqlx::query(&q)
515                .fetch_all(&self.pool)
516                .await
517                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
518        };
519
520        // Filter by tags if provided
521        let mut tasks = rows
522            .iter()
523            .map(map_task_row)
524            .collect::<ThingsResult<Vec<Task>>>()?;
525
526        if let Some(ref filter_tags) = tags {
527            if !filter_tags.is_empty() {
528                tasks.retain(|task| {
529                    // Check if task has all required tags
530                    filter_tags
531                        .iter()
532                        .all(|filter_tag| task.tags.contains(filter_tag))
533                });
534            }
535        }
536
537        debug!("Found {} completed tasks in logbook", tasks.len());
538        Ok(tasks)
539    }
540
541    /// Get inbox tasks (incomplete tasks without project)
542    ///
543    /// # Errors
544    ///
545    /// Returns an error if the database query fails or if task data is invalid
546    #[instrument(skip(self))]
547    pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
548        let query = if let Some(limit) = limit {
549            format!("SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE type IN (0, 2) AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC LIMIT {limit}")
550        } else {
551            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE type IN (0, 2) AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC"
552                .to_string()
553        };
554
555        let rows = sqlx::query(&query)
556            .fetch_all(&self.pool)
557            .await
558            .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
559
560        let tasks = rows
561            .iter()
562            .map(map_task_row)
563            .collect::<ThingsResult<Vec<Task>>>()?;
564
565        Ok(tasks)
566    }
567
568    /// Get today's tasks (incomplete tasks due today or started today)
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the database query fails or if task data is invalid
573    ///
574    /// # Panics
575    ///
576    /// Panics if the current date cannot be converted to a valid time with hours, minutes, and seconds
577    #[instrument(skip(self))]
578    pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
579        // Things 3 uses the `todayIndex` column to mark tasks that appear in "Today"
580        // A task is in "Today" if todayIndex IS NOT NULL AND todayIndex != 0
581        let query = if let Some(limit) = limit {
582            format!(
583                "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC LIMIT {limit}"
584            )
585        } else {
586            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC".to_string()
587        };
588
589        let rows = sqlx::query(&query)
590            .fetch_all(&self.pool)
591            .await
592            .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
593
594        let tasks = rows
595            .iter()
596            .map(map_task_row)
597            .collect::<ThingsResult<Vec<Task>>>()?;
598
599        Ok(tasks)
600    }
601
602    /// Get a task by its UUID
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if the task does not exist or if the database query fails
607    #[instrument(skip(self))]
608    pub async fn get_task_by_uuid(&self, id: &ThingsId) -> ThingsResult<Option<Task>> {
609        let row = sqlx::query(
610            r"
611            SELECT
612                uuid, title, status, type,
613                startDate, deadline, stopDate,
614                project, area, heading,
615                notes, (SELECT GROUP_CONCAT(tg.title, char(31))
616                          FROM TMTaskTag tt
617                          JOIN TMTag tg ON tg.uuid = tt.tags
618                         WHERE tt.tasks = TMTask.uuid) AS tags_csv,
619                creationDate, userModificationDate,
620                trashed
621            FROM TMTask
622            WHERE uuid = ?
623            ",
624        )
625        .bind(id.as_str())
626        .fetch_optional(&self.pool)
627        .await
628        .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
629
630        if let Some(row) = row {
631            // Check if trashed
632            let trashed: i64 = row.get("trashed");
633            if trashed == 1 {
634                return Ok(None); // Return None for trashed tasks
635            }
636
637            // Use the centralized mapper
638            let task = map_task_row(&row)?;
639            Ok(Some(task))
640        } else {
641            Ok(None)
642        }
643    }
644}