use std::collections::HashSet;
use chrono::{DateTime, NaiveDate, Utc};
use crate::enums::{
CalendarEventKind, CalendarEventStatus, ObligationSort, ObligationStatus,
OwedBy,
};
use crate::govlog::{Obligation, Until};
use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId};
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()
}
fn dated<'a>(
&self,
obligations: &'a [ResolvedObligation],
) -> Vec<&'a ResolvedObligation> {
obligations
.iter()
.filter(|o| {
o.obligation.state.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],
skip_next: bool,
limit: usize,
) -> Vec<CalendarItem> {
let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
.iter()
.filter_map(|o| {
Some((o.resolved.sitting?, o.obligation.state.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: o.obligation.data.text.clone(),
owed_by: o.obligation.data.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],
unreadable: Vec<GovernanceLogId>,
) -> Self {
Self {
how_to_read: CALENDAR_HOW_TO_READ.to_string(),
items: calendar.upcoming(obligations, false, CALENDAR_LIMIT),
unreadable_obligations: crate::responses::UnreadableObligations::of(
unreadable,
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedObligation {
pub obligation: Obligation,
pub resolved: Resolved,
pub overdue: bool,
}
impl ResolvedObligation {
pub fn new(
obligation: Obligation,
calendar: &Calendar,
decided: &HashSet<GovernanceLogId>,
) -> Self {
let resolved = calendar
.resolve(obligation.state.until.as_ref(), |id| {
decided.contains(id)
});
let overdue = obligation.state.status == ObligationStatus::Open
&& resolved.date_binds
&& resolved.due.is_some_and(|d| d < calendar.today);
Self {
obligation,
resolved,
overdue,
}
}
fn closed(&self) -> bool {
!matches!(
self.obligation.state.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 ObligationListing {
fn from(r: ResolvedObligation) -> Self {
let ResolvedObligation {
obligation,
resolved,
overdue,
} = r;
let Obligation {
id,
created_at,
data,
state,
..
} = obligation;
Self {
id,
source: data.source,
kind: data.kind,
owed_by: data.owed_by,
status: state.status,
text: data.text,
until: state.until,
due: resolved.due,
due_approximate: resolved.approximate,
overdue,
lapsed_at: state.lapsed_at,
plan: state.plan,
created_at,
}
}
}
impl From<&[ResolvedObligation]> for ObligationTally {
fn from(rows: &[ResolvedObligation]) -> Self {
Self {
lapsed: rows
.iter()
.filter(|r| {
r.obligation.state.status == ObligationStatus::Lapsed
})
.count() as u64,
overdue: rows.iter().filter(|r| r.overdue).count() as u64,
owed_by: OwedBy::ALL
.into_iter()
.filter(|who| {
rows.iter().any(|r| {
r.obligation.data.owed_by == *who
&& (r.overdue
|| r.obligation.state.status
== ObligationStatus::Lapsed)
})
})
.collect(),
unreadable: Vec::new(),
}
}
}
#[cfg(test)]
mod tests;