spacecell 0.1.0

Datetime library with ISO8601/RFC3339 parsing, calendar arithmetic, and business day calculations
Documentation
//! # **Calendar Module** - *Calendar operations*
//!
//! Week numbers, quarters, half-years, and period boundaries.

use super::civil::{day_of_week, days_in_month, days_to_ymd, ymd_to_days};
use super::config::CalendarConfig;

/// Calculate ISO 8601 week number
///
/// Rules:
/// - Week 1 is the first week with a Thursday
/// - Weeks start on Monday
/// - Week numbers can be 1-53
///
/// # Returns
/// (year, week_number) tuple - year may differ from input for days in week 1 or 53
pub fn week_of_year_iso(year: i32, month: u32, day: u32) -> (i32, u32) {
    let days = ymd_to_days(year, month, day);
    let weekday = day_of_week(days); // 0=Monday

    // Find Thursday of the same week
    let thursday_offset = 3 - weekday as i64;
    let thursday_days = days + thursday_offset;
    let (thursday_year, _thursday_month, _thursday_day) = days_to_ymd(thursday_days);

    // Find Jan 4 of Thursday's year (always in week 1)
    let jan4_days = ymd_to_days(thursday_year, 1, 4);
    let jan4_weekday = day_of_week(jan4_days);

    // Find Monday of week 1 (the week containing Jan 4)
    let week1_monday = jan4_days - jan4_weekday as i64;

    // Calculate week number
    let days_since_week1_monday = thursday_days - week1_monday;
    let week_num = (days_since_week1_monday / 7 + 1) as u32;

    (thursday_year, week_num)
}

/// Calculate week number with custom week start
///
/// # Arguments
/// - `year`: Year
/// - `month`: Month (1-12)
/// - `day`: Day of month
/// - `config`: Calendar configuration (defines week_start)
///
/// # Returns
/// Week number (1-53)
pub fn week_of_year_custom(
    year: i32,
    month: u32,
    day: u32,
    config: &CalendarConfig,
) -> u32 {
    let days = ymd_to_days(year, month, day);
    let jan1_days = ymd_to_days(year, 1, 1);

    // Adjust for custom week start
    let jan1_weekday = day_of_week(jan1_days);

    let adjusted_jan1_weekday = (jan1_weekday + 7 - config.week_start) % 7;

    // Days from adjusted week start of first week to current day
    let week1_start = jan1_days - adjusted_jan1_weekday as i64;
    let days_since_week1 = days - week1_start;

    ((days_since_week1 / 7) + 1) as u32
}

/// Calculate week within the month
///
/// # Returns
/// Week number within month (1-5)
pub fn week_of_month(year: i32, month: u32, day: u32, config: &CalendarConfig) -> u32 {
    let first_day_days = ymd_to_days(year, month, 1);
    let first_day_weekday = day_of_week(first_day_days);

    let adjusted_first_weekday = (first_day_weekday + 7 - config.week_start) % 7;

    // Week 1 starts on the first occurrence of week_start
    let week1_day = if adjusted_first_weekday == 0 {
        1
    } else {
        8 - adjusted_first_weekday
    };

    if day < week1_day {
        1
    } else {
        ((day - week1_day) / 7) + 2
    }
}

/// Get calendar quarter (1-4)
#[inline]
pub fn quarter_of_year(month: u32) -> u32 {
    (month - 1) / 3 + 1
}

/// Get fiscal quarter based on config
pub fn fiscal_quarter(_year: i32, month: u32, config: &CalendarConfig) -> u32 {
    let offset = if month >= config.fiscal_quarter_start {
        month - config.fiscal_quarter_start
    } else {
        12 + month - config.fiscal_quarter_start
    };
    (offset / 3) + 1
}

/// Get fiscal year based on config
pub fn fiscal_year(year: i32, month: u32, config: &CalendarConfig) -> i32 {
    if month >= config.fiscal_year_start {
        year
    } else {
        year - 1
    }
}

/// Get calendar half (1 or 2)
#[inline]
pub fn half_year(month: u32) -> u32 {
    if month <= 6 {
        1
    } else {
        2
    }
}

/// Get fiscal half-year based on config
pub fn fiscal_half_year(_year: i32, month: u32, config: &CalendarConfig) -> u32 {
    let offset = if month >= config.fiscal_year_start {
        month - config.fiscal_year_start
    } else {
        12 + month - config.fiscal_year_start
    };

    if offset < 6 {
        1
    } else {
        2
    }
}

/// Get first day of week containing the given date
pub fn first_day_of_week(
    year: i32,
    month: u32,
    day: u32,
    config: &CalendarConfig,
) -> (i32, u32, u32) {
    let days = ymd_to_days(year, month, day);
    let weekday = day_of_week(days);
    let adjusted_weekday = (weekday + 7 - config.week_start) % 7;
    let first_day_days = days - adjusted_weekday as i64;
    days_to_ymd(first_day_days)
}

/// Get last day of week containing the given date
pub fn last_day_of_week(
    year: i32,
    month: u32,
    day: u32,
    config: &CalendarConfig,
) -> (i32, u32, u32) {
    let days = ymd_to_days(year, month, day);
    let weekday = day_of_week(days);
    let adjusted_weekday = (weekday + 7 - config.week_start) % 7;
    let last_day_days = days + (6 - adjusted_weekday) as i64;
    days_to_ymd(last_day_days)
}

