Repository: BurntSushi/jiff (language: rust)

Issue:
fill out the `ISOWeekDate` API a bit more
See below for the discussion question that motivated this, but `ISOWeekDate` could really stand to be filled out a bit more. It's very bare bones right now. At minimum, we should add:

* `ISOWeekDate::weeks_in_year`
* `ISOWeekDate::first_in_week`
* `ISOWeekDate::last_in_week`
* `ISOWeekDate::first_in_year`
* `ISOWeekDate::last_in_year`
* `ISOWeekDate::series`

And maybe some others. These mostly match (or are consistent with) APIs on `Date` itself.

I didn't add these originally probably because I didn't think of it, or ran out steam or because I was unsure of how much this type would be used. Much to my surprise, the ISO 8601 week date stuff is probably one of the more requested aspects of Jiff that should be "beefed" up. Maybe it is just in my part of world (New England), but I've never ever seen or even heard of the ISO 8601 week calendar being used before in real life.

### Discussed in https://github.com/BurntSushi/jiff/discussions/226

<div type='discussions-op-text'>

<sup>Originally posted by **luc4hu** January 27, 2025</sup>
Hi there!

First of all, let me say that I really enjoy `jiff` and its thorough documentation.

My use case is that I want to aggregate some statistics for every week of a given target year. I need the weeks to always contain 7 days, so I think going for the ISO week date format is the correct choice.

Here's a working version:

```rust
use jiff::civil::ISOWeekDate;

fn main() {
    let target_year = 2024;
    let iso_week_date = ISOWeekDate::new(target_year, 1, jiff::civil::Weekday::Monday).unwrap();

    let weeks_in_year = if iso_week_date.in_long_year() { 53 } else { 52 };

    for week_number in 1..=weeks_in_year {
        let start_of_week = ISOWeekDate::new(
            iso_week_date.year(),
            week_number,
            jiff::civil::Weekday::Monday,
        )
        .unwrap();

        let end_of_week = ISOWeekDate::new(
            iso_week_date.year(),
            week_number,
            jiff::civil::Weekday::Sunday,
        )
        .unwrap();

        println!(
            "ISO week {}: {} - {}",
            week_number,
            start_of_week.date(),
            end_of_week.date()
        );
    }
}
```

Is there a better way to calculate this?
One thing that bothers me a little is that I need some manual handling for getting the number of weeks in a year.

C# offers a convenient [function](https://learn.microsoft.com/en-us/dotnet/api/system.globalization.isoweek.getweeksinyear?view=net-9.0) to get the number of weeks. 

Is there a specific design decision behind omitting this function? 
</div>


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c3a472..11a6233 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # CHANGELOG
 
+0.1.29 (TBD)
+============
+TODO
+
+Enhancements:
+
+* [#227](https://github.com/BurntSushi/jiff/issues/227):
+The `civil::ISOWeekDate` API has been beefed up with a few convenience methods.
+
+
 0.1.28 (2025-01-27)
 ===================
 This is a small release that just removes the dev-dependency on `serde_yml`.
diff --git a/src/civil/iso_week_date.rs b/src/civil/iso_week_date.rs
index d5dea30..e20efb0 100644
--- a/src/civil/iso_week_date.rs
+++ b/src/civil/iso_week_date.rs
@@ -77,6 +77,38 @@ use crate::{
 ///     1904, 1908, 1920, 1932, 1936, 1948, 1960, 1964, 1976, 1988, 1992,
 /// ]);
 /// ```
+///
+/// # Example: printing all weeks in a year
+///
+/// The ISO 8601 week calendar can be useful when you want to categorize
+/// things into buckets of weeks where all weeks are exactly 7 days, _and_
+/// you don't care as much about the precise Gregorian year. Here's an example
+/// that prints all of the ISO 8601 weeks in one ISO 8601 week year:
+///
+/// ```
+/// use jiff::{civil::{ISOWeekDate, Weekday}, ToSpan};
+///
+/// let target_year = 2024;
+/// let iso_week_date = ISOWeekDate::new(target_year, 1, Weekday::Monday)?;
+/// // Create a series of dates via the Gregorian calendar. But since a
+/// // Gregorian week and an ISO 8601 week calendar week are both 7 days,
+/// // this works fine.
+/// let weeks = iso_week_date
+///     .date()
+///     .series(1.week())
+///     .map(|d| d.iso_week_date())
+///     .take_while(|wd| wd.year() == target_year);
+/// for start_of_week in weeks {
+///     let end_of_week = start_of_week.last_of_week()?;
+///     println!(
+///         "ISO week {}: {} - {}",
+///         start_of_week.week(),
+///         start_of_week.date(),
+///         end_of_week.date()
+///     );
+/// }
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
 #[derive(Clone, Copy, Hash)]
 pub struct ISOWeekDate {
     year: ISOYear,
@@ -267,6 +299,244 @@ impl ISOWeekDate {
         self.weekday
     }
 
+    /// Returns the ISO 8601 week date corresponding to the first day in the
+    /// week of this week date. The date returned is guaranteed to have a
+    /// weekday of [`Weekday::Monday`].
+    ///
+    /// # Errors
+    ///
+    /// Since `-9999-01-01` falls on a Monday, it follows that the minimum
+    /// support Gregorian date is exactly equivalent to the minimum supported
+    /// ISO 8601 week date. This means that this routine can never actually
+    /// fail, but only insomuch as the minimums line up. For that reason, and
+    /// for consistency with [`ISOWeekDate::last_of_week`], the API is
+    /// fallible.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday, date};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(wd.date(), date(2025, 1, 29));
+    /// assert_eq!(
+    ///     wd.first_of_week()?,
+    ///     ISOWeekDate::new(2025, 5, Weekday::Monday).unwrap(),
+    /// );
+    ///
+    /// // Works even for the minimum date.
+    /// assert_eq!(
+    ///     ISOWeekDate::MIN.first_of_week()?,
+    ///     ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap(),
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn first_of_week(self) -> Result<ISOWeekDate, Error> {
+        // I believe this can never return an error because `Monday` is in
+        // bounds for all possible year-and-week combinations. This is *only*
+        // because -9999-01-01 corresponds to -9999-W01-Monday. Which is kinda
+        // lucky. And I guess if we ever change the ranges, this could become
+        // fallible.
+        ISOWeekDate::new_ranged(
+            self.year_ranged(),
+            self.week_ranged(),
+            Weekday::Monday,
+        )
+    }
+
+    /// Returns the ISO 8601 week date corresponding to the last day in the
+    /// week of this week date. The date returned is guaranteed to have a
+    /// weekday of [`Weekday::Sunday`].
+    ///
+    /// # Errors
+    ///
+    /// This can return an error if the last day of the week exceeds Jiff's
+    /// maximum Gregorian date of `9999-12-31`. It turns out this can happen
+    /// since `9999-12-31` falls on a Friday.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday, date};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(wd.date(), date(2025, 1, 29));
+    /// assert_eq!(
+    ///     wd.last_of_week()?,
+    ///     ISOWeekDate::new(2025, 5, Weekday::Sunday).unwrap(),
+    /// );
+    ///
+    /// // Unlike `first_of_week`, this routine can actually fail on real
+    /// // values, although, only when close to the maximum supported date.
+    /// assert_eq!(
+    ///     ISOWeekDate::MAX.last_of_week().unwrap_err().to_string(),
+    ///     "parameter 'weekday' with value 7 is not \
+    ///      in the required range of 1..=5",
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn last_of_week(self) -> Result<ISOWeekDate, Error> {
+        // This can return an error when in the last week of the maximum year
+        // supported by Jiff. That's because the Saturday and Sunday of that
+        // week are actually in Gregorian year 10,000.
+        ISOWeekDate::new_ranged(
+            self.year_ranged(),
+            self.week_ranged(),
+            Weekday::Sunday,
+        )
+    }
+
+    /// Returns the ISO 8601 week date corresponding to the first day in the
+    /// year of this week date. The date returned is guaranteed to have a
+    /// weekday of [`Weekday::Monday`].
+    ///
+    /// # Errors
+    ///
+    /// Since `-9999-01-01` falls on a Monday, it follows that the minimum
+    /// support Gregorian date is exactly equivalent to the minimum supported
+    /// ISO 8601 week date. This means that this routine can never actually
+    /// fail, but only insomuch as the minimums line up. For that reason, and
+    /// for consistency with [`ISOWeekDate::last_of_year`], the API is
+    /// fallible.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday, date};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(wd.date(), date(2025, 1, 29));
+    /// assert_eq!(
+    ///     wd.first_of_year()?,
+    ///     ISOWeekDate::new(2025, 1, Weekday::Monday).unwrap(),
+    /// );
+    ///
+    /// // Works even for the minimum date.
+    /// assert_eq!(
+    ///     ISOWeekDate::MIN.first_of_year()?,
+    ///     ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap(),
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn first_of_year(self) -> Result<ISOWeekDate, Error> {
+        // I believe this can never return an error because `Monday` is in
+        // bounds for all possible years. This is *only* because -9999-01-01
+        // corresponds to -9999-W01-Monday. Which is kinda lucky. And I guess
+        // if we ever change the ranges, this could become fallible.
+        ISOWeekDate::new_ranged(self.year_ranged(), C(1), Weekday::Monday)
+    }
+
+    /// Returns the ISO 8601 week date corresponding to the last day in the
+    /// year of this week date. The date returned is guaranteed to have a
+    /// weekday of [`Weekday::Sunday`].
+    ///
+    /// # Errors
+    ///
+    /// This can return an error if the last day of the year exceeds Jiff's
+    /// maximum Gregorian date of `9999-12-31`. It turns out this can happen
+    /// since `9999-12-31` falls on a Friday.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday, date};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(wd.date(), date(2025, 1, 29));
+    /// assert_eq!(
+    ///     wd.last_of_year()?,
+    ///     ISOWeekDate::new(2025, 52, Weekday::Sunday).unwrap(),
+    /// );
+    ///
+    /// // Works correctly for "long" years.
+    /// let wd = ISOWeekDate::new(2026, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(wd.date(), date(2026, 1, 28));
+    /// assert_eq!(
+    ///     wd.last_of_year()?,
+    ///     ISOWeekDate::new(2026, 53, Weekday::Sunday).unwrap(),
+    /// );
+    ///
+    /// // Unlike `first_of_year`, this routine can actually fail on real
+    /// // values, although, only when close to the maximum supported date.
+    /// assert_eq!(
+    ///     ISOWeekDate::MAX.last_of_year().unwrap_err().to_string(),
+    ///     "parameter 'weekday' with value 7 is not \
+    ///      in the required range of 1..=5",
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn last_of_year(self) -> Result<ISOWeekDate, Error> {
+        // This can return an error when in the maximum year supported by
+        // Jiff. That's because the last Saturday and Sunday of that year are
+        // actually in Gregorian year 10,000.
+        let week = if self.in_long_year() {
+            ISOWeek::V::<53, 52, 53>()
+        } else {
+            ISOWeek::V::<52, 52, 53>()
+        };
+        ISOWeekDate::new_ranged(self.year_ranged(), week, Weekday::Sunday)
+    }
+
+    /// Returns the total number of days in the year of this ISO 8601 week
+    /// date.
+    ///
+    /// It is guaranteed that the value returned is either 364 or 371. The
+    /// latter case occurs precisely when [`ISOWeekDate::in_long_year`]
+    /// returns `true`.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday};
+    ///
+    /// let weekdate = ISOWeekDate::new(2025, 7, Weekday::Monday).unwrap();
+    /// assert_eq!(weekdate.days_in_year(), 364);
+    /// let weekdate = ISOWeekDate::new(2026, 7, Weekday::Monday).unwrap();
+    /// assert_eq!(weekdate.days_in_year(), 371);
+    /// ```
+    #[inline]
+    pub fn days_in_year(self) -> i16 {
+        if self.in_long_year() {
+            371
+        } else {
+            364
+        }
+    }
+
+    /// Returns the total number of weeks in the year of this ISO 8601 week
+    /// date.
+    ///
+    /// It is guaranteed that the value returned is either 52 or 53. The
+    /// latter case occurs precisely when [`ISOWeekDate::in_long_year`]
+    /// returns `true`.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday};
+    ///
+    /// let weekdate = ISOWeekDate::new(2025, 7, Weekday::Monday).unwrap();
+    /// assert_eq!(weekdate.weeks_in_year(), 52);
+    /// let weekdate = ISOWeekDate::new(2026, 7, Weekday::Monday).unwrap();
+    /// assert_eq!(weekdate.weeks_in_year(), 53);
+    /// ```
+    #[inline]
+    pub fn weeks_in_year(self) -> i8 {
+        if self.in_long_year() {
+            53
+        } else {
+            52
+        }
+    }
+
     /// Returns true if and only if the year of this week date is a "long"
     /// year.
     ///
@@ -288,6 +558,64 @@ impl ISOWeekDate {
         is_long_year(self.year_ranged())
     }
 
+    /// Returns the ISO 8601 date immediately following this one.
+    ///
+    /// # Errors
+    ///
+    /// This returns an error when this date is the maximum value.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(
+    ///     wd.tomorrow()?,
+    ///     ISOWeekDate::new(2025, 5, Weekday::Thursday).unwrap(),
+    /// );
+    ///
+    /// // The max doesn't have a tomorrow.
+    /// assert!(ISOWeekDate::MAX.tomorrow().is_err());
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn tomorrow(self) -> Result<ISOWeekDate, Error> {
+        // I suppose we could probably implement this in a more efficient
+        // manner but avoiding the roundtrip through Gregorian dates.
+        self.date().tomorrow().map(|d| d.iso_week_date())
+    }
+
+    /// Returns the ISO 8601 week date immediately preceding this one.
+    ///
+    /// # Errors
+    ///
+    /// This returns an error when this date is the minimum value.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::civil::{ISOWeekDate, Weekday};
+    ///
+    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
+    /// assert_eq!(
+    ///     wd.yesterday()?,
+    ///     ISOWeekDate::new(2025, 5, Weekday::Tuesday).unwrap(),
+    /// );
+    ///
+    /// // The min doesn't have a yesterday.
+    /// assert!(ISOWeekDate::MIN.yesterday().is_err());
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn yesterday(self) -> Result<ISOWeekDate, Error> {
+        // I suppose we could probably implement this in a more efficient
+        // manner but avoiding the roundtrip through Gregorian dates.
+        self.date().yesterday().map(|d| d.iso_week_date())
+    }
+
     /// Converts this ISO week date to a Gregorian [`Date`].
     ///
     /// The minimum and maximum allowed values of an ISO week date are
@@ -369,7 +697,7 @@ impl ISOWeekDate {
         {
             return Err(Error::range(
                 "weekday",
-                weekday.to_monday_zero_offset(),
+                weekday.to_monday_one_offset(),
                 Weekday::Monday.to_monday_one_offset(),
                 Weekday::Friday.to_monday_one_offset(),
             ));


Test patch: