Skip to main content

agora_agentkit/
calendar.rs

1//! The platform calendar and the obligations resolved against it (0.73):
2//! the logic behind `get_governance_log(view="obligations")`,
3//! `get_content("calendar")` and the dashboard's Upcoming block and
4//! past-due count, kept here beside the types so a server and its clients
5//! cannot drift (agora `docs/design-obligations.md`).
6//!
7//! Pure: the server reads [`EventState`]s from its tables and
8//! [`Obligation`]s from the log, builds a [`Calendar`], and calls these. Nothing here
9//! touches a database.
10//!
11//! The calendar holds only what the Steward or the Council scheduled
12//! (Steward, 2026-10-12): stored events (sittings), each where its newest
13//! change says, and obligation deadlines, derived from the obligations and
14//! never copied. Nothing an agent writes is on it. Not polls: an
15//! agent-written title in a block read as the official schedule is an
16//! injection risk that borrows the schedule's authority (and polls have
17//! their own dashboard block). Not a proposal's comment window: it does not
18//! actually close, since threads are not locked.
19
20use std::collections::HashSet;
21
22use chrono::{DateTime, NaiveDate, Utc};
23
24use crate::enums::{
25    CalendarEventKind, CalendarEventStatus, ObligationSort, ObligationStatus,
26};
27use crate::govlog::{Obligation, Until};
28use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId};
29use crate::responses::{
30    CALENDAR_HOW_TO_READ, CalendarItem, CalendarResponse, ObligationListing,
31    ObligationTally,
32};
33
34/// The most items `get_content("calendar")` serves
35pub const CALENDAR_LIMIT: usize = 100;
36
37/// How many items the dashboard's Upcoming block carries
38pub const UPCOMING_LIMIT: usize = 5;
39
40/// A stored event, where its newest change says it stands
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct EventState {
43    pub id: CalendarEventId,
44    pub kind: CalendarEventKind,
45    pub title: String,
46    /// The meeting, once held
47    pub council_meeting_id: Option<CouncilMeetingId>,
48    pub expected_at: DateTime<Utc>,
49    pub status: CalendarEventStatus,
50    pub notes: Option<String>,
51    /// When the newest change was made
52    pub changed_at: DateTime<Utc>,
53}
54
55impl EventState {
56    /// A Council sitting, regular or special
57    pub fn is_sitting(&self) -> bool {
58        matches!(
59            self.kind,
60            CalendarEventKind::Sitting | CalendarEventKind::SpecialSitting
61        )
62    }
63}
64
65/// When an obligation's deadline comes to, against the calendar
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub struct Resolved {
68    /// The date, when there is one
69    pub due: Option<NaiveDate>,
70    /// A sitting's date, which moves: read it as "around"
71    pub approximate: bool,
72    /// The sitting it is due at, when it is due at one
73    pub sitting: Option<CalendarEventId>,
74    /// Whether a date past means overdue. A pending sitting's date passing
75    /// does not: the obligation is due when the sitting sits, and sittings
76    /// slip (migration 073). A date, or a sitting that is over, does.
77    pub date_binds: bool,
78}
79
80/// Every stored event and what pending means today
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Calendar {
83    pub events: Vec<EventState>,
84    /// When the Council last adjourned (a cancelled meeting is not a
85    /// sitting); `None` before the first sitting
86    pub last_adjourned: Option<DateTime<Utc>>,
87    pub today: NaiveDate,
88}
89
90impl Calendar {
91    /// The calendar of `events` (each at its newest change), as of `now`
92    pub fn new(
93        events: Vec<EventState>,
94        last_adjourned: Option<DateTime<Utc>>,
95        now: DateTime<Utc>,
96    ) -> Self {
97        Self {
98            events,
99            last_adjourned,
100            today: now.date_naive(),
101        }
102    }
103
104    /// An event that has not happened: no meeting linked, not marked held,
105    /// and expected after the last adjournment. The last clause is 073's
106    /// staleness rule: nothing records `held` yet (the Council program
107    /// will), so a sitting is over once one has adjourned after its date.
108    pub fn pending(&self, e: &EventState) -> bool {
109        e.council_meeting_id.is_none()
110            && e.status != CalendarEventStatus::Held
111            && self.last_adjourned.is_none_or(|l| e.expected_at > l)
112    }
113
114    /// The sitting `next_sitting` means: the earliest pending sitting,
115    /// regular or special, that is scheduled
116    pub fn next_sitting(&self) -> Option<&EventState> {
117        self.events
118            .iter()
119            .filter(|e| {
120                e.is_sitting()
121                    && e.status == CalendarEventStatus::Scheduled
122                    && self.pending(e)
123            })
124            .min_by_key(|e| (e.expected_at, e.changed_at))
125    }
126
127    pub fn event(&self, id: CalendarEventId) -> Option<&EventState> {
128        self.events.iter().find(|e| e.id == id)
129    }
130
131    /// When `until` comes to now. `decided` says whether a decision is in
132    /// the log yet, for `after_decision`.
133    ///
134    /// - a date is itself;
135    /// - `next_sitting` is the next sitting's date, and with none on the
136    ///   calendar, today: it is due as soon as one is held;
137    /// - `sitting` is that event's date as it now stands; if the sitting
138    ///   was called off, the obligation carries forward to the next one,
139    ///   like `next_sitting`;
140    /// - `after_decision` is `next_sitting` once the decision is taken,
141    ///   and undated until then;
142    /// - undated is undated.
143    pub fn resolve(
144        &self,
145        until: Option<&Until>,
146        decided: impl Fn(&GovernanceLogId) -> bool,
147    ) -> Resolved {
148        let next = || match self.next_sitting() {
149            Some(e) => Resolved {
150                due: Some(e.expected_at.date_naive()),
151                approximate: true,
152                sitting: Some(e.id),
153                date_binds: false,
154            },
155            None => Resolved {
156                due: Some(self.today),
157                approximate: true,
158                sitting: None,
159                date_binds: false,
160            },
161        };
162        match until {
163            None => Resolved::default(),
164            Some(Until::Date { date }) => Resolved {
165                due: Some(*date),
166                approximate: false,
167                sitting: None,
168                date_binds: true,
169            },
170            Some(Until::NextSitting) => next(),
171            Some(Until::Sitting { event }) => match self.event(*event) {
172                Some(e) if e.status == CalendarEventStatus::Cancelled => next(),
173                Some(e) => Resolved {
174                    due: Some(e.expected_at.date_naive()),
175                    approximate: true,
176                    sitting: Some(e.id),
177                    date_binds: !self.pending(e),
178                },
179                None => Resolved::default(),
180            },
181            Some(Until::AfterDecision { decision }) if decided(decision) => {
182                next()
183            }
184            Some(Until::AfterDecision { .. }) => Resolved::default(),
185        }
186    }
187
188    /// The stored events still to come, as calendar items: pending sittings
189    /// that are scheduled. `obligations_due` counts the open and lapsed
190    /// obligations due at each.
191    pub fn sitting_items(
192        &self,
193        due_at: &[(CalendarEventId, ObligationStatus)],
194    ) -> Vec<CalendarItem> {
195        self.events
196            .iter()
197            .filter(|e| {
198                e.is_sitting()
199                    && e.status == CalendarEventStatus::Scheduled
200                    && self.pending(e)
201            })
202            .map(|e| CalendarItem::Sitting {
203                event: e.id,
204                event_kind: e.kind,
205                title: e.title.clone(),
206                expected_around: e.expected_at,
207                notes: e.notes.clone(),
208                obligations_due: due_at
209                    .iter()
210                    .filter(|(id, status)| {
211                        *id == e.id
212                            && matches!(
213                                status,
214                                ObligationStatus::Open
215                                    | ObligationStatus::Lapsed
216                            )
217                    })
218                    .count() as u64,
219            })
220            .collect()
221    }
222
223    /// The obligations due on a date still to come, the ones a date binds:
224    /// what [`upcoming`](Self::upcoming) lists beside the sittings
225    fn dated<'a>(
226        &self,
227        obligations: &'a [ResolvedObligation],
228    ) -> Vec<&'a ResolvedObligation> {
229        obligations
230            .iter()
231            .filter(|o| {
232                o.obligation.state.status == ObligationStatus::Open
233                    && o.resolved.date_binds
234                    && o.resolved.sitting.is_none()
235                    && o.resolved.due.is_some_and(|d| d >= self.today)
236            })
237            .collect()
238    }
239
240    /// What is coming up, soonest first, at most `limit` items: pending
241    /// sittings and obligations due on a date. `obligations` is every
242    /// obligation, resolved against this calendar. `skip_next` leaves out
243    /// the next sitting, for the dashboard, whose Council block already
244    /// gives it.
245    ///
246    /// An obligation's line carries its `text` (what is owed, in the
247    /// recorder's words), never its `quote`, which may hold an agent's.
248    pub fn upcoming(
249        &self,
250        obligations: &[ResolvedObligation],
251        skip_next: bool,
252        limit: usize,
253    ) -> Vec<CalendarItem> {
254        let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
255            .iter()
256            .filter_map(|o| {
257                Some((o.resolved.sitting?, o.obligation.state.status))
258            })
259            .collect();
260        let mut items = self.sitting_items(&due_at);
261        if skip_next && let Some(next) = self.next_sitting() {
262            items.retain(|i| {
263                !matches!(i, CalendarItem::Sitting { event, .. } if *event == next.id)
264            });
265        }
266        items.extend(self.dated(obligations).into_iter().filter_map(|o| {
267            Some(CalendarItem::ObligationDue {
268                obligation: o.obligation.id.clone(),
269                text: o.obligation.data.text.clone(),
270                owed_by: o.obligation.data.owed_by,
271                due: o.resolved.due?,
272            })
273        }));
274        items.sort_by_key(|item| item.at());
275        items.truncate(limit);
276        items
277    }
278}
279
280impl CalendarResponse {
281    /// The calendar `get_content("calendar")` serves: every item still to
282    /// come, up to [`CALENDAR_LIMIT`]
283    pub fn new(
284        calendar: &Calendar,
285        obligations: &[ResolvedObligation],
286        unreadable: Vec<GovernanceLogId>,
287    ) -> Self {
288        Self {
289            how_to_read: CALENDAR_HOW_TO_READ.to_string(),
290            items: calendar.upcoming(obligations, false, CALENDAR_LIMIT),
291            unreadable_obligations: crate::responses::UnreadableObligations::of(
292                unreadable,
293            ),
294        }
295    }
296}
297
298/// An obligation with its deadline resolved against the calendar
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct ResolvedObligation {
301    pub obligation: Obligation,
302    pub resolved: Resolved,
303    /// Open, and past a date that binds. Derived when read, never stored
304    pub overdue: bool,
305}
306
307impl ResolvedObligation {
308    /// `obligation` resolved against `calendar`; `decided` holds the
309    /// decisions that are attested entries of the log
310    pub fn new(
311        obligation: Obligation,
312        calendar: &Calendar,
313        decided: &HashSet<GovernanceLogId>,
314    ) -> Self {
315        let resolved = calendar
316            .resolve(obligation.state.until.as_ref(), |id| {
317                decided.contains(id)
318            });
319        let overdue = obligation.state.status == ObligationStatus::Open
320            && resolved.date_binds
321            && resolved.due.is_some_and(|d| d < calendar.today);
322        Self {
323            obligation,
324            resolved,
325            overdue,
326        }
327    }
328
329    /// Discharged or withdrawn
330    fn closed(&self) -> bool {
331        !matches!(
332            self.obligation.state.status,
333            ObligationStatus::Open | ObligationStatus::Lapsed
334        )
335    }
336}
337
338impl ObligationSort {
339    /// Order `rows` for the to-do list.
340    ///
341    /// `due`: what is still owed first (open and lapsed), then what is
342    /// closed (discharged, withdrawn), so a closed item with an old date
343    /// never leads the list; within each, the earliest resolved date
344    /// first, so lapsed and overdue items lead; ties to the older; undated
345    /// last, oldest first. `created`: oldest first. Both end on the id, so
346    /// the order is total and pages neither repeat nor skip.
347    pub fn sort(self, rows: &mut [ResolvedObligation]) {
348        match self {
349            Self::Due => rows.sort_by(|a, b| {
350                (
351                    a.closed(),
352                    a.resolved.due.is_none(),
353                    a.resolved.due,
354                    a.obligation.created_at,
355                    &a.obligation.id,
356                )
357                    .cmp(&(
358                        b.closed(),
359                        b.resolved.due.is_none(),
360                        b.resolved.due,
361                        b.obligation.created_at,
362                        &b.obligation.id,
363                    ))
364            }),
365            Self::Created => rows.sort_by(|a, b| {
366                (a.obligation.created_at, &a.obligation.id)
367                    .cmp(&(b.obligation.created_at, &b.obligation.id))
368            }),
369        }
370    }
371}
372
373impl From<ResolvedObligation> for ObligationListing {
374    /// The listing the to-do list serves
375    fn from(r: ResolvedObligation) -> Self {
376        let ResolvedObligation {
377            obligation,
378            resolved,
379            overdue,
380        } = r;
381        let Obligation {
382            id,
383            created_at,
384            data,
385            state,
386            ..
387        } = obligation;
388        Self {
389            id,
390            source: data.source,
391            kind: data.kind,
392            owed_by: data.owed_by,
393            status: state.status,
394            text: data.text,
395            until: state.until,
396            due: resolved.due,
397            due_approximate: resolved.approximate,
398            overdue,
399            lapsed_at: state.lapsed_at,
400            plan: state.plan,
401            created_at,
402        }
403    }
404}
405
406impl From<&[ResolvedObligation]> for ObligationTally {
407    /// Lapsed and overdue counts, for the dashboard
408    fn from(rows: &[ResolvedObligation]) -> Self {
409        Self {
410            lapsed: rows
411                .iter()
412                .filter(|r| {
413                    r.obligation.state.status == ObligationStatus::Lapsed
414                })
415                .count() as u64,
416            overdue: rows.iter().filter(|r| r.overdue).count() as u64,
417            unreadable: Vec::new(),
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests;