icu_calendar/
week.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5//! Functions for region-specific weekday information.
6
7use crate::{error::RangeError, provider::*, types::Weekday};
8use icu_locale_core::preferences::define_preferences;
9use icu_provider::prelude::*;
10
11/// Minimum number of days in a month unit required for using this module
12const MIN_UNIT_DAYS: u16 = 14;
13
14define_preferences!(
15    /// The preferences for the week information.
16    [Copy]
17    WeekPreferences,
18    {}
19);
20
21/// Information about the first day of the week and the weekend.
22#[derive(Clone, Copy, Debug)]
23#[non_exhaustive]
24pub struct WeekInformation {
25    /// The first day of a week.
26    pub first_weekday: Weekday,
27    /// The set of weekend days
28    pub weekend: WeekdaySet,
29}
30
31impl WeekInformation {
32    icu_provider::gen_buffer_data_constructors!(
33        (prefs: WeekPreferences) -> error: DataError,
34        /// Creates a new [`WeekCalculator`] from compiled data.
35    );
36
37    #[doc = icu_provider::gen_buffer_unstable_docs!(UNSTABLE, Self::try_new)]
38    pub fn try_new_unstable<P>(provider: &P, prefs: WeekPreferences) -> Result<Self, DataError>
39    where
40        P: DataProvider<crate::provider::CalendarWeekV1> + ?Sized,
41    {
42        let locale = CalendarWeekV1::make_locale(prefs.locale_preferences);
43        provider
44            .load(DataRequest {
45                id: DataIdentifierBorrowed::for_locale(&locale),
46                ..Default::default()
47            })
48            .map(|response| WeekInformation {
49                first_weekday: response.payload.get().first_weekday,
50                weekend: response.payload.get().weekend,
51            })
52    }
53
54    /// Weekdays that are part of the 'weekend', for calendar purposes.
55    /// Days may not be contiguous, and order is based off the first weekday.
56    pub fn weekend(self) -> WeekdaySetIterator {
57        WeekdaySetIterator::new(self.first_weekday, self.weekend)
58    }
59}
60
61#[derive(Clone, Copy, Debug)]
62pub(crate) struct WeekCalculator {
63    first_weekday: Weekday,
64    min_week_days: u8,
65}
66
67impl WeekCalculator {
68    pub(crate) const ISO: Self = Self {
69        first_weekday: Weekday::Monday,
70        min_week_days: 4,
71    };
72
73    /// Returns the zero based index of `weekday` vs this calendar's start of week.
74    fn weekday_index(self, weekday: Weekday) -> i8 {
75        (7 + (weekday as i8) - (self.first_weekday as i8)) % 7
76    }
77
78    /// Computes & returns the week of given month/year according to `calendar`.
79    ///
80    /// # Arguments
81    ///  - calendar: Calendar information used to compute the week number.
82    ///  - num_days_in_previous_unit: The number of days in the preceding month/year.
83    ///  - num_days_in_unit: The number of days in the month/year.
84    ///  - day: 1-based day of month/year.
85    ///  - week_day: The weekday of `day`..
86    ///
87    /// # Error
88    /// If num_days_in_unit/num_days_in_previous_unit < MIN_UNIT_DAYS
89    pub(crate) fn week_of(
90        self,
91        num_days_in_previous_unit: u16,
92        num_days_in_unit: u16,
93        day: u16,
94        week_day: Weekday,
95    ) -> Result<WeekOf, RangeError> {
96        let current = UnitInfo::new(
97            // The first day of this month/year is (day - 1) days from `day`.
98            add_to_weekday(week_day, 1 - i32::from(day)),
99            num_days_in_unit,
100        )?;
101
102        match current.relative_week(self, day) {
103            RelativeWeek::LastWeekOfPreviousUnit => {
104                let previous = UnitInfo::new(
105                    add_to_weekday(current.first_day, -i32::from(num_days_in_previous_unit)),
106                    num_days_in_previous_unit,
107                )?;
108
109                Ok(WeekOf {
110                    week: previous.num_weeks(self),
111                    unit: RelativeUnit::Previous,
112                })
113            }
114            RelativeWeek::WeekOfCurrentUnit(w) => Ok(WeekOf {
115                week: w,
116                unit: RelativeUnit::Current,
117            }),
118            RelativeWeek::FirstWeekOfNextUnit => Ok(WeekOf {
119                week: 1,
120                unit: RelativeUnit::Next,
121            }),
122        }
123    }
124}
125
126/// Returns the weekday that's `num_days` after `weekday`.
127fn add_to_weekday(weekday: Weekday, num_days: i32) -> Weekday {
128    let new_weekday = (7 + (weekday as i32) + (num_days % 7)) % 7;
129    Weekday::from_days_since_sunday(new_weekday as isize)
130}
131
132/// Which year or month that a calendar assigns a week to relative to the year/month
133/// the week is in.
134#[derive(Clone, Copy, Debug, PartialEq)]
135#[allow(clippy::enum_variant_names)]
136enum RelativeWeek {
137    /// A week that is assigned to the last week of the previous year/month. e.g. 2021-01-01 is week 54 of 2020 per the ISO calendar.
138    LastWeekOfPreviousUnit,
139    /// A week that's assigned to the current year/month. The offset is 1-based. e.g. 2021-01-11 is week 2 of 2021 per the ISO calendar so would be WeekOfCurrentUnit(2).
140    WeekOfCurrentUnit(u8),
141    /// A week that is assigned to the first week of the next year/month. e.g. 2019-12-31 is week 1 of 2020 per the ISO calendar.
142    FirstWeekOfNextUnit,
143}
144
145/// Information about a year or month.
146#[derive(Clone, Copy)]
147struct UnitInfo {
148    /// The weekday of this year/month's first day.
149    first_day: Weekday,
150    /// The number of days in this year/month.
151    duration_days: u16,
152}
153
154impl UnitInfo {
155    /// Creates a UnitInfo for a given year or month.
156    fn new(first_day: Weekday, duration_days: u16) -> Result<UnitInfo, RangeError> {
157        if duration_days < MIN_UNIT_DAYS {
158            return Err(RangeError {
159                field: "num_days_in_unit",
160                value: duration_days as i32,
161                min: MIN_UNIT_DAYS as i32,
162                max: i32::MAX,
163            });
164        }
165        Ok(UnitInfo {
166            first_day,
167            duration_days,
168        })
169    }
170
171    /// Returns the start of this unit's first week.
172    ///
173    /// The returned value can be negative if this unit's first week started during the previous
174    /// unit.
175    fn first_week_offset(self, calendar: WeekCalculator) -> i8 {
176        let first_day_index = calendar.weekday_index(self.first_day);
177        if 7 - first_day_index >= calendar.min_week_days as i8 {
178            -first_day_index
179        } else {
180            7 - first_day_index
181        }
182    }
183
184    /// Returns the number of weeks in this unit according to `calendar`.
185    fn num_weeks(self, calendar: WeekCalculator) -> u8 {
186        let first_week_offset = self.first_week_offset(calendar);
187        let num_days_including_first_week =
188            (self.duration_days as i32) - (first_week_offset as i32);
189        debug_assert!(
190            num_days_including_first_week >= 0,
191            "Unit is shorter than a week."
192        );
193        ((num_days_including_first_week + 7 - (calendar.min_week_days as i32)) / 7) as u8
194    }
195
196    /// Returns the week number for the given day in this unit.
197    fn relative_week(self, calendar: WeekCalculator, day: u16) -> RelativeWeek {
198        let days_since_first_week =
199            i32::from(day) - i32::from(self.first_week_offset(calendar)) - 1;
200        if days_since_first_week < 0 {
201            return RelativeWeek::LastWeekOfPreviousUnit;
202        }
203
204        let week_number = (1 + days_since_first_week / 7) as u8;
205        if week_number > self.num_weeks(calendar) {
206            return RelativeWeek::FirstWeekOfNextUnit;
207        }
208        RelativeWeek::WeekOfCurrentUnit(week_number)
209    }
210}
211
212/// The year or month that a calendar assigns a week to relative to the year/month that it is in.
213#[derive(Debug, PartialEq)]
214#[allow(clippy::exhaustive_enums)] // this type is stable
215pub(crate) enum RelativeUnit {
216    /// A week that is assigned to previous year/month. e.g. 2021-01-01 is week 54 of 2020 per the ISO calendar.
217    Previous,
218    /// A week that's assigned to the current year/month. e.g. 2021-01-11 is week 2 of 2021 per the ISO calendar.
219    Current,
220    /// A week that is assigned to the next year/month. e.g. 2019-12-31 is week 1 of 2020 per the ISO calendar.
221    Next,
222}
223
224/// The week number assigned to a given week according to a calendar.
225#[derive(Debug, PartialEq)]
226#[allow(clippy::exhaustive_structs)] // this type is stable
227pub(crate) struct WeekOf {
228    /// Week of month/year. 1 based.
229    pub week: u8,
230    /// The month/year that this week is in, relative to the month/year of the input date.
231    pub unit: RelativeUnit,
232}
233
234/// [Iterator] that yields weekdays that are part of the weekend.
235#[derive(Clone, Copy, Debug, PartialEq)]
236pub struct WeekdaySetIterator {
237    /// Determines the order in which we should start reading values from `weekend`.
238    first_weekday: Weekday,
239    /// Day being evaluated.
240    current_day: Weekday,
241    /// Bitset to read weekdays from.
242    weekend: WeekdaySet,
243}
244
245impl WeekdaySetIterator {
246    /// Creates the Iterator. Sets `current_day` to the day after `first_weekday`.
247    pub(crate) fn new(first_weekday: Weekday, weekend: WeekdaySet) -> Self {
248        WeekdaySetIterator {
249            first_weekday,
250            current_day: first_weekday,
251            weekend,
252        }
253    }
254}
255
256impl Iterator for WeekdaySetIterator {
257    type Item = Weekday;
258
259    fn next(&mut self) -> Option<Self::Item> {
260        // Check each bit until we find one that is ON or until we are back to the start of the week.
261        while self.current_day.next_day() != self.first_weekday {
262            if self.weekend.contains(self.current_day) {
263                let result = self.current_day;
264                self.current_day = self.current_day.next_day();
265                return Some(result);
266            } else {
267                self.current_day = self.current_day.next_day();
268            }
269        }
270
271        if self.weekend.contains(self.current_day) {
272            // Clear weekend, we've seen all bits.
273            // Breaks the loop next time `next()` is called
274            self.weekend = WeekdaySet::new(&[]);
275            return Some(self.current_day);
276        }
277
278        Option::None
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{types::Weekday, Date, DateDuration, RangeError};
286
287    static ISO_CALENDAR: WeekCalculator = WeekCalculator {
288        first_weekday: Weekday::Monday,
289        min_week_days: 4,
290    };
291
292    static AE_CALENDAR: WeekCalculator = WeekCalculator {
293        first_weekday: Weekday::Saturday,
294        min_week_days: 4,
295    };
296
297    static US_CALENDAR: WeekCalculator = WeekCalculator {
298        first_weekday: Weekday::Sunday,
299        min_week_days: 1,
300    };
301
302    #[test]
303    fn test_weekday_index() {
304        assert_eq!(ISO_CALENDAR.weekday_index(Weekday::Monday), 0);
305        assert_eq!(ISO_CALENDAR.weekday_index(Weekday::Sunday), 6);
306
307        assert_eq!(AE_CALENDAR.weekday_index(Weekday::Saturday), 0);
308        assert_eq!(AE_CALENDAR.weekday_index(Weekday::Friday), 6);
309    }
310
311    #[test]
312    fn test_first_week_offset() {
313        let first_week_offset =
314            |calendar, day| UnitInfo::new(day, 30).unwrap().first_week_offset(calendar);
315        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Monday), 0);
316        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Tuesday), -1);
317        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Wednesday), -2);
318        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Thursday), -3);
319        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Friday), 3);
320        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Saturday), 2);
321        assert_eq!(first_week_offset(ISO_CALENDAR, Weekday::Sunday), 1);
322
323        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Saturday), 0);
324        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Sunday), -1);
325        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Monday), -2);
326        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Tuesday), -3);
327        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Wednesday), 3);
328        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Thursday), 2);
329        assert_eq!(first_week_offset(AE_CALENDAR, Weekday::Friday), 1);
330
331        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Sunday), 0);
332        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Monday), -1);
333        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Tuesday), -2);
334        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Wednesday), -3);
335        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Thursday), -4);
336        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Friday), -5);
337        assert_eq!(first_week_offset(US_CALENDAR, Weekday::Saturday), -6);
338    }
339
340    #[test]
341    fn test_num_weeks() {
342        // 4 days in first & last week.
343        assert_eq!(
344            UnitInfo::new(Weekday::Thursday, 4 + 2 * 7 + 4)
345                .unwrap()
346                .num_weeks(ISO_CALENDAR),
347            4
348        );
349        // 3 days in first week, 4 in last week.
350        assert_eq!(
351            UnitInfo::new(Weekday::Friday, 3 + 2 * 7 + 4)
352                .unwrap()
353                .num_weeks(ISO_CALENDAR),
354            3
355        );
356        // 3 days in first & last week.
357        assert_eq!(
358            UnitInfo::new(Weekday::Friday, 3 + 2 * 7 + 3)
359                .unwrap()
360                .num_weeks(ISO_CALENDAR),
361            2
362        );
363
364        // 1 day in first & last week.
365        assert_eq!(
366            UnitInfo::new(Weekday::Saturday, 1 + 2 * 7 + 1)
367                .unwrap()
368                .num_weeks(US_CALENDAR),
369            4
370        );
371    }
372
373    /// Uses enumeration & bucketing to assign each day of a month or year `unit` to a week.
374    ///
375    /// This alternative implementation serves as an exhaustive safety check
376    /// of relative_week() (in addition to the manual test points used
377    /// for testing week_of()).
378    fn classify_days_of_unit(calendar: WeekCalculator, unit: &UnitInfo) -> Vec<RelativeWeek> {
379        let mut weeks: Vec<Vec<Weekday>> = Vec::new();
380        for day_index in 0..unit.duration_days {
381            let day = super::add_to_weekday(unit.first_day, i32::from(day_index));
382            if day == calendar.first_weekday || weeks.is_empty() {
383                weeks.push(Vec::new());
384            }
385            weeks.last_mut().unwrap().push(day);
386        }
387
388        let mut day_week_of_units = Vec::new();
389        let mut weeks_in_unit = 0;
390        for (index, week) in weeks.iter().enumerate() {
391            let week_of_unit = if week.len() < usize::from(calendar.min_week_days) {
392                match index {
393                    0 => RelativeWeek::LastWeekOfPreviousUnit,
394                    x if x == weeks.len() - 1 => RelativeWeek::FirstWeekOfNextUnit,
395                    _ => panic!(),
396                }
397            } else {
398                weeks_in_unit += 1;
399                RelativeWeek::WeekOfCurrentUnit(weeks_in_unit)
400            };
401
402            day_week_of_units.append(&mut [week_of_unit].repeat(week.len()));
403        }
404        day_week_of_units
405    }
406
407    #[test]
408    fn test_relative_week_of_month() {
409        for min_week_days in 1..7 {
410            for start_of_week in 1..7 {
411                let calendar = WeekCalculator {
412                    first_weekday: Weekday::from_days_since_sunday(start_of_week),
413                    min_week_days,
414                };
415                for unit_duration in super::MIN_UNIT_DAYS..400 {
416                    for start_of_unit in 1..7 {
417                        let unit = UnitInfo::new(
418                            Weekday::from_days_since_sunday(start_of_unit),
419                            unit_duration,
420                        )
421                        .unwrap();
422                        let expected = classify_days_of_unit(calendar, &unit);
423                        for (index, expected_week_of) in expected.iter().enumerate() {
424                            let day = index + 1;
425                            assert_eq!(
426                                unit.relative_week(calendar, day as u16),
427                                *expected_week_of,
428                                "For the {day}/{unit_duration} starting on Weekday \
429                        {start_of_unit} using start_of_week {start_of_week} \
430                        & min_week_days {min_week_days}"
431                            );
432                        }
433                    }
434                }
435            }
436        }
437    }
438
439    fn week_of_month_from_iso_date(
440        calendar: WeekCalculator,
441        yyyymmdd: u32,
442    ) -> Result<WeekOf, RangeError> {
443        let year = (yyyymmdd / 10000) as i32;
444        let month = ((yyyymmdd / 100) % 100) as u8;
445        let day = (yyyymmdd % 100) as u8;
446
447        let date = Date::try_new_iso(year, month, day)?;
448        let previous_month = date.added(DateDuration::new(0, -1, 0, 0));
449
450        calendar.week_of(
451            u16::from(previous_month.days_in_month()),
452            u16::from(date.days_in_month()),
453            u16::from(day),
454            date.day_of_week(),
455        )
456    }
457
458    #[test]
459    fn test_week_of_month_using_dates() {
460        assert_eq!(
461            week_of_month_from_iso_date(ISO_CALENDAR, 20210418).unwrap(),
462            WeekOf {
463                week: 3,
464                unit: RelativeUnit::Current,
465            }
466        );
467        assert_eq!(
468            week_of_month_from_iso_date(ISO_CALENDAR, 20210419).unwrap(),
469            WeekOf {
470                week: 4,
471                unit: RelativeUnit::Current,
472            }
473        );
474
475        // First day of year is a Thursday.
476        assert_eq!(
477            week_of_month_from_iso_date(ISO_CALENDAR, 20180101).unwrap(),
478            WeekOf {
479                week: 1,
480                unit: RelativeUnit::Current,
481            }
482        );
483        // First day of year is a Friday.
484        assert_eq!(
485            week_of_month_from_iso_date(ISO_CALENDAR, 20210101).unwrap(),
486            WeekOf {
487                week: 5,
488                unit: RelativeUnit::Previous,
489            }
490        );
491
492        // The month ends on a Wednesday.
493        assert_eq!(
494            week_of_month_from_iso_date(ISO_CALENDAR, 20200930).unwrap(),
495            WeekOf {
496                week: 1,
497                unit: RelativeUnit::Next,
498            }
499        );
500        // The month ends on a Thursday.
501        assert_eq!(
502            week_of_month_from_iso_date(ISO_CALENDAR, 20201231).unwrap(),
503            WeekOf {
504                week: 5,
505                unit: RelativeUnit::Current,
506            }
507        );
508
509        // US calendar always assigns the week to the current month. 2020-12-31 is a Thursday.
510        assert_eq!(
511            week_of_month_from_iso_date(US_CALENDAR, 20201231).unwrap(),
512            WeekOf {
513                week: 5,
514                unit: RelativeUnit::Current,
515            }
516        );
517        assert_eq!(
518            week_of_month_from_iso_date(US_CALENDAR, 20210101).unwrap(),
519            WeekOf {
520                week: 1,
521                unit: RelativeUnit::Current,
522            }
523        );
524    }
525}
526
527#[test]
528fn test_weekend() {
529    use icu_locale_core::locale;
530
531    assert_eq!(
532        WeekInformation::try_new(locale!("und").into())
533            .unwrap()
534            .weekend()
535            .collect::<Vec<_>>(),
536        vec![Weekday::Saturday, Weekday::Sunday],
537    );
538
539    assert_eq!(
540        WeekInformation::try_new(locale!("und-FR").into())
541            .unwrap()
542            .weekend()
543            .collect::<Vec<_>>(),
544        vec![Weekday::Saturday, Weekday::Sunday],
545    );
546
547    assert_eq!(
548        WeekInformation::try_new(locale!("und-IQ").into())
549            .unwrap()
550            .weekend()
551            .collect::<Vec<_>>(),
552        vec![Weekday::Saturday, Weekday::Friday],
553    );
554
555    assert_eq!(
556        WeekInformation::try_new(locale!("und-IR").into())
557            .unwrap()
558            .weekend()
559            .collect::<Vec<_>>(),
560        vec![Weekday::Friday],
561    );
562}
563
564#[test]
565fn test_weekdays_iter() {
566    use Weekday::*;
567
568    // Weekend ends one day before week starts
569    let default_weekend = WeekdaySetIterator::new(Monday, WeekdaySet::new(&[Saturday, Sunday]));
570    assert_eq!(vec![Saturday, Sunday], default_weekend.collect::<Vec<_>>());
571
572    // Non-contiguous weekend
573    let fri_sun_weekend = WeekdaySetIterator::new(Monday, WeekdaySet::new(&[Friday, Sunday]));
574    assert_eq!(vec![Friday, Sunday], fri_sun_weekend.collect::<Vec<_>>());
575
576    let multiple_contiguous_days = WeekdaySetIterator::new(
577        Monday,
578        WeekdaySet::new(&[
579            Weekday::Tuesday,
580            Weekday::Wednesday,
581            Weekday::Thursday,
582            Weekday::Friday,
583        ]),
584    );
585    assert_eq!(
586        vec![Tuesday, Wednesday, Thursday, Friday],
587        multiple_contiguous_days.collect::<Vec<_>>()
588    );
589
590    // Non-contiguous days and iterator yielding elements based off first_weekday
591    let multiple_non_contiguous_days = WeekdaySetIterator::new(
592        Wednesday,
593        WeekdaySet::new(&[
594            Weekday::Tuesday,
595            Weekday::Thursday,
596            Weekday::Friday,
597            Weekday::Sunday,
598        ]),
599    );
600    assert_eq!(
601        vec![Thursday, Friday, Sunday, Tuesday],
602        multiple_non_contiguous_days.collect::<Vec<_>>()
603    );
604}
605
606#[test]
607fn test_iso_weeks() {
608    use crate::types::IsoWeekOfYear;
609    use crate::Date;
610
611    #[allow(clippy::zero_prefixed_literal)]
612    for ((y, m, d), (iso_year, week_number)) in [
613        // 2010 starts on a Thursday, so 2009 has 53 ISO weeks
614        ((2009, 12, 30), (2009, 53)),
615        ((2009, 12, 31), (2009, 53)),
616        ((2010, 01, 01), (2009, 53)),
617        ((2010, 01, 02), (2009, 53)),
618        ((2010, 01, 03), (2009, 53)),
619        ((2010, 01, 04), (2010, 1)),
620        ((2010, 01, 05), (2010, 1)),
621        // 2030 starts on a Monday
622        ((2029, 12, 29), (2029, 52)),
623        ((2029, 12, 30), (2029, 52)),
624        ((2029, 12, 31), (2030, 1)),
625        ((2030, 01, 01), (2030, 1)),
626        ((2030, 01, 02), (2030, 1)),
627        ((2030, 01, 03), (2030, 1)),
628        ((2030, 01, 04), (2030, 1)),
629    ] {
630        assert_eq!(
631            Date::try_new_iso(y, m, d).unwrap().week_of_year(),
632            IsoWeekOfYear {
633                iso_year,
634                week_number
635            }
636        );
637    }
638}