Skip to main content

aven_core/api/
task_list.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, HashSet};
3
4use super::*;
5
6/// The key a task list is ordered by. Ties always break on the display ref.
7#[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/// One task list read. Values within a facet combine with OR and facets combine
23/// with AND; an empty facet does not filter. `text` uses task-search matching.
24#[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    /// Tasks in scope that have this value and match the other facets.
55    pub count: u32,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct TaskList {
60    pub rows: Vec<TaskListRow>,
61    /// Matching tasks before `limit` is applied.
62    pub total: u32,
63    pub statuses: Vec<TaskListFacet<TaskStatus>>,
64    pub priorities: Vec<TaskListFacet<TaskPriority>>,
65    /// Labels on tasks in scope, plus any selected label, by name.
66    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    /// Lists the workspace's tasks, or one project's, with facet counts for the
90    /// status, priority and label filters.
91    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        // Tasks without a due date stay last in both directions.
250        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}