/// Get first day of month
#[inline]
pub fn first_day_of_month(year: i32, month: u32) -> (i32, u32, u32) {
    (year, month, 1)
}

/// Get last day of month
#[inline]
pub fn last_day_of_month(year: i32, month: u32) -> (i32, u32, u32) {
    (year, month, days_in_month(year, month))
}

/// Get first day of quarter
pub fn first_day_of_quarter(year: i32, quarter: u32) -> (i32, u32, u32) {
    let month = (quarter - 1) * 3 + 1;
    (year, month, 1)
}

/// Get last day of quarter
pub fn last_day_of_quarter(year: i32, quarter: u32) -> (i32, u32, u32) {
    let month = quarter * 3;
    (year, month, days_in_month(year, month))
}

/// Get first day of year
#[inline]
pub fn first_day_of_year(year: i32) -> (i32, u32, u32) {
    (year, 1, 1)
}

/// Get last day of year
#[inline]
pub fn last_day_of_year(year: i32) -> (i32, u32, u32) {
    (year, 12, 31)
}

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

    #[test]
    fn test_quarter_of_year() {
        assert_eq!(quarter_of_year(1), 1);
        assert_eq!(quarter_of_year(3), 1);
        assert_eq!(quarter_of_year(4), 2);
        assert_eq!(quarter_of_year(6), 2);
        assert_eq!(quarter_of_year(7), 3);
        assert_eq!(quarter_of_year(9), 3);
        assert_eq!(quarter_of_year(10), 4);
        assert_eq!(quarter_of_year(12), 4);
    }

    #[test]
    fn test_half_year() {
        assert_eq!(half_year(1), 1);
        assert_eq!(half_year(6), 1);
        assert_eq!(half_year(7), 2);
        assert_eq!(half_year(12), 2);
    }

    #[test]
    fn test_fiscal_quarter() {
        // Fiscal year starting in July
        let config = CalendarConfig::new().with_fiscal_quarter_start(7);

        // July = Q1 of fiscal year
        assert_eq!(fiscal_quarter(2024, 7, &config), 1);
        assert_eq!(fiscal_quarter(2024, 9, &config), 1);

        // October = Q2
        assert_eq!(fiscal_quarter(2024, 10, &config), 2);

        // January = Q3
        assert_eq!(fiscal_quarter(2024, 1, &config), 3);

        // April = Q4
        assert_eq!(fiscal_quarter(2024, 4, &config), 4);
    }

    #[test]
    fn test_fiscal_year() {
        // Fiscal year starting in July
        let config = CalendarConfig::new().with_fiscal_year_start(7);

        // July 2024 onwards = FY 2024
        assert_eq!(fiscal_year(2024, 7, &config), 2024);
        assert_eq!(fiscal_year(2024, 12, &config), 2024);

        // January-June 2024 = FY 2023
        assert_eq!(fiscal_year(2024, 1, &config), 2023);
        assert_eq!(fiscal_year(2024, 6, &config), 2023);
    }

    #[test]
    fn test_week_of_year_iso() {
        // 2024-01-01 is a Monday (week 1)
        let (wy, wn) = week_of_year_iso(2024, 1, 1);
        assert_eq!(wy, 2024);
        assert_eq!(wn, 1);

        // 2023-01-01 is a Sunday (belongs to 2022 week 52)
        let (wy, wn) = week_of_year_iso(2023, 1, 1);
        assert_eq!(wy, 2022);
        assert_eq!(wn, 52);
    }

    #[test]
    fn test_first_last_day_of_month() {
        assert_eq!(first_day_of_month(2024, 2), (2024, 2, 1));
        assert_eq!(last_day_of_month(2024, 2), (2024, 2, 29)); // Leap year
        assert_eq!(last_day_of_month(2023, 2), (2023, 2, 28)); // Non-leap
    }

    #[test]
    fn test_first_last_day_of_quarter() {
        assert_eq!(first_day_of_quarter(2024, 1), (2024, 1, 1));
        assert_eq!(last_day_of_quarter(2024, 1), (2024, 3, 31));

        assert_eq!(first_day_of_quarter(2024, 2), (2024, 4, 1));
        assert_eq!(last_day_of_quarter(2024, 2), (2024, 6, 30));

        assert_eq!(first_day_of_quarter(2024, 4), (2024, 10, 1));
        assert_eq!(last_day_of_quarter(2024, 4), (2024, 12, 31));
    }

    #[test]
    fn test_first_last_day_of_year() {
        assert_eq!(first_day_of_year(2024), (2024, 1, 1));
        assert_eq!(last_day_of_year(2024), (2024, 12, 31));
    }

    #[test]
    fn test_first_day_of_week() {
        let config = CalendarConfig::new(); // Monday start

        // 2024-01-03 is a Wednesday
        // First day of that week should be Monday 2024-01-01
        let (y, m, d) = first_day_of_week(2024, 1, 3, &config);
        assert_eq!((y, m, d), (2024, 1, 1));
    }

    #[test]
    fn test_last_day_of_week() {
        let config = CalendarConfig::new(); // Monday start

        // 2024-01-03 is a Wednesday
        // Last day of that week should be Sunday 2024-01-07
        let (y, m, d) = last_day_of_week(2024, 1, 3, &config);
        assert_eq!((y, m, d), (2024, 1, 7));
    }
}