zinzen 0.3.0

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

use super::calendar::Calendar;

#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Goal {
    pub id: String,
    #[serde(default)]
    pub start: NaiveDateTime,
    #[serde(default)]
    pub deadline: Option<NaiveDateTime>,
    /// Fixed uniform block size (also the scheduled chunk length), in public units
    /// (minutes; legacy schema: hours). Converted to slots during normalization.
    pub min_duration: Option<usize>,
    pub title: String,
    /// Optional reference into `Input.budgets[].id`.
    pub budget_id: Option<String>,
    /// User feedback on proposed schedules: these slots are refused for this Goal.
    pub not_on: Option<Vec<Slot>>,
}

#[derive(Deserialize, Debug, Clone)]
pub struct Slot {
    pub start: NaiveDateTime,
    pub end: NaiveDateTime,
}

impl Goal {
    pub fn get_adj_start_deadline(
        &self,
        calendar: &Calendar,
    ) -> (NaiveDateTime, Option<NaiveDateTime>) {
        let mut adjusted_goal_start = self.start;
        if self.start.year() == 1970 || self.start < calendar.start_date_time {
            adjusted_goal_start = calendar.start_date_time;
        }
        (adjusted_goal_start, self.deadline)
    }
}

/// Public budget entity referenced by `Goal.budget_id`.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Budget {
    pub id: String,
    #[serde(default)]
    pub title: Option<String>,
    /// Weekly total minimum across **all** periods (slots after normalize).
    pub min_per_week: usize,
    /// Weekly total maximum across **all** periods (slots after normalize).
    pub max_per_week: usize,
    /// At least one period required.
    pub periods: Vec<BudgetPeriod>,
}

/// One scheduling window inside a budget, with its own min/max capacity.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct BudgetPeriod {
    #[serde(default)]
    pub id: Option<String>,
    pub on_days: Vec<chrono::Weekday>,
    pub window: PeriodWindow,
    /// Minimum **within this period** (aggregate across all matching days).
    pub min_for_period: usize,
    /// Maximum **within this period** (aggregate across all matching days).
    pub max_for_period: usize,
}

#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PeriodWindow {
    /// Minutes since midnight [0, 1440) in schema v2 (v1: hours). Slots after normalize.
    pub after_time: usize,
    pub before_time: usize,
}

impl Budget {
    /// Soft consistency hint: sum of period mins should not exceed `max_per_week`.
    /// Hard errors are elsewhere (`min > max`, empty periods, unknown `budgetId`).
    pub fn soft_period_mins_exceed_week_max(&self) -> bool {
        let mut sum = 0usize;
        for period in &self.periods {
            sum = sum.saturating_add(period.min_for_period);
        }
        sum > self.max_per_week
    }
}