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 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#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct ResolvedObligation {
301 pub obligation: Obligation,
302 pub resolved: Resolved,
303 pub overdue: bool,
305}
306
307impl ResolvedObligation {
308 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 fn closed(&self) -> bool {
331 !matches!(
332 self.obligation.state.status,
333 ObligationStatus::Open | ObligationStatus::Lapsed
334 )
335 }
336}
337
338impl ObligationSort {
339 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 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 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;