Skip to main content

aven_core/
queue.rs

1use std::cmp::Ordering;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use chrono::NaiveDate;
5
6use crate::choices::{TaskPriority, TaskStatus};
7use crate::types::Task;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum QueueBand {
11    NeedsAction,
12    Available,
13    Focus,
14    Soon,
15    Triage,
16    Blocked,
17    #[default]
18    Later,
19    Epics,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub struct QueueMeta {
24    pub band: QueueBand,
25    pub score: i32,
26    pub idle_days: Option<i64>,
27    pub idle_seconds: Option<i64>,
28}
29
30impl QueueBand {
31    pub fn label(self) -> &'static str {
32        match self {
33            Self::NeedsAction => "needs action",
34            Self::Available => "available",
35            Self::Blocked => "blocked",
36            Self::Focus => "focus",
37            Self::Soon => "soon",
38            Self::Triage => "triage",
39            Self::Later => "later",
40            Self::Epics => "epics",
41        }
42    }
43
44    pub fn order(self) -> u8 {
45        match self {
46            Self::NeedsAction => 0,
47            Self::Available => 1,
48            Self::Focus => 2,
49            Self::Soon => 3,
50            Self::Triage => 4,
51            Self::Blocked => 5,
52            Self::Later => 6,
53            Self::Epics => 7,
54        }
55    }
56}
57
58pub fn queue_meta_on(
59    task: &Task,
60    has_conflict: bool,
61    has_unresolved_blockers: bool,
62    dependent_count: i64,
63    now_seconds: i64,
64    local_today: NaiveDate,
65) -> QueueMeta {
66    let available = available_since_defer(task, now_seconds);
67    let visible = work_visible(task, now_seconds);
68    let due_days = task
69        .due_on
70        .as_deref()
71        .and_then(|due| NaiveDate::parse_from_str(due, "%Y-%m-%d").ok())
72        .map(|due| due.signed_duration_since(local_today).num_days());
73    let activity_at = if available {
74        task.available_at.as_deref().unwrap_or("")
75    } else {
76        &task.queue_activity_at
77    };
78    let idle_seconds =
79        unix_seconds(activity_at).map(|activity| now_seconds.saturating_sub(activity).max(0));
80    let idle_days = idle_seconds.map(|seconds| seconds.saturating_div(86_400));
81    let idle = idle_days.unwrap_or(0);
82    let score = status_score(task.status)
83        + priority_score(task.priority)
84        + idle_score(task.status, idle)
85        + dependent_score(dependent_count)
86        + if visible && !task.is_epic {
87            due_score(due_days)
88        } else {
89            0
90        }
91        + if available { 100 } else { 0 }
92        + if has_conflict { 50 } else { 0 };
93    QueueMeta {
94        band: queue_band(
95            task,
96            has_conflict,
97            has_unresolved_blockers,
98            idle,
99            available,
100            visible && due_days.is_some_and(|days| days <= 0),
101        ),
102        score,
103        idle_days,
104        idle_seconds,
105    }
106}
107
108#[cfg(test)]
109fn queue_meta(
110    task: &Task,
111    has_conflict: bool,
112    has_unresolved_blockers: bool,
113    dependent_count: i64,
114    now_seconds: i64,
115) -> QueueMeta {
116    queue_meta_on(
117        task,
118        has_conflict,
119        has_unresolved_blockers,
120        dependent_count,
121        now_seconds,
122        NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
123    )
124}
125
126pub fn queue_order(a: (&Task, QueueMeta), b: (&Task, QueueMeta)) -> Ordering {
127    a.1.band
128        .order()
129        .cmp(&b.1.band.order())
130        .then_with(|| b.1.score.cmp(&a.1.score))
131        .then_with(|| priority_score(b.0.priority).cmp(&priority_score(a.0.priority)))
132        .then_with(|| a.0.created_at.cmp(&b.0.created_at))
133        .then_with(|| a.0.id.cmp(&b.0.id))
134}
135
136fn due_score(days: Option<i64>) -> i32 {
137    match days {
138        Some(days) if days <= 0 => 40,
139        Some(days) if days <= 7 => (5 * (8 - days)) as i32,
140        _ => 0,
141    }
142}
143
144pub fn now_seconds() -> i64 {
145    SystemTime::now()
146        .duration_since(UNIX_EPOCH)
147        .map(|duration| duration.as_secs() as i64)
148        .unwrap_or(0)
149}
150
151pub fn unix_seconds(value: &str) -> Option<i64> {
152    let value = value.trim();
153    if let Ok(seconds) = value.parse::<i64>() {
154        return Some(seconds);
155    }
156    let (date, time) = value.trim_end_matches('Z').split_once('T')?;
157    let mut date = date.split('-');
158    let year = date.next()?.parse::<i64>().ok()?;
159    let month = date.next()?.parse::<u32>().ok()?;
160    let day = date.next()?.parse::<u32>().ok()?;
161    let mut time = time.split(':');
162    let hour = time.next()?.parse::<i64>().ok()?;
163    let minute = time.next()?.parse::<i64>().ok()?;
164    let second = time.next()?.parse::<i64>().ok()?;
165    Some(unix_days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second)
166}
167
168pub fn work_visible(task: &Task, now_seconds: i64) -> bool {
169    !task.deleted
170        && task.status.is_open()
171        && task.available_at.as_deref().is_none_or(|available_at| {
172            unix_seconds(available_at).is_some_and(|available_at| available_at <= now_seconds)
173        })
174}
175
176pub fn available_since_defer(task: &Task, now_seconds: i64) -> bool {
177    if task.deleted || !task.status.is_open() {
178        return false;
179    }
180    let Some(available_at) = task.available_at.as_deref().and_then(unix_seconds) else {
181        return false;
182    };
183    if available_at > now_seconds {
184        return false;
185    }
186    let activity = unix_seconds(&task.queue_activity_at).unwrap_or(0);
187    activity < available_at || creation_seeded_queue_activity(task)
188}
189
190fn creation_seeded_queue_activity(task: &Task) -> bool {
191    task.queue_activity_at == task.created_at && task.updated_at == task.created_at
192}
193
194fn queue_band(
195    task: &Task,
196    has_conflict: bool,
197    has_unresolved_blockers: bool,
198    idle_days: i64,
199    available: bool,
200    due_actionable: bool,
201) -> QueueBand {
202    if task.is_epic {
203        QueueBand::Epics
204    } else if has_conflict
205        || task.priority == TaskPriority::Urgent
206        || (task.status == TaskStatus::Active && idle_days >= 7)
207    {
208        QueueBand::NeedsAction
209    } else if has_unresolved_blockers {
210        QueueBand::Blocked
211    } else if due_actionable {
212        QueueBand::NeedsAction
213    } else if available {
214        QueueBand::Available
215    } else if task.status == TaskStatus::Active
216        || (task.status == TaskStatus::Todo && task.priority == TaskPriority::High)
217    {
218        QueueBand::Focus
219    } else if task.status == TaskStatus::Todo && task.priority == TaskPriority::Medium {
220        QueueBand::Soon
221    } else if task.status == TaskStatus::Inbox {
222        QueueBand::Triage
223    } else {
224        QueueBand::Later
225    }
226}
227
228fn priority_score(priority: TaskPriority) -> i32 {
229    match priority {
230        TaskPriority::Urgent => 40,
231        TaskPriority::High => 30,
232        TaskPriority::Medium => 20,
233        TaskPriority::Low => 10,
234        TaskPriority::None => 0,
235    }
236}
237
238fn dependent_score(dependent_count: i64) -> i32 {
239    dependent_count.clamp(0, 5) as i32 * 6
240}
241
242fn status_score(status: TaskStatus) -> i32 {
243    match status {
244        TaskStatus::Active => 50,
245        TaskStatus::Todo => 35,
246        TaskStatus::Inbox => 25,
247        TaskStatus::Backlog => 5,
248        TaskStatus::Done | TaskStatus::Canceled => 0,
249    }
250}
251
252fn idle_score(status: TaskStatus, idle_days: i64) -> i32 {
253    match status {
254        TaskStatus::Active if idle_days >= 14 => 25,
255        TaskStatus::Active if idle_days >= 7 => 15,
256        TaskStatus::Todo if idle_days >= 30 => 15,
257        TaskStatus::Todo if idle_days >= 14 => 10,
258        TaskStatus::Inbox if idle_days >= 14 => 10,
259        TaskStatus::Inbox if idle_days >= 7 => 5,
260        _ => 0,
261    }
262}
263
264fn unix_days_from_civil(year: i64, month: u32, day: u32) -> i64 {
265    let year = year - if month <= 2 { 1 } else { 0 };
266    let era = if year >= 0 { year } else { year - 399 } / 400;
267    let yoe = year - era * 400;
268    let month = month as i64;
269    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day as i64 - 1;
270    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
271    era * 146_097 + doe - 719_468
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn task(status: &str, priority: &str, queue_activity_at: &str) -> Task {
279        Task {
280            id: crate::test_support::task_id(&format!("{status}-{priority}")),
281            workspace_id: "0000000000000001".parse().unwrap(),
282            title: "task".to_string(),
283            description: String::new(),
284            project_id: "0000000000000001".parse().unwrap(),
285            project_key: "app".to_string(),
286            project_prefix: "APP".to_string(),
287            status: TaskStatus::parse(status).expect("valid status"),
288            priority: TaskPriority::parse(priority).expect("valid priority"),
289            created_at: queue_activity_at.to_string(),
290            updated_at: queue_activity_at.to_string(),
291            queue_activity_at: queue_activity_at.to_string(),
292            available_at: None,
293            due_on: None,
294            deleted: false,
295            is_epic: false,
296        }
297    }
298
299    fn epic(status: &str, priority: &str, queue_activity_at: &str) -> Task {
300        Task {
301            is_epic: true,
302            ..task(status, priority, queue_activity_at)
303        }
304    }
305
306    #[test]
307    fn epic_tasks_have_epic_band() {
308        assert_eq!(
309            queue_meta(&epic("active", "urgent", "0"), true, true, 0, 8 * 86_400).band,
310            QueueBand::Epics
311        );
312    }
313
314    #[test]
315    fn urgent_and_conflicted_tasks_need_action() {
316        let urgent = task("todo", "urgent", "1000");
317        let conflicted = task("todo", "none", "1000");
318
319        assert_eq!(
320            queue_meta(&urgent, false, false, 0, 1000).band,
321            QueueBand::NeedsAction
322        );
323        assert_eq!(
324            queue_meta(&conflicted, true, false, 0, 1000).band,
325            QueueBand::NeedsAction
326        );
327    }
328
329    #[test]
330    fn active_and_high_todo_are_focus() {
331        assert_eq!(
332            queue_meta(&task("active", "none", "1000"), false, false, 0, 1000).band,
333            QueueBand::Focus
334        );
335        assert_eq!(
336            queue_meta(&task("todo", "high", "1000"), false, false, 0, 1000).band,
337            QueueBand::Focus
338        );
339    }
340
341    #[test]
342    fn medium_todo_tasks_are_soon() {
343        assert_eq!(
344            queue_meta(&task("todo", "medium", "1000"), false, false, 0, 1000).band,
345            QueueBand::Soon
346        );
347    }
348
349    #[test]
350    fn old_active_tasks_need_action() {
351        assert_eq!(
352            queue_meta(&task("active", "none", "0"), false, false, 0, 8 * 86_400).band,
353            QueueBand::NeedsAction
354        );
355    }
356
357    #[test]
358    fn blocked_tasks_sort_below_actionable_groups() {
359        assert!(QueueBand::Triage.order() < QueueBand::Blocked.order());
360        assert!(QueueBand::Blocked.order() < QueueBand::Later.order());
361    }
362
363    #[test]
364    fn open_dependents_add_queue_weight() {
365        let plain = queue_meta(&task("todo", "medium", "1000"), false, false, 0, 1000);
366        let blocker = queue_meta(&task("todo", "medium", "1000"), false, false, 3, 1000);
367
368        assert_eq!(plain.band, QueueBand::Soon);
369        assert_eq!(blocker.band, QueueBand::Soon);
370        assert!(blocker.score > plain.score);
371    }
372
373    #[test]
374    fn old_inbox_tasks_gain_triage_weight() {
375        let old = queue_meta(&task("inbox", "none", "0"), false, false, 0, 14 * 86_400);
376        let fresh = queue_meta(&task("inbox", "none", "0"), false, false, 0, 0);
377
378        assert_eq!(old.band, QueueBand::Triage);
379        assert!(old.score > fresh.score);
380    }
381
382    #[test]
383    fn deferred_task_surfaces_when_it_becomes_available() {
384        let mut deferred = task("inbox", "none", "1000");
385        deferred.available_at = Some("2000".to_string());
386
387        let meta = queue_meta(&deferred, false, false, 0, 2000);
388
389        assert_eq!(meta.band, QueueBand::Available);
390        assert_eq!(meta.idle_seconds, Some(0));
391    }
392
393    #[test]
394    fn activity_after_availability_acknowledges_resurfacing() {
395        let mut deferred = task("inbox", "none", "1000");
396        deferred.available_at = Some("2000".to_string());
397        deferred.updated_at = "2001".to_string();
398        deferred.queue_activity_at = "2001".to_string();
399
400        assert_eq!(
401            queue_meta(&deferred, false, false, 0, 2001).band,
402            QueueBand::Triage
403        );
404    }
405
406    #[test]
407    fn blocked_deferred_task_remains_blocked_when_available() {
408        let mut deferred = task("todo", "none", "1000");
409        deferred.available_at = Some("2000".to_string());
410
411        assert_eq!(
412            queue_meta(&deferred, false, true, 0, 2000).band,
413            QueueBand::Blocked
414        );
415    }
416
417    #[test]
418    fn due_today_and_overdue_visible_tasks_need_action() {
419        let today = NaiveDate::from_ymd_opt(2026, 7, 16).unwrap();
420        let mut due_today = task("todo", "none", "1000");
421        due_today.due_on = Some("2026-07-16".to_string());
422        let mut overdue = task("inbox", "none", "1000");
423        overdue.due_on = Some("2026-07-15".to_string());
424
425        assert_eq!(
426            queue_meta_on(&due_today, false, false, 0, 2000, today).band,
427            QueueBand::NeedsAction
428        );
429        assert_eq!(
430            queue_meta_on(&overdue, false, false, 0, 2000, today).band,
431            QueueBand::NeedsAction
432        );
433    }
434
435    #[test]
436    fn due_does_not_override_blockers_epics_or_future_availability() {
437        let today = NaiveDate::from_ymd_opt(2026, 7, 16).unwrap();
438        let mut due = task("todo", "none", "1000");
439        due.due_on = Some("2026-07-15".to_string());
440        assert_eq!(
441            queue_meta_on(&due, false, true, 0, 2000, today).band,
442            QueueBand::Blocked
443        );
444
445        due.is_epic = true;
446        let epic_with_due = queue_meta_on(&due, false, false, 0, 2000, today);
447        assert_eq!(epic_with_due.band, QueueBand::Epics);
448        due.due_on = None;
449        assert_eq!(
450            epic_with_due.score,
451            queue_meta_on(&due, false, false, 0, 2000, today).score
452        );
453
454        due.is_epic = false;
455        due.due_on = Some("2026-07-15".to_string());
456        due.available_at = Some("3000".to_string());
457        assert_eq!(
458            queue_meta_on(&due, false, false, 0, 2000, today).band,
459            QueueBand::Later
460        );
461    }
462
463    #[test]
464    fn due_week_adds_bounded_queue_weight() {
465        let today = NaiveDate::from_ymd_opt(2026, 7, 16).unwrap();
466        let mut near = task("todo", "medium", "1000");
467        near.due_on = Some("2026-07-17".to_string());
468        let mut far = near.clone();
469        far.due_on = Some("2026-07-23".to_string());
470
471        let near_score = queue_meta_on(&near, false, false, 0, 2000, today).score;
472        let far_score = queue_meta_on(&far, false, false, 0, 2000, today).score;
473        assert!(near_score > far_score);
474    }
475
476    #[test]
477    fn unix_seconds_parses_utc_timestamp() {
478        assert_eq!(unix_seconds("1970-01-02T01:02:03Z"), Some(90_123));
479    }
480}