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}