tiny_counter/
time_unit.rs

1use chrono::{DateTime, Duration, Utc};
2#[cfg(feature = "serde")]
3use serde::{Deserialize, Serialize};
4
5use crate::{Error, Result};
6
7/// Time unit definitions for interval configuration.
8///
9/// Represents different time granularities for event counting.
10///
11/// # Calendar-Aligned Buckets vs Uniform Buckets
12///
13/// **Default (calendar-aligned):**
14/// - Buckets snap to calendar boundaries in local time
15/// - `Days` rotate at local midnight (12:00 AM)
16/// - `Weeks` rotate Monday at midnight
17/// - `Months` rotate on the 1st
18/// - `Years` rotate January 1st
19///
20/// **Uniform buckets** (disable `calendar` feature):
21/// - Buckets align to January 1st of the current year at 00:00 UTC
22/// - `Days` = 24-hour periods from that point
23/// - `Weeks` = 7-day periods
24/// - `Months` = 30-day periods (not calendar months)
25/// - `Years` = 365-day periods (ignores leap days)
26/// - All counters of the same time unit have aligned start times
27///
28/// # Why Calendar-Aligned by Default?
29///
30/// Best for client-side and user-facing use cases:
31/// - Aligns to how users talk: "today", "this week", "this month"
32/// - Day boundaries at local midnight (not arbitrary 24-hour windows)
33/// - Matches how users think: "I used the app 3 times today"
34/// - Works with daily/weekly/monthly goals and limits
35///
36/// Use uniform buckets (disable `calendar` feature) when you need:
37/// - Consistent bucket sizes for statistical analysis
38/// - Predictable memory usage (30 days ≈ 1 month)
39/// - Industry-standard "30-day rolling window" (backend analytics)
40/// - Better year approximation: 12 × 30 = 360 days (1.4% error vs 365)
41///
42/// TimeUnits are ordered from smallest to largest:
43/// Minutes < Hours < Days < Weeks < Months < Years < Ever
44///
45/// `Ever` is a special variant that represents "use the longest configured
46/// time unit for this event." It is resolved lazily during query execution.
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub enum TimeUnit {
50    Seconds,
51    Minutes,
52    Hours,
53    Days,   // Calendar days at local midnight (or 24-hour periods without `calendar`)
54    Weeks,  // Calendar weeks on Monday (or 7-day periods without `calendar`)
55    Months, // Calendar months on 1st (or 30-day periods without `calendar`)
56    Years,  // Calendar years on Jan 1 (or 365-day periods without `calendar`)
57    Ever,   // Special: resolved to longest configured unit during query
58}
59
60/// Represents a time window for rate limiting constraints.
61///
62/// This type enables flexible expression of time windows in rate limits:
63/// - `TimeUnit` - defaults to 1 unit (e.g., `TimeUnit::Days` means 1 day)
64/// - `(usize, TimeUnit)` - explicit count (e.g., `(7, TimeUnit::Days)` means 7 days)
65/// - `Duration` - converted to best matching TimeUnit
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct TimeWindow {
68    pub count: usize,
69    pub time_unit: TimeUnit,
70}
71
72impl From<TimeUnit> for TimeWindow {
73    fn from(unit: TimeUnit) -> Self {
74        TimeWindow {
75            count: 1,
76            time_unit: unit,
77        }
78    }
79}
80
81impl From<(usize, TimeUnit)> for TimeWindow {
82    fn from(window: (usize, TimeUnit)) -> Self {
83        TimeWindow {
84            count: window.0,
85            time_unit: window.1,
86        }
87    }
88}
89
90impl From<Duration> for TimeWindow {
91    fn from(dur: Duration) -> Self {
92        // Handle negative durations by taking absolute value
93        let dur = dur.abs();
94
95        // Pick best matching unit based on duration
96        if dur.num_days() > 0 {
97            TimeWindow {
98                count: dur.num_days() as usize,
99                time_unit: TimeUnit::Days,
100            }
101        } else if dur.num_hours() > 0 {
102            TimeWindow {
103                count: dur.num_hours() as usize,
104                time_unit: TimeUnit::Hours,
105            }
106        } else if dur.num_minutes() > 0 {
107            TimeWindow {
108                count: dur.num_minutes() as usize,
109                time_unit: TimeUnit::Minutes,
110            }
111        } else {
112            TimeWindow {
113                count: dur.num_seconds() as usize,
114                time_unit: TimeUnit::Minutes,
115            }
116        }
117    }
118}
119
120/// Helper functions for calendar-aligned time unit operations.
121#[cfg(feature = "calendar")]
122mod calendar {
123    use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Utc};
124
125    /// Convert a local date to UTC at midnight.
126    pub(super) fn local_midnight_to_utc(date: NaiveDate) -> DateTime<Utc> {
127        Local
128            .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
129            .earliest()
130            .unwrap()
131            .with_timezone(&Utc)
132    }
133
134    /// Convert a local date and time to UTC.
135    pub(super) fn local_time_to_utc(date: NaiveDate, hour: u32) -> DateTime<Utc> {
136        Local
137            .from_local_datetime(&date.and_hms_opt(hour, 0, 0).unwrap())
138            .earliest()
139            .unwrap()
140            .with_timezone(&Utc)
141    }
142
143    /// Find Monday of the week containing the given date.
144    pub(super) fn find_monday(date: NaiveDate) -> NaiveDate {
145        let days_since_monday = date.weekday().num_days_from_monday();
146        date - chrono::Days::new(days_since_monday as u64)
147    }
148
149    /// Add months to a (year, month) pair, handling overflow/underflow.
150    ///
151    /// Returns (final_year, final_month) where month is 1-based (1-12).
152    pub(super) fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
153        let total_month = month as i32 + delta;
154        // Convert to 0-based months (0-11) for proper modulo arithmetic
155        let month0 = total_month - 1;
156        let year_offset = month0.div_euclid(12);
157        let final_year = year + year_offset;
158        let final_month = (month0.rem_euclid(12) + 1) as u32;
159        (final_year, final_month)
160    }
161}
162
163impl TimeUnit {
164    /// Returns the duration represented by this time unit.
165    ///
166    /// Note: Months use a 30-day approximation, Years use 365 days.
167    ///
168    /// # Panics
169    ///
170    /// Panics if called on `TimeUnit::Ever`, which has no fixed duration.
171    /// `Ever` must be resolved to a concrete time unit before calling this method.
172    pub fn duration(&self) -> Duration {
173        match self {
174            Self::Seconds => Duration::seconds(1),
175            Self::Minutes => Duration::minutes(1),
176            Self::Hours => Duration::hours(1),
177            Self::Days => Duration::days(1),
178            Self::Weeks => Duration::weeks(1),
179            Self::Months => Duration::days(30),
180            Self::Years => Duration::days(365),
181            Self::Ever => {
182                panic!("TimeUnit::Ever has no fixed duration. Resolve to concrete time unit first.")
183            }
184        }
185    }
186
187    /// Counts bucket boundaries crossed between two times.
188    ///
189    /// Counts boundary crossings, not complete periods elapsed:
190    /// - Days: midnight boundaries in local timezone
191    /// - Weeks: Monday boundaries (ISO weeks)
192    /// - Months: first-of-month boundaries
193    ///
194    /// Each boundary crossing rotates the ring buffer once.
195    ///
196    /// Returns:
197    /// - Positive: `now` is after `then` (past events)
198    /// - Zero: `now` and `then` are in the same bucket
199    /// - Negative: `now` is before `then` (future events)
200    pub(crate) fn num_rotations(&self, then: DateTime<Utc>, now: DateTime<Utc>) -> i64 {
201        #[cfg(feature = "calendar")]
202        {
203            use chrono::{Datelike, Local};
204
205            match self {
206                TimeUnit::Days => {
207                    let then_local = then.with_timezone(&Local).date_naive();
208                    let now_local = now.with_timezone(&Local).date_naive();
209                    return (now_local - then_local).num_days();
210                }
211                TimeUnit::Weeks => {
212                    let then_local = then.with_timezone(&Local).date_naive();
213                    let now_local = now.with_timezone(&Local).date_naive();
214                    let week_diff =
215                        now_local.iso_week().week() as i64 - then_local.iso_week().week() as i64;
216                    // Use iso_week().year() not year() to handle weeks at year boundaries correctly
217                    // e.g. Dec 31, 2024 is in ISO Week 1 of 2025
218                    let year_diff = (now_local.iso_week().year() as i64
219                        - then_local.iso_week().year() as i64)
220                        * 52;
221                    return week_diff + year_diff;
222                }
223                TimeUnit::Months => {
224                    let then_local = then.with_timezone(&Local);
225                    let now_local = now.with_timezone(&Local);
226                    let then_months = then_local.year() as i64 * 12 + then_local.month() as i64;
227                    let now_months = now_local.year() as i64 * 12 + now_local.month() as i64;
228                    return now_months - then_months;
229                }
230                TimeUnit::Years => {
231                    let then_local = then.with_timezone(&Local);
232                    let now_local = now.with_timezone(&Local);
233                    return (now_local.year() - then_local.year()) as i64;
234                }
235                _ => {} // Fall through to fixed-duration logic
236            }
237        }
238
239        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
240        let duration = now.signed_duration_since(then);
241        let unit_duration = self.duration();
242        duration.num_seconds() / unit_duration.num_seconds()
243    }
244
245    pub(crate) fn rotate_start_interval(
246        &self,
247        interval_start: DateTime<Utc>,
248        rotations: i64,
249    ) -> DateTime<Utc> {
250        #[cfg(feature = "calendar")]
251        {
252            use chrono::{Datelike, Local, TimeZone};
253
254            match self {
255                TimeUnit::Days => {
256                    let local = interval_start.with_timezone(&Local);
257                    let target_date = local.date_naive() + chrono::Days::new(rotations as u64);
258                    return calendar::local_midnight_to_utc(target_date);
259                }
260                TimeUnit::Weeks => {
261                    let local = interval_start.with_timezone(&Local);
262                    let this_monday = calendar::find_monday(local.date_naive());
263                    let target_monday = this_monday + chrono::Days::new((rotations * 7) as u64);
264                    return calendar::local_midnight_to_utc(target_monday);
265                }
266                TimeUnit::Months => {
267                    let local = interval_start.with_timezone(&Local);
268                    let (final_year, final_month) =
269                        calendar::add_months(local.year(), local.month(), rotations as i32);
270                    return Local
271                        .with_ymd_and_hms(final_year, final_month, 1, 0, 0, 0)
272                        .earliest()
273                        .unwrap()
274                        .with_timezone(&Utc);
275                }
276                TimeUnit::Years => {
277                    let local = interval_start.with_timezone(&Local);
278                    let target_year = local.year() + rotations as i32;
279                    return Local
280                        .with_ymd_and_hms(target_year, 1, 1, 0, 0, 0)
281                        .earliest()
282                        .unwrap()
283                        .with_timezone(&Utc);
284                }
285                _ => {} // Fall through to fixed-duration logic
286            }
287        }
288
289        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
290        let duration = self.duration();
291        interval_start + duration * rotations as i32
292    }
293
294    pub(crate) fn bucket_idx(
295        &self,
296        interval_start: DateTime<Utc>,
297        time: DateTime<Utc>,
298    ) -> Result<usize> {
299        let rotations = self.num_rotations(time, interval_start);
300
301        // Step 2: Bucket selection logic
302        if rotations < 0 {
303            // Event is in the future
304            return Err(Error::FutureEvent);
305        }
306        Ok(if rotations == 0 {
307            // Same interval as starting_instant
308            if time >= interval_start {
309                0 // Current interval, after instant
310            } else {
311                1 // Current interval, before instant
312            }
313        } else {
314            // Past intervals - use rotations directly as bucket index
315            rotations as usize
316        })
317    }
318
319    /// Returns the end time of a bucket.
320    ///
321    /// - bucket_idx=0: returns `now` (current bucket ends at now)
322    /// - bucket_idx>0: returns the end time of that historical bucket
323    pub(crate) fn bucket_end(&self, now: DateTime<Utc>, bucket_idx: usize) -> DateTime<Utc> {
324        if bucket_idx == 0 {
325            return now;
326        }
327
328        #[cfg(feature = "calendar")]
329        {
330            use chrono::{Datelike, Local, TimeZone};
331
332            match self {
333                TimeUnit::Days => {
334                    let now_local = now.with_timezone(&Local);
335                    let target_date = now_local.date_naive() - chrono::Days::new(bucket_idx as u64);
336                    return calendar::local_midnight_to_utc(target_date);
337                }
338                TimeUnit::Weeks => {
339                    let now_local = now.with_timezone(&Local);
340                    let this_monday = calendar::find_monday(now_local.date_naive());
341                    let target_monday = this_monday - chrono::Days::new((bucket_idx * 7) as u64);
342                    return calendar::local_midnight_to_utc(target_monday);
343                }
344                TimeUnit::Months => {
345                    let now_local = now.with_timezone(&Local);
346                    let (final_year, final_month) = calendar::add_months(
347                        now_local.year(),
348                        now_local.month(),
349                        -(bucket_idx as i32),
350                    );
351                    return Local
352                        .with_ymd_and_hms(final_year, final_month, 1, 0, 0, 0)
353                        .earliest()
354                        .unwrap()
355                        .with_timezone(&Utc);
356                }
357                TimeUnit::Years => {
358                    let now_local = now.with_timezone(&Local);
359                    let target_year = now_local.year() - bucket_idx as i32;
360                    return Local
361                        .with_ymd_and_hms(target_year, 1, 1, 0, 0, 0)
362                        .earliest()
363                        .unwrap()
364                        .with_timezone(&Utc);
365                }
366                _ => {} // Fall through to fixed-duration logic
367            }
368        }
369
370        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
371        now - (self.duration() * bucket_idx as i32)
372    }
373
374    /// Returns the start time of a bucket.
375    pub(crate) fn bucket_start(&self, now: DateTime<Utc>, bucket_idx: usize) -> DateTime<Utc> {
376        self.bucket_end(now, bucket_idx + 1)
377    }
378
379    /// Returns the duration of a bucket.
380    #[allow(dead_code)] // Used in calendar feature only
381    pub(crate) fn bucket_duration(&self, now: DateTime<Utc>, bucket_idx: usize) -> Duration {
382        let bucket_start = self.bucket_start(now, bucket_idx);
383        let bucket_end = self.bucket_end(now, bucket_idx);
384        bucket_end - bucket_start
385    }
386
387    /// Returns the midpoint time of a bucket.
388    pub(crate) fn bucket_midway(
389        &self,
390        clock_now: DateTime<Utc>,
391        interval_start: DateTime<Utc>,
392        bucket_idx: usize,
393    ) -> DateTime<Utc> {
394        if bucket_idx == 0 {
395            let elapsed = clock_now - interval_start;
396            return interval_start + (elapsed / 2);
397        }
398
399        #[cfg(feature = "calendar")]
400        {
401            use chrono::Local;
402
403            match self {
404                TimeUnit::Days => {
405                    let now_local = clock_now.with_timezone(&Local);
406                    let target_date = now_local.date_naive() - chrono::Days::new(bucket_idx as u64);
407                    return calendar::local_time_to_utc(target_date, 12);
408                }
409                TimeUnit::Weeks | TimeUnit::Months | TimeUnit::Years => {
410                    let bucket_start = self.bucket_start(clock_now, bucket_idx);
411                    let duration = self.bucket_duration(clock_now, bucket_idx);
412                    return bucket_start + (duration / 2);
413                }
414                _ => {} // Fall through to fixed-duration logic
415            }
416        }
417
418        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
419        let duration = self.duration();
420        interval_start - (duration * bucket_idx as i32 + duration / 2)
421    }
422
423    /// Returns the earliest moment tracked by all buckets.
424    pub(crate) fn first_moment_ever(
425        &self,
426        now: DateTime<Utc>,
427        bucket_count: usize,
428    ) -> DateTime<Utc> {
429        #[cfg(feature = "calendar")]
430        {
431            use chrono::{Datelike, Local, TimeZone};
432
433            match self {
434                TimeUnit::Days => {
435                    let now_local = now.with_timezone(&Local);
436                    let target_date =
437                        now_local.date_naive() - chrono::Days::new(bucket_count as u64);
438                    return calendar::local_midnight_to_utc(target_date);
439                }
440                TimeUnit::Weeks => {
441                    let now_local = now.with_timezone(&Local);
442                    let this_monday = calendar::find_monday(now_local.date_naive());
443                    let target_monday = this_monday - chrono::Days::new((bucket_count * 7) as u64);
444                    return calendar::local_midnight_to_utc(target_monday);
445                }
446                TimeUnit::Months => {
447                    let now_local = now.with_timezone(&Local);
448                    let (final_year, final_month) = calendar::add_months(
449                        now_local.year(),
450                        now_local.month(),
451                        -(bucket_count as i32),
452                    );
453                    return Local
454                        .with_ymd_and_hms(final_year, final_month, 1, 0, 0, 0)
455                        .earliest()
456                        .unwrap()
457                        .with_timezone(&Utc);
458                }
459                TimeUnit::Years => {
460                    let now_local = now.with_timezone(&Local);
461                    let target_year = now_local.year() - bucket_count as i32;
462                    return Local
463                        .with_ymd_and_hms(target_year, 1, 1, 0, 0, 0)
464                        .earliest()
465                        .unwrap()
466                        .with_timezone(&Utc);
467                }
468                _ => {} // Fall through to fixed-duration logic
469            }
470        }
471
472        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
473        now - (self.duration() * bucket_count as i32)
474    }
475
476    /// Returns a representative time within a bucket (used for conversion).
477    ///
478    /// This is similar to bucket_midway but with different arithmetic for historical buckets.
479    pub(crate) fn bucket_time(
480        &self,
481        clock_now: DateTime<Utc>,
482        interval_start: DateTime<Utc>,
483        bucket_idx: usize,
484    ) -> DateTime<Utc> {
485        if bucket_idx == 0 {
486            let elapsed = clock_now - interval_start;
487            return interval_start + (elapsed / 2);
488        }
489
490        #[cfg(feature = "calendar")]
491        {
492            use chrono::Local;
493
494            match self {
495                TimeUnit::Days => {
496                    let now_local = clock_now.with_timezone(&Local);
497                    let target_date = now_local.date_naive() - chrono::Days::new(bucket_idx as u64);
498                    return calendar::local_time_to_utc(target_date, 12);
499                }
500                TimeUnit::Weeks | TimeUnit::Months | TimeUnit::Years => {
501                    let bucket_start = self.bucket_start(clock_now, bucket_idx);
502                    let duration = self.bucket_duration(clock_now, bucket_idx);
503                    return bucket_start + (duration / 2);
504                }
505                _ => {} // Fall through to fixed-duration logic
506            }
507        }
508
509        // Fixed-duration units (Seconds, Minutes, Hours) use simple arithmetic
510        let duration = self.duration();
511        interval_start - (duration * bucket_idx as i32) + duration / 2
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use chrono::TimeZone;
519
520    #[cfg(feature = "calendar")]
521    mod calendar_helpers {
522        use super::*;
523        use chrono::Local;
524
525        /// Calculate how many calendar days are crossed between two UTC times
526        /// in the local timezone.
527        pub fn calendar_days_between(then: DateTime<Utc>, now: DateTime<Utc>) -> i64 {
528            // Convert to local dates
529            let then_local = then.with_timezone(&Local).date_naive();
530            let now_local = now.with_timezone(&Local).date_naive();
531
532            // Count calendar day boundaries crossed
533            (now_local - then_local).num_days()
534        }
535    }
536
537    #[test]
538    fn test_duration_minutes() {
539        assert_eq!(TimeUnit::Minutes.duration(), Duration::minutes(1));
540    }
541
542    #[test]
543    fn test_duration_hours() {
544        assert_eq!(TimeUnit::Hours.duration(), Duration::hours(1));
545    }
546
547    #[test]
548    fn test_duration_days() {
549        // Note: In calendar mode, Days don't have fixed duration (23-25 hours due to DST)
550        // but duration() still returns 24 hours for fallback/non-calendar units
551        assert_eq!(TimeUnit::Days.duration(), Duration::days(1));
552    }
553
554    #[test]
555    fn test_duration_weeks() {
556        assert_eq!(TimeUnit::Weeks.duration(), Duration::weeks(1));
557    }
558
559    #[test]
560    fn test_duration_months() {
561        // Note: In calendar mode, Months don't have fixed duration (28-31 days)
562        // but duration() still returns 30 days for fallback/non-calendar units
563        assert_eq!(TimeUnit::Months.duration(), Duration::days(30));
564    }
565
566    #[test]
567    fn test_duration_years() {
568        // Years are approximated as 365 days
569        assert_eq!(TimeUnit::Years.duration(), Duration::days(365));
570    }
571
572    #[test]
573    fn test_num_rotations_same_time() {
574        let time = Utc::now();
575        assert_eq!(TimeUnit::Days.num_rotations(time, time), 0);
576    }
577
578    #[test]
579    fn test_num_rotations_past_time() {
580        let now = Utc.with_ymd_and_hms(2025, 1, 10, 12, 0, 0).unwrap();
581        let then = Utc.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
582
583        // 5 days have passed
584        assert_eq!(TimeUnit::Days.num_rotations(then, now), 5);
585    }
586
587    #[test]
588    fn test_num_rotations_future_time() {
589        let now = Utc.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
590        let future = Utc.with_ymd_and_hms(2025, 1, 10, 12, 0, 0).unwrap();
591
592        // Event is 5 days in the future (negative)
593        assert_eq!(TimeUnit::Days.num_rotations(future, now), -5);
594    }
595
596    #[test]
597    fn test_num_rotations_hours() {
598        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap();
599        let then = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
600
601        // 5 hours have passed
602        assert_eq!(TimeUnit::Hours.num_rotations(then, now), 5);
603    }
604
605    #[test]
606    fn test_num_rotations_seconds() {
607        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 45).unwrap();
608        let then = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
609
610        // 45 seconds have passed
611        assert_eq!(TimeUnit::Seconds.num_rotations(then, now), 45);
612    }
613
614    #[test]
615    fn test_num_rotations_minutes() {
616        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
617        let then = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
618
619        // 30 minutes have passed
620        assert_eq!(TimeUnit::Minutes.num_rotations(then, now), 30);
621    }
622
623    #[test]
624    fn test_num_rotations_weeks() {
625        let now = Utc.with_ymd_and_hms(2025, 1, 22, 0, 0, 0).unwrap();
626        let then = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
627
628        // 3 weeks have passed (21 days)
629        assert_eq!(TimeUnit::Weeks.num_rotations(then, now), 3);
630    }
631
632    #[test]
633    #[cfg(not(feature = "calendar"))]
634    fn test_num_rotations_months() {
635        let now = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
636        let then = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
637
638        // 90 days = 3 complete 30-day months (fixed-duration mode)
639        let days_diff = (now - then).num_days();
640        assert_eq!(days_diff, 90);
641        assert_eq!(TimeUnit::Months.num_rotations(then, now), 3);
642    }
643
644    #[test]
645    #[cfg(feature = "calendar")]
646    fn test_num_rotations_months_calendar_year_boundary() {
647        // Test that months calculation handles year boundaries correctly
648        // Dec 31, 2024 → March 1, 2025
649        let then = Utc.with_ymd_and_hms(2024, 12, 31, 12, 0, 0).unwrap();
650        let now = Utc.with_ymd_and_hms(2025, 3, 1, 12, 0, 0).unwrap();
651
652        let rotations = TimeUnit::Months.num_rotations(then, now);
653
654        // Calculation:
655        //   then_months = 2024 * 12 + 12 = 24300
656        //   now_months = 2025 * 12 + 3 = 24303
657        //   diff = 3 months
658        // Counts month number difference: December (12) → March (3) = 3 months
659        assert_eq!(
660            rotations, 3,
661            "Should be 3 months from Dec 31, 2024 to March 1, 2025"
662        );
663    }
664
665    #[test]
666    #[cfg(feature = "calendar")]
667    fn test_num_rotations_months_calendar_multi_year_span() {
668        // Test spanning multiple years with same month
669        // Dec 15, 2023 → Dec 1, 2025 should be 24 months
670        let then = Utc.with_ymd_and_hms(2023, 12, 15, 0, 0, 0).unwrap();
671        let now = Utc.with_ymd_and_hms(2025, 12, 1, 0, 0, 0).unwrap();
672
673        let rotations = TimeUnit::Months.num_rotations(then, now);
674
675        // Calculation:
676        //   then_months = 2023 * 12 + 12 = 24288
677        //   now_months = 2025 * 12 + 12 = 24312
678        //   diff = 24 months
679        assert_eq!(
680            rotations, 24,
681            "Should be 24 months from Dec 2023 to Dec 2025"
682        );
683    }
684
685    #[test]
686    #[cfg(feature = "calendar")]
687    fn test_num_rotations_months_calendar() {
688        let now = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
689        let then = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
690
691        // Jan 1 to Apr 1 = 3 calendar months (Jan→Feb→Mar→Apr)
692        assert_eq!(TimeUnit::Months.num_rotations(then, now), 3);
693
694        // Test non-boundary dates
695        let now = Utc.with_ymd_and_hms(2025, 4, 15, 12, 0, 0).unwrap();
696        let then = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
697
698        // Jan 15 to Apr 15 = still 3 calendar months
699        assert_eq!(TimeUnit::Months.num_rotations(then, now), 3);
700    }
701
702    #[test]
703    #[cfg(feature = "calendar")]
704    fn test_num_rotations_weeks_calendar_year_boundary() {
705        use chrono::Datelike;
706
707        // Critical edge case: Dec 31, 2024 is in ISO Week 1 of 2025
708        // Dec 31, 2024 is a Tuesday
709        // The week containing it: Mon Dec 30, 2024 → Sun Jan 5, 2025
710        // This week contains the first Thursday of 2025 (Jan 2), so it's ISO Week 1 of 2025
711        let then = Utc.with_ymd_and_hms(2024, 12, 31, 12, 0, 0).unwrap();
712        let now = Utc.with_ymd_and_hms(2025, 3, 1, 12, 0, 0).unwrap();
713
714        // Verify ISO week properties
715        use chrono::Local;
716        let then_local = then.with_timezone(&Local).date_naive();
717        let now_local = now.with_timezone(&Local).date_naive();
718
719        // Dec 31, 2024: calendar year = 2024, ISO week year = 2025, ISO week = 1
720        assert_eq!(
721            then_local.iso_week().year(),
722            2025,
723            "Dec 31, 2024 should be in ISO week year 2025"
724        );
725        assert_eq!(
726            then_local.iso_week().week(),
727            1,
728            "Dec 31, 2024 should be in ISO week 1"
729        );
730
731        // March 1, 2025: ISO week = 9
732        assert_eq!(
733            now_local.iso_week().week(),
734            9,
735            "March 1, 2025 should be in ISO week 9"
736        );
737
738        let rotations = TimeUnit::Weeks.num_rotations(then, now);
739
740        // Should be 8 weeks (not 60 weeks!)
741        // If we incorrectly used calendar year instead of ISO week year:
742        //   year_diff = (2025 - 2024) * 52 = 52
743        //   week_diff = 9 - 1 = 8
744        //   WRONG total = 60 weeks
745        //
746        // Correct calculation using ISO week year:
747        //   year_diff = (2025 - 2025) * 52 = 0
748        //   week_diff = 9 - 1 = 8
749        //   CORRECT total = 8 weeks
750        assert_eq!(
751            rotations, 8,
752            "Should be 8 ISO weeks from Dec 31, 2024 to March 1, 2025"
753        );
754    }
755
756    #[test]
757    #[cfg(feature = "calendar")]
758    fn test_num_rotations_weeks_calendar_multi_year_span() {
759        use chrono::Datelike;
760
761        // Test case that would fail with "anniversary" approach but passes with ISO week year
762        // Dec 30, 2024 is a Monday (ISO Week 1 of 2025)
763        // Dec 29, 2025 is a Monday (ISO Week 1 of 2026)
764        // From Dec 30 to Dec 29 next year = 364 days = 52 weeks
765        let then = Utc.with_ymd_and_hms(2024, 12, 30, 0, 0, 0).unwrap();
766        let now = Utc.with_ymd_and_hms(2025, 12, 29, 0, 0, 0).unwrap();
767
768        use chrono::Local;
769        let then_local = then.with_timezone(&Local).date_naive();
770        let now_local = now.with_timezone(&Local).date_naive();
771
772        // Verify ISO week properties
773        assert_eq!(
774            then_local.iso_week().year(),
775            2025,
776            "Dec 30, 2024 should be in ISO week year 2025"
777        );
778        assert_eq!(
779            then_local.iso_week().week(),
780            1,
781            "Dec 30, 2024 should be in ISO week 1"
782        );
783
784        assert_eq!(
785            now_local.iso_week().year(),
786            2026,
787            "Dec 29, 2025 should be in ISO week year 2026"
788        );
789        assert_eq!(
790            now_local.iso_week().week(),
791            1,
792            "Dec 29, 2025 should be in ISO week 1"
793        );
794
795        let rotations = TimeUnit::Weeks.num_rotations(then, now);
796
797        // ISO week year approach (CORRECT):
798        //   year_diff = (2026 - 2025) * 52 = 52
799        //   week_diff = 1 - 1 = 0
800        //   total = 52 weeks ✓
801        //
802        // Alternative "anniversary" approach (WRONG):
803        //   year_diff = (2025 - 2024) = 1
804        //   Since now (Dec 29) < then (Dec 30), decrement: year_diff = 0
805        //   week_diff = 1 - 1 = 0
806        //   WRONG total = 0 weeks (should be 52!)
807        assert_eq!(
808            rotations, 52,
809            "Should be 52 ISO weeks from Dec 30, 2024 to Dec 29, 2025"
810        );
811    }
812
813    #[test]
814    #[cfg(feature = "calendar")]
815    fn test_num_rotations_days_calendar() {
816        use calendar_helpers::calendar_days_between;
817
818        // Test that Days counts calendar day boundaries (local midnight), not 24-hour periods
819        // then: Jan 1, 2025, 23:00 UTC (11 PM)
820        // now:  Jan 5, 2025, 01:00 UTC (1 AM)
821        // UTC elapsed: 3 days, 2 hours
822        let then = Utc.with_ymd_and_hms(2025, 1, 1, 23, 0, 0).unwrap();
823        let now = Utc.with_ymd_and_hms(2025, 1, 5, 1, 0, 0).unwrap();
824
825        // Calculate expected calendar days in local timezone
826        let expected_days = calendar_days_between(then, now);
827
828        let rotations = TimeUnit::Days.num_rotations(then, now);
829
830        // Should match exact calendar days crossed in local timezone
831        assert_eq!(
832            rotations, expected_days,
833            "Expected {} calendar days (local timezone), got {}",
834            expected_days, rotations
835        );
836    }
837
838    #[test]
839    fn test_num_rotations_years() {
840        let now = Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap();
841        let then = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
842
843        // 730 days = 2 years (365 days each)
844        assert_eq!(TimeUnit::Years.num_rotations(then, now), 2);
845    }
846
847    #[test]
848    fn test_num_rotations_partial_unit() {
849        let now = Utc.with_ymd_and_hms(2025, 1, 1, 12, 30, 0).unwrap();
850        let then = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
851
852        // 30 minutes = 0 complete hours
853        assert_eq!(TimeUnit::Hours.num_rotations(then, now), 0);
854    }
855
856    // Property test: num_rotations is transitive
857    #[test]
858    fn test_num_rotations_transitive() {
859        let a = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
860        let b = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
861        let c = Utc.with_ymd_and_hms(2025, 1, 10, 0, 0, 0).unwrap();
862
863        let ab = TimeUnit::Days.num_rotations(a, b);
864        let bc = TimeUnit::Days.num_rotations(b, c);
865        let ac = TimeUnit::Days.num_rotations(a, c);
866
867        assert_eq!(ab + bc, ac);
868    }
869
870    #[test]
871    fn test_time_unit_is_copy() {
872        let unit = TimeUnit::Days;
873        let _unit2 = unit; // Should compile (Copy)
874        let _unit3 = unit; // Should still work
875    }
876
877    #[test]
878    fn test_time_unit_is_eq() {
879        assert_eq!(TimeUnit::Days, TimeUnit::Days);
880        assert_ne!(TimeUnit::Days, TimeUnit::Hours);
881    }
882
883    #[test]
884    fn test_time_unit_is_hash() {
885        use std::collections::HashSet;
886        let mut set = HashSet::new();
887        set.insert(TimeUnit::Days);
888        assert!(set.contains(&TimeUnit::Days));
889    }
890
891    #[test]
892    fn test_large_time_jump() {
893        let now = Utc.with_ymd_and_hms(2025, 12, 31, 0, 0, 0).unwrap();
894        let then = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
895
896        // Many days have passed
897        let rotations = TimeUnit::Days.num_rotations(then, now);
898        assert!(rotations > 2000); // At least 5+ years worth of days
899    }
900
901    // TimeWindow conversion tests
902    #[test]
903    fn test_time_window_from_time_unit() {
904        let window: TimeWindow = TimeUnit::Days.into();
905        assert_eq!(window.count, 1);
906        assert_eq!(window.time_unit, TimeUnit::Days);
907    }
908
909    #[test]
910    fn test_time_window_from_tuple() {
911        let window: TimeWindow = (7, TimeUnit::Days).into();
912        assert_eq!(window.count, 7);
913        assert_eq!(window.time_unit, TimeUnit::Days);
914    }
915
916    #[test]
917    fn test_time_window_from_duration_days() {
918        let window: TimeWindow = Duration::days(7).into();
919        assert_eq!(window.count, 7);
920        assert_eq!(window.time_unit, TimeUnit::Days);
921    }
922
923    #[test]
924    fn test_time_window_from_duration_hours() {
925        let window: TimeWindow = Duration::hours(12).into();
926        assert_eq!(window.count, 12);
927        assert_eq!(window.time_unit, TimeUnit::Hours);
928    }
929
930    #[test]
931    fn test_time_window_from_duration_minutes() {
932        let window: TimeWindow = Duration::minutes(30).into();
933        assert_eq!(window.count, 30);
934        assert_eq!(window.time_unit, TimeUnit::Minutes);
935    }
936
937    #[test]
938    fn test_time_window_from_duration_seconds() {
939        let window: TimeWindow = Duration::seconds(45).into();
940        // Seconds are converted to minutes (0 minutes)
941        assert_eq!(window.count, 45);
942        assert_eq!(window.time_unit, TimeUnit::Minutes);
943    }
944
945    // Ord implementation tests
946    #[test]
947    fn test_time_unit_ord() {
948        assert!(TimeUnit::Minutes < TimeUnit::Hours);
949        assert!(TimeUnit::Hours < TimeUnit::Days);
950        assert!(TimeUnit::Days < TimeUnit::Weeks);
951        assert!(TimeUnit::Weeks < TimeUnit::Months);
952        assert!(TimeUnit::Months < TimeUnit::Years);
953    }
954
955    #[test]
956    fn test_time_unit_ord_transitive() {
957        assert!(TimeUnit::Minutes < TimeUnit::Days);
958        assert!(TimeUnit::Hours < TimeUnit::Weeks);
959        assert!(TimeUnit::Days < TimeUnit::Years);
960    }
961
962    #[test]
963    fn test_time_unit_ord_reflexive() {
964        assert!(TimeUnit::Minutes <= TimeUnit::Minutes);
965        assert!(TimeUnit::Hours >= TimeUnit::Hours);
966    }
967
968    #[test]
969    fn test_time_unit_ever_is_largest() {
970        assert!(TimeUnit::Years < TimeUnit::Ever);
971        assert!(TimeUnit::Minutes < TimeUnit::Ever);
972        assert!(TimeUnit::Days < TimeUnit::Ever);
973    }
974
975    #[test]
976    #[should_panic(expected = "TimeUnit::Ever has no fixed duration")]
977    fn test_time_unit_ever_duration_panics() {
978        TimeUnit::Ever.duration();
979    }
980
981    #[test]
982    fn test_time_window_from_negative_duration_days() {
983        let window: TimeWindow = Duration::days(-7).into();
984        // Should convert absolute value
985        assert_eq!(window.count, 7);
986        assert_eq!(window.time_unit, TimeUnit::Days);
987    }
988
989    #[test]
990    fn test_time_window_from_negative_duration_hours() {
991        let window: TimeWindow = Duration::hours(-12).into();
992        assert_eq!(window.count, 12);
993        assert_eq!(window.time_unit, TimeUnit::Hours);
994    }
995
996    #[test]
997    fn test_time_window_from_negative_duration_minutes() {
998        let window: TimeWindow = Duration::minutes(-30).into();
999        assert_eq!(window.count, 30);
1000        assert_eq!(window.time_unit, TimeUnit::Minutes);
1001    }
1002
1003    #[test]
1004    fn test_time_window_from_negative_duration_seconds() {
1005        let window: TimeWindow = Duration::seconds(-45).into();
1006        assert_eq!(window.count, 45);
1007        assert_eq!(window.time_unit, TimeUnit::Minutes);
1008    }
1009
1010    #[test]
1011    #[cfg(feature = "calendar")]
1012    fn test_bucket_end_days_calendar() {
1013        use chrono::{Local, Timelike};
1014
1015        let now = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
1016
1017        // bucket_idx=0 should return now
1018        assert_eq!(TimeUnit::Days.bucket_end(now, 0), now);
1019
1020        // bucket_idx=1 should return local midnight of yesterday
1021        let bucket_end = TimeUnit::Days.bucket_end(now, 1);
1022        let local_bucket = bucket_end.with_timezone(&Local);
1023
1024        // Should be at midnight
1025        assert_eq!(local_bucket.hour(), 0);
1026        assert_eq!(local_bucket.minute(), 0);
1027        assert_eq!(local_bucket.second(), 0);
1028    }
1029
1030    #[test]
1031    #[cfg(feature = "calendar")]
1032    fn test_bucket_end_months_calendar() {
1033        use chrono::{Datelike, Local, Timelike};
1034
1035        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1036
1037        // bucket_idx=0 should return now
1038        assert_eq!(TimeUnit::Months.bucket_end(now, 0), now);
1039
1040        // bucket_idx=1 should return local midnight of 1st of March
1041        let bucket_end = TimeUnit::Months.bucket_end(now, 1);
1042        let local_bucket = bucket_end.with_timezone(&Local);
1043
1044        // Should be March 1st at midnight
1045        assert_eq!(local_bucket.month(), 3);
1046        assert_eq!(local_bucket.day(), 1);
1047        assert_eq!(local_bucket.hour(), 0);
1048        assert_eq!(local_bucket.minute(), 0);
1049        assert_eq!(local_bucket.second(), 0);
1050    }
1051
1052    #[test]
1053    #[cfg(feature = "calendar")]
1054    fn test_bucket_start_calendar() {
1055        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1056
1057        // bucket_start should be bucket_end of next bucket
1058        let bucket_0_start = TimeUnit::Months.bucket_start(now, 0);
1059        let bucket_1_end = TimeUnit::Months.bucket_end(now, 1);
1060
1061        assert_eq!(bucket_0_start, bucket_1_end);
1062    }
1063
1064    #[test]
1065    #[cfg(feature = "calendar")]
1066    fn test_bucket_start_months_calendar() {
1067        use chrono::{Datelike, Local, Timelike};
1068
1069        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1070
1071        // Bucket 1 start should be bucket 2 end (Feb 1 at midnight)
1072        let bucket_1_start = TimeUnit::Months.bucket_start(now, 1);
1073        let local_start = bucket_1_start.with_timezone(&Local);
1074
1075        // Should be Feb 1 at midnight
1076        assert_eq!(local_start.month(), 2);
1077        assert_eq!(local_start.day(), 1);
1078        assert_eq!(local_start.hour(), 0);
1079        assert_eq!(local_start.minute(), 0);
1080        assert_eq!(local_start.second(), 0);
1081
1082        // Verify relationship: bucket_start(n) == bucket_end(n+1)
1083        let bucket_2_end = TimeUnit::Months.bucket_end(now, 2);
1084        assert_eq!(bucket_1_start, bucket_2_end);
1085    }
1086
1087    #[test]
1088    #[cfg(feature = "calendar")]
1089    fn test_rotate_start_interval_days_calendar() {
1090        use chrono::{Datelike, Local, Timelike};
1091
1092        let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1093
1094        // Rotate forward 3 days
1095        let rotated = TimeUnit::Days.rotate_start_interval(start, 3);
1096        let local_rotated = rotated.with_timezone(&Local);
1097
1098        // Should be Jan 4 at local midnight
1099        assert_eq!(local_rotated.day(), 4);
1100        assert_eq!(local_rotated.hour(), 0);
1101        assert_eq!(local_rotated.minute(), 0);
1102    }
1103
1104    #[test]
1105    #[cfg(feature = "calendar")]
1106    fn test_rotate_start_interval_months_calendar() {
1107        use chrono::{Datelike, Local, Timelike};
1108
1109        let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1110
1111        // Rotate forward 3 months
1112        let rotated = TimeUnit::Months.rotate_start_interval(start, 3);
1113        let local_rotated = rotated.with_timezone(&Local);
1114
1115        // Should be April 1 at local midnight
1116        assert_eq!(local_rotated.month(), 4);
1117        assert_eq!(local_rotated.day(), 1);
1118        assert_eq!(local_rotated.hour(), 0);
1119        assert_eq!(local_rotated.minute(), 0);
1120
1121        // Test with year boundary
1122        let start = Utc.with_ymd_and_hms(2025, 11, 1, 0, 0, 0).unwrap();
1123        let rotated = TimeUnit::Months.rotate_start_interval(start, 3);
1124        let local_rotated = rotated.with_timezone(&Local);
1125
1126        // Should be Feb 1, 2026 at local midnight
1127        assert_eq!(local_rotated.year(), 2026);
1128        assert_eq!(local_rotated.month(), 2);
1129        assert_eq!(local_rotated.day(), 1);
1130    }
1131
1132    #[test]
1133    #[cfg(feature = "calendar")]
1134    fn test_bucket_midway_days_calendar() {
1135        use chrono::{Local, Timelike};
1136
1137        let now = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
1138        let interval_start = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
1139
1140        // Bucket 0 (current): midpoint between interval_start and now
1141        let midway_0 = TimeUnit::Days.bucket_midway(now, interval_start, 0);
1142        let expected_0 = interval_start + (now - interval_start) / 2;
1143        assert_eq!(midway_0, expected_0);
1144
1145        // Bucket 1 (yesterday): should be noon of yesterday
1146        let midway_1 = TimeUnit::Days.bucket_midway(now, interval_start, 1);
1147        let local_midway = midway_1.with_timezone(&Local);
1148
1149        // Should be at noon (12:00)
1150        assert_eq!(local_midway.hour(), 12);
1151        assert_eq!(local_midway.minute(), 0);
1152        assert_eq!(local_midway.second(), 0);
1153    }
1154
1155    #[test]
1156    #[cfg(feature = "calendar")]
1157    fn test_bucket_midway_months_calendar() {
1158        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1159        let interval_start = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
1160
1161        // Bucket 1 (last month): midpoint should be in middle of March
1162        let midway_1 = TimeUnit::Months.bucket_midway(now, interval_start, 1);
1163
1164        // Get bucket boundaries
1165        let bucket_start = TimeUnit::Months.bucket_start(now, 1);
1166        let bucket_end = TimeUnit::Months.bucket_end(now, 1);
1167        let expected = bucket_start + (bucket_end - bucket_start) / 2;
1168
1169        assert_eq!(midway_1, expected);
1170    }
1171
1172    #[test]
1173    #[cfg(feature = "calendar")]
1174    fn test_first_moment_ever_days_calendar() {
1175        use chrono::{Datelike, Local, Timelike};
1176
1177        let now = Utc.with_ymd_and_hms(2025, 1, 10, 14, 30, 0).unwrap();
1178
1179        // 5 buckets means 5 days back
1180        let first = TimeUnit::Days.first_moment_ever(now, 5);
1181        let local_first = first.with_timezone(&Local);
1182
1183        // Should be 5 days ago at local midnight
1184        let now_local = now.with_timezone(&Local);
1185        assert_eq!(local_first.day(), now_local.day() - 5);
1186        assert_eq!(local_first.hour(), 0);
1187        assert_eq!(local_first.minute(), 0);
1188        assert_eq!(local_first.second(), 0);
1189    }
1190
1191    #[test]
1192    #[cfg(feature = "calendar")]
1193    fn test_first_moment_ever_months_calendar() {
1194        use chrono::{Datelike, Local, Timelike};
1195
1196        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1197
1198        // 3 buckets means 3 months back
1199        let first = TimeUnit::Months.first_moment_ever(now, 3);
1200        let local_first = first.with_timezone(&Local);
1201
1202        // Should be January 1 at local midnight
1203        assert_eq!(local_first.month(), 1);
1204        assert_eq!(local_first.day(), 1);
1205        assert_eq!(local_first.hour(), 0);
1206        assert_eq!(local_first.minute(), 0);
1207    }
1208
1209    #[test]
1210    #[cfg(feature = "calendar")]
1211    fn test_bucket_time_days_calendar() {
1212        use chrono::{Local, Timelike};
1213
1214        let now = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
1215        let interval_start = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
1216
1217        // Bucket 1 (yesterday): representative time should be noon
1218        let bucket_time_1 = TimeUnit::Days.bucket_time(now, interval_start, 1);
1219        let local_time = bucket_time_1.with_timezone(&Local);
1220
1221        // Should be at noon (12:00)
1222        assert_eq!(local_time.hour(), 12);
1223        assert_eq!(local_time.minute(), 0);
1224        assert_eq!(local_time.second(), 0);
1225    }
1226
1227    // ========================================================================
1228    // Comprehensive Week Tests
1229    // ========================================================================
1230
1231    #[test]
1232    #[cfg(feature = "calendar")]
1233    fn test_bucket_end_weeks_calendar() {
1234        use chrono::{Datelike, Local, Timelike, Weekday};
1235
1236        // Jan 15, 2025 is a Wednesday
1237        let now = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
1238
1239        // bucket_idx=0 should return now
1240        assert_eq!(TimeUnit::Weeks.bucket_end(now, 0), now);
1241
1242        // bucket_idx=1 should return local midnight of Monday of last week
1243        let bucket_end = TimeUnit::Weeks.bucket_end(now, 1);
1244        let local_bucket = bucket_end.with_timezone(&Local);
1245
1246        // Should be Monday at midnight
1247        assert_eq!(local_bucket.weekday(), Weekday::Mon);
1248        assert_eq!(local_bucket.hour(), 0);
1249        assert_eq!(local_bucket.minute(), 0);
1250        assert_eq!(local_bucket.second(), 0);
1251
1252        // Should be Monday Jan 13 minus 7 days = Monday Jan 6
1253        let now_local = now.with_timezone(&Local);
1254        let this_monday = calendar::find_monday(now_local.date_naive());
1255        let expected_monday = this_monday - chrono::Days::new(7);
1256
1257        assert_eq!(local_bucket.date_naive(), expected_monday);
1258    }
1259
1260    #[test]
1261    #[cfg(feature = "calendar")]
1262    fn test_bucket_midway_weeks_calendar() {
1263        // Jan 15, 2025 is a Wednesday
1264        let now = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
1265        let interval_start = Utc.with_ymd_and_hms(2025, 1, 13, 0, 0, 0).unwrap(); // Monday
1266
1267        // Bucket 1 (last week): midpoint should be Thursday noon
1268        let midway_1 = TimeUnit::Weeks.bucket_midway(now, interval_start, 1);
1269
1270        // Calculate expected: bucket_start + duration/2
1271        let bucket_start = TimeUnit::Weeks.bucket_start(now, 1);
1272        let bucket_end = TimeUnit::Weeks.bucket_end(now, 1);
1273        let expected = bucket_start + (bucket_end - bucket_start) / 2;
1274
1275        assert_eq!(
1276            midway_1, expected,
1277            "Week midpoint should be bucket_start + duration/2"
1278        );
1279    }
1280
1281    #[test]
1282    #[cfg(feature = "calendar")]
1283    fn test_rotate_start_interval_weeks_calendar() {
1284        use chrono::{Datelike, Local, Timelike, Weekday};
1285
1286        // Start at Monday Jan 6, 2025 at midnight
1287        let start = Utc.with_ymd_and_hms(2025, 1, 6, 0, 0, 0).unwrap();
1288
1289        // Rotate forward 2 weeks
1290        let rotated = TimeUnit::Weeks.rotate_start_interval(start, 2);
1291        let local_rotated = rotated.with_timezone(&Local);
1292
1293        // Should be Monday Jan 20 at local midnight
1294        assert_eq!(local_rotated.weekday(), Weekday::Mon);
1295        assert_eq!(local_rotated.day(), 20);
1296        assert_eq!(local_rotated.hour(), 0);
1297        assert_eq!(local_rotated.minute(), 0);
1298    }
1299
1300    #[test]
1301    #[cfg(feature = "calendar")]
1302    fn test_first_moment_ever_weeks_calendar() {
1303        use chrono::{Datelike, Local, Timelike, Weekday};
1304
1305        let now = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
1306
1307        // 3 buckets means 3 weeks back from this Monday
1308        let first = TimeUnit::Weeks.first_moment_ever(now, 3);
1309        let local_first = first.with_timezone(&Local);
1310
1311        // Should be Monday 3 weeks ago at local midnight
1312        assert_eq!(local_first.weekday(), Weekday::Mon);
1313        assert_eq!(local_first.hour(), 0);
1314        assert_eq!(local_first.minute(), 0);
1315
1316        // Calculate expected Monday
1317        let now_local = now.with_timezone(&Local);
1318        let this_monday = calendar::find_monday(now_local.date_naive());
1319        let expected_monday = this_monday - chrono::Days::new(3 * 7);
1320
1321        assert_eq!(local_first.date_naive(), expected_monday);
1322    }
1323
1324    // ========================================================================
1325    // Comprehensive Years Tests
1326    // ========================================================================
1327
1328    #[test]
1329    #[cfg(feature = "calendar")]
1330    fn test_bucket_end_years_calendar() {
1331        use chrono::{Datelike, Local, Timelike};
1332
1333        let now = Utc.with_ymd_and_hms(2025, 4, 15, 14, 30, 0).unwrap();
1334
1335        // bucket_idx=0 should return now
1336        assert_eq!(TimeUnit::Years.bucket_end(now, 0), now);
1337
1338        // bucket_idx=1 should return local midnight of Jan 1, 2024
1339        let bucket_end = TimeUnit::Years.bucket_end(now, 1);
1340        let local_bucket = bucket_end.with_timezone(&Local);
1341
1342        // Should be Jan 1, 2024 at midnight
1343        assert_eq!(local_bucket.year(), 2024);
1344        assert_eq!(local_bucket.month(), 1);
1345        assert_eq!(local_bucket.day(), 1);
1346        assert_eq!(local_bucket.hour(), 0);
1347        assert_eq!(local_bucket.minute(), 0);
1348        assert_eq!(local_bucket.second(), 0);
1349    }
1350
1351    #[test]
1352    #[cfg(feature = "calendar")]
1353    fn test_bucket_midway_years_calendar() {
1354        let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 30, 0).unwrap();
1355        let interval_start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1356
1357        // Bucket 1 (last year): midpoint should be middle of 2024
1358        let midway_1 = TimeUnit::Years.bucket_midway(now, interval_start, 1);
1359
1360        // Calculate expected: bucket_start + duration/2
1361        let bucket_start = TimeUnit::Years.bucket_start(now, 1);
1362        let bucket_end = TimeUnit::Years.bucket_end(now, 1);
1363        let expected = bucket_start + (bucket_end - bucket_start) / 2;
1364
1365        assert_eq!(
1366            midway_1, expected,
1367            "Year midpoint should be bucket_start + duration/2"
1368        );
1369    }
1370
1371    #[test]
1372    #[cfg(feature = "calendar")]
1373    fn test_rotate_start_interval_years_calendar() {
1374        use chrono::{Datelike, Local, Timelike};
1375
1376        let start = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
1377
1378        // Rotate forward 2 years
1379        let rotated = TimeUnit::Years.rotate_start_interval(start, 2);
1380        let local_rotated = rotated.with_timezone(&Local);
1381
1382        // Should be Jan 1, 2025 at local midnight
1383        assert_eq!(local_rotated.year(), 2025);
1384        assert_eq!(local_rotated.month(), 1);
1385        assert_eq!(local_rotated.day(), 1);
1386        assert_eq!(local_rotated.hour(), 0);
1387        assert_eq!(local_rotated.minute(), 0);
1388    }
1389
1390    #[test]
1391    #[cfg(feature = "calendar")]
1392    fn test_first_moment_ever_years_calendar() {
1393        use chrono::{Datelike, Local, Timelike};
1394
1395        let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 30, 0).unwrap();
1396
1397        // 3 buckets means 3 years back
1398        let first = TimeUnit::Years.first_moment_ever(now, 3);
1399        let local_first = first.with_timezone(&Local);
1400
1401        // Should be January 1, 2022 at local midnight
1402        assert_eq!(local_first.year(), 2022);
1403        assert_eq!(local_first.month(), 1);
1404        assert_eq!(local_first.day(), 1);
1405        assert_eq!(local_first.hour(), 0);
1406        assert_eq!(local_first.minute(), 0);
1407    }
1408
1409    // ========================================================================
1410    // Year Rollover Tests
1411    // ========================================================================
1412
1413    #[test]
1414    #[cfg(feature = "calendar")]
1415    fn test_week_spanning_year_boundary() {
1416        use chrono::{Datelike, Local, Timelike, Weekday};
1417
1418        // Dec 30, 2024 is a Monday, Jan 6, 2025 is also a Monday
1419        let now = Utc.with_ymd_and_hms(2025, 1, 3, 10, 0, 0).unwrap(); // Friday, Jan 3
1420
1421        // bucket_idx=1 should return last week's Monday (Dec 23, 2024)
1422        let bucket_end = TimeUnit::Weeks.bucket_end(now, 1);
1423        let local_bucket = bucket_end.with_timezone(&Local);
1424
1425        assert_eq!(local_bucket.year(), 2024);
1426        assert_eq!(local_bucket.month(), 12);
1427        assert_eq!(local_bucket.day(), 23);
1428        assert_eq!(local_bucket.weekday(), Weekday::Mon);
1429        assert_eq!(local_bucket.hour(), 0);
1430        assert_eq!(local_bucket.minute(), 0);
1431    }
1432
1433    #[test]
1434    #[cfg(feature = "calendar")]
1435    fn test_month_bucket_end_crossing_year_backward() {
1436        use chrono::{Datelike, Local, Timelike};
1437
1438        // Start in January 2025
1439        let now = Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap();
1440
1441        // bucket_idx=2 should return Nov 1, 2024 at midnight
1442        let bucket_end = TimeUnit::Months.bucket_end(now, 2);
1443        let local_bucket = bucket_end.with_timezone(&Local);
1444
1445        assert_eq!(local_bucket.year(), 2024);
1446        assert_eq!(local_bucket.month(), 11);
1447        assert_eq!(local_bucket.day(), 1);
1448        assert_eq!(local_bucket.hour(), 0);
1449        assert_eq!(local_bucket.minute(), 0);
1450    }
1451
1452    #[test]
1453    #[cfg(feature = "calendar")]
1454    fn test_num_rotations_days_crossing_year() {
1455        // From Dec 28, 2024 to Jan 5, 2025 (8 calendar days)
1456        let then = Utc.with_ymd_and_hms(2024, 12, 28, 12, 0, 0).unwrap();
1457        let now = Utc.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
1458
1459        let rotations = TimeUnit::Days.num_rotations(then, now);
1460
1461        // Calculate expected in local timezone
1462        use chrono::Local;
1463        let then_local = then.with_timezone(&Local).date_naive();
1464        let now_local = now.with_timezone(&Local).date_naive();
1465        let expected = (now_local - then_local).num_days();
1466
1467        assert_eq!(rotations, expected);
1468        // Should be 8 days (but verify with actual local calculation)
1469    }
1470
1471    #[test]
1472    #[cfg(feature = "calendar")]
1473    fn test_num_rotations_months_crossing_year() {
1474        // From Oct 2024 to Feb 2025 (4 months)
1475        let then = Utc.with_ymd_and_hms(2024, 10, 15, 12, 0, 0).unwrap();
1476        let now = Utc.with_ymd_and_hms(2025, 2, 15, 12, 0, 0).unwrap();
1477
1478        let rotations = TimeUnit::Months.num_rotations(then, now);
1479
1480        // Oct → Nov → Dec → Jan → Feb = 4 month boundaries crossed
1481        assert_eq!(rotations, 4);
1482    }
1483
1484    #[test]
1485    #[cfg(feature = "calendar")]
1486    fn test_num_rotations_years_multiple() {
1487        // From 2020 to 2025 (5 years)
1488        let then = Utc.with_ymd_and_hms(2020, 6, 15, 12, 0, 0).unwrap();
1489        let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1490
1491        let rotations = TimeUnit::Years.num_rotations(then, now);
1492
1493        // 2020 → 2021 → 2022 → 2023 → 2024 → 2025 = 5 year boundaries
1494        assert_eq!(rotations, 5);
1495    }
1496}