Skip to main content

kando_core/board/
age.rs

1use chrono::{DateTime, Utc};
2
3use super::{Board, Card, Policies};
4
5/// Format a card's age as a human-readable string.
6pub fn format_age(created: DateTime<Utc>, now: DateTime<Utc>) -> String {
7    let days = (now - created).num_days().max(0) as u64;
8    if days == 0 {
9        "new".to_string()
10    } else if days < 14 {
11        format!("{days}d")
12    } else if days < 60 {
13        format!("{}w", days / 7)
14    } else {
15        format!("{}mo", days / 30)
16    }
17}
18
19/// How stale a card is, based on `updated` and bubble-up policy.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Staleness {
22    /// Card was recently updated, no warning needed.
23    Fresh,
24    /// Card has passed the bubble-up threshold.
25    Stale,
26    /// Card is very stale (2x the bubble-up threshold).
27    VeryStale,
28}
29
30/// Determine how stale a card is relative to the bubble-up policy.
31pub fn staleness(card: &Card, policies: &Policies, now: DateTime<Utc>) -> Staleness {
32    if policies.stale_days == 0 {
33        return Staleness::Fresh;
34    }
35    let days_since_update = (now - card.updated).num_days().max(0) as u32;
36    if days_since_update >= policies.stale_days * 2 {
37        Staleness::VeryStale
38    } else if days_since_update >= policies.stale_days {
39        Staleness::Stale
40    } else {
41        Staleness::Fresh
42    }
43}
44
45/// Classify a card's staleness as a human-readable label for the filter picker.
46///
47/// - `"new"` — created today (same UTC day)
48/// - `"normal"` — fresh but not new
49/// - `"stale"` — past the bubble-up threshold
50/// - `"very stale"` — past 2× the bubble-up threshold
51pub fn card_staleness_label(card: &Card, policies: &Policies, now: DateTime<Utc>) -> &'static str {
52    match staleness(card, policies, now) {
53        Staleness::VeryStale => "very stale",
54        Staleness::Stale => "stale",
55        Staleness::Fresh => {
56            if card.created.date_naive() == now.date_naive() {
57                "new"
58            } else {
59                "normal"
60            }
61        }
62    }
63}
64
65/// Check if a card should be auto-closed.
66pub fn should_auto_close(card: &Card, policies: &Policies, now: DateTime<Utc>) -> bool {
67    if policies.auto_close_days == 0 {
68        return false;
69    }
70    let days_since_update = (now - card.updated).num_days().max(0) as u32;
71    days_since_update >= policies.auto_close_days
72}
73
74/// A card that was moved to the auto-close target column.
75#[derive(Debug)]
76pub struct ClosedCard {
77    pub id: String,
78    pub title: String,
79    pub from_col_slug: String,
80}
81
82/// Run the auto-close policy on the board.
83///
84/// Returns one [`ClosedCard`] per card that was moved to the auto-close target.
85pub fn run_auto_close(board: &mut Board, now: DateTime<Utc>) -> Vec<ClosedCard> {
86    let auto_close_days = board.policies.auto_close_days;
87    if auto_close_days == 0 {
88        return Vec::new();
89    }
90
91    // Find the target column index
92    let target_col = match board
93        .columns
94        .iter()
95        .position(|c| c.slug == board.policies.auto_close_target)
96    {
97        Some(idx) => idx,
98        None => return Vec::new(),
99    };
100
101    // Collect cards to auto-close (skip cards already in the target column)
102    let mut to_move: Vec<(usize, usize)> = Vec::new();
103    for (col_idx, col) in board.columns.iter().enumerate() {
104        if col_idx == target_col {
105            continue;
106        }
107        for (card_idx, card) in col.cards.iter().enumerate() {
108            if should_auto_close(card, &board.policies, now) {
109                to_move.push((col_idx, card_idx));
110            }
111        }
112    }
113
114    // Move in reverse order to preserve indices
115    let mut closed_ids = Vec::new();
116    for &(col_idx, card_idx) in to_move.iter().rev() {
117        let from_col_slug = board.columns[col_idx].slug.clone();
118        let card = board.columns[col_idx].cards.remove(card_idx);
119        closed_ids.push(ClosedCard { id: card.id.clone(), title: card.title.clone(), from_col_slug });
120        board.columns[target_col].cards.push(card);
121    }
122
123    // Re-sort affected columns
124    for col in &mut board.columns {
125        col.sort_cards();
126    }
127
128    closed_ids
129}
130
131/// A card that was moved to the archive column by the auto-archive policy.
132#[derive(Debug)]
133pub struct ArchivedCard {
134    pub id: String,
135    pub title: String,
136    /// Slug of the source column (e.g. `"done"`). Use this to look up the display
137    /// name at the call site, consistent with [`ClosedCard::from_col_slug`].
138    pub from_col_slug: String,
139}
140
141/// Run the auto-archive policy on the board.
142///
143/// Moves cards that have been in the `done` column for at least
144/// `policies.archive_after_days` days to the `archive` column.
145/// Uses `card.completed` as the age reference, falling back to `card.updated`.
146///
147/// Returns one [`ArchivedCard`] per card moved.
148pub fn run_auto_archive(board: &mut Board, now: DateTime<Utc>) -> Vec<ArchivedCard> {
149    let days = board.policies.archive_after_days;
150    if days == 0 {
151        return Vec::new();
152    }
153    let threshold = chrono::Duration::days(days as i64);
154
155    let done_idx = board.columns.iter().position(|c| c.slug == "done");
156    let archive_idx = board.columns.iter().position(|c| c.slug == "archive");
157    let (Some(done_idx), Some(archive_idx)) = (done_idx, archive_idx) else {
158        return Vec::new();
159    };
160
161    // Collect indices of cards old enough to archive, in reverse order so that
162    // removing by index doesn't shift subsequent indices.
163    let to_archive: Vec<usize> = board.columns[done_idx]
164        .cards
165        .iter()
166        .enumerate()
167        .filter(|(_, card)| {
168            let age_start = card.completed.unwrap_or(card.updated);
169            now.signed_duration_since(age_start) >= threshold
170        })
171        .map(|(i, _)| i)
172        .rev()
173        .collect();
174
175    let done_slug = board.columns[done_idx].slug.clone();
176    let mut archived = Vec::new();
177    for idx in to_archive {
178        let card = board.columns[done_idx].cards.remove(idx);
179        archived.push(ArchivedCard {
180            id: card.id.clone(),
181            title: card.title.clone(),
182            from_col_slug: done_slug.clone(),
183        });
184        // Direct push bypasses move_card() to preserve the completed timestamp.
185        board.columns[archive_idx].cards.push(card);
186    }
187
188    board.columns[done_idx].sort_cards();
189    board.columns[archive_idx].sort_cards();
190
191    archived
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use chrono::TimeZone;
198
199    #[test]
200    fn test_format_age() {
201        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
202
203        let created_today = now;
204        assert_eq!(format_age(created_today, now), "new");
205
206        let created_3d = Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap();
207        assert_eq!(format_age(created_3d, now), "3d");
208
209        let created_2w = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
210        assert_eq!(format_age(created_2w, now), "2w");
211
212        let created_3mo = Utc.with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap();
213        assert_eq!(format_age(created_3mo, now), "3mo");
214    }
215
216    #[test]
217    fn test_staleness() {
218        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
219        let policies = Policies {
220            stale_days: 7,
221            ..Default::default()
222        };
223
224        let fresh_card = Card {
225            updated: Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap(),
226            ..Card::new("001".into(), "Test".into())
227        };
228        assert_eq!(staleness(&fresh_card, &policies, now), Staleness::Fresh);
229
230        let stale_card = Card {
231            updated: Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(),
232            ..Card::new("002".into(), "Test".into())
233        };
234        assert_eq!(staleness(&stale_card, &policies, now), Staleness::Stale);
235
236        let very_stale_card = Card {
237            updated: Utc.with_ymd_and_hms(2025, 5, 28, 12, 0, 0).unwrap(),
238            ..Card::new("003".into(), "Test".into())
239        };
240        assert_eq!(
241            staleness(&very_stale_card, &policies, now),
242            Staleness::VeryStale
243        );
244    }
245
246    #[test]
247    fn test_should_auto_close() {
248        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
249        let policies = Policies {
250            auto_close_days: 30,
251            ..Default::default()
252        };
253
254        let recent = Card {
255            updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(),
256            ..Card::new("001".into(), "Recent".into())
257        };
258        assert!(!should_auto_close(&recent, &policies, now));
259
260        let old = Card {
261            updated: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
262            ..Card::new("002".into(), "Old".into())
263        };
264        assert!(should_auto_close(&old, &policies, now));
265
266        let disabled = Policies {
267            auto_close_days: 0,
268            ..Default::default()
269        };
270        assert!(!should_auto_close(&old, &disabled, now));
271    }
272
273    // ── run_auto_close tests ──
274
275    fn make_column(slug: &str, name: &str, cards: Vec<Card>) -> super::super::Column {
276        super::super::Column {
277            slug: slug.into(),
278            name: name.into(),
279            order: 0,
280            wip_limit: None,
281            hidden: false,
282            cards,
283        }
284    }
285
286    fn make_board(columns: Vec<super::super::Column>, policies: Policies) -> Board {
287        Board {
288            name: "Test".into(),
289            next_card_id: 100,
290            policies,
291            sync_branch: None,
292
293            nerd_font: false,
294            created_at: None,
295            columns,
296        }
297    }
298
299    #[test]
300    fn run_auto_close_moves_stale_cards() {
301        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
302        let mut stale_card = Card::new("1".into(), "Stale".into());
303        stale_card.updated = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(); // 45 days ago
304
305        let policies = Policies {
306            auto_close_days: 30,
307            auto_close_target: "archive".to_string(),
308            ..Default::default()
309        };
310
311        let mut board = make_board(vec![
312            make_column("backlog", "Backlog", vec![stale_card]),
313            make_column("in-progress", "In Progress", vec![]),
314            make_column("archive", "Archive", vec![]),
315        ], policies);
316
317        let closed = run_auto_close(&mut board, now);
318        assert_eq!(closed.len(), 1);
319        assert_eq!(closed[0].id, "1");
320        assert_eq!(closed[0].from_col_slug, "backlog");
321        assert_eq!(board.columns[0].cards.len(), 0); // removed from backlog
322        assert_eq!(board.columns[2].cards.len(), 1); // moved to archive
323        assert_eq!(board.columns[2].cards[0].id, "1");
324    }
325
326    #[test]
327    fn run_auto_close_skips_fresh_cards() {
328        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
329        let mut fresh_card = Card::new("1".into(), "Fresh".into());
330        fresh_card.updated = Utc.with_ymd_and_hms(2025, 6, 10, 12, 0, 0).unwrap(); // 5 days ago
331
332        let policies = Policies {
333            auto_close_days: 30,
334            auto_close_target: "archive".to_string(),
335            ..Default::default()
336        };
337
338        let mut board = make_board(vec![
339            make_column("backlog", "Backlog", vec![fresh_card]),
340            make_column("archive", "Archive", vec![]),
341        ], policies);
342
343        let closed = run_auto_close(&mut board, now);
344        assert!(closed.is_empty());
345        assert_eq!(board.columns[0].cards.len(), 1); // still in backlog
346    }
347
348    #[test]
349    fn run_auto_close_skips_cards_in_target_column() {
350        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
351        let mut old_card = Card::new("1".into(), "Old".into());
352        old_card.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
353
354        let policies = Policies {
355            auto_close_days: 30,
356            auto_close_target: "archive".to_string(),
357            ..Default::default()
358        };
359
360        let mut board = make_board(vec![
361            make_column("backlog", "Backlog", vec![]),
362            make_column("archive", "Archive", vec![old_card]),
363        ], policies);
364
365        let closed = run_auto_close(&mut board, now);
366        assert!(closed.is_empty());
367        assert_eq!(board.columns[1].cards.len(), 1); // stays in archive
368    }
369
370    #[test]
371    fn run_auto_close_disabled_when_zero() {
372        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
373        let mut stale_card = Card::new("1".into(), "Stale".into());
374        stale_card.updated = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
375
376        let policies = Policies {
377            auto_close_days: 0,
378            auto_close_target: "archive".to_string(),
379            ..Default::default()
380        };
381
382        let mut board = make_board(vec![
383            make_column("backlog", "Backlog", vec![stale_card]),
384            make_column("archive", "Archive", vec![]),
385        ], policies);
386
387        let closed = run_auto_close(&mut board, now);
388        assert!(closed.is_empty());
389        assert_eq!(board.columns[0].cards.len(), 1); // untouched
390    }
391
392    #[test]
393    fn run_auto_close_no_target_column_returns_empty() {
394        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
395        let mut stale_card = Card::new("1".into(), "Stale".into());
396        stale_card.updated = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
397
398        let policies = Policies {
399            auto_close_days: 30,
400            auto_close_target: "nonexistent".to_string(),
401            ..Default::default()
402        };
403
404        let mut board = make_board(vec![
405            make_column("backlog", "Backlog", vec![stale_card]),
406        ], policies);
407
408        let closed = run_auto_close(&mut board, now);
409        assert!(closed.is_empty());
410    }
411
412    #[test]
413    fn run_auto_close_multiple_stale_across_columns() {
414        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
415        let mut stale1 = Card::new("1".into(), "Stale1".into());
416        stale1.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
417        let mut stale2 = Card::new("2".into(), "Stale2".into());
418        stale2.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
419
420        let policies = Policies {
421            auto_close_days: 30,
422            auto_close_target: "archive".to_string(),
423            ..Default::default()
424        };
425
426        let mut board = make_board(vec![
427            make_column("backlog", "Backlog", vec![stale1]),
428            make_column("in-progress", "In Progress", vec![stale2]),
429            make_column("archive", "Archive", vec![]),
430        ], policies);
431
432        let closed = run_auto_close(&mut board, now);
433        assert_eq!(closed.len(), 2);
434        assert_eq!(board.columns[2].cards.len(), 2); // both in archive
435    }
436
437    // ── format_age boundary tests ──
438
439    #[test]
440    fn format_age_boundary_14_days() {
441        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
442        // 13 days → still "Xd"
443        let d13 = Utc.with_ymd_and_hms(2025, 6, 2, 12, 0, 0).unwrap();
444        assert_eq!(format_age(d13, now), "13d");
445        // 14 days → switches to weeks
446        let d14 = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
447        assert_eq!(format_age(d14, now), "2w");
448    }
449
450    #[test]
451    fn format_age_boundary_60_days() {
452        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
453        // 59 days → still weeks
454        let d59 = Utc.with_ymd_and_hms(2025, 4, 17, 12, 0, 0).unwrap();
455        assert_eq!(format_age(d59, now), "8w");
456        // 60 days → switches to months
457        let d60 = Utc.with_ymd_and_hms(2025, 4, 16, 12, 0, 0).unwrap();
458        assert_eq!(format_age(d60, now), "2mo");
459    }
460
461    #[test]
462    fn format_age_future_date_returns_new() {
463        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
464        let future = Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
465        assert_eq!(format_age(future, now), "new");
466    }
467
468    // ── staleness boundary tests ──
469
470    #[test]
471    fn staleness_disabled_when_zero() {
472        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
473        let policies = Policies { stale_days: 0, ..Default::default() };
474        let old = Card {
475            updated: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
476            ..Card::new("1".into(), "Ancient".into())
477        };
478        assert_eq!(staleness(&old, &policies, now), Staleness::Fresh);
479    }
480
481    #[test]
482    fn staleness_boundary_exact_threshold() {
483        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
484        let policies = Policies { stale_days: 7, ..Default::default() };
485        // Exactly 7 days ago → Stale (>=)
486        let card = Card {
487            updated: Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap(),
488            ..Card::new("1".into(), "Test".into())
489        };
490        assert_eq!(staleness(&card, &policies, now), Staleness::Stale);
491    }
492
493    #[test]
494    fn staleness_boundary_exact_double() {
495        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
496        let policies = Policies { stale_days: 7, ..Default::default() };
497        // Exactly 14 days ago → VeryStale (>= 2x)
498        let card = Card {
499            updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(),
500            ..Card::new("1".into(), "Test".into())
501        };
502        assert_eq!(staleness(&card, &policies, now), Staleness::VeryStale);
503    }
504
505    // ── ClosedCard / run_auto_close additions ──
506
507    #[test]
508    fn run_auto_close_closed_card_title_is_correct() {
509        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
510        let mut stale = Card::new("7".into(), "Important Work".into());
511        stale.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
512
513        let policies = Policies {
514            auto_close_days: 30,
515            auto_close_target: "archive".to_string(),
516            ..Default::default()
517        };
518        let mut board = make_board(vec![
519            make_column("backlog", "Backlog", vec![stale]),
520            make_column("archive", "Archive", vec![]),
521        ], policies);
522
523        let closed = run_auto_close(&mut board, now);
524        assert_eq!(closed.len(), 1);
525        assert_eq!(closed[0].title, "Important Work");
526    }
527
528    #[test]
529    fn run_auto_close_at_exact_boundary_is_moved() {
530        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
531        // Exactly 30 days ago — should meet the >= threshold
532        let mut card = Card::new("1".into(), "Boundary".into());
533        card.updated = Utc.with_ymd_and_hms(2025, 5, 16, 12, 0, 0).unwrap();
534
535        let policies = Policies {
536            auto_close_days: 30,
537            auto_close_target: "archive".to_string(),
538            ..Default::default()
539        };
540        let mut board = make_board(vec![
541            make_column("backlog", "Backlog", vec![card]),
542            make_column("archive", "Archive", vec![]),
543        ], policies);
544
545        let closed = run_auto_close(&mut board, now);
546        assert_eq!(closed.len(), 1, "card at exact threshold should be moved");
547    }
548
549    #[test]
550    fn run_auto_close_one_day_short_not_moved() {
551        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
552        // 29 days ago — one day short of the 30-day threshold
553        let mut card = Card::new("1".into(), "Almost Stale".into());
554        card.updated = Utc.with_ymd_and_hms(2025, 5, 17, 12, 0, 0).unwrap();
555
556        let policies = Policies {
557            auto_close_days: 30,
558            auto_close_target: "archive".to_string(),
559            ..Default::default()
560        };
561        let mut board = make_board(vec![
562            make_column("backlog", "Backlog", vec![card]),
563            make_column("archive", "Archive", vec![]),
564        ], policies);
565
566        let closed = run_auto_close(&mut board, now);
567        assert!(closed.is_empty(), "card 29 days old should not be moved at 30-day threshold");
568        assert_eq!(board.columns[0].cards.len(), 1);
569    }
570
571    #[test]
572    fn run_auto_close_multiple_stale_same_column_all_moved() {
573        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
574        let old = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
575        let mut c1 = Card::new("1".into(), "First".into());
576        c1.updated = old;
577        let mut c2 = Card::new("2".into(), "Second".into());
578        c2.updated = old;
579        let mut c3 = Card::new("3".into(), "Third".into());
580        c3.updated = old;
581
582        let policies = Policies {
583            auto_close_days: 30,
584            auto_close_target: "archive".to_string(),
585            ..Default::default()
586        };
587        let mut board = make_board(vec![
588            make_column("backlog", "Backlog", vec![c1, c2, c3]),
589            make_column("archive", "Archive", vec![]),
590        ], policies);
591
592        let closed = run_auto_close(&mut board, now);
593        assert_eq!(closed.len(), 3);
594        assert_eq!(board.columns[0].cards.len(), 0);
595        assert_eq!(board.columns[1].cards.len(), 3);
596    }
597
598    #[test]
599    fn run_auto_close_from_col_slug_reflects_source_column() {
600        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
601        let old = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
602        let mut card_a = Card::new("1".into(), "A".into());
603        card_a.updated = old;
604        let mut card_b = Card::new("2".into(), "B".into());
605        card_b.updated = old;
606
607        let policies = Policies {
608            auto_close_days: 30,
609            auto_close_target: "archive".to_string(),
610            ..Default::default()
611        };
612        let mut board = make_board(vec![
613            make_column("backlog", "Backlog", vec![card_a]),
614            make_column("in-progress", "In Progress", vec![card_b]),
615            make_column("archive", "Archive", vec![]),
616        ], policies);
617
618        let mut closed = run_auto_close(&mut board, now);
619        assert_eq!(closed.len(), 2);
620        closed.sort_by(|a, b| a.id.cmp(&b.id));
621        assert_eq!(closed[0].from_col_slug, "backlog");
622        assert_eq!(closed[1].from_col_slug, "in-progress");
623    }
624
625    #[test]
626    fn run_auto_close_empty_board_no_panic() {
627        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
628        let policies = Policies {
629            auto_close_days: 30,
630            auto_close_target: "archive".to_string(),
631            ..Default::default()
632        };
633        let mut board = make_board(vec![], policies);
634        let closed = run_auto_close(&mut board, now);
635        assert!(closed.is_empty());
636    }
637
638    // ── run_auto_archive tests ──
639
640    #[test]
641    fn run_auto_archive_moves_card_from_done_to_archive() {
642        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
643        let completed = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(); // 10 days ago
644        let mut card = Card::new("1".into(), "Done task".into());
645        card.completed = Some(completed);
646
647        let policies = Policies { archive_after_days: 7, ..Default::default() };
648        let mut board = make_board(vec![
649            make_column("done", "Done", vec![card]),
650            make_column("archive", "Archive", vec![]),
651        ], policies);
652
653        let archived = run_auto_archive(&mut board, now);
654        assert_eq!(archived.len(), 1);
655        assert_eq!(archived[0].id, "1");
656        assert_eq!(board.columns[0].cards.len(), 0, "done column should be empty");
657        assert_eq!(board.columns[1].cards.len(), 1, "archive column should have one card");
658        assert_eq!(board.columns[1].cards[0].id, "1");
659    }
660
661    #[test]
662    fn run_auto_archive_disabled_when_zero() {
663        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
664        let old = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
665        let mut card = Card::new("1".into(), "Old task".into());
666        card.completed = Some(old);
667
668        let policies = Policies { archive_after_days: 0, ..Default::default() };
669        let mut board = make_board(vec![
670            make_column("done", "Done", vec![card]),
671            make_column("archive", "Archive", vec![]),
672        ], policies);
673
674        let archived = run_auto_archive(&mut board, now);
675        assert!(archived.is_empty());
676        assert_eq!(board.columns[0].cards.len(), 1, "card should remain in done");
677    }
678
679    #[test]
680    fn run_auto_archive_at_exact_threshold_is_moved() {
681        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
682        // Exactly 7 days ago — should meet the >= threshold
683        let completed = Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap();
684        let mut card = Card::new("1".into(), "Boundary".into());
685        card.completed = Some(completed);
686
687        let policies = Policies { archive_after_days: 7, ..Default::default() };
688        let mut board = make_board(vec![
689            make_column("done", "Done", vec![card]),
690            make_column("archive", "Archive", vec![]),
691        ], policies);
692
693        let archived = run_auto_archive(&mut board, now);
694        assert_eq!(archived.len(), 1, "card at exact 7-day threshold should be archived");
695    }
696
697    #[test]
698    fn run_auto_archive_one_day_short_not_moved() {
699        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
700        // 6 days ago — one short of 7-day threshold
701        let completed = Utc.with_ymd_and_hms(2025, 6, 9, 12, 0, 0).unwrap();
702        let mut card = Card::new("1".into(), "Almost ready".into());
703        card.completed = Some(completed);
704
705        let policies = Policies { archive_after_days: 7, ..Default::default() };
706        let mut board = make_board(vec![
707            make_column("done", "Done", vec![card]),
708            make_column("archive", "Archive", vec![]),
709        ], policies);
710
711        let archived = run_auto_archive(&mut board, now);
712        assert!(archived.is_empty(), "card 6 days old should not be archived at 7-day threshold");
713        assert_eq!(board.columns[0].cards.len(), 1, "card should still be in done");
714    }
715
716    #[test]
717    fn run_auto_archive_preserves_completed_timestamp() {
718        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
719        let completed = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(); // 10 days ago
720        let mut card = Card::new("1".into(), "Task".into());
721        card.completed = Some(completed);
722
723        let policies = Policies { archive_after_days: 7, ..Default::default() };
724        let mut board = make_board(vec![
725            make_column("done", "Done", vec![card]),
726            make_column("archive", "Archive", vec![]),
727        ], policies);
728
729        run_auto_archive(&mut board, now);
730
731        let archived_card = &board.columns[1].cards[0];
732        assert_eq!(
733            archived_card.completed,
734            Some(completed),
735            "completed timestamp must be preserved after auto-archiving"
736        );
737    }
738
739    #[test]
740    fn run_auto_archive_uses_completed_over_updated() {
741        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
742        // completed is recent (3 days ago — below 7-day threshold)
743        let completed = Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap();
744        // updated is very old (30 days ago — above threshold)
745        let updated = Utc.with_ymd_and_hms(2025, 5, 16, 12, 0, 0).unwrap();
746        let mut card = Card::new("1".into(), "Recently completed".into());
747        card.completed = Some(completed);
748        card.updated = updated;
749
750        let policies = Policies { archive_after_days: 7, ..Default::default() };
751        let mut board = make_board(vec![
752            make_column("done", "Done", vec![card]),
753            make_column("archive", "Archive", vec![]),
754        ], policies);
755
756        let archived = run_auto_archive(&mut board, now);
757        assert!(
758            archived.is_empty(),
759            "completed (3 days ago) takes priority over updated (30 days ago); must not be archived"
760        );
761        assert_eq!(board.columns[0].cards.len(), 1, "card should remain in done");
762    }
763
764    #[test]
765    fn run_auto_archive_falls_back_to_updated_when_no_completed() {
766        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
767        // No completed timestamp; updated is 10 days ago (above 7-day threshold)
768        let updated = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap();
769        let mut card = Card::new("1".into(), "No completed".into());
770        card.updated = updated;
771        // card.completed is None by default
772
773        let policies = Policies { archive_after_days: 7, ..Default::default() };
774        let mut board = make_board(vec![
775            make_column("done", "Done", vec![card]),
776            make_column("archive", "Archive", vec![]),
777        ], policies);
778
779        let archived = run_auto_archive(&mut board, now);
780        assert_eq!(archived.len(), 1, "should fall back to updated when completed is None");
781    }
782
783    #[test]
784    fn run_auto_archive_only_moves_from_done_not_other_columns() {
785        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
786        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(); // 45 days ago
787        // Old card in backlog — must NOT be archived (only "done" is in scope)
788        let mut backlog_card = Card::new("1".into(), "Backlog old".into());
789        backlog_card.updated = old;
790        // Old card in done — must be archived
791        let mut done_card = Card::new("2".into(), "Done old".into());
792        done_card.completed = Some(old);
793
794        let policies = Policies { archive_after_days: 7, ..Default::default() };
795        let mut board = make_board(vec![
796            make_column("backlog", "Backlog", vec![backlog_card]),
797            make_column("done", "Done", vec![done_card]),
798            make_column("archive", "Archive", vec![]),
799        ], policies);
800
801        let archived = run_auto_archive(&mut board, now);
802        assert_eq!(archived.len(), 1, "only done-column cards should be archived");
803        assert_eq!(archived[0].id, "2");
804        assert_eq!(board.columns[0].cards.len(), 1, "backlog card should be untouched");
805        assert_eq!(board.columns[1].cards.len(), 0, "done card should be moved");
806        assert_eq!(board.columns[2].cards.len(), 1, "archive should have the done card");
807    }
808
809    #[test]
810    fn run_auto_archive_no_done_column_returns_empty() {
811        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
812        let policies = Policies { archive_after_days: 7, ..Default::default() };
813        let mut board = make_board(vec![
814            make_column("backlog", "Backlog", vec![]),
815            make_column("archive", "Archive", vec![]),
816        ], policies);
817
818        let archived = run_auto_archive(&mut board, now);
819        assert!(archived.is_empty());
820    }
821
822    #[test]
823    fn run_auto_archive_no_archive_column_returns_empty() {
824        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
825        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
826        let mut card = Card::new("1".into(), "Old done".into());
827        card.completed = Some(old);
828
829        let policies = Policies { archive_after_days: 7, ..Default::default() };
830        let mut board = make_board(vec![
831            make_column("done", "Done", vec![card]),
832        ], policies);
833
834        let archived = run_auto_archive(&mut board, now);
835        assert!(archived.is_empty());
836        assert_eq!(board.columns[0].cards.len(), 1, "card must stay when no archive column exists");
837    }
838
839    #[test]
840    fn run_auto_archive_multiple_done_cards_all_old_all_moved() {
841        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
842        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
843        let mut c1 = Card::new("1".into(), "First".into());
844        c1.completed = Some(old);
845        let mut c2 = Card::new("2".into(), "Second".into());
846        c2.completed = Some(old);
847        let mut c3 = Card::new("3".into(), "Third".into());
848        c3.completed = Some(old);
849
850        let policies = Policies { archive_after_days: 7, ..Default::default() };
851        let mut board = make_board(vec![
852            make_column("done", "Done", vec![c1, c2, c3]),
853            make_column("archive", "Archive", vec![]),
854        ], policies);
855
856        let archived = run_auto_archive(&mut board, now);
857        assert_eq!(archived.len(), 3, "all three old cards should be archived");
858        assert_eq!(board.columns[0].cards.len(), 0, "done column should be empty");
859        assert_eq!(board.columns[1].cards.len(), 3, "archive column should have 3 cards");
860    }
861
862    #[test]
863    fn run_auto_archive_mixed_old_and_young_only_old_moved() {
864        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
865        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
866        let recent = Utc.with_ymd_and_hms(2025, 6, 13, 12, 0, 0).unwrap(); // 2 days ago
867        let mut old_card = Card::new("1".into(), "Old".into());
868        old_card.completed = Some(old);
869        let mut new_card = Card::new("2".into(), "New".into());
870        new_card.completed = Some(recent);
871
872        let policies = Policies { archive_after_days: 7, ..Default::default() };
873        let mut board = make_board(vec![
874            make_column("done", "Done", vec![old_card, new_card]),
875            make_column("archive", "Archive", vec![]),
876        ], policies);
877
878        let archived = run_auto_archive(&mut board, now);
879        assert_eq!(archived.len(), 1, "only the old card should be archived");
880        assert_eq!(archived[0].id, "1");
881        assert_eq!(board.columns[0].cards.len(), 1, "recent card should remain in done");
882        assert_eq!(board.columns[0].cards[0].id, "2", "the remaining card should be the recent one");
883    }
884
885    #[test]
886    fn run_auto_archive_returns_correct_id_and_title() {
887        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
888        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
889        let mut card = Card::new("042".into(), "Very Important Task".into());
890        card.completed = Some(old);
891
892        let policies = Policies { archive_after_days: 7, ..Default::default() };
893        let mut board = make_board(vec![
894            make_column("done", "Done", vec![card]),
895            make_column("archive", "Archive", vec![]),
896        ], policies);
897
898        let archived = run_auto_archive(&mut board, now);
899        assert_eq!(archived.len(), 1);
900        assert_eq!(archived[0].id, "042");
901        assert_eq!(archived[0].title, "Very Important Task");
902    }
903
904    #[test]
905    fn run_auto_archive_from_col_slug_is_done_slug() {
906        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
907        let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
908        let mut card = Card::new("1".into(), "Task".into());
909        card.completed = Some(old);
910
911        let policies = Policies { archive_after_days: 7, ..Default::default() };
912        let mut board = make_board(vec![
913            make_column("done", "Done", vec![card]),
914            make_column("archive", "Archive", vec![]),
915        ], policies);
916
917        let archived = run_auto_archive(&mut board, now);
918        assert_eq!(archived.len(), 1);
919        assert_eq!(archived[0].from_col_slug, "done");
920    }
921
922    #[test]
923    fn run_auto_archive_does_not_update_updated_field() {
924        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
925        let completed = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
926        let updated = Utc.with_ymd_and_hms(2025, 4, 20, 12, 0, 0).unwrap();
927        let mut card = Card::new("1".into(), "Task".into());
928        card.completed = Some(completed);
929        card.updated = updated;
930
931        let policies = Policies { archive_after_days: 7, ..Default::default() };
932        let mut board = make_board(vec![
933            make_column("done", "Done", vec![card]),
934            make_column("archive", "Archive", vec![]),
935        ], policies);
936
937        run_auto_archive(&mut board, now);
938
939        let archived_card = &board.columns[1].cards[0];
940        assert_eq!(
941            archived_card.updated, updated,
942            "archiving must not change updated (touch() is intentionally skipped)"
943        );
944    }
945
946    #[test]
947    fn run_auto_archive_preserves_started_timestamp() {
948        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
949        let completed = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
950        let started = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
951        let mut card = Card::new("1".into(), "Task".into());
952        card.completed = Some(completed);
953        card.started = Some(started);
954
955        let policies = Policies { archive_after_days: 7, ..Default::default() };
956        let mut board = make_board(vec![
957            make_column("done", "Done", vec![card]),
958            make_column("archive", "Archive", vec![]),
959        ], policies);
960
961        run_auto_archive(&mut board, now);
962
963        let archived_card = &board.columns[1].cards[0];
964        assert_eq!(
965            archived_card.started,
966            Some(started),
967            "started timestamp must be preserved after auto-archiving"
968        );
969    }
970
971    #[test]
972    fn run_auto_archive_empty_done_column_returns_empty() {
973        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
974        let policies = Policies { archive_after_days: 7, ..Default::default() };
975        let mut board = make_board(vec![
976            make_column("done", "Done", vec![]),
977            make_column("archive", "Archive", vec![]),
978        ], policies);
979
980        let archived = run_auto_archive(&mut board, now);
981        assert!(archived.is_empty());
982    }
983
984    #[test]
985    fn run_auto_archive_future_completed_not_moved() {
986        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
987        // completed is in the future — signed_duration_since < threshold
988        let completed = Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
989        let mut card = Card::new("1".into(), "Future done".into());
990        card.completed = Some(completed);
991
992        let policies = Policies { archive_after_days: 7, ..Default::default() };
993        let mut board = make_board(vec![
994            make_column("done", "Done", vec![card]),
995            make_column("archive", "Archive", vec![]),
996        ], policies);
997
998        let archived = run_auto_archive(&mut board, now);
999        assert!(archived.is_empty(), "card with future completed timestamp must not be archived");
1000        assert_eq!(board.columns[0].cards.len(), 1);
1001    }
1002
1003    // ── card_staleness_label tests ──
1004
1005    #[test]
1006    fn card_staleness_label_returns_new_for_card_created_today() {
1007        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1008        let policies = Policies { stale_days: 7, ..Default::default() };
1009        let card = Card {
1010            created: now,
1011            updated: now,
1012            ..Card::new("1".into(), "Test".into())
1013        };
1014        assert_eq!(card_staleness_label(&card, &policies, now), "new");
1015    }
1016
1017    #[test]
1018    fn card_staleness_label_returns_normal_for_fresh_non_new() {
1019        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1020        let policies = Policies { stale_days: 7, ..Default::default() };
1021        let card = Card {
1022            created: Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap(),
1023            updated: Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap(),
1024            ..Card::new("1".into(), "Test".into())
1025        };
1026        assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1027    }
1028
1029    #[test]
1030    fn card_staleness_label_returns_stale() {
1031        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1032        let policies = Policies { stale_days: 7, ..Default::default() };
1033        let card = Card {
1034            created: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
1035            updated: Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap(), // exactly 7 days ago
1036            ..Card::new("1".into(), "Test".into())
1037        };
1038        assert_eq!(card_staleness_label(&card, &policies, now), "stale");
1039    }
1040
1041    #[test]
1042    fn card_staleness_label_returns_very_stale() {
1043        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1044        let policies = Policies { stale_days: 7, ..Default::default() };
1045        let card = Card {
1046            created: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
1047            updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(), // 14 days ago (2x threshold)
1048            ..Card::new("1".into(), "Test".into())
1049        };
1050        assert_eq!(card_staleness_label(&card, &policies, now), "very stale");
1051    }
1052
1053    #[test]
1054    fn card_staleness_label_stale_days_zero_new_card() {
1055        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1056        let policies = Policies { stale_days: 0, ..Default::default() };
1057        let card = Card {
1058            created: now,
1059            updated: now,
1060            ..Card::new("1".into(), "Test".into())
1061        };
1062        assert_eq!(card_staleness_label(&card, &policies, now), "new");
1063    }
1064
1065    #[test]
1066    fn card_staleness_label_stale_days_zero_old_card() {
1067        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1068        let policies = Policies { stale_days: 0, ..Default::default() };
1069        let card = Card {
1070            created: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
1071            updated: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
1072            ..Card::new("1".into(), "Test".into())
1073        };
1074        // stale_days=0 means staleness is always Fresh, but card is old → "normal"
1075        assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1076    }
1077
1078    #[test]
1079    fn card_staleness_label_utc_day_boundary() {
1080        // Card created just before midnight, now is just after midnight → different UTC days
1081        let created = Utc.with_ymd_and_hms(2025, 6, 14, 23, 59, 59).unwrap();
1082        let now = Utc.with_ymd_and_hms(2025, 6, 15, 0, 0, 1).unwrap();
1083        let policies = Policies { stale_days: 7, ..Default::default() };
1084        let card = Card {
1085            created,
1086            updated: created,
1087            ..Card::new("1".into(), "Test".into())
1088        };
1089        // Card is only 2 seconds old but created on a different UTC day → "normal" not "new"
1090        assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1091    }
1092}