1use std::cmp::Ordering;
2use std::collections::{BTreeMap, HashSet};
3
4use super::*;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TaskListSort {
9 Created,
10 Updated,
11 Priority,
12 DueOn,
13 Title,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TaskListDirection {
18 Asc,
19 Desc,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct TaskListQuery {
26 pub project_id: Option<ProjectId>,
27 pub sort: TaskListSort,
28 pub direction: TaskListDirection,
29 pub statuses: Vec<TaskStatus>,
30 pub priorities: Vec<TaskPriority>,
31 pub labels: Vec<String>,
32 pub text: String,
33 pub limit: Option<u32>,
34}
35
36impl TaskListQuery {
37 pub fn new(sort: TaskListSort, direction: TaskListDirection) -> Self {
38 Self {
39 project_id: None,
40 sort,
41 direction,
42 statuses: Vec::new(),
43 priorities: Vec::new(),
44 labels: Vec::new(),
45 text: String::new(),
46 limit: None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct TaskListFacet<T> {
53 pub value: T,
54 pub count: u32,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct TaskList {
60 pub rows: Vec<TaskListRow>,
61 pub total: u32,
63 pub statuses: Vec<TaskListFacet<TaskStatus>>,
64 pub priorities: Vec<TaskListFacet<TaskPriority>>,
65 pub labels: Vec<TaskListFacet<String>>,
67}
68
69const STATUS_ORDER: [TaskStatus; 6] = [
70 TaskStatus::Inbox,
71 TaskStatus::Backlog,
72 TaskStatus::Todo,
73 TaskStatus::Active,
74 TaskStatus::Done,
75 TaskStatus::Canceled,
76];
77
78const PRIORITY_ORDER: [TaskPriority; 5] = [
79 TaskPriority::Urgent,
80 TaskPriority::High,
81 TaskPriority::Medium,
82 TaskPriority::Low,
83 TaskPriority::None,
84];
85
86const MAX_TEXT_CHARS: usize = 256;
87
88impl Store {
89 pub async fn task_list(
92 &self,
93 workspace_id: &WorkspaceId,
94 query: TaskListQuery,
95 ) -> Result<TaskList, Error> {
96 self.workspace(workspace_id).await?;
97 let text = query.text.trim();
98 if text.chars().count() > MAX_TEXT_CHARS {
99 return Err(Error::new(
100 ErrorCode::Validation,
101 "search query is too long".to_string(),
102 ));
103 }
104 let project_key = match &query.project_id {
105 Some(project_id) => Some(
106 self.database
107 .find_project_by_id(workspace_id, project_id)
108 .await
109 .map_err(Error::from_internal)?
110 .ok_or_else(|| {
111 Error::new(ErrorCode::NotFound, "project not found".to_string())
112 })?
113 .key,
114 ),
115 None => None,
116 };
117 let mut items = self
118 .database
119 .list_task_summary_items(
120 workspace_id,
121 TaskFilters {
122 project: project_key.clone(),
123 ..TaskFilters::default()
124 },
125 TaskQueryMode::Flat,
126 TaskSort::Updated,
127 SortDirection::Desc,
128 None,
129 )
130 .await
131 .map_err(Error::from_internal)?;
132 if !text.is_empty() {
133 let matches = self
134 .database
135 .search_task_items(
136 workspace_id,
137 TaskSearchQuery {
138 text: text.to_string(),
139 project: project_key,
140 metadata: Vec::new(),
141 has_metadata: Vec::new(),
142 missing_metadata: Vec::new(),
143 include_deleted: false,
144 limit: usize::MAX,
145 },
146 )
147 .await
148 .map_err(Error::from_internal)?
149 .into_iter()
150 .map(|result| result.item.task.id)
151 .collect::<HashSet<_>>();
152 items.retain(|item| matches.contains(&item.task.id));
153 }
154
155 let status_matches = |item: &TaskListItem| {
156 query.statuses.is_empty() || query.statuses.contains(&item.task.status)
157 };
158 let priority_matches = |item: &TaskListItem| {
159 query.priorities.is_empty() || query.priorities.contains(&item.task.priority)
160 };
161 let label_matches = |item: &TaskListItem| {
162 query.labels.is_empty() || item.labels.iter().any(|label| query.labels.contains(label))
163 };
164
165 let statuses = STATUS_ORDER
166 .iter()
167 .map(|status| TaskListFacet {
168 value: *status,
169 count: count(items.iter().filter(|item| {
170 item.task.status == *status && priority_matches(item) && label_matches(item)
171 })),
172 })
173 .collect();
174 let priorities = PRIORITY_ORDER
175 .iter()
176 .map(|priority| TaskListFacet {
177 value: *priority,
178 count: count(items.iter().filter(|item| {
179 item.task.priority == *priority && status_matches(item) && label_matches(item)
180 })),
181 })
182 .collect();
183 let mut label_counts = BTreeMap::<String, u32>::new();
184 for label in &query.labels {
185 label_counts.entry(label.clone()).or_default();
186 }
187 for item in &items {
188 let counted = status_matches(item) && priority_matches(item);
189 for label in &item.labels {
190 let entry = label_counts.entry(label.clone()).or_default();
191 if counted {
192 *entry = entry.saturating_add(1);
193 }
194 }
195 }
196 let labels = label_counts
197 .into_iter()
198 .map(|(value, count)| TaskListFacet { value, count })
199 .collect();
200
201 items.retain(|item| status_matches(item) && priority_matches(item) && label_matches(item));
202 let mut rows = items.into_iter().map(TaskListRow::from).collect::<Vec<_>>();
203 rows.sort_by(|a, b| compare_rows(a, b, query.sort, query.direction));
204 let total = count(rows.iter());
205 if let Some(limit) = query.limit {
206 rows.truncate(limit as usize);
207 }
208 Ok(TaskList {
209 rows,
210 total,
211 statuses,
212 priorities,
213 labels,
214 })
215 }
216}
217
218fn count<T>(values: impl Iterator<Item = T>) -> u32 {
219 u32::try_from(values.count()).unwrap_or(u32::MAX)
220}
221
222fn priority_rank(priority: TaskPriority) -> u8 {
223 match priority {
224 TaskPriority::None => 0,
225 TaskPriority::Low => 1,
226 TaskPriority::Medium => 2,
227 TaskPriority::High => 3,
228 TaskPriority::Urgent => 4,
229 }
230}
231
232fn compare_rows(
233 a: &TaskListRow,
234 b: &TaskListRow,
235 sort: TaskListSort,
236 direction: TaskListDirection,
237) -> Ordering {
238 let directed = |ordering: Ordering| match direction {
239 TaskListDirection::Asc => ordering,
240 TaskListDirection::Desc => ordering.reverse(),
241 };
242 let primary = match sort {
243 TaskListSort::Created => directed(a.created_at.cmp(&b.created_at)),
244 TaskListSort::Updated => directed(a.updated_at.cmp(&b.updated_at)),
245 TaskListSort::Priority => {
246 directed(priority_rank(a.priority).cmp(&priority_rank(b.priority)))
247 }
248 TaskListSort::Title => directed(a.title.to_lowercase().cmp(&b.title.to_lowercase())),
249 TaskListSort::DueOn => match (&a.due_on, &b.due_on) {
251 (Some(a), Some(b)) => directed(a.cmp(b)),
252 (Some(_), None) => Ordering::Less,
253 (None, Some(_)) => Ordering::Greater,
254 (None, None) => Ordering::Equal,
255 },
256 };
257 primary.then_with(|| a.display_ref.cmp(&b.display_ref))
258}