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