zinzen 0.3.0

Algorithm for auto-scheduling time-constrained tasks on a timeline
use crate::models::activity::ActivityStatus::{BestEffort, Postponed, Scheduled, Unprocessed};
use crate::models::activity::ActivityType;
use crate::models::activity::ActivityType::{
    GetToMinDayBudget, GetToMinWeekBudget, TopUpWeekBudget,
};
use crate::models::budget::TimeBudgetType::Week;
use crate::models::calendar_interval::CalIntStatus;
use crate::models::calendar_interval::CalIntStatus::Claimable;
use crate::models::interval::Interval;
use crate::models::time_grid;
use crate::models::{activity::Activity, calendar::Calendar};
use std::cmp::{max, min};
use std::collections::BTreeSet;
use std::fmt::{Debug, Formatter};

struct LeastConflict {
    start: usize,
    end: usize,
    claims: usize,
}

impl Debug for LeastConflict {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let _ = writeln!(
            f,
            "{:?} claims on {:?}-{:?} ({}-{}) day {:?}",
            self.claims,
            self.start,
            self.end,
            time_grid::format_slot_time_of_day(self.start),
            time_grid::format_slot_time_of_day(self.end),
            self.start / time_grid::SLOTS_PER_DAY
        );
        Ok(())
    }
}
pub(crate) fn place(calendar: &mut Calendar, activities: &mut [Activity]) {
    crate::log_debug!("Starting placing...");
    //Todo first check if there are any tasks_done_today
    calendar.register_activities(activities);
    crate::log_dbg!(&calendar);
    postpone(calendar, activities);

    while let Some(act_index) = find_next_act_index(calendar, activities) {
        crate::log_debug!(
            "Found activity {} to schedule, act_index {}",
            activities[act_index].title,
            act_index,
        );
        crate::log_debug!("  with flex {}.", activities[act_index].flex());
        let least_conflict: Option<LeastConflict> =
            get_best_index_for(calendar, &activities[act_index]);
        match least_conflict {
            None => {
                crate::log_debug!(
                    "No suitable position found for activity {}...",
                    activities[act_index].title
                );
                activities[act_index].mark_impossible();
                continue;
            }
            Some(least_conflict_position) => {
                crate::log_dbg!(&least_conflict_position);
                let interval_to_use = Interval {
                    start: least_conflict_position.start,
                    end: least_conflict_position.end,
                };
                calendar.register(&interval_to_use, act_index);
                if calendar.is_participating_in_a_budget(&activities[act_index].goal_id) {
                    calendar.reduce_budgets_for(
                        &activities[act_index].goal_id,
                        interval_to_use.start,
                        interval_to_use.end,
                    );
                }
                //Adjust activity internals
                //Todo: Simplify mess below
                match activities[act_index].activity_type {
                    ActivityType::SimpleGoal => {
                        activities[act_index].duration_left -=
                            interval_to_use.end - interval_to_use.start;
                        if activities[act_index].duration_left == 0 {
                            activities[act_index].status = Scheduled; //all at once, not per hour scheduling like before
                            activities[act_index].reset_compatible_intervals();
                        }
                    }
                    GetToMinDayBudget => {
                        activities[act_index].duration_left -=
                            interval_to_use.end - interval_to_use.start;
                        if activities[act_index].duration_left == 0 {
                            activities[act_index].status = Scheduled;
                            activities[act_index].reset_compatible_intervals();
                        }
                    }
                    GetToMinWeekBudget => {
                        //Only mark it scheduled if budget got to min per week amount
                        activities[act_index].duration_left -=
                            interval_to_use.end - interval_to_use.start;
                        if activities[act_index].duration_left == 0 {
                            activities[act_index].status = Scheduled;
                            activities[act_index].reset_compatible_intervals();
                        }
                    }
                    TopUpWeekBudget => {
                        activities[act_index].duration_left -=
                            interval_to_use.end - interval_to_use.start;
                        for budget in &calendar.budgets {
                            if budget
                                .participating_goals
                                .contains(&activities[act_index].goal_id)
                            {
                                for time_budget in &budget.time_budgets {
                                    if time_budget.time_budget_type == Week
                                        && time_budget.max_scheduled == time_budget.scheduled
                                    {
                                        activities[act_index].status = Scheduled;
                                        activities[act_index].reset_compatible_intervals();
                                    }
                                }
                            }
                        }
                        if activities[act_index].duration_left == 0 {
                            activities[act_index].status = Scheduled;
                            activities[act_index].reset_compatible_intervals();
                        }
                    }
                }
                //Now we know if the activity has been scheduled - even if it is a budget_min_week
                //This helps us in de decision to let go of other claims inside occupy function
                calendar.occupy(&interval_to_use, act_index, activities);
            }
        }
        crate::log_debug!("Finding next activity to schedule...");
        crate::log_dbg!(&calendar);
    }
    crate::log_debug!("No more activities to schedule.");
}

fn postpone(calendar: &mut Calendar, activities: &mut [Activity]) {
    for activity in activities.iter_mut() {
        if activity.deadline.is_none()
            && activity.status != BestEffort
            && activity.activity_type != GetToMinDayBudget
            && activity.activity_type != GetToMinWeekBudget
            && activity.activity_type != TopUpWeekBudget
            && !calendar.is_participating_in_a_budget(&activity.goal_id)
        {
            crate::log_debug!(
                "Skipping activity {} and setting to Postponed since there is no deadline.",
                activity.title
            );
            activity.status = Postponed;
            continue;
        }
    }
}

