1use std::collections::HashSet;
21
22use chrono::{DateTime, NaiveDate, Utc};
23
24use crate::enums::{
25 CalendarEventKind, CalendarEventStatus, ObligationSort, ObligationStatus,
26 OwedBy,
27};
28use crate::govlog::{Obligation, Until};
29use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId};
30use crate::responses::{
31 CALENDAR_HOW_TO_READ, CalendarItem, CalendarResponse, ObligationListing,
32 ObligationTally,
33};
34
35pub const CALENDAR_LIMIT: usize = 100;
37
38pub const UPCOMING_LIMIT: usize = 5;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EventState {
44 pub id: CalendarEventId,
45 pub kind: CalendarEventKind,
46 pub title: String,
47 pub council_meeting_id: Option<CouncilMeetingId>,
49 pub expected_at: DateTime<Utc>,
50 pub status: CalendarEventStatus,
51 pub notes: Option<String>,
52 pub changed_at: DateTime<Utc>,
54}
55
56impl EventState {
57 pub fn is_sitting(&self) -> bool {
59 matches!(
60 self.kind,
61 CalendarEventKind::Sitting | CalendarEventKind::SpecialSitting
62 )
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub struct Resolved {
69 pub due: Option<NaiveDate>,
71 pub approximate: bool,
73 pub sitting: Option<CalendarEventId>,
75 pub date_binds: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Calendar {
84 pub events: Vec<EventState>,
85 pub last_adjourned: Option<DateTime<Utc>>,
88 pub today: NaiveDate,
89}
90
91impl Calendar {
92 pub fn new(
94 events: Vec<EventState>,
95 last_adjourned: Option<DateTime<Utc>>,
96 now: DateTime<Utc>,
97 ) -> Self {
98 Self {
99 events,
100 last_adjourned,
101 today: now.date_naive(),
102 }
103 }
104
105 pub fn pending(&self, e: &EventState) -> bool {
110 e.council_meeting_id.is_none()
111 && e.status != CalendarEventStatus::Held
112 && self.last_adjourned.is_none_or(|l| e.expected_at > l)
113 }
114
115 pub fn next_sitting(&self) -> Option<&EventState> {
118 self.events
119 .iter()
120 .filter(|e| {
121 e.is_sitting()
122 && e.status == CalendarEventStatus::Scheduled
123 && self.pending(e)
124 })
125 .min_by_key(|e| (e.expected_at, e.changed_at))
126 }
127
128 pub fn event(&self, id: CalendarEventId) -> Option<&EventState> {
129 self.events.iter().find(|e| e.id == id)
130 }
131
132 pub fn resolve(
145 &self,
146 until: Option<&Until>,
147 decided: impl Fn(&GovernanceLogId) -> bool,
148 ) -> Resolved {
149 let next = || match self.next_sitting() {
150 Some(e) => Resolved {
151 due: Some(e.expected_at.date_naive()),
152 approximate: true,
153 sitting: Some(e.id),
154 date_binds: false,
155 },
156 None => Resolved {
157 due: Some(self.today),
158 approximate: true,
159 sitting: None,
160 date_binds: false,
161 },
162 };
163 match until {
164 None => Resolved::default(),
165 Some(Until::Date { date }) => Resolved {
166 due: Some(*date),
167 approximate: false,
168 sitting: None,
169 date_binds: true,
170 },
171 Some(Until::NextSitting) => next(),
172 Some(Until::Sitting { event }) => match self.event(*event) {
173 Some(e) if e.status == CalendarEventStatus::Cancelled => next(),
174 Some(e) => Resolved {
175 due: Some(e.expected_at.date_naive()),
176 approximate: true,
177 sitting: Some(e.id),
178 date_binds: !self.pending(e),
179 },
180 None => Resolved::default(),
181 },
182 Some(Until::AfterDecision { decision }) if decided(decision) => {
183 next()
184 }
185 Some(Until::AfterDecision { .. }) => Resolved::default(),
186 }
187 }
188
189 pub fn sitting_items(
193 &self,
194 due_at: &[(CalendarEventId, ObligationStatus)],
195 ) -> Vec<CalendarItem> {
196 self.events
197 .iter()
198 .filter(|e| {
199 e.is_sitting()
200 && e.status == CalendarEventStatus::Scheduled
201 && self.pending(e)
202 })
203 .map(|e| CalendarItem::Sitting {
204 event: e.id,
205 event_kind: e.kind,
206 title: e.title.clone(),
207 expected_around: e.expected_at,
208 notes: e.notes.clone(),
209 obligations_due: due_at
210 .iter()
211 .filter(|(id, status)| {
212 *id == e.id
213 && matches!(
214 status,
215 ObligationStatus::Open
216 | ObligationStatus::Lapsed
217 )
218 })
219 .count() as u64,
220 })
221 .collect()
222 }
223
224 fn dated<'a>(
227 &self,
228 obligations: &'a [ResolvedObligation],
229 ) -> Vec<&'a ResolvedObligation> {
230 obligations
231 .iter()
232 .filter(|o| {
233 o.obligation.state.status == ObligationStatus::Open
234 && o.resolved.date_binds
235 && o.resolved.sitting.is_none()
236 && o.resolved.due.is_some_and(|d| d >= self.today)
237 })
238 .collect()
239 }
240
241 pub fn upcoming(
250 &self,
251 obligations: &[ResolvedObligation],
252 skip_next: bool,
253 limit: usize,
254 ) -> Vec<CalendarItem> {
255 let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
256 .iter()
257 .filter_map(|o| {
258 Some((o.resolved.sitting?, o.obligation.state.status))
259 })
260 .collect();
261 let mut items = self.sitting_items(&due_at);
262 if skip_next && let Some(next) = self.next_sitting() {
263 items.retain(|i| {
264 !matches!(i, CalendarItem::Sitting { event, .. } if *event == next.id)
265 });
266 }
267 items.extend(self.dated(obligations).into_iter().filter_map(|o| {
268 Some(CalendarItem::ObligationDue {
269 obligation: o.obligation.id.clone(),
270 text: o.obligation.data.text.clone(),
271 owed_by: o.obligation.data.owed_by,
272 due: o.resolved.due?,
273 })
274 }));
275 items.sort_by_key(|item| item.at());
276 items.truncate(limit);
277 items
278 }
279}
280
281impl CalendarResponse {
282 pub fn new(
285 calendar: &Calendar,
286 obligations: &[ResolvedObligation],
287 unreadable: Vec<GovernanceLogId>,
288 ) -> Self {
289 Self {
290 how_to_read: CALENDAR_HOW_TO_READ.to_string(),
291 items: calendar.upcoming(obligations, false, CALENDAR_LIMIT),
292 unreadable_obligations: crate::responses::UnreadableObligations::of(
293 unreadable,
294 ),
295 }
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct ResolvedObligation {
302 pub obligation: Obligation,
303 pub resolved: Resolved,
304 pub overdue: bool,
306}
307
308impl ResolvedObligation {
309 pub fn new(
312 obligation: Obligation,
313 calendar: &Calendar,
314 decided: &HashSet<GovernanceLogId>,
315 ) -> Self {
316 let resolved = calendar
317 .resolve(obligation.state.until.as_ref(), |id| {
318 decided.contains(id)
319 });
320 let overdue = obligation.state.status == ObligationStatus::Open
321 && resolved.date_binds
322 && resolved.due.is_some_and(|d| d < calendar.today);
323 Self {
324 obligation,
325 resolved,
326 overdue,
327 }
328 }
329
330 fn closed(&self) -> bool {
332 !matches!(
333 self.obligation.state.status,
334 ObligationStatus::Open | ObligationStatus::Lapsed
335 )
336 }
337}
338
339impl ObligationSort {
340 pub fn sort(self, rows: &mut [ResolvedObligation]) {
349 match self {
350 Self::Due => rows.sort_by(|a, b| {
351 (
352 a.closed(),
353 a.resolved.due.is_none(),
354 a.resolved.due,
355 a.obligation.created_at,
356 &a.obligation.id,
357 )
358 .cmp(&(
359 b.closed(),
360 b.resolved.due.is_none(),
361 b.resolved.due,
362 b.obligation.created_at,
363 &b.obligation.id,
364 ))
365 }),
366 Self::Created => rows.sort_by(|a, b| {
367 (a.obligation.created_at, &a.obligation.id)
368 .cmp(&(b.obligation.created_at, &b.obligation.id))
369 }),
370 }
371 }
372}
373
374impl From<ResolvedObligation> for ObligationListing {
375 fn from(r: ResolvedObligation) -> Self {
377 let ResolvedObligation {
378 obligation,
379 resolved,
380 overdue,
381 } = r;
382 let Obligation {
383 id,
384 created_at,
385 data,
386 state,
387 ..
388 } = obligation;
389 Self {
390 id,
391 source: data.source,
392 kind: data.kind,
393 owed_by: data.owed_by,
394 status: state.status,
395 text: data.text,
396 until: state.until,
397 due: resolved.due,
398 due_approximate: resolved.approximate,
399 overdue,
400 lapsed_at: state.lapsed_at,
401 plan: state.plan,
402 created_at,
403 }
404 }
405}
406
407impl From<&[ResolvedObligation]> for ObligationTally {
408 fn from(rows: &[ResolvedObligation]) -> Self {
410 Self {
411 lapsed: rows
412 .iter()
413 .filter(|r| {
414 r.obligation.state.status == ObligationStatus::Lapsed
415 })
416 .count() as u64,
417 overdue: rows.iter().filter(|r| r.overdue).count() as u64,
418 owed_by: OwedBy::ALL
419 .into_iter()
420 .filter(|who| {
421 rows.iter().any(|r| {
422 r.obligation.data.owed_by == *who
423 && (r.overdue
424 || r.obligation.state.status
425 == ObligationStatus::Lapsed)
426 })
427 })
428 .collect(),
429 unreadable: Vec::new(),
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests;