use std::cmp::{min, PartialEq};
use std::collections::{BTreeMap, HashSet};
use std::fmt::{Debug, Formatter};
use std::ops::{Add, Sub};
use std::rc::Rc;
use chrono::{Duration, NaiveDateTime, Weekday};
use serde::{Deserialize, Serialize};
use crate::models::activity::ActivityStatus::{BestEffort, Impossible, Scheduled};
use crate::models::activity::ActivityType::TopUpWeekBudget;
use crate::models::budget::TimeBudgetType::{Period, Week};
use crate::models::calendar_interval::CalIntStatus::Claimable;
use crate::models::calendar_interval::{CalIntStatus, CalendarInterval};
use crate::models::interval::Interval;
use crate::models::time_grid;
use crate::technical::error::SchedulerError;
use super::activity::{Activity, ActivityStatus};
use super::budget::{get_time_budgets_from, CalendarBudget};
use super::goal::{Budget, BudgetPeriod, Goal};
use super::task::{DayTasks, FinalTasks, Task};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ImpossibleActivity {
pub id: String,
pub minutes_missing: usize,
pub period_start_date_time: NaiveDateTime,
pub period_end_date_time: Option<NaiveDateTime>,
}
pub struct Calendar {
pub start_date_time: NaiveDateTime,
pub end_date_time: NaiveDateTime,
pub impossible_activities: Vec<ImpossibleActivity>,
pub budgets: Vec<CalendarBudget>,
pub intervals: Vec<CalendarInterval>,
registered_act_index: usize,
}
impl Calendar {
pub(crate) fn get_datetime_of(&self, index: usize) -> NaiveDateTime {
time_grid::datetime_at(self.start_date_time, index)
}
}
impl Calendar {
pub(crate) fn unregister(&mut self, interval: &Interval, act_index: usize) {
for cal_int in &mut self.intervals {
let is_overlapping =
interval.start < cal_int.interval.end && cal_int.interval.start < interval.end;
if is_overlapping {
match cal_int.status {
Claimable(ref mut claims) => {
claims.remove(&act_index);
}
CalIntStatus::Occupied(_, _) => {
}
}
}
}
}
}
impl Calendar {
pub(crate) fn register_activities(&mut self, activities: &[Activity]) {
for (act_index, activity) in activities.iter().enumerate() {
if act_index < self.registered_act_index {
continue;
}
for interval in &activity.compatible_intervals {
crate::log_debug!(
"Registering activity {} with act_index {}",
activity.title,
act_index
);
self.register(interval, act_index);
}
if activities[act_index].status != BestEffort {
self.registered_act_index += 1;
}
}
}
}
impl Calendar {
pub(crate) fn reduce_budgets_for(
&mut self,
goal_id: &str,
cal_index_start: usize,
cal_index_end: usize,
) {
let calendar_start = self.start_date_time;
for budget in &mut self.budgets {
budget.reduce_for_(calendar_start, goal_id, cal_index_start, cal_index_end);
}
}
}
impl Calendar {
pub(crate) fn update_compatible_intervals(&self, activity: &mut Activity) {
if activity.status == Scheduled
|| activity.status == Impossible
|| activity.status == ActivityStatus::Processed
|| activity.compatible_intervals.is_empty()
{
return;
}
for budget in &self.budgets {
if budget.applies_to(&activity.goal_id) {
for time_budget in &budget.time_budgets {
if time_budget.time_budget_type == Week
&& time_budget.max_scheduled == time_budget.scheduled
{
activity.reset_compatible_intervals();
return;
}
if time_budget.time_budget_type == Period
&& time_budget.max_scheduled == time_budget.scheduled
{
let Some(period_index) = time_budget.period_index else {
continue;
};
let period = &budget.periods[period_index];
let mut day_start = time_budget.calendar_start_index;
while day_start < time_budget.calendar_end_index {
let day_end = day_start + time_grid::SLOTS_PER_DAY;
let weekday = time_grid::weekday_at(self.start_date_time, day_start);
if period.on_days.contains(&weekday) {
let after = period.window.after_time;
let before = period.window.before_time;
if after < before {
activity.remove_interval(&Interval {
start: day_start + after,
end: day_start + before,
});
} else {
activity.remove_interval(&Interval {
start: day_start + after,
end: day_end,
});
activity.remove_interval(&Interval {
start: day_start,
end: day_start + before,
});
}
}
day_start = day_end;
}
}
}
}
}
let mut intervals_that_cant_fit_in_budget: Vec<Interval> = vec![];
for act_int in &activity.compatible_intervals {
for hour_index in act_int.start..act_int.end - activity.min_block_size {
for budget in &self.budgets {
if !budget.applies_to(&activity.goal_id) {
continue;
}
for time_budget in &budget.time_budgets {
let mut slots_toward_budget = 0usize;
for offset in 0..activity.min_block_size {
if budget.slot_counts_toward(
self.start_date_time,
time_budget,
hour_index + offset,
) {
slots_toward_budget += 1;
}
}
if slots_toward_budget == 0 {
continue;
}
let budget_left = time_budget.max_scheduled - time_budget.scheduled;
if slots_toward_budget > budget_left {
intervals_that_cant_fit_in_budget.push(Interval {
start: hour_index,
end: hour_index + 1,
});
}
}
}
}
}
if !intervals_that_cant_fit_in_budget.is_empty() {
crate::log_dbg!(&intervals_that_cant_fit_in_budget);
for interval in intervals_that_cant_fit_in_budget {
crate::log_debug!(
"Removing interval {}-{} from activity{}",
interval.start,
interval.end,
activity.title
);
activity.remove_interval(&interval);
}
}
}
}
impl PartialEq<&Interval> for CalendarInterval {
fn eq(&self, other: &&Interval) -> bool {
if self.interval.start == other.start && self.interval.end == other.end {
return true;
}
false
}
}
impl PartialEq<Interval> for CalendarInterval {
fn eq(&self, other: &Interval) -> bool {
if self.interval.start == other.start && self.interval.end == other.end {
return true;
}
false
}
}
impl Calendar {
pub(crate) fn occupy(
&mut self,
interval: &Interval,
act_index: usize,
activities: &mut [Activity],
) {
let mut impacted_act_indexes: HashSet<usize> = HashSet::new();
for cal_interval in &mut self.intervals {
let is_overlapping = interval.start < cal_interval.interval.end
&& cal_interval.interval.start < interval.end;
if is_overlapping {
#[cfg(debug_assertions)]
assert!(
cal_interval.interval.end <= interval.end && cal_interval.interval.start >= interval.start,
"Assumption broken: If cal_interval and interval overlap, cal_interval should always be equal or subset of occupied interval."
);
if let Claimable(claims) = &mut cal_interval.status {
for act_index_in_claim in claims.iter() {
activities[*act_index_in_claim].remove_interval(interval); if *act_index_in_claim != act_index {
impacted_act_indexes.insert(*act_index_in_claim);
}
}
}
cal_interval.status =
CalIntStatus::Occupied(act_index, activities[act_index].goal_id.clone());
} else if let Claimable(claims) = &mut cal_interval.status {
if claims.contains(&act_index) && activities[act_index].status == Scheduled {
claims.remove(&act_index);
for act_index_in_claim in claims.iter() {
activities[*act_index_in_claim].flex_reset();
}
}
}
}
for act_index_impacted in &impacted_act_indexes {
for incompatible_int in &activities[*act_index_impacted].incompatible_intervals {
self.register(incompatible_int, *act_index_impacted);
self.unregister(incompatible_int, *act_index_impacted);
}
activities[*act_index_impacted].incompatible_intervals = vec![];
}
}
}
impl PartialEq<Rc<Activity>> for Activity {
fn eq(&self, other: &Rc<Activity>) -> bool {
self.goal_id == other.goal_id
}
}
impl PartialEq for Activity {
fn eq(&self, other: &Activity) -> bool {
self.goal_id == other.goal_id
}
}
impl Calendar {
pub(crate) fn register(&mut self, interval: &Interval, act_index: usize) {
let old = std::mem::take(&mut self.intervals);
let mut result: Vec<CalendarInterval> = Vec::with_capacity(old.len() + 2);
for cal_interval in old {
let is_overlapping = interval.start < cal_interval.interval.end
&& cal_interval.interval.start < interval.end;
if !is_overlapping {
result.push(cal_interval);
continue;
}
let cal_start = cal_interval.interval.start;
let cal_end = cal_interval.interval.end;
let status = cal_interval.status;
let empty_begin = interval.start > cal_start;
let empty_end = interval.end < cal_end;
if empty_begin {
result.push(CalendarInterval {
interval: Interval {
start: cal_start,
end: interval.start,
},
status: status.clone(),
});
}
let overlap_start = if empty_begin {
interval.start
} else {
cal_start
};
let overlap_end = if empty_end { interval.end } else { cal_end };
let mut claimed = CalendarInterval {
interval: Interval {
start: overlap_start,
end: overlap_end,
},
status: status.clone(),
};
claimed.claim_by(act_index);
result.push(claimed);
if empty_end {
result.push(CalendarInterval {
interval: Interval {
start: interval.end,
end: cal_end,
},
status,
});
}
}
self.intervals = result;
}
}
impl PartialEq for CalIntStatus {
fn eq(&self, other: &Self) -> bool {
match self {
Claimable(_) => match other {
Claimable(_) => return true,
CalIntStatus::Occupied(_, _) => {}
},
CalIntStatus::Occupied(_, goal_id) => match other {
Claimable(_) => {}
CalIntStatus::Occupied(_, goal_id2) => {
if goal_id.eq(goal_id2) {
return true;
}
}
},
}
false
}
}
impl Calendar {
pub fn new(start_date_time: NaiveDateTime, end_date_time: NaiveDateTime) -> Self {
let span_minutes = (end_date_time - start_date_time).num_minutes();
crate::log_debug!(
"Calendar span {:?} minutes, from {:?} to {:?}",
span_minutes,
start_date_time,
end_date_time,
);
let span_slots = (span_minutes / time_grid::SLOT_MINUTES) as usize;
let extended_calendar_slots = 2 * time_grid::BUFFER_SLOTS + span_slots;
let intervals = vec![CalendarInterval {
interval: Interval {
start: 0,
end: extended_calendar_slots,
},
status: Claimable(HashSet::new()),
}];
Self {
start_date_time,
end_date_time,
impossible_activities: vec![],
budgets: vec![],
intervals,
registered_act_index: 0,
}
}
pub(crate) fn hours(&self) -> usize {
self.intervals
.last()
.expect("when calling hours there should be at least one interval in calendar.")
.interval
.end
}
pub fn get_week_day_of(&self, index_to_test: usize) -> Weekday {
#[cfg(debug_assertions)]
assert!(index_to_test < self.hours(),
"Can't request weekday for index {:?} outside of calendar capacity {:?}\nIndexes start at 0.\n",
index_to_test,
self.hours()
);
time_grid::weekday_at(self.start_date_time, index_to_test)
}
pub fn is_participating_in_a_budget(&self, goal_id: &str) -> bool {
for budget in &self.budgets {
if budget.applies_to(goal_id) {
return true;
}
}
false
}
pub fn get_index_of(&self, date_time: NaiveDateTime) -> usize {
if date_time < self.start_date_time.sub(Duration::days(1))
|| date_time > self.end_date_time.add(Duration::days(1))
{
panic!(
"can't request an index more than 1 day outside of calendar bounds for date {:?}\nCalendar starts at {:?} and ends at {:?}", date_time, self.start_date_time, self.end_date_time
)
}
time_grid::index_at(self.start_date_time, date_time)
}
pub fn print_new(&mut self, activities: &Vec<Activity>) -> FinalTasks {
crate::log_debug!("Printing new calendar:");
crate::log_dbg!(&self);
crate::log_debug!("Now consolidating intervals and splitting on day boundaries...");
consolidate_intervals_on_goal_id(&mut self.intervals);
split_intervals_on_day_boundaries(&mut self.intervals);
crate::log_dbg!(&self);
let mut scheduled: Vec<DayTasks> = transform_intervals_to_day_tasks(
self.intervals.clone(),
activities,
self.start_date_time,
);
FinalTasks {
scheduled: scheduled.drain(1..scheduled.len() - 1).collect::<Vec<_>>(), impossible: self.impossible_activities.clone(),
}
}
pub fn add_budgets_from(
&mut self,
goal_map: &BTreeMap<String, Goal>,
input_budgets: &[Budget],
) -> Result<(), SchedulerError> {
crate::log_debug!("Adding budgets (not activities) to calendar...");
let mut seen_ids = HashSet::new();
for budget in input_budgets {
if !seen_ids.insert(budget.id.clone()) {
return Err(SchedulerError::DuplicateBudgetId {
budget_id: budget.id.clone(),
});
}
if budget.periods.is_empty() {
return Err(SchedulerError::BudgetMissingPeriods {
budget_id: budget.id.clone(),
});
}
if budget.min_per_week > budget.max_per_week {
return Err(SchedulerError::InvalidBudgetBounds {
budget_id: budget.id.clone(),
what: "minPerWeek > maxPerWeek",
});
}
for period in &budget.periods {
if period.min_for_period > period.max_for_period {
return Err(SchedulerError::InvalidBudgetBounds {
budget_id: budget.id.clone(),
what: "minForPeriod > maxForPeriod",
});
}
if period.on_days.is_empty() {
return Err(SchedulerError::InvalidBudgetBounds {
budget_id: budget.id.clone(),
what: "period.onDays is empty",
});
}
}
#[cfg(debug_assertions)]
if budget.soft_period_mins_exceed_week_max() {
crate::log_debug!(
"Soft check: sum of period mins exceeds maxPerWeek for budget {}",
budget.id
);
}
}
for goal in goal_map.values() {
if let Some(budget_id) = &goal.budget_id {
if !input_budgets.iter().any(|b| b.id == *budget_id) {
return Err(SchedulerError::UnknownBudgetId {
goal_id: goal.id.clone(),
budget_id: budget_id.clone(),
});
}
}
}
for budget in input_budgets {
let mut participating_goals: Vec<String> = goal_map
.values()
.filter(|g| g.budget_id.as_deref() == Some(budget.id.as_str()))
.map(|g| g.id.clone())
.collect();
participating_goals.sort();
self.budgets.push(CalendarBudget {
budget_id: budget.id.clone(),
title: budget.title.clone().unwrap_or_else(|| budget.id.clone()),
participating_goals,
time_budgets: get_time_budgets_from(self, budget),
periods: budget.periods.clone(),
});
}
Ok(())
}
pub fn log_impossible_activities(&mut self, activities: &Vec<Activity>) {
for budget in &self.budgets {
for time_budget in &budget.time_budgets {
if time_budget.time_budget_type != Period {
continue;
}
if time_budget.scheduled < time_budget.min_scheduled
&& time_budget.calendar_end_index < self.get_index_of(self.end_date_time)
{
self.impossible_activities.push(ImpossibleActivity {
id: budget.budget_id.clone(),
minutes_missing: time_grid::slots_to_minutes(
time_budget.min_scheduled - time_budget.scheduled,
),
period_start_date_time: self.start_date_time
+ time_grid::slot_span(time_budget.calendar_start_index as i64),
period_end_date_time: Some(
self.start_date_time
+ time_grid::slot_span(time_budget.calendar_end_index as i64),
),
});
}
}
for time_budget in &budget.time_budgets {
if time_budget.time_budget_type != Week {
continue;
}
if time_budget.scheduled < time_budget.min_scheduled
&& time_budget.calendar_end_index <= self.get_index_of(self.end_date_time)
{
self.impossible_activities.push(ImpossibleActivity {
id: budget.budget_id.clone(),
minutes_missing: time_grid::slots_to_minutes(
time_budget.min_scheduled - time_budget.scheduled,
),
period_start_date_time: self.start_date_time
+ time_grid::slot_span(time_budget.calendar_start_index as i64),
period_end_date_time: Some(
self.start_date_time
+ time_grid::slot_span(time_budget.calendar_end_index as i64),
),
});
}
}
}
for activity in activities {
if activity.status == Impossible
&& activity.deadline.is_some()
&& activity.activity_type != TopUpWeekBudget
&& activity.deadline.unwrap() <= self.end_date_time
{
self.impossible_activities.push(ImpossibleActivity {
id: activity.goal_id.clone(),
minutes_missing: time_grid::slots_to_minutes(activity.duration_left),
period_start_date_time: activity.start,
period_end_date_time: activity.deadline,
});
}
}
}
pub(crate) fn get_periods_for(&self, id: &str) -> Option<&[BudgetPeriod]> {
for budget in &self.budgets {
if budget.applies_to(id) {
return Some(&budget.periods);
}
}
None
}
}
impl Debug for Calendar {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
writeln!(f)?;
writeln!(
f,
"{:?} impossible activities",
self.impossible_activities.len()
)?;
for budget in &self.budgets {
writeln!(f, "{:?}", &budget)?;
}
for interval in &self.intervals {
writeln!(f, "{:?}", &interval)?;
}
Ok(())
}
}
fn consolidate_intervals_on_goal_id(cal_ints: &mut Vec<CalendarInterval>) {
if cal_ints.is_empty() {
return;
}
let mut write_index = 0;
for read_index in 1..cal_ints.len() {
if cal_ints[read_index].status == cal_ints[write_index].status {
cal_ints[write_index].interval.end = cal_ints[read_index].interval.end;
} else {
write_index += 1;
cal_ints[write_index] = cal_ints[read_index].clone();
}
}
cal_ints.truncate(write_index + 1);
}
fn split_intervals_on_day_boundaries(intervals: &mut Vec<CalendarInterval>) {
let mut i = 0;
while i < intervals.len() {
let start = intervals[i].interval.start;
let end = intervals[i].interval.end;
let first_multiple = start.div_ceil(time_grid::SLOTS_PER_DAY) * time_grid::SLOTS_PER_DAY;
if first_multiple < end {
let mut new_intervals = Vec::new();
if start < first_multiple {
new_intervals.push(CalendarInterval {
interval: Interval {
start,
end: first_multiple,
},
status: intervals[i].status.clone(),
});
}
let mut current = first_multiple;
while current + time_grid::SLOTS_PER_DAY < end {
new_intervals.push(CalendarInterval {
interval: Interval {
start: current,
end: current + time_grid::SLOTS_PER_DAY,
},
status: intervals[i].status.clone(),
});
current += time_grid::SLOTS_PER_DAY;
}
new_intervals.push(CalendarInterval {
interval: Interval {
start: current,
end,
},
status: intervals[i].status.clone(),
});
intervals.splice(i..=i, new_intervals.clone());
i += new_intervals.len();
} else {
i += 1;
}
}
}
fn transform_intervals_to_day_tasks(
intervals: Vec<CalendarInterval>,
activities: &Vec<Activity>,
calendar_start: NaiveDateTime,
) -> Vec<DayTasks> {
let mut task_counter: usize = 0;
let mut day_tasks: Vec<DayTasks> = Vec::new();
let mut current_day = 0;
let mut current_day_tasks = Vec::new();
#[allow(clippy::explicit_counter_loop)]
for interval in intervals {
let day_start =
(interval.interval.start / time_grid::SLOTS_PER_DAY) * time_grid::SLOTS_PER_DAY;
let day_end = day_start + time_grid::SLOTS_PER_DAY;
if day_start > current_day {
if !current_day_tasks.is_empty() {
day_tasks.push(DayTasks {
day: (calendar_start
+ time_grid::slot_span(
current_day as i64 - time_grid::BUFFER_SLOTS as i64,
))
.into(),
tasks: current_day_tasks,
});
}
current_day = day_start;
current_day_tasks = Vec::new();
}
let start = interval.interval.start % time_grid::SLOTS_PER_DAY;
let end = min(interval.interval.end, day_end) % time_grid::SLOTS_PER_DAY;
let duration = if end > start {
end - start
} else {
(time_grid::SLOTS_PER_DAY - start) + end
};
let task = Task {
taskid: task_counter,
goalid: match interval.status {
CalIntStatus::Occupied(.., ref goal_id) => goal_id.clone(),
Claimable(_) => "free".to_string(),
},
title: match interval.status {
CalIntStatus::Occupied(act_index, ..) => activities[act_index].title.clone(),
Claimable(_) => "free".to_string(),
},
duration: time_grid::slots_to_minutes(duration),
start: calendar_start
+ time_grid::slot_span(
day_start as i64 + start as i64 - time_grid::BUFFER_SLOTS as i64,
),
deadline: calendar_start
+ time_grid::slot_span(
day_start as i64 + start as i64 + duration as i64
- time_grid::BUFFER_SLOTS as i64,
),
};
if day_start > 0 {
task_counter += 1;
}
current_day_tasks.push(task);
if interval.interval.end > day_end {
let remaining_interval = CalendarInterval {
interval: Interval {
start: day_end,
end: interval.interval.end,
},
status: interval.status,
};
day_tasks.extend(transform_intervals_to_day_tasks(
vec![remaining_interval],
activities,
calendar_start,
));
}
}
if !current_day_tasks.is_empty() {
day_tasks.push(DayTasks {
day: (calendar_start + time_grid::slot_span(current_day as i64)).into(),
tasks: current_day_tasks,
});
}
day_tasks
}