zinzen 0.3.0

Algorithm for auto-scheduling time-constrained tasks on a timeline
//! Input normalization: convert public schema units to internal slots.
//!
//! Build plan `02-five-minute-interval-granularity.md` §6–§7: the public API
//! speaks in minutes (or legacy hours via `schemaVersion` 1); the engine only
//! ever sees slot counts after this pass.

use crate::models::goal::{Budget, Goal};
use crate::models::time_grid;
use crate::technical::error::SchedulerError;

/// Legacy schema: durations, budgets and period windows are expressed in whole hours.
pub const SCHEMA_VERSION_LEGACY_HOURS: u32 = 1;

/// Current schema: durations and budgets in minutes; windows as minutes since midnight.
pub const SCHEMA_VERSION_MINUTES: u32 = 2;

fn to_minutes(
    value: usize,
    schema_version: u32,
    field: &'static str,
) -> Result<usize, SchedulerError> {
    let minutes = match schema_version {
        SCHEMA_VERSION_LEGACY_HOURS => value
            .checked_mul(60)
            .ok_or(SchedulerError::ValueOutOfRange { field, value })?,
        SCHEMA_VERSION_MINUTES => value,
        other => {
            return Err(SchedulerError::UnsupportedSchemaVersion { version: other });
        }
    };
    if minutes % time_grid::SLOT_MINUTES as usize != 0 {
        return Err(SchedulerError::NotSlotAligned {
            field,
            value: minutes,
            slot_minutes: time_grid::SLOT_MINUTES as usize,
        });
    }
    Ok(minutes)
}

fn minutes_to_slots(minutes: usize, field: &'static str) -> Result<usize, SchedulerError> {
    time_grid::minutes_to_slots(minutes).ok_or(SchedulerError::NotSlotAligned {
        field,
        value: minutes,
        slot_minutes: time_grid::SLOT_MINUTES as usize,
    })
}

/// Convert deserialized goals from public units (minutes or legacy hours) into
/// internal slot counts.
pub fn normalize_goals(goals: &mut [Goal], schema_version: u32) -> Result<(), SchedulerError> {
    for goal in goals.iter_mut() {
        if let Some(min_duration) = &mut goal.min_duration {
            let minutes = to_minutes(*min_duration, schema_version, "goal.minDuration")?;
            *min_duration = minutes_to_slots(minutes, "goal.minDuration")?;
        }
    }
    Ok(())
}

/// Convert budget week totals, period bounds, and windows into internal slots.
pub fn normalize_budgets(
    budgets: &mut [Budget],
    schema_version: u32,
) -> Result<(), SchedulerError> {
    for budget in budgets.iter_mut() {
        budget.min_per_week = minutes_to_slots(
            to_minutes(budget.min_per_week, schema_version, "budget.minPerWeek")?,
            "budget.minPerWeek",
        )?;
        budget.max_per_week = minutes_to_slots(
            to_minutes(budget.max_per_week, schema_version, "budget.maxPerWeek")?,
            "budget.maxPerWeek",
        )?;

        for period in budget.periods.iter_mut() {
            period.min_for_period = minutes_to_slots(
                to_minutes(period.min_for_period, schema_version, "period.minForPeriod")?,
                "period.minForPeriod",
            )?;
            period.max_for_period = minutes_to_slots(
                to_minutes(period.max_for_period, schema_version, "period.maxForPeriod")?,
                "period.maxForPeriod",
            )?;

            let after_minutes = to_minutes(
                period.window.after_time,
                schema_version,
                "period.window.afterTime",
            )?;
            let before_minutes = to_minutes(
                period.window.before_time,
                schema_version,
                "period.window.beforeTime",
            )?;
            period.window.after_time = minutes_to_slots(after_minutes, "period.window.afterTime")?;
            period.window.before_time =
                minutes_to_slots(before_minutes, "period.window.beforeTime")?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::goal::{Budget, BudgetPeriod, Goal, PeriodWindow};
    use chrono::NaiveDate;
    use chrono::Weekday;

    fn goal_with_duration(min_duration: usize) -> Goal {
        Goal {
            id: "g1".into(),
            start: NaiveDate::from_ymd_opt(2022, 1, 1)
                .unwrap()
                .and_hms_opt(0, 0, 0)
                .unwrap(),
            deadline: None,
            min_duration: Some(min_duration),
            title: "test".into(),
            budget_id: None,
            not_on: None,
        }
    }

    #[test]
    fn legacy_hours_convert_to_slots() {
        let mut goals = vec![goal_with_duration(1)];
        normalize_goals(&mut goals, SCHEMA_VERSION_LEGACY_HOURS).unwrap();
        assert_eq!(goals[0].min_duration, Some(12)); // 1 hour -> 60 min -> 12 slots
    }

    #[test]
    fn minutes_schema_converts_to_slots() {
        let mut goals = vec![goal_with_duration(25)];
        normalize_goals(&mut goals, SCHEMA_VERSION_MINUTES).unwrap();
        assert_eq!(goals[0].min_duration, Some(5)); // 25 min -> 5 slots
    }

    #[test]
    fn rejects_non_aligned_minutes() {
        let mut goals = vec![goal_with_duration(7)];
        let err = normalize_goals(&mut goals, SCHEMA_VERSION_MINUTES).unwrap_err();
        assert!(matches!(err, SchedulerError::NotSlotAligned { .. }));
    }

    #[test]
    fn period_window_minutes_convert_to_slots() {
        let mut budgets = vec![Budget {
            id: "b1".into(),
            title: None,
            min_per_week: 60,
            max_per_week: 120,
            periods: vec![BudgetPeriod {
                id: None,
                on_days: vec![Weekday::Mon],
                window: PeriodWindow {
                    after_time: 1095,  // 18:15
                    before_time: 1170, // 19:30
                },
                min_for_period: 30,
                max_for_period: 60,
            }],
        }];
        normalize_budgets(&mut budgets, SCHEMA_VERSION_MINUTES).unwrap();
        assert_eq!(budgets[0].periods[0].window.after_time, 219);
        assert_eq!(budgets[0].periods[0].window.before_time, 234);
        assert_eq!(budgets[0].periods[0].min_for_period, 6);
        assert_eq!(budgets[0].min_per_week, 12);
    }
}