use std::collections::{HashMap, HashSet};
use chrono::{DateTime, NaiveDate, Utc};
use crate::enums::{
CalendarEventKind, CalendarEventStatus, ObligationKind, ObligationSort,
ObligationStatus, OwedBy,
};
use crate::govlog::Until;
use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId, PostId};
use crate::responses::{
CALENDAR_HOW_TO_READ, CalendarItem, CalendarResponse, ObligationListing,
ObligationTally,
};
pub const CALENDAR_LIMIT: usize = 100;
pub const UPCOMING_LIMIT: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventState {
pub id: CalendarEventId,
pub kind: CalendarEventKind,
pub title: String,
pub council_meeting_id: Option<CouncilMeetingId>,
pub expected_at: DateTime<Utc>,
pub status: CalendarEventStatus,
pub notes: Option<String>,
pub changed_at: DateTime<Utc>,
}
impl EventState {
pub fn is_sitting(&self) -> bool {
matches!(
self.kind,
CalendarEventKind::Sitting | CalendarEventKind::SpecialSitting
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Resolved {
pub due: Option<NaiveDate>,
pub approximate: bool,
pub sitting: Option<CalendarEventId>,
pub date_binds: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Calendar {
pub events: Vec<EventState>,
pub last_adjourned: Option<DateTime<Utc>>,
pub today: NaiveDate,
}
impl Calendar {
pub fn new(
events: Vec<EventState>,
last_adjourned: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Self {
Self {
events,
last_adjourned,
today: now.date_naive(),
}
}
pub fn pending(&self, e: &EventState) -> bool {
e.council_meeting_id.is_none()
&& e.status != CalendarEventStatus::Held
&& self.last_adjourned.is_none_or(|l| e.expected_at > l)
}
pub fn next_sitting(&self) -> Option<&EventState> {
self.events
.iter()
.filter(|e| {
e.is_sitting()
&& e.status == CalendarEventStatus::Scheduled
&& self.pending(e)
})
.min_by_key(|e| (e.expected_at, e.changed_at))
}
pub fn event(&self, id: CalendarEventId) -> Option<&EventState> {
self.events.iter().find(|e| e.id == id)
}
pub fn resolve(
&self,
until: Option<&Until>,
decided: impl Fn(&GovernanceLogId) -> bool,
) -> Resolved {
let next = || match self.next_sitting() {
Some(e) => Resolved {
due: Some(e.expected_at.date_naive()),
approximate: true,
sitting: Some(e.id),
date_binds: false,
},
None => Resolved {
due: Some(self.today),
approximate: true,
sitting: None,
date_binds: false,
},
};
match until {
None => Resolved::default(),
Some(Until::Date { date }) => Resolved {
due: Some(*date),
approximate: false,
sitting: None,
date_binds: true,
},
Some(Until::NextSitting) => next(),
Some(Until::Sitting { event }) => match self.event(*event) {
Some(e) if e.status == CalendarEventStatus::Cancelled => next(),
Some(e) => Resolved {
due: Some(e.expected_at.date_naive()),
approximate: true,
sitting: Some(e.id),
date_binds: !self.pending(e),
},
None => Resolved::default(),
},
Some(Until::AfterDecision { decision }) if decided(decision) => {
next()
}
Some(Until::AfterDecision { .. }) => Resolved::default(),
}
}
pub fn sitting_items(
&self,
due_at: &[(CalendarEventId, ObligationStatus)],
) -> Vec<CalendarItem> {
self.events
.iter()
.filter(|e| {
e.is_sitting()
&& e.status == CalendarEventStatus::Scheduled
&& self.pending(e)
})
.map(|e| CalendarItem::Sitting {
event: e.id,
event_kind: e.kind,
title: e.title.clone(),
expected_around: e.expected_at,
notes: e.notes.clone(),
obligations_due: due_at
.iter()
.filter(|(id, status)| {
*id == e.id
&& matches!(
status,
ObligationStatus::Open
| ObligationStatus::Lapsed
)
})
.count() as u64,
})
.collect()
}
pub fn dated<'a>(
&self,
obligations: &'a [ResolvedObligation],
) -> Vec<&'a ResolvedObligation> {
obligations
.iter()
.filter(|o| {
o.obligation.status == ObligationStatus::Open
&& o.resolved.date_binds
&& o.resolved.sitting.is_none()
&& o.resolved.due.is_some_and(|d| d >= self.today)
})
.collect()
}
pub fn upcoming(
&self,
obligations: &[ResolvedObligation],
texts: &HashMap<GovernanceLogId, String>,
skip_next: bool,
limit: usize,
) -> Vec<CalendarItem> {
let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
.iter()
.filter_map(|o| Some((o.resolved.sitting?, o.obligation.status)))
.collect();
let mut items = self.sitting_items(&due_at);
if skip_next && let Some(next) = self.next_sitting() {
items.retain(|i| {
!matches!(i, CalendarItem::Sitting { event, .. } if *event == next.id)
});
}
items.extend(self.dated(obligations).into_iter().filter_map(|o| {
Some(CalendarItem::ObligationDue {
obligation: o.obligation.id.clone(),
text: texts.get(&o.obligation.id)?.clone(),
owed_by: o.obligation.owed_by,
due: o.resolved.due?,
})
}));
items.sort_by_key(|item| item.at());
items.truncate(limit);
items
}
}
impl CalendarResponse {
pub fn new(
calendar: &Calendar,
obligations: &[ResolvedObligation],
texts: &HashMap<GovernanceLogId, String>,
) -> Self {
Self {
how_to_read: CALENDAR_HOW_TO_READ.to_string(),
items: calendar.upcoming(obligations, texts, false, CALENDAR_LIMIT),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObligationProjection {
pub id: GovernanceLogId,
pub source: Option<GovernanceLogId>,
pub kind: ObligationKind,
pub owed_by: OwedBy,
pub status: ObligationStatus,
pub until: Option<Until>,
pub lapsed_at: Option<DateTime<Utc>>,
pub plan: Option<PostId>,
pub created_at: DateTime<Utc>,
}
impl ObligationProjection {
pub fn awaited_decision(&self) -> Option<&GovernanceLogId> {
match &self.until {
Some(Until::AfterDecision { decision }) => Some(decision),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedObligation {
pub obligation: ObligationProjection,
pub resolved: Resolved,
pub overdue: bool,
}
impl ResolvedObligation {
pub fn new(
obligation: ObligationProjection,
calendar: &Calendar,
decided: &HashSet<GovernanceLogId>,
) -> Self {
let resolved = calendar
.resolve(obligation.until.as_ref(), |id| decided.contains(id));
let overdue = obligation.status == ObligationStatus::Open
&& resolved.date_binds
&& resolved.due.is_some_and(|d| d < calendar.today);
Self {
obligation,
resolved,
overdue,
}
}
pub fn into_listing(self, text: String) -> ObligationListing {
let o = self.obligation;
ObligationListing {
id: o.id,
source: o.source,
kind: o.kind,
owed_by: o.owed_by,
status: o.status,
text,
until: o.until,
due: self.resolved.due,
due_approximate: self.resolved.approximate,
overdue: self.overdue,
lapsed_at: o.lapsed_at,
plan: o.plan,
created_at: o.created_at,
}
}
fn closed(&self) -> bool {
!matches!(
self.obligation.status,
ObligationStatus::Open | ObligationStatus::Lapsed
)
}
}
impl ObligationSort {
pub fn sort(self, rows: &mut [ResolvedObligation]) {
match self {
Self::Due => rows.sort_by(|a, b| {
(
a.closed(),
a.resolved.due.is_none(),
a.resolved.due,
a.obligation.created_at,
&a.obligation.id,
)
.cmp(&(
b.closed(),
b.resolved.due.is_none(),
b.resolved.due,
b.obligation.created_at,
&b.obligation.id,
))
}),
Self::Created => rows.sort_by(|a, b| {
(a.obligation.created_at, &a.obligation.id)
.cmp(&(b.obligation.created_at, &b.obligation.id))
}),
}
}
}
impl From<&[ResolvedObligation]> for ObligationTally {
fn from(rows: &[ResolvedObligation]) -> Self {
Self {
lapsed: rows
.iter()
.filter(|r| r.obligation.status == ObligationStatus::Lapsed)
.count() as u64,
overdue: rows.iter().filter(|r| r.overdue).count() as u64,
}
}
}
#[cfg(test)]
mod tests;