1use std::collections::{HashMap, HashSet};
21
22use chrono::{DateTime, NaiveDate, Utc};
23
24use crate::enums::{
25 CalendarEventKind, CalendarEventStatus, ObligationKind, ObligationSort,
26 ObligationStatus, OwedBy,
27};
28use crate::govlog::Until;
29use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId, PostId};
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 pub fn dated<'a>(
228 &self,
229 obligations: &'a [ResolvedObligation],
230 ) -> Vec<&'a ResolvedObligation> {
231 obligations
232 .iter()
233 .filter(|o| {
234 o.obligation.status == ObligationStatus::Open
235 && o.resolved.date_binds
236 && o.resolved.sitting.is_none()
237 && o.resolved.due.is_some_and(|d| d >= self.today)
238 })
239 .collect()
240 }
241
242 pub fn upcoming(
252 &self,
253 obligations: &[ResolvedObligation],
254 texts: &HashMap<GovernanceLogId, String>,
255 skip_next: bool,
256 limit: usize,
257 ) -> Vec<CalendarItem> {
258 let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
259 .iter()
260 .filter_map(|o| Some((o.resolved.sitting?, o.obligation.status)))
261 .collect();
262 let mut items = self.sitting_items(&due_at);
263 if skip_next && let Some(next) = self.next_sitting() {
264 items.retain(|i| {
265 !matches!(i, CalendarItem::Sitting { event, .. } if *event == next.id)
266 });
267 }
268 items.extend(self.dated(obligations).into_iter().filter_map(|o| {
269 Some(CalendarItem::ObligationDue {
270 obligation: o.obligation.id.clone(),
271 text: texts.get(&o.obligation.id)?.clone(),
272 owed_by: o.obligation.owed_by,
273 due: o.resolved.due?,
274 })
275 }));
276 items.sort_by_key(|item| item.at());
277 items.truncate(limit);
278 items
279 }
280}
281
282impl CalendarResponse {
283 pub fn new(
286 calendar: &Calendar,
287 obligations: &[ResolvedObligation],
288 texts: &HashMap<GovernanceLogId, String>,
289 ) -> Self {
290 Self {
291 how_to_read: CALENDAR_HOW_TO_READ.to_string(),
292 items: calendar.upcoming(obligations, texts, false, CALENDAR_LIMIT),
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct ObligationProjection {
301 pub id: GovernanceLogId,
302 pub source: Option<GovernanceLogId>,
303 pub kind: ObligationKind,
304 pub owed_by: OwedBy,
305 pub status: ObligationStatus,
306 pub until: Option<Until>,
308 pub lapsed_at: Option<DateTime<Utc>>,
309 pub plan: Option<PostId>,
310 pub created_at: DateTime<Utc>,
311}
312
313impl ObligationProjection {
314 pub fn awaited_decision(&self) -> Option<&GovernanceLogId> {
317 match &self.until {
318 Some(Until::AfterDecision { decision }) => Some(decision),
319 _ => None,
320 }
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct ResolvedObligation {
327 pub obligation: ObligationProjection,
328 pub resolved: Resolved,
329 pub overdue: bool,
331}
332
333impl ResolvedObligation {
334 pub fn new(
337 obligation: ObligationProjection,
338 calendar: &Calendar,
339 decided: &HashSet<GovernanceLogId>,
340 ) -> Self {
341 let resolved = calendar
342 .resolve(obligation.until.as_ref(), |id| decided.contains(id));
343 let overdue = obligation.status == ObligationStatus::Open
344 && resolved.date_binds
345 && resolved.due.is_some_and(|d| d < calendar.today);
346 Self {
347 obligation,
348 resolved,
349 overdue,
350 }
351 }
352
353 pub fn into_listing(self, text: String) -> ObligationListing {
355 let o = self.obligation;
356 ObligationListing {
357 id: o.id,
358 source: o.source,
359 kind: o.kind,
360 owed_by: o.owed_by,
361 status: o.status,
362 text,
363 until: o.until,
364 due: self.resolved.due,
365 due_approximate: self.resolved.approximate,
366 overdue: self.overdue,
367 lapsed_at: o.lapsed_at,
368 plan: o.plan,
369 created_at: o.created_at,
370 }
371 }
372
373 fn closed(&self) -> bool {
375 !matches!(
376 self.obligation.status,
377 ObligationStatus::Open | ObligationStatus::Lapsed
378 )
379 }
380}
381
382impl ObligationSort {
383 pub fn sort(self, rows: &mut [ResolvedObligation]) {
392 match self {
393 Self::Due => rows.sort_by(|a, b| {
394 (
395 a.closed(),
396 a.resolved.due.is_none(),
397 a.resolved.due,
398 a.obligation.created_at,
399 &a.obligation.id,
400 )
401 .cmp(&(
402 b.closed(),
403 b.resolved.due.is_none(),
404 b.resolved.due,
405 b.obligation.created_at,
406 &b.obligation.id,
407 ))
408 }),
409 Self::Created => rows.sort_by(|a, b| {
410 (a.obligation.created_at, &a.obligation.id)
411 .cmp(&(b.obligation.created_at, &b.obligation.id))
412 }),
413 }
414 }
415}
416
417impl From<&[ResolvedObligation]> for ObligationTally {
418 fn from(rows: &[ResolvedObligation]) -> Self {
420 Self {
421 lapsed: rows
422 .iter()
423 .filter(|r| r.obligation.status == ObligationStatus::Lapsed)
424 .count() as u64,
425 overdue: rows.iter().filter(|r| r.overdue).count() as u64,
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests;