Skip to main content

agd_core/agenda/
summary.rs

1//! The day at a glance, one answer for every surface: the overdue list that
2//! Today, Triage and both panes show ([`overdue_entries`]), and the counts of the
3//! status band (`agd agenda --status`, the TUI's header and pane), each
4//! counting an item once ([`AgendaSummary`]).
5
6use std::cmp::Reverse;
7use std::collections::BTreeSet;
8
9use chrono::NaiveDate;
10use serde::Serialize;
11
12use crate::{AgdError, AgendaEntry, AgendaQuery, JournalRecord, JournalStatus, OccurrenceType};
13
14use super::engine::AgendaEngine;
15
16/// The overdue rows among `entries` (a day's agenda), one per item: missed
17/// deadlines first, most days late first, then past-scheduled reminders,
18/// oldest first. Rows of equal lateness keep the agenda's order. Done
19/// items are never overdue.
20pub fn overdue_entries(entries: &[AgendaEntry]) -> Vec<AgendaEntry> {
21    let mut late: Vec<&AgendaEntry> = entries
22        .iter()
23        .filter(|entry| entry.occurrence.late && !entry.item.done)
24        .collect();
25    late.sort_by_key(|entry| {
26        (
27            entry.occurrence.kind != OccurrenceType::Deadline,
28            Reverse(entry.occurrence.days),
29        )
30    });
31    let mut seen = BTreeSet::new();
32    late.into_iter()
33        .filter(|entry| seen.insert(entry.item.item_ref.as_str()))
34        .cloned()
35        .collect()
36}
37
38/// What the status band counts, each item once.
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
40pub struct AgendaSummary {
41    /// Items in the overdue list.
42    pub overdue: usize,
43    /// Other open items scheduled, due or timestamped today; a deadline's
44    /// warning (`In 3 d.:`) is not today's.
45    pub today: usize,
46    /// Items in the inbox, where captures land.
47    pub inbox: usize,
48    /// Agent changes not yet reviewed.
49    pub unreviewed: usize,
50}
51
52impl AgendaEngine<'_> {
53    /// The status band's counts for `today`, with unreviewed agent changes
54    /// read from `journal` (`WriteEngine::journal`).
55    pub fn summary(
56        &self,
57        today: NaiveDate,
58        journal: &[JournalRecord],
59    ) -> Result<AgendaSummary, AgdError> {
60        let entries = self.agenda(&AgendaQuery {
61            from: today,
62            to: today,
63            today,
64            match_expression: None,
65            log_mode: false,
66        })?;
67        let overdue = overdue_entries(&entries);
68        let mut counted: BTreeSet<&str> = overdue
69            .iter()
70            .map(|entry| entry.item.item_ref.as_str())
71            .collect();
72        let today_items = entries
73            .iter()
74            .filter(|entry| {
75                !entry.item.done && entry.occurrence.kind != OccurrenceType::UpcomingDeadline
76            })
77            .filter(|entry| counted.insert(entry.item.item_ref.as_str()))
78            .count();
79        let unreviewed = journal
80            .iter()
81            .filter(|record| record.agent().is_some() && record.status == JournalStatus::Committed)
82            .count();
83        Ok(AgendaSummary {
84            overdue: overdue.len(),
85            today: today_items,
86            inbox: self.inbox().len(),
87            unreviewed,
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::agenda::test_support::fixture;
96
97    #[test]
98    fn overdue_lists_missed_deadlines_then_old_schedules_each_item_once() {
99        let (_temp, index) = fixture(
100            "* TODO Scheduled long ago\nSCHEDULED: <2026-09-01 Tue>\n\
101* TODO Deadline missed by two\nDEADLINE: <2026-10-11 Sun>\n\
102* TODO Both, deadline missed by five\nSCHEDULED: <2026-10-01 Thu> DEADLINE: <2026-10-08 Thu>\n\
103* TODO Scheduled yesterday\nSCHEDULED: <2026-10-12 Mon>\n\
104* DONE Finished late\nSCHEDULED: <2026-10-01 Thu>\n\
105* TODO Due soon\nDEADLINE: <2026-10-16 Fri>\n\
106* TODO Today\nSCHEDULED: <2026-10-13 Tue>\n",
107        );
108        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
109        let engine = AgendaEngine::new(&index);
110        let entries = engine
111            .agenda(&AgendaQuery {
112                from: today,
113                to: today,
114                today,
115                match_expression: None,
116                log_mode: false,
117            })
118            .unwrap();
119        let rows: Vec<(String, i64)> = overdue_entries(&entries)
120            .into_iter()
121            .map(|entry| (entry.item.title, entry.occurrence.days))
122            .collect();
123        let expected = [
124            ("Both, deadline missed by five", 5),
125            ("Deadline missed by two", 2),
126            ("Scheduled long ago", 42),
127            ("Scheduled yesterday", 1),
128        ]
129        .map(|(title, days)| (title.to_string(), days));
130        assert_eq!(rows, expected);
131        let due_soon = entries
132            .iter()
133            .find(|entry| entry.item.title == "Due soon")
134            .unwrap();
135        assert_eq!(
136            (due_soon.occurrence.days, due_soon.occurrence.late),
137            (-3, false)
138        );
139
140        let record = |actor: &str, status: JournalStatus| JournalRecord {
141            schema: crate::JOURNAL_SCHEMA.to_string(),
142            id: format!("{actor}-{status:?}"),
143            status,
144            timestamp: "2026-10-13T09:00:00+00:00".to_string(),
145            actor: actor.to_string(),
146            project: "test".to_string(),
147            file: "agenda.org".to_string(),
148            item_ref: None,
149            transaction: None,
150            operation: Some(crate::JournalOperation::Capture),
151            pre_hash: String::new(),
152            post_hash: String::new(),
153            patches: Vec::new(),
154            diff: String::new(),
155        };
156        let journal = [
157            record("mcp:codex", JournalStatus::Committed),
158            record("mcp:codex", JournalStatus::Reviewed),
159            record("cli", JournalStatus::Committed),
160        ];
161        let summary = engine.summary(today, &journal).unwrap();
162        assert_eq!(
163            summary,
164            AgendaSummary {
165                overdue: 4,
166                today: 1,
167                inbox: 0,
168                unreviewed: 1,
169            }
170        );
171    }
172}