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