1use 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
34pub const CALENDAR_LIMIT: usize = 100;
36
37pub const UPCOMING_LIMIT: usize = 5;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct EventState {
43 pub id: CalendarEventId,
44 pub kind: CalendarEventKind,
45 pub title: String,
46 pub council_meeting_id: Option<CouncilMeetingId>,
48 pub expected_at: DateTime<Utc>,
49 pub status: CalendarEventStatus,
50 pub notes: Option<String>,
51 pub changed_at: DateTime<Utc>,
53}
54
55impl EventState {
56 pub fn is_sitting(&self) -> bool {
58 matches!(
59 self.kind,
60 CalendarEventKind::Sitting | CalendarEventKind::SpecialSitting
61 )
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub struct Resolved {
68 pub due: Option<NaiveDate>,
70 pub approximate: bool,
72 pub sitting: Option<CalendarEventId>,
74 pub date_binds: bool,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Calendar {
83 pub events: Vec<EventState>,
84 pub last_adjourned: Option<DateTime<Utc>>,
87 pub today: NaiveDate,
88}
89
90impl Calendar {
91 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 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 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 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 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 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 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 pub fn new(
284 calendar: &Calendar,
285 obligations: &[ResolvedObligation],
286 ) -> Self {
287 Self {
288 how_to_read: CALENDAR_HOW_TO_READ.to_string(),
289 items: calendar.upcoming(obligations, false, CALENDAR_LIMIT),
290 }
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct ResolvedObligation {
297 pub obligation: Obligation,
298 pub resolved: Resolved,
299 pub overdue: bool,
301}
302
303impl ResolvedObligation {
304 pub fn new(
307 obligation: Obligation,
308 calendar: &Calendar,
309 decided: &HashSet<GovernanceLogId>,
310 ) -> Self {
311 let resolved = calendar
312 .resolve(obligation.state.until.as_ref(), |id| {
313 decided.contains(id)
314 });
315 let overdue = obligation.state.status == ObligationStatus::Open
316 && resolved.date_binds
317 && resolved.due.is_some_and(|d| d < calendar.today);
318 Self {
319 obligation,
320 resolved,
321 overdue,
322 }
323 }
324
325 fn closed(&self) -> bool {
327 !matches!(
328 self.obligation.state.status,
329 ObligationStatus::Open | ObligationStatus::Lapsed
330 )
331 }
332}
333
334impl ObligationSort {
335 pub fn sort(self, rows: &mut [ResolvedObligation]) {
344 match self {
345 Self::Due => rows.sort_by(|a, b| {
346 (
347 a.closed(),
348 a.resolved.due.is_none(),
349 a.resolved.due,
350 a.obligation.created_at,
351 &a.obligation.id,
352 )
353 .cmp(&(
354 b.closed(),
355 b.resolved.due.is_none(),
356 b.resolved.due,
357 b.obligation.created_at,
358 &b.obligation.id,
359 ))
360 }),
361 Self::Created => rows.sort_by(|a, b| {
362 (a.obligation.created_at, &a.obligation.id)
363 .cmp(&(b.obligation.created_at, &b.obligation.id))
364 }),
365 }
366 }
367}
368
369impl From<ResolvedObligation> for ObligationListing {
370 fn from(r: ResolvedObligation) -> Self {
372 let ResolvedObligation {
373 obligation,
374 resolved,
375 overdue,
376 } = r;
377 let Obligation {
378 id,
379 created_at,
380 data,
381 state,
382 ..
383 } = obligation;
384 Self {
385 id,
386 source: data.source,
387 kind: data.kind,
388 owed_by: data.owed_by,
389 status: state.status,
390 text: data.text,
391 until: state.until,
392 due: resolved.due,
393 due_approximate: resolved.approximate,
394 overdue,
395 lapsed_at: state.lapsed_at,
396 plan: state.plan,
397 created_at,
398 }
399 }
400}
401
402impl From<&[ResolvedObligation]> for ObligationTally {
403 fn from(rows: &[ResolvedObligation]) -> Self {
405 Self {
406 lapsed: rows
407 .iter()
408 .filter(|r| {
409 r.obligation.state.status == ObligationStatus::Lapsed
410 })
411 .count() as u64,
412 overdue: rows.iter().filter(|r| r.overdue).count() as u64,
413 }
414 }
415}
416
417#[cfg(test)]
418mod tests;