agd_core/agenda/
summary.rs1use 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
16pub 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
40pub struct AgendaSummary {
41 pub overdue: usize,
43 pub today: usize,
46 pub inbox: usize,
48 pub unreviewed: usize,
50}
51
52impl AgendaEngine<'_> {
53 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}