zinzen 0.3.0

Algorithm for auto-scheduling time-constrained tasks on a timeline
//! Central time <-> calendar-index ("slot") conversions.
//!
//! The scheduler discretizes wall-clock time into fixed-size "slots" and works
//! internally with integer *calendar indices* that count slots from a fixed
//! buffer offset placed before the calendar start. All resolution-dependent
//! arithmetic lives here, so the rest of the engine is expressed in slots
//! rather than hardcoded hours.
//!
//! Improvement-plan P1-4: this module replaces the "magic 24" offsets and
//! ad-hoc `Duration::hours(index ± 24)` arithmetic that used to be duplicated
//! across `calendar.rs`, `budget.rs`, `activity.rs` and `interval_helper.rs`.
//!
//! Build plan `02-five-minute-interval-granularity.md`: `SLOT_MINUTES` is the
//! single lever for resolution. Set it to `60` to reproduce one-hour behaviour.

use chrono::{Datelike, Duration, NaiveDateTime, Timelike, Weekday};

/// Minutes represented by a single calendar slot.
pub const SLOT_MINUTES: i64 = 5;

/// Number of calendar slots per hour.
pub const SLOTS_PER_HOUR: usize = (60 / SLOT_MINUTES) as usize;

/// Number of slots in a day.
pub const SLOTS_PER_DAY: usize = 24 * SLOTS_PER_HOUR;

/// Number of slots in a week.
pub const SLOTS_PER_WEEK: usize = 7 * SLOTS_PER_DAY;

/// Leading/trailing buffer (one day) kept at each end of the calendar.
pub const BUFFER_SLOTS: usize = SLOTS_PER_DAY;

/// Wall-clock duration spanned by `slots` slots.
#[inline]
pub fn slot_span(slots: i64) -> Duration {
    Duration::minutes(slots * SLOT_MINUTES)
}

/// Convert a minute count to slots; returns `None` when not exactly divisible.
#[inline]
pub fn minutes_to_slots(minutes: usize) -> Option<usize> {
    if !minutes.is_multiple_of(SLOT_MINUTES as usize) {
        return None;
    }
    Some(minutes / SLOT_MINUTES as usize)
}

/// Slots to minutes for output.
#[inline]
pub fn slots_to_minutes(slots: usize) -> usize {
    slots * SLOT_MINUTES as usize
}

/// Whether a datetime is aligned to the slot grid (seconds/nanos zero, minutes divisible).
#[inline]
pub fn is_datetime_aligned(dt: NaiveDateTime) -> bool {
    dt.second() == 0 && dt.nanosecond() == 0 && (dt.minute() as i64) % SLOT_MINUTES == 0
}

/// Datetime at calendar `index`, given the calendar `start`.
///
/// Index [`BUFFER_SLOTS`] maps exactly to `start`; smaller indices fall in the
/// leading buffer.
#[inline]
pub fn datetime_at(start: NaiveDateTime, index: usize) -> NaiveDateTime {
    start + slot_span(index as i64 - BUFFER_SLOTS as i64)
}

/// Calendar index for `datetime`, given the calendar `start`.
///
/// Inverse of [`datetime_at`] for slot-aligned datetimes.
#[inline]
pub fn index_at(start: NaiveDateTime, datetime: NaiveDateTime) -> usize {
    let minutes_from_buffer_start =
        ((datetime - start) + slot_span(BUFFER_SLOTS as i64)).num_minutes();
    debug_assert_eq!(
        minutes_from_buffer_start % SLOT_MINUTES,
        0,
        "datetime must be slot-aligned"
    );
    (minutes_from_buffer_start / SLOT_MINUTES) as usize
}

/// Weekday at calendar `index`, given the calendar `start`.
#[inline]
pub fn weekday_at(start: NaiveDateTime, index: usize) -> Weekday {
    datetime_at(start, index).weekday()
}

/// Format a slot offset within a day as `HH:MM` for debug output.
pub fn format_slot_time_of_day(slot: usize) -> String {
    let minutes = (slot % SLOTS_PER_DAY) * SLOT_MINUTES as usize;
    format!("{:02}:{:02}", minutes / 60, minutes % 60)
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;

    fn dt(y: i32, m: u32, d: u32, h: u32) -> NaiveDateTime {
        NaiveDate::from_ymd_opt(y, m, d)
            .unwrap()
            .and_hms_opt(h, 0, 0)
            .unwrap()
    }

    fn dt_min(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
        NaiveDate::from_ymd_opt(y, m, d)
            .unwrap()
            .and_hms_opt(h, min, 0)
            .unwrap()
    }

    #[test]
    fn slot_constants_are_consistent() {
        assert_eq!(SLOTS_PER_HOUR, 12);
        assert_eq!(SLOTS_PER_DAY, 288);
        assert_eq!(SLOTS_PER_WEEK, 2016);
        assert_eq!(BUFFER_SLOTS, SLOTS_PER_DAY);
        assert_eq!(SLOT_MINUTES * SLOTS_PER_HOUR as i64, 60);
    }

    #[test]
    fn buffer_index_maps_to_start() {
        let start = dt(2022, 1, 1, 0);
        assert_eq!(datetime_at(start, BUFFER_SLOTS), start);
        assert_eq!(index_at(start, start), BUFFER_SLOTS);
    }

    #[test]
    fn datetime_and_index_are_inverse_at_five_minute_resolution() {
        let start = dt(2022, 1, 1, 0);
        for index in 0..(2 * SLOTS_PER_WEEK) {
            assert_eq!(index_at(start, datetime_at(start, index)), index);
        }
    }

    #[test]
    fn five_minute_datetimes_round_trip() {
        let start = dt(2022, 1, 1, 0);
        let at_1815 = dt_min(2022, 1, 1, 18, 15);
        let index = index_at(start, at_1815);
        assert_eq!(datetime_at(start, index), at_1815);
    }

    #[test]
    fn weekday_tracks_calendar() {
        // 2022-01-01 is a Saturday.
        let start = dt(2022, 1, 1, 0);
        assert_eq!(weekday_at(start, BUFFER_SLOTS), Weekday::Sat);
        assert_eq!(
            weekday_at(start, BUFFER_SLOTS + SLOTS_PER_DAY),
            Weekday::Sun
        );
        assert_eq!(
            weekday_at(start, BUFFER_SLOTS + 7 * SLOTS_PER_DAY),
            Weekday::Sat
        );
    }

    #[test]
    fn minutes_to_slots_rejects_non_aligned() {
        assert_eq!(minutes_to_slots(60), Some(12));
        assert_eq!(minutes_to_slots(25), Some(5));
        assert_eq!(minutes_to_slots(7), None);
    }

    #[test]
    fn alignment_detection() {
        assert!(is_datetime_aligned(dt_min(2022, 1, 1, 18, 15)));
        assert!(is_datetime_aligned(dt_min(2022, 1, 1, 18, 0)));
        assert!(!is_datetime_aligned(dt_min(2022, 1, 1, 18, 7)));
    }
}