zinzen 0.3.0

Algorithm for auto-scheduling time-constrained tasks on a timeline
use chrono::{Datelike, Timelike};
use serde::Deserialize;

use super::calendar::Calendar;
use super::goal::{Budget, BudgetPeriod};
use super::time_grid;
use chrono::NaiveDateTime;

/// Runtime budget attached to a calendar, built from `Input.budgets` + `Goal.budget_id`.
#[derive(Debug, Clone, Deserialize)]
pub struct CalendarBudget {
    pub budget_id: String,
    pub title: String,
    pub participating_goals: Vec<String>,
    pub time_budgets: Vec<TimeBudget>,
    pub periods: Vec<BudgetPeriod>,
}

impl CalendarBudget {
    /// Whether an activity attributed to `id` should affect this budget:
    /// fill activities use `budget_id`; goals use their own id via `budgetId`.
    pub fn applies_to(&self, id: &str) -> bool {
        self.budget_id == id || self.participating_goals.iter().any(|g| g == id)
    }

    pub fn reduce_for_(
        &mut self,
        calendar_start: NaiveDateTime,
        goal: &str,
        cal_index: usize,
        cal_index_end: usize,
    ) {
        if !self.applies_to(goal) {
            return;
        }
        for time_budget in self.time_budgets.iter_mut() {
            for offset in 0..(cal_index_end - cal_index) {
                let slot = cal_index + offset;
                if slot < time_budget.calendar_start_index || slot >= time_budget.calendar_end_index
                {
                    continue;
                }
                match time_budget.time_budget_type {
                    TimeBudgetType::Week => {
                        time_budget.scheduled += 1;
                    }
                    TimeBudgetType::Period => {
                        let Some(period_index) = time_budget.period_index else {
                            continue;
                        };
                        if slot_in_period_window(calendar_start, &self.periods[period_index], slot)
                        {
                            time_budget.scheduled += 1;
                        }
                    }
                }
            }
        }
    }

    /// Whether `slot` counts toward the given time budget (week range, or period window).
    pub fn slot_counts_toward(
        &self,
        calendar_start: NaiveDateTime,
        time_budget: &TimeBudget,
        slot: usize,
    ) -> bool {
        if slot < time_budget.calendar_start_index || slot >= time_budget.calendar_end_index {
            return false;
        }
        match time_budget.time_budget_type {
            TimeBudgetType::Week => true,
            TimeBudgetType::Period => {
                let Some(period_index) = time_budget.period_index else {
                    return false;
                };
                slot_in_period_window(calendar_start, &self.periods[period_index], slot)
            }
        }
    }
}

/// Whether a calendar slot falls inside a period's weekdays + time window.
pub fn slot_in_period_window(
    calendar_start: NaiveDateTime,
    period: &BudgetPeriod,
    slot: usize,
) -> bool {
    let dt = time_grid::datetime_at(calendar_start, slot);
    if !period.on_days.contains(&dt.weekday()) {
        return false;
    }
    let minutes = dt.hour() as usize * 60 + dt.minute() as usize;
    let slot_of_day = minutes / time_grid::SLOT_MINUTES as usize;
    let after = period.window.after_time;
    let before = period.window.before_time;
    if after < before {
        slot_of_day >= after && slot_of_day < before
    } else {
        // Midnight-crossing: [after, day) ∪ [0, before)
        slot_of_day >= after || slot_of_day < before
    }
}

#[derive(Deserialize, Debug, Clone, PartialEq)]
pub enum TimeBudgetType {
    /// Aggregate capacity for one budget period over a calendar week
    /// (across all matching days in that week).
    Period,
    /// Weekly total across all periods.
    Week,
}

#[derive(Clone, Deserialize)]
pub struct TimeBudget {
    pub time_budget_type: TimeBudgetType,
    /// Index into [`CalendarBudget::periods`] when `time_budget_type` is [`TimeBudgetType::Period`].
    pub period_index: Option<usize>,
    pub calendar_start_index: usize,
    pub calendar_end_index: usize,
    pub scheduled: usize,
    pub min_scheduled: usize,
    pub max_scheduled: usize,
}

impl std::fmt::Debug for TimeBudget {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(
            f,
            "\n{:?} budget (period={:?}) from index {:?}-{:?}: Scheduled {:?} / {:?}-{:?}\n",
            &self.time_budget_type,
            &self.period_index,
            &self.calendar_start_index,
            &self.calendar_end_index,
            &self.scheduled,
            &self.min_scheduled,
            &self.max_scheduled
        )
    }
}

/// Build period (aggregate) and week time budgets for a public [`Budget`].
pub fn get_time_budgets_from(calendar: &Calendar, budget: &Budget) -> Vec<TimeBudget> {
    crate::log_debug!("Getting time budgets from budget {}", budget.id);

    let mut time_budgets: Vec<TimeBudget> = vec![];

    for week_start in (time_grid::BUFFER_SLOTS..calendar.hours() - time_grid::BUFFER_SLOTS)
        .step_by(time_grid::SLOTS_PER_WEEK)
    {
        #[cfg(debug_assertions)]
        {
            use chrono::NaiveTime;
            assert_eq!(
                calendar.get_datetime_of(week_start).time(),
                NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
                "Assumed week boundary should be at midnight mark."
            );
        }
        let week_end = week_start + time_grid::SLOTS_PER_WEEK;

        for (period_index, period) in budget.periods.iter().enumerate() {
            time_budgets.push(TimeBudget {
                time_budget_type: TimeBudgetType::Period,
                period_index: Some(period_index),
                calendar_start_index: week_start,
                calendar_end_index: week_end,
                scheduled: 0,
                min_scheduled: period.min_for_period,
                max_scheduled: period.max_for_period,
            });
        }

        time_budgets.push(TimeBudget {
            time_budget_type: TimeBudgetType::Week,
            period_index: None,
            calendar_start_index: week_start,
            calendar_end_index: week_end,
            scheduled: 0,
            min_scheduled: budget.min_per_week,
            max_scheduled: budget.max_per_week,
        });
    }

    crate::log_dbg!(&time_budgets);
    time_budgets
}