Skip to main content

todoapp_app/
query.rs

1//! Query views (FR-23..FR-25): a filter + sort evaluated over the store, each
2//! hit carrying its breadcrumb path (FR-14). Pure given the store snapshot.
3
4use std::cmp::Ordering;
5
6use todoapp_core::{
7    ComponentStore, Dir, DueFilter, Filter, Id, Query, SortField, SortKey, Status, TaskEntityStore,
8    Title,
9};
10
11use crate::service::{Services, TaskSnapshot};
12
13/// A task in a query result, with its ancestor titles (root → parent).
14#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
15pub struct QueryHit {
16    pub task: TaskSnapshot,
17    pub path: Vec<String>,
18}
19
20impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
21    pub async fn evaluate(&self, q: &Query) -> Vec<QueryHit> {
22        let today = self.clock.today();
23        // Filter at the port (Turso → SQL `WHERE`; mem → reference scan); the
24        // sort + breadcrumb assembly below is shared and runs the same for any
25        // store. `sort_by` is sync, so each hit carries its precomputed
26        // tree-priority key for the comparator.
27        let mut hits: Vec<(QueryHit, Vec<f64>)> = Vec::new();
28        for id in self.query.select(&q.filter, today).await {
29            let Ok(t) = self.snapshot(&id).await else {
30                continue;
31            };
32            let path = self.breadcrumb(&t.id).await;
33            let key = self.priority_key(&t.id).await;
34            hits.push((QueryHit { task: t, path }, key));
35        }
36
37        let keys = if q.sort.is_empty() {
38            vec![SortKey {
39                key: SortField::Priority,
40                dir: Dir::Asc,
41            }]
42        } else {
43            q.sort.clone()
44        };
45        hits.sort_by(|a, b| cmp_hits(a, b, &keys));
46        hits.into_iter().map(|(h, _)| h).collect()
47    }
48
49    // ---- built-in parameterized queries (FR-25) ---------------------------
50
51    /// `what-next`: `status:todo` by priority.
52    pub async fn what_next(&self) -> Vec<QueryHit> {
53        self.what_next_for(None, None, None).await
54    }
55
56    /// `what-next-for`: `status:todo` optionally scoped by assignee/subtree/tag.
57    pub async fn what_next_for(
58        &self,
59        assignee: Option<Id>,
60        within: Option<Id>,
61        tag: Option<String>,
62    ) -> Vec<QueryHit> {
63        self.evaluate(&Query {
64            filter: Filter {
65                status: vec![Status::Todo],
66                assignee,
67                within,
68                tags: tag.into_iter().collect(),
69                archived: Some(false),
70                ..Default::default()
71            },
72            sort: vec![SortKey {
73                key: SortField::Priority,
74                dir: Dir::Asc,
75            }],
76        })
77        .await
78    }
79
80    /// `due-today`: `due:today`, sorted by due then priority.
81    pub async fn due_today(&self) -> Vec<QueryHit> {
82        self.evaluate(&Query {
83            filter: Filter {
84                due: Some(DueFilter::Today),
85                archived: Some(false),
86                ..Default::default()
87            },
88            sort: vec![
89                SortKey {
90                    key: SortField::Due,
91                    dir: Dir::Asc,
92                },
93                SortKey {
94                    key: SortField::Priority,
95                    dir: Dir::Asc,
96                },
97            ],
98        })
99        .await
100    }
101
102    // ---- internals --------------------------------------------------------
103
104    /// Ancestor titles from root down to the immediate parent.
105    async fn breadcrumb(&self, id: &Id) -> Vec<String> {
106        let mut chain = Vec::new();
107        let mut cur = self.parent_of(id).await;
108        while let Some(pid) = cur {
109            if let Some(t) = self.store.get::<Title>(&pid).await {
110                chain.push(t.0);
111                cur = self.parent_of(&pid).await;
112            } else {
113                break;
114            }
115        }
116        chain.reverse();
117        chain
118    }
119
120    /// Tree-priority key: the path of `position`s root → task. Sorts a flat
121    /// result back into tree order.
122    async fn priority_key(&self, id: &Id) -> Vec<f64> {
123        let mut key = Vec::new();
124        let mut cur = id.clone();
125        while let Some(parent) = self.parent_of(&cur).await {
126            let pos = self
127                .children_of(&parent)
128                .await
129                .into_iter()
130                .find(|l| l.to == cur)
131                .map(|l| l.position.0)
132                .unwrap_or(0.0);
133            key.push(pos);
134            cur = parent;
135        }
136        key.reverse();
137        key
138    }
139}
140
141/// Compare two decorated hits `(hit, precomputed tree-priority key)`.
142fn cmp_hits(a: &(QueryHit, Vec<f64>), b: &(QueryHit, Vec<f64>), keys: &[SortKey]) -> Ordering {
143    for k in keys {
144        let ord = match k.key {
145            SortField::Priority => cmp_f64_seq(&a.1, &b.1),
146            SortField::Due => a.0.task.due_date.cmp(&b.0.task.due_date),
147            SortField::Created => a.0.task.created_at.cmp(&b.0.task.created_at),
148            SortField::Updated => a.0.task.updated_at.cmp(&b.0.task.updated_at),
149        };
150        let ord = match k.dir {
151            Dir::Asc => ord,
152            Dir::Desc => ord.reverse(),
153        };
154        if ord != Ordering::Equal {
155            return ord;
156        }
157    }
158    // stable, deterministic tie-break
159    a.0.task.id.cmp(&b.0.task.id)
160}
161
162/// Lexicographic compare of position paths (f64 has no `Ord`).
163fn cmp_f64_seq(a: &[f64], b: &[f64]) -> Ordering {
164    for (x, y) in a.iter().zip(b.iter()) {
165        let o = x.total_cmp(y);
166        if o != Ordering::Equal {
167            return o;
168        }
169    }
170    a.len().cmp(&b.len())
171}