pub(crate) mod utils;
use anyhow::{ensure, Result};
use chrono::{DateTime, TimeZone, Utc};
use fn_error_context::context;
use intervaltree::{Element, IntervalTree};
use serde::{Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt::Write;
use std::ops::Range;
use std::time::Duration;
pub(crate) const MAX_WEEKLY_MINS: u32 = 7 * 24 * 60;
pub(crate) const MAX_WEEKLY_SECS: u64 = (MAX_WEEKLY_MINS as u64) * 60;
pub(crate) type MinuteInWeek = u32;
#[derive(Clone, Debug)]
pub struct WeeklyCalendar {
windows: IntervalTree<MinuteInWeek, WeeklyWindow>,
}
impl WeeklyCalendar {
pub fn new(input: Vec<WeeklyWindow>) -> Self {
let intervals = input
.into_iter()
.map(|win| Element::from((win.range_weekly_minutes(), win)));
Self {
windows: intervals.collect(),
}
}
pub fn contains_datetime(&self, datetime: &DateTime<impl TimeZone>) -> bool {
let timepoint = utils::datetime_as_weekly_minute(datetime);
self.windows.query_point(timepoint).count() > 0
}
pub fn next_window_minute_in_week(
&self,
datetime: &DateTime<impl TimeZone>,
) -> Option<MinuteInWeek> {
if self.is_empty() {
return None;
}
if self.contains_datetime(datetime) {
return Some(utils::datetime_as_weekly_minute(datetime));
}
let timepoint = utils::datetime_as_weekly_minute(datetime);
if let Some(next) = self
.windows
.iter_sorted()
.find(|x| x.range.start >= timepoint)
{
let next_minute_in_week = next.range.start;
return Some(next_minute_in_week);
};
let first_window_next_week = self
.windows
.iter_sorted()
.next()
.expect("unexpected empty weekly calendar")
.range
.start;
Some(first_window_next_week)
}
pub fn remaining_to_datetime(&self, datetime: &DateTime<Utc>) -> Option<chrono::Duration> {
if self.is_empty() {
return None;
}
if self.contains_datetime(datetime) {
return Some(chrono::Duration::zero());
}
let timepoint = utils::datetime_as_weekly_minute(datetime);
if let Some(next) = self
.windows
.iter_sorted()
.find(|x| x.range.start >= timepoint)
{
let remaining_mins = next.range.start.saturating_sub(timepoint);
return Some(chrono::Duration::minutes(i64::from(remaining_mins)));
};
let remaining_mins = {
let remaining_this_week: i64 = MAX_WEEKLY_MINS.saturating_sub(timepoint).into();
let first_window_next_week = self
.windows
.iter_sorted()
.next()
.expect("unexpected empty weekly calendar");
remaining_this_week.saturating_add(first_window_next_week.range.start.into())
};
Some(chrono::Duration::minutes(remaining_mins))
}
pub fn human_remaining_duration(remaining: &chrono::Duration) -> Result<String> {
if remaining.is_zero() {
return Ok("now".to_string());
}
let mut human_readable = "in".to_string();
let days = remaining.num_days() % 7;
let earlier_output = if days > 0 {
write!(&mut human_readable, " {}d", days)?;
true
} else {
false
};
let hours = remaining.num_hours() % 24;
if hours > 0 || earlier_output {
write!(&mut human_readable, " {}h", hours)?;
}
let minutes = remaining.num_minutes() % 60;
write!(&mut human_readable, " {}m", minutes)?;
Ok(human_readable)
}
#[allow(clippy::reversed_empty_ranges)]
pub fn length_minutes(&self) -> u64 {
let mut measured = 0u32;
let mut last_range = Range {
start: 0u32,
end: 0u32,
};
for win in self.windows.iter_sorted() {
if win.range.start > last_range.end {
let last_length = last_range
.end
.saturating_sub(last_range.start)
.saturating_sub(1);
measured = measured.saturating_add(last_length);
last_range = win.range.clone();
} else {
last_range.end = u32::max(last_range.end, win.range.end);
};
}
let last_length = last_range
.end
.saturating_sub(last_range.start)
.saturating_sub(1);
measured = measured.saturating_add(last_length);
u64::from(measured)
}
pub fn is_empty(&self) -> bool {
self.windows.iter().next().is_none()
}
#[cfg(test)]
pub fn total_length_minutes(&self) -> u64 {
self.windows.iter().fold(0u64, |len, win| {
len.saturating_add(win.value.length_minutes().into())
})
}
#[cfg(test)]
pub fn containing_windows(&self, datetime: &DateTime<impl TimeZone>) -> Vec<&WeeklyWindow> {
let timepoint = utils::datetime_as_weekly_minute(datetime);
self.windows
.query_point(timepoint)
.map(|elem| &elem.value)
.collect()
}
}
impl Serialize for WeeklyCalendar {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeSeq;
let len = self.windows.iter().count();
let mut seq = serializer.serialize_seq(Some(len))?;
for interval in self.windows.iter() {
seq.serialize_element(&interval.value)?;
}
seq.end()
}
}
impl Default for WeeklyCalendar {
fn default() -> Self {
Self::new(vec![])
}
}
#[derive(Clone, Debug, Eq, Serialize)]
pub struct WeeklyWindow {
start_day: chrono::Weekday,
start_hour: u8,
start_minute: u8,
length: Duration,
}
impl WeeklyWindow {
#[context("failed to parse timespan into weekly windows")]
pub fn parse_timespan(
start_day: chrono::Weekday,
start_hour: u8,
start_minute: u8,
length: Duration,
) -> Result<Vec<Self>> {
ensure!(
start_hour <= 24 && start_minute <= 59,
"invalid start time: {}:{}",
start_hour,
start_minute
);
utils::check_duration(&length)?;
let remaining_len = {
let start = utils::time_as_weekly_minute(start_day, start_hour, start_minute);
let end_of_timespan_secs = u64::from(start)
.saturating_mul(60)
.saturating_add(length.as_secs());
let remaining_secs = end_of_timespan_secs.saturating_sub(MAX_WEEKLY_SECS);
Duration::from_secs(remaining_secs)
};
let chopped_len = length - remaining_len;
utils::check_duration(&chopped_len)?;
let win1 = Self {
start_day,
start_hour,
start_minute,
length: chopped_len,
};
let mut windows = vec![win1];
if remaining_len.as_secs() > 0 {
utils::check_duration(&remaining_len)?;
let win2 = Self {
start_day: chrono::Weekday::Mon,
start_hour: 0,
start_minute: 0,
length: remaining_len,
};
windows.push(win2);
}
Ok(windows)
}
pub fn length_minutes(&self) -> u32 {
(self.length.as_secs() / 60) as u32
}
pub fn range_weekly_minutes(&self) -> Range<MinuteInWeek> {
Range {
start: self.start_minutes(),
end: self.end_minutes().saturating_add(1),
}
}
fn start_minutes(&self) -> MinuteInWeek {
let minutes = u32::from(self.start_minute);
let hours = u32::from(self.start_hour).saturating_mul(60);
let days = self
.start_day
.num_days_from_monday()
.saturating_mul(24)
.saturating_mul(60);
days.saturating_add(hours).saturating_add(minutes)
}
fn end_minutes(&self) -> MinuteInWeek {
let start = self.start_minutes();
let length = self.length_minutes();
start.saturating_add(length)
}
#[cfg(test)]
pub fn contains_datetime(&self, datetime: &DateTime<Utc>) -> bool {
let instant = utils::datetime_as_weekly_minute(datetime);
self.start_minutes() <= instant && instant <= self.end_minutes()
}
}
impl Ord for WeeklyWindow {
fn cmp(&self, other: &Self) -> Ordering {
match self.start_minutes().cmp(&other.start_minutes()) {
Ordering::Equal => self.end_minutes().cmp(&other.end_minutes()),
cmp => cmp,
}
}
}
impl PartialOrd for WeeklyWindow {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for WeeklyWindow {
fn eq(&self, other: &Self) -> bool {
self.start_minutes() == other.start_minutes() && self.end_minutes() == other.end_minutes()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
#[test]
fn window_basic() {
let start_minutes = (2 * 24 * 60) + (6 * 60);
let end_minutes = start_minutes + 45;
let length = utils::check_minutes(45).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Wed, 6, 00, length).unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].length_minutes(), 45);
assert_eq!(windows[0].start_minutes(), start_minutes);
assert_eq!(windows[0].end_minutes(), end_minutes);
}
#[test]
fn window_split_timespan() {
let length = utils::check_minutes(60).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 45, length).unwrap();
assert_eq!(windows.len(), 2);
assert_eq!(windows[0].length_minutes(), 15);
assert_eq!(windows[1].length_minutes(), 45);
assert_eq!(windows[1].start_minutes(), 0);
assert_eq!(windows[1].end_minutes(), 45);
}
#[test]
fn calendar_basic() {
let length = utils::check_minutes(60).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 23, 45, length).unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].length_minutes(), 60);
let calendar = WeeklyCalendar::new(windows);
assert_eq!(calendar.windows.iter().count(), 1);
}
#[test]
fn window_contains_datetime() {
let length = utils::check_minutes(120).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 14, 30, length).unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].length_minutes(), 120);
let before_start = DateTime::parse_from_rfc3339("2019-06-24T14:29:59+00:00").unwrap();
assert!(!windows[0].contains_datetime(&before_start.into()));
let start = DateTime::parse_from_rfc3339("2019-06-24T14:30:00+00:00").unwrap();
assert!(windows[0].contains_datetime(&start.into()));
let after_start = DateTime::parse_from_rfc3339("2019-06-24T14:30:00+00:00").unwrap();
assert!(windows[0].contains_datetime(&after_start.into()));
let before_end = DateTime::parse_from_rfc3339("2019-06-24T16:29:59+00:00").unwrap();
assert!(windows[0].contains_datetime(&before_end.into()));
let end = DateTime::parse_from_rfc3339("2019-06-24T16:30:59+00:00").unwrap();
assert!(windows[0].contains_datetime(&end.into()));
let after_end = DateTime::parse_from_rfc3339("2019-06-24T16:31:00+00:00").unwrap();
assert!(!windows[0].contains_datetime(&after_end.into()));
}
#[test]
fn window_week_boundary() {
let length = utils::check_minutes(1).unwrap();
let single = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 59, length).unwrap();
assert_eq!(single.len(), 1);
assert_eq!(single[0].length_minutes(), 1);
let length = utils::check_minutes(2).unwrap();
let chopped = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 59, length).unwrap();
assert_eq!(chopped.len(), 2);
assert_eq!(chopped[0].length_minutes(), 1);
assert_eq!(chopped[1].length_minutes(), 1);
}
#[test]
fn calendar_contains_datetime() {
let length = utils::check_minutes(75).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Tue, 21, 0, length).unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].length_minutes(), 75);
let calendar = WeeklyCalendar::new(windows);
assert_eq!(calendar.windows.iter().count(), 1);
let datetime = Utc.ymd(2019, 6, 25).and_hms(21, 10, 0);
assert!(calendar.contains_datetime(&datetime));
let datetime = Local.ymd(2019, 6, 25).and_hms(21, 10, 0);
assert!(calendar.contains_datetime(&datetime));
}
#[test]
fn calendar_whole_week() {
let length = utils::check_minutes(MAX_WEEKLY_MINS).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 0, 0, length).unwrap();
assert_eq!(windows.len(), 1);
let calendar = WeeklyCalendar::new(windows);
assert_eq!(calendar.windows.iter().count(), 1);
assert_eq!(calendar.total_length_minutes(), u64::from(MAX_WEEKLY_MINS));
assert_eq!(calendar.length_minutes(), u64::from(MAX_WEEKLY_MINS));
let datetime = chrono::Utc::now();
assert!(calendar.contains_datetime(&datetime));
let datetime = chrono::Local::now();
assert!(calendar.contains_datetime(&datetime));
}
#[test]
fn calendar_containing_window() {
let length = utils::check_minutes(75).unwrap();
let windows = WeeklyWindow::parse_timespan(chrono::Weekday::Tue, 21, 0, length).unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].length_minutes(), 75);
let calendar = WeeklyCalendar::new(windows.clone());
assert_eq!(calendar.windows.iter().count(), 1);
let datetime = DateTime::parse_from_rfc3339("2019-06-25T21:10:00+00:00").unwrap();
assert!(calendar.contains_datetime(&datetime));
let containing_windows = calendar.containing_windows(&datetime);
assert_eq!(containing_windows.len(), 1);
assert_eq!(containing_windows[0], &windows[0]);
}
#[test]
fn calendar_length() {
let l1 = utils::check_minutes(45).unwrap();
let mut w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 15, l1).unwrap();
assert_eq!(w1.len(), 1);
assert_eq!(w1[0].length_minutes(), 45);
let l2 = utils::check_minutes(120).unwrap();
let w2 = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 30, l2).unwrap();
assert_eq!(w2.len(), 2);
assert_eq!(w2[0].length_minutes(), 30);
assert_eq!(w2[1].length_minutes(), 90);
w1.extend(w2);
let calendar = WeeklyCalendar::new(w1);
assert_eq!(calendar.windows.iter().count(), 3);
assert_eq!(calendar.total_length_minutes(), 165);
assert_eq!(calendar.length_minutes(), 150);
}
#[test]
fn datetime_remaining() {
let length = utils::check_minutes(15).unwrap();
let w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 30, length).unwrap();
let calendar = WeeklyCalendar::new(w1);
let cases = vec![
("2020-11-23T00:15:00+00:00", 60 + 15),
("2020-11-23T01:29:30+00:00", 1),
("2020-11-23T01:30:00+00:00", 0),
("2020-11-23T01:45:00+00:00", 0),
("2020-11-23T02:00:00+00:00", 60 * 24 * 7 - 120 + 90),
("2020-11-22T01:30:00+00:00", 60 * 24),
];
for (input, remaining) in cases {
let datetime = DateTime::parse_from_rfc3339(input).unwrap();
let output = calendar
.remaining_to_datetime(&datetime.into())
.unwrap()
.num_minutes();
assert_eq!(output, remaining, "{}", input);
}
}
#[test]
fn human_remaining() {
use chrono::Duration;
let cases = vec![
(0, "now"),
(1, "in 1m"),
(59, "in 59m"),
(60, "in 1h 0m"),
(61, "in 1h 1m"),
(120, "in 2h 0m"),
(1439, "in 23h 59m"),
(1440, "in 1d 0h 0m"),
(1441, "in 1d 0h 1m"),
(1501, "in 1d 1h 1m"),
(2879, "in 1d 23h 59m"),
(2880, "in 2d 0h 0m"),
(4503, "in 3d 3h 3m"),
];
for (mins, human) in cases {
let remaining = Duration::minutes(mins);
let output = WeeklyCalendar::human_remaining_duration(&remaining).unwrap();
assert_eq!(output, human, "{}", mins);
}
}
#[test]
fn test_next_window_minute_in_week() {
use chrono::{NaiveDate, TimeZone};
use tzfile::Tz;
let l1 = utils::check_minutes(45).unwrap();
let mut w1 = WeeklyWindow::parse_timespan(chrono::Weekday::Mon, 1, 15, l1).unwrap();
let l2 = utils::check_minutes(30).unwrap();
let w2 = WeeklyWindow::parse_timespan(chrono::Weekday::Wed, 16, 00, l2).unwrap();
let l3 = utils::check_minutes(120).unwrap();
let w3 = WeeklyWindow::parse_timespan(chrono::Weekday::Sun, 23, 00, l3).unwrap();
w1.extend(w2.clone());
w1.extend(w3.clone());
let calendar = WeeklyCalendar::new(w1.clone());
let tz = Tz::named("UTC").unwrap();
let dt0 = (&tz).from_utc_datetime(&NaiveDate::from_ymd(2021, 4, 12).and_hms(0, 0, 0));
let dt1 = (&tz).from_utc_datetime(&NaiveDate::from_ymd(2021, 4, 12).and_hms(1, 5, 0));
let dt2 = (&tz).from_utc_datetime(&NaiveDate::from_ymd(2021, 4, 12).and_hms(2, 16, 0));
let dt3 = (&tz).from_utc_datetime(&NaiveDate::from_ymd(2021, 4, 16).and_hms(15, 14, 56));
let dt4 = (&tz).from_utc_datetime(&NaiveDate::from_ymd(2021, 4, 18).and_hms(23, 35, 00));
let cases = vec![
(
calendar.next_window_minute_in_week(&dt0),
Some(utils::datetime_as_weekly_minute(&dt0)),
),
(
calendar.next_window_minute_in_week(&dt1),
Some(w1[0].range_weekly_minutes().start),
),
(
calendar.next_window_minute_in_week(&dt2),
Some(w2[0].range_weekly_minutes().start),
),
(
calendar.next_window_minute_in_week(&dt3),
Some(w3[0].range_weekly_minutes().start),
),
(
calendar.next_window_minute_in_week(&dt4),
Some(utils::datetime_as_weekly_minute(&dt4)),
),
];
for (actual, expected) in cases {
assert_eq!(actual, expected);
}
}
}