timewarp/
doy.rs

1use crate::day_of_week::DayOfWeek;
2use crate::error::parse_error;
3use crate::month_of_year::Month;
4use crate::DayOfWeek::{Fri, Sun, Thu};
5use crate::TimeWarpError;
6use std::cmp::Ordering;
7use std::convert::TryFrom;
8use std::fmt::{Debug, Display, Formatter};
9use std::num::ParseIntError;
10use std::ops::{Add, Sub};
11use std::time::SystemTime;
12
13/// Day Of Year. Helper-class to easily calculate dates.
14#[must_use]
15#[derive(Eq, PartialEq, Copy, Clone, Debug)]
16pub struct Doy {
17    pub year: i32,
18    pub doy: i32,
19}
20
21impl Doy {
22    pub const SECOND: u128 = 1000;
23    pub const MINUTE: u128 = Self::SECOND * 60;
24    pub const HOUR: u128 = Self::MINUTE * 60;
25    pub const DAY: u128 = Self::HOUR * 24;
26    pub const YEAR: u128 = Self::DAY * 365 + Self::HOUR * 6;
27
28    /// returns the Doy representing today.
29    pub fn today() -> Self {
30        let millis = SystemTime::now()
31            .duration_since(SystemTime::UNIX_EPOCH)
32            .unwrap()
33            .as_millis();
34        Self::from_millis(millis)
35    }
36
37    /// converts milliseconds from POSIX time or Epoch time to Doy.
38    pub fn from_millis(millis: u128) -> Self {
39        let offset = millis % Self::YEAR;
40        let year = 1970 + ((millis - offset) / Self::YEAR) as i32;
41        let doy_offset = offset % Self::DAY;
42        let doy = 1 + ((offset - doy_offset) / Self::DAY) as i32;
43        Self { year, doy }
44    }
45
46    /// Creates a new Doy, by the give `dayOfYear` and the `year`.
47    /// 1 = 1. Jan, 32 = 1. Feb, 0 = 31. Dec (year - 1)  
48    pub fn new(doy: i32, year: i32) -> Self {
49        if doy < 1 {
50            return Self::new(365 + i32::from(Self::is_leapyear(year - 1)) + doy, year - 1);
51        }
52        let max_doy = 365 + i32::from(Self::is_leapyear(year));
53        if doy > max_doy {
54            Self::new(doy - max_doy, year + 1)
55        } else {
56            Self { year, doy }
57        }
58    }
59
60    #[inline]
61    fn day_per_month(year: i32) -> Vec<i32> {
62        let leap = Self::is_leapyear(year) as i32;
63        vec![31, 28 + leap, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
64    }
65
66    /// Creates a Doy from `year`, `month` and `day`.
67    ///
68    /// # Panics
69    /// panics if `month` is not in 1..12
70    pub fn from_ymd(year: i32, month: i32, day: i32) -> Self {
71        assert!(month > 0 && month < 13, "Month has to be in 1..12");
72        let day_of_year = Self::day_per_month(year)
73            .iter()
74            .take(month as usize - 1)
75            .sum::<i32>()
76            + day;
77        Self::new(day_of_year, year)
78    }
79
80    /// Creates a Doy for the Monday of the given week (iso 8601)
81    ///
82    /// # Panics
83    /// panics if `week` is not in 1..53
84    pub fn from_week(year: i32, week: i32) -> Self {
85        assert!(week > 0 && week < 54, "Week has to be in 1..53");
86        // weekday of 4th, Jan.
87        let weekday = Self::new(4, year).day_of_week();
88        let day_of_year = (week - 1) * 7
89            + match weekday {
90                Sun => -2,
91                _ => Fri as i32 - weekday as i32,
92            };
93        Self::new(day_of_year, year)
94    }
95
96    /// Is the given `year` a leap-year?
97    #[inline]
98    pub fn is_leapyear(year: i32) -> bool {
99        year % 4 == 0 && year % 100 != 0
100    }
101
102    /// Is this year a leap-year?
103    pub fn leapyear(self) -> bool {
104        Self::is_leapyear(self.year)
105    }
106
107    /// converts a *day of year* to `mmdd`.
108    fn as_date(self) -> (i32, i32) {
109        let mut doy = self.doy;
110        let mut m = 1;
111        for ds in Self::day_per_month(self.year) {
112            if doy <= ds {
113                return (m, doy);
114            }
115            m += 1;
116            doy -= ds;
117        }
118        (-1, -1)
119    }
120
121    /// returns this doy in iso-format `yyyy-mm-dd`.
122    pub fn as_iso_date(self) -> String {
123        format!("{self:#}")
124    }
125
126    /// Day of Week
127    #[inline]
128    pub fn day_of_week(self) -> DayOfWeek {
129        let y = self.year % 100;
130        let y_off = y + (y / 4) + 6 - self.leapyear() as i32;
131        DayOfWeek::from(y_off + self.doy)
132    }
133
134    /// The ISO 8601 Weeks start with Monday and end on Sunday. The first week of the year always
135    /// contains January 4th. And the first Thursday is always in the first week of the year.
136    ///
137    /// returns the week in iso-8601-format: `yyyy`-W`ww`
138    pub fn iso8601week(self) -> String {
139        let dow = self.day_of_week();
140        let thursday = match dow {
141            Sun => self + Thu - 7, // Sunday => last day of ISO-week.
142            _ => self + Thu - dow,
143        };
144        let kw = (thursday.doy + 6) / 7;
145        format!("{}-W{kw:02}", thursday.year)
146    }
147
148    /// Returns the day of month.
149    pub fn day_of_month(self) -> i32 {
150        self.as_date().1
151    }
152
153    /// Returns just the `Month`.
154    pub fn month(self) -> Month {
155        Month::from(self.as_date().0)
156    }
157}
158
159impl From<Doy> for String {
160    fn from(doy: Doy) -> Self {
161        doy.to_string()
162    }
163}
164
165impl From<u128> for Doy {
166    fn from(value: u128) -> Self {
167        Doy::from_millis(value)
168    }
169}
170
171impl Display for Doy {
172    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
173        let (month, day) = self.as_date();
174        let year = self.year;
175        if f.alternate() {
176            write!(f, "{year:04}-{month:02}-{day:02}")
177        } else {
178            write!(f, "{year:04}{month:02}{day:02}")
179        }
180    }
181}
182
183macro_rules! gen_calcs {
184    ($($key:ident),+) => {
185    $(
186        impl Add<$key> for Doy {
187            type Output = Doy;
188
189            fn add(self, rhs: $key) -> Self::Output {
190                Doy::new(self.doy + rhs as i32, self.year)
191            }
192        }
193
194        impl Sub<$key> for Doy {
195            type Output = Doy;
196
197            fn sub(self, rhs: $key) -> Self::Output {
198                Doy::new(self.doy - rhs as i32, self.year)
199            }
200        }
201    )+
202    }
203}
204
205gen_calcs!(i8, i16, i32, i64, u8, u16, u32, u64, DayOfWeek);
206
207impl PartialOrd for Doy {
208    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
209        let p = if self.lt(other) {
210            Ordering::Less
211        } else if self.gt(other) {
212            Ordering::Greater
213        } else {
214            Ordering::Equal
215        };
216        Some(p)
217    }
218
219    fn lt(&self, other: &Self) -> bool {
220        self.year < other.year || (self.year == other.year && self.doy < other.doy)
221    }
222
223    fn le(&self, other: &Self) -> bool {
224        self.year < other.year || (self.year == other.year && self.doy <= other.doy)
225    }
226
227    fn gt(&self, other: &Self) -> bool {
228        self.year > other.year || (self.year == other.year && self.doy > other.doy)
229    }
230
231    fn ge(&self, other: &Self) -> bool {
232        self.year > other.year || (self.year == other.year && self.doy >= other.doy)
233    }
234}
235
236impl TryFrom<&str> for Doy {
237    type Error = TimeWarpError;
238
239    fn try_from(value: &str) -> Result<Self, Self::Error> {
240        use std::str::FromStr;
241        let err = |e: ParseIntError| -> Result<i32, TimeWarpError> {
242            parse_error(format!("Error converting into number: '{value}'\n{e}",))
243        };
244        let y = i32::from_str(&value[0..4]).map_err(err)?;
245        let (m, d) = if value.len() == 10 && &value[4..5] == "-" && &value[7..8] == "-" {
246            (
247                i32::from_str(&value[5..7]).map_err(err)?,
248                i32::from_str(&value[8..10]).map_err(err)?,
249            )
250        } else if value.len() == 8 {
251            (
252                i32::from_str(&value[4..6]).map_err(err)?,
253                i32::from_str(&value[6..8]).map_err(err)?,
254            )
255        } else {
256            return parse_error(format!("Wrong date-format: '{value}'"));
257        };
258        if m < 1 || m > 12 {
259            return parse_error(format!("Month out of range 0..12: '{m}'"));
260        }
261        let days_in_month = Self::day_per_month(y).as_slice()[(m - 1) as usize];
262        if d < 1 || d > days_in_month {
263            return parse_error(format!(
264                "Days exceeded in month {m} '{d}' ({days_in_month})"
265            ));
266        }
267        Ok(Self::from_ymd(y, m, d))
268    }
269}
270
271/// A timespan in whole days.
272///
273#[derive(Debug, Eq, PartialEq)]
274pub enum Tempus {
275    Moment(Doy),
276    Interval(Doy, Doy),
277}
278
279impl Tempus {
280    /// The start-date of this `DaySpan` (inclusive).
281    pub fn start(&self) -> Doy {
282        match *self {
283            Tempus::Moment(d) | Tempus::Interval(d, _) => d,
284        }
285    }
286
287    /// The end-date of this `DaySpan` (exclusive).
288    pub fn end(&self) -> Doy {
289        match *self {
290            Tempus::Moment(d) => d + 1,
291            Tempus::Interval(_, e) => e,
292        }
293    }
294}
295
296#[cfg(test)]
297mod should {
298    use crate::day_of_week::DayOfWeek::*;
299    use crate::doy::Doy;
300    use crate::month_of_year::Month;
301    use std::convert::TryFrom;
302
303    #[test]
304    fn try_from() {
305        assert_eq!(
306            "2018-01-01",
307            Doy::try_from("2018-01-01").unwrap().as_iso_date()
308        );
309        assert!(Doy::try_from("2018-13-01").is_err());
310        assert!(Doy::try_from("2018-02-29").is_err());
311        assert!(Doy::try_from("20180431").is_err());
312        assert!(Doy::try_from("2018/04/15").is_err());
313    }
314
315    #[test]
316    fn from_week_of_year() {
317        assert_eq!("2018-01-01", Doy::from_week(2018, 1).as_iso_date());
318        assert_eq!("2018-12-31", Doy::from_week(2019, 1).as_iso_date());
319        assert_eq!("2019-12-30", Doy::from_week(2020, 1).as_iso_date());
320        assert_eq!("2021-01-04", Doy::from_week(2021, 1).as_iso_date());
321        assert_eq!("2022-01-03", Doy::from_week(2022, 1).as_iso_date());
322    }
323
324    #[test]
325    fn into_week_of_year() {
326        // 1. Rule: 4th of January is always in W01
327        assert_eq!("2018-W01", Doy::from_ymd(2018, 1, 4).iso8601week());
328        assert_eq!("2019-W01", Doy::from_ymd(2019, 1, 4).iso8601week());
329        assert_eq!("2020-W01", Doy::from_ymd(2020, 1, 4).iso8601week());
330        assert_eq!("2021-W01", Doy::from_ymd(2021, 1, 4).iso8601week());
331        assert_eq!("2022-W01", Doy::from_ymd(2022, 1, 4).iso8601week());
332        assert_eq!("2023-W01", Doy::from_ymd(2023, 1, 4).iso8601week());
333        assert_eq!("2026-W01", Doy::from_ymd(2026, 1, 4).iso8601week());
334
335        assert_eq!("2018-W01", Doy::from_ymd(2018, 1, 1).iso8601week());
336        assert_eq!("2019-W01", Doy::from_ymd(2019, 1, 1).iso8601week());
337        assert_eq!("2020-W53", Doy::from_ymd(2021, 1, 1).iso8601week());
338        assert_eq!("2021-W52", Doy::from_ymd(2022, 1, 1).iso8601week());
339
340        assert_eq!("2018-W26", Doy::from_ymd(2018, 7, 1).iso8601week());
341        assert_eq!("2019-W27", Doy::from_ymd(2019, 7, 1).iso8601week());
342        assert_eq!("2020-W27", Doy::from_ymd(2020, 7, 1).iso8601week());
343        assert_eq!("2021-W26", Doy::from_ymd(2021, 7, 1).iso8601week());
344    }
345
346    #[test]
347    fn day_of_month() {
348        let test = Doy::from_ymd(2018, 4, 13);
349        assert_eq!(test.as_iso_date(), "2018-04-13");
350        assert_eq!(test.month(), Month::Apr);
351
352        let test = Doy::from_ymd(2018, 3, 6);
353        assert_eq!(test.as_iso_date(), "2018-03-06");
354        assert_eq!(test.month(), Month::Mar);
355    }
356
357    #[test]
358    fn create_by_doy_year() {
359        let proof = Doy::new(-7, 2020);
360        let test = Doy::new(358, 2019);
361        assert_eq!(test, proof);
362        let proof = Doy::new(-1, 2020);
363        assert_eq!("20191230", proof.to_string());
364        let proof = Doy::new(-1, 2021);
365        assert_eq!("20201230", proof.to_string());
366    }
367
368    #[test]
369    fn return_leapyear() {
370        assert!(Doy::new(1, 2020).leapyear());
371        assert!(!Doy::new(1, 2018).leapyear());
372        assert!(!Doy::new(1, 2000).leapyear());
373    }
374
375    #[test]
376    fn convert_to_string() {
377        assert_eq!("20201225", Doy::new(360, 2020).to_string());
378        assert_eq!("20181225", Doy::new(359, 2018).to_string());
379    }
380
381    #[test]
382    fn calc_day_of_week() {
383        assert_eq!(Wed, Doy::new(31, 2018).day_of_week());
384        assert_eq!(Thu, Doy::new(31, 2019).day_of_week());
385        assert_eq!(Fri, Doy::new(31, 2020).day_of_week());
386        // Wochentag vom 1. Weihnachtstag 25.12.
387        assert_eq!(Tue, Doy::new(359, 2018).day_of_week());
388        assert_eq!(Fri, Doy::new(360, 2020).day_of_week());
389        assert_eq!(Sat, Doy::new(359, 2021).day_of_week());
390    }
391
392    #[test]
393    fn create_via_try_from() {
394        assert_eq!("20200229", Doy::from_ymd(2020, 2, 29).to_string());
395        assert_eq!("19990814", Doy::from_ymd(1999, 8, 14).to_string());
396        let d = "20240721";
397        assert_eq!(d, &Doy::try_from(d).unwrap().to_string());
398    }
399
400    #[test]
401    fn order_gt_or_lt() {
402        let a = Doy::new(112, 2020);
403        let b = Doy::new(225, 2020);
404        let c = Doy::new(85, 2021);
405
406        assert!(a < b);
407        assert!(c > a);
408        assert!(b < c);
409        assert!(a >= a);
410        assert!(b <= c);
411    }
412
413    #[test]
414    fn add_i32() {
415        let d = Doy::new(15, 2020) + 2;
416        assert_eq!(Doy::new(17, 2020), d);
417    }
418
419    #[test]
420    fn from_millis() {
421        assert_eq!("20230317", Doy::from_millis(1679086777511).to_string());
422        assert_eq!("20230101", Doy::from_millis(1672570315000).to_string());
423        assert_eq!("20181231", Doy::from_millis(1546253515000).to_string());
424    }
425}