fn find_next_act_index(calendar: &mut Calendar, activities: &mut [Activity]) -> Option<usize> {
    //check budget validity for all positions of all cal_ints - for all activities
    //since last activity placed might put some positions over the max / day or max/week
    // todo later as possible optimization (measure/reason if useful!): not all activities have to be checked, just the ones that share the same budget(s)
    for activity in activities.iter_mut() {
        calendar.update_compatible_intervals(activity);
    }

    let mut highest_flex = 0;
    let mut act_index_next_to_schedule: Option<usize> = None;

    for (act_index, activity) in activities.iter_mut().enumerate() {
        if !(activity.status == Unprocessed || activity.status == BestEffort)
        //only get Flex for Unprocessed or BestEffort
        {
            continue;
        };
        crate::log_debug!(
            "Getting flex for {}, act_index {}.",
            activity.title,
            act_index
        );

        let flex = activity.flex();
        match flex {
            0 => {
                //no place possible
                activity.mark_impossible();
            }
            1 => {
                //only one place possible => need to fix_on_calendar
                crate::log_debug!("Flex of 1 found for activity {}", activity.title);
                act_index_next_to_schedule = Some(act_index);
                break;
            }
            _ => {
                if flex > highest_flex {
                    highest_flex = flex;
                    act_index_next_to_schedule = Some(act_index);
                }
            }
        }
    }
    act_index_next_to_schedule
}

fn get_conflicts_for(calendar: &Calendar, start_index: usize, end_index: usize) -> usize {
    let mut conflicts: Option<usize> = None;
    for cal_int in &calendar.intervals {
        if end_index <= cal_int.interval.start {
            //no more possible overlaps
            return conflicts.expect("When calling get conflicts a result is expected");
        }
        if start_index >= cal_int.interval.end {
            //no overlap yet
            continue;
        }
        //overlap
        let overlap_start = max(start_index, cal_int.interval.start);
        let overlap_end = min(end_index, cal_int.interval.end);
        match &cal_int.status {
            Claimable(claims) => match conflicts {
                None => {
                    conflicts = Some((overlap_end - overlap_start) * claims.len());
                }
                Some(number_of_conflicts) => {
                    conflicts =
                        Some(number_of_conflicts + (overlap_end - overlap_start) * claims.len());
                }
            },
            CalIntStatus::Occupied(_act_index, _goal_id) => {}
        }
    }
    conflicts.expect("When calling get conflicts a result is expected")
}
fn candidate_offsets(
    calendar: &Calendar,
    interval_start: usize,
    interval_end: usize,
    block_size: usize,
) -> Vec<usize> {
    let max_offset = interval_end
        .saturating_sub(interval_start)
        .saturating_sub(block_size);
    let mut offsets = BTreeSet::new();
    offsets.insert(0);
    if max_offset > 0 {
        offsets.insert(max_offset);
    }

    let search_end = interval_start + max_offset + block_size;
    for cal_int in &calendar.intervals {
        if cal_int.interval.end <= interval_start {
            continue;
        }
        if cal_int.interval.start >= search_end {
            break;
        }
        if !matches!(cal_int.status, Claimable(_)) {
            continue;
        }
        for boundary in [cal_int.interval.start, cal_int.interval.end] {
            if boundary > interval_start {
                let o_start = boundary - interval_start;
                if o_start <= max_offset {
                    offsets.insert(o_start);
                }
            }
            if boundary >= interval_start + block_size {
                let o_end = boundary
                    .saturating_sub(interval_start)
                    .saturating_sub(block_size);
                if o_end <= max_offset {
                    offsets.insert(o_end);
                }
            }
        }
    }

    offsets.into_iter().collect()
}

fn get_best_index_for(calendar: &Calendar, activity: &Activity) -> Option<LeastConflict> {
    let mut least_conflict: Option<LeastConflict> = None;

    for interval in &activity.compatible_intervals {
        let interval_len = interval.end - interval.start;
        #[cfg(debug_assertions)]
        assert!(
            interval_len >= activity.min_block_size,
            "Length of compatible activity interval should be >= min_block_size"
        );
        for inner_offset in candidate_offsets(
            calendar,
            interval.start,
            interval.end,
            activity.min_block_size,
        ) {
            let new_conflicts = get_conflicts_for(
                calendar,
                interval.start + inner_offset,
                interval.start + inner_offset + activity.min_block_size,
            );
            match least_conflict {
                None => {
                    least_conflict = Some(LeastConflict {
                        start: interval.start + inner_offset,
                        end: interval.start + inner_offset + activity.min_block_size,
                        claims: new_conflicts,
                    });
                }
                Some(ref mut least_conflict) => {
                    if new_conflicts < least_conflict.claims {
                        least_conflict.start = interval.start + inner_offset;
                        least_conflict.end =
                            interval.start + inner_offset + activity.min_block_size;
                        least_conflict.claims = new_conflicts;
                    }
                    if new_conflicts == 1 {
                        break;
                    }
                }
            }
        }
    }
    least_conflict
}

pub(crate) fn place_postponed_as_best_effort(calendar: &mut Calendar, activities: &mut [Activity]) {
    crate::log_debug!("Placing postponed activities best effort...");
    for activity in activities.iter_mut() {
        if activity.status == Postponed {
            crate::log_debug!(
                "Setting postponed activity {} to BestEffort.",
                activity.title
            );
            activity.status = BestEffort;
        }
    }
    place(calendar, activities);
}