Skip to main content

deep_time/ymdhms/
mod.rs

1use crate::{ATTOS_PER_SEC_I128, Dt, Scale};
2#[cfg(feature = "jiff-tz")]
3use crate::{DtErr, DtErrKind, an_err};
4
5mod printer;
6
7/// Combined Gregorian date + wall time with subsecond precision.
8/// Has calendar aware and, when the `jiff-tz` feature is enabled,
9/// timezone aware math functions.
10///
11/// ## Examples
12///
13/// **Creating a** [`YmdHms`].
14///
15/// ```rust
16/// use deep_time::{Dt, Scale};
17///
18/// // clamped to 29
19/// let x = Dt::from_ymd(2000, 2, 30, 0, 0, 0, 0, Scale::UTC).to_ymd();
20///
21/// assert_eq!(x.day(), 29);
22/// ```
23///
24/// **Adding a year.** 2000 is a leap year and Feb. 29th is possible, but
25/// 2001 isn't a leap year so the day is clamped to the 28th.
26///
27/// ```rust
28/// use deep_time::{Dt, Scale};
29///
30/// let x = Dt::from_ymd(2000, 2, 29, 0, 0, 0, 0, Scale::UTC).to_ymd();
31/// let x = x.add_yr(1);
32///
33/// assert_eq!(x.day(), 28);
34/// ```
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[cfg_attr(feature = "js", derive(tsify::Tsify))]
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub struct YmdHms {
39    pub(crate) yr: i64,
40    pub(crate) mo: u8,
41    pub(crate) day: u8,
42    pub(crate) hr: u8,
43    pub(crate) min: u8,
44    pub(crate) sec: u8,    // 0–60 (60 only during leap seconds)
45    pub(crate) attos: u64, // attoseconds (0 ≤ subsec < 10¹⁸)
46    pub(crate) scale: Scale,
47}
48
49impl YmdHms {
50    /// Reconstructs a [`Dt`].
51    #[inline]
52    pub const fn to_dt(&self) -> Dt {
53        Dt::from_ymd(
54            self.yr, self.mo, self.day, self.hr, self.min, self.sec, self.attos, self.scale,
55        )
56    }
57
58    /// Internal helper that round-trips through [`Dt`] to obtain a normalized
59    /// `YmdHms` (handles clamping, leap seconds, etc.).
60    #[inline(always)]
61    const fn reconstruct(
62        yr: i64,
63        mo: u8,
64        day: u8,
65        hr: u8,
66        min: u8,
67        sec: u8,
68        attos: u64,
69        scale: Scale,
70    ) -> Self {
71        Dt::from_ymd(yr, mo, day, hr, min, sec, attos, scale).to_ymd()
72    }
73
74    /// Adds (or subtracts) whole years, preserving month and day-of-month.
75    /// - Uses standard last-day-of-month clamping.
76    /// - Negative values subtract.
77    pub const fn add_yr(&self, n: i64) -> Self {
78        if n == 0 {
79            return *self;
80        }
81        let new_yr = self.yr.saturating_add(n);
82        let max_day = Dt::days_in_month(new_yr, self.mo);
83        let new_day = Dt::clamp_u8(self.day, 1, max_day);
84        Self::reconstruct(
85            new_yr, self.mo, new_day, self.hr, self.min, self.sec, self.attos, self.scale,
86        )
87    }
88
89    /// Adds (or subtracts) calendar months. Negative values subtract.
90    pub const fn add_mo(&self, n: i64) -> Self {
91        if n == 0 {
92            return *self;
93        }
94
95        let yr = self.yr as i128;
96        let mo = self.mo as i128;
97        let delta = n as i128;
98
99        let total_months = yr * 12 + (mo - 1) + delta;
100
101        let new_yr = Dt::i128_to_i64(total_months.div_euclid(12));
102        let new_mo = Dt::clamp_u8((total_months.rem_euclid(12) + 1) as u8, 1, 12);
103
104        let max_day = Dt::days_in_month(new_yr, new_mo);
105        let new_day = Dt::clamp_u8(self.day, 1, max_day);
106
107        Self::reconstruct(
108            new_yr, new_mo, new_day, self.hr, self.min, self.sec, self.attos, self.scale,
109        )
110    }
111
112    /// Adds (or subtracts) calendar weeks. Negative values subtract.
113    #[inline]
114    pub const fn add_wk(&self, n: i64) -> Self {
115        self.add_days(n.saturating_mul(7))
116    }
117
118    /// Adds (or subtracts) calendar days. Negative values subtract.
119    pub const fn add_days(&self, n: i64) -> Self {
120        if n == 0 {
121            return *self;
122        }
123        let jd = Dt::ymd_to_jd(self.yr, self.mo, self.day);
124        let new_jd = jd.saturating_add(n);
125        let (new_yr, new_mo, new_day) = Dt::jd_to_ymd(new_jd);
126        Self::reconstruct(
127            new_yr, new_mo, new_day, self.hr, self.min, self.sec, self.attos, self.scale,
128        )
129    }
130
131    /// Internal implementation detail for all sub-day / physical-time additions.
132    /// Creates a temporary [`Dt`], performs the addition, then converts back to `YmdHms`.
133    #[inline(never)]
134    const fn _add_attos(&self, attos_delta: i128) -> Self {
135        let tai = Dt::from_ymd(
136            self.yr, self.mo, self.day, self.hr, self.min, self.sec, self.attos, self.scale,
137        );
138        let new_tai = tai.add(Dt::span(attos_delta));
139        new_tai.to_ymd()
140    }
141
142    /// Adds (or subtracts) attoseconds. Negative values subtract.
143    #[inline]
144    pub const fn add_attos(&self, n: i128) -> Self {
145        self._add_attos(n)
146    }
147
148    /// Adds (or subtracts) whole seconds. Negative values subtract.
149    #[inline]
150    pub const fn add_sec(&self, n: i64) -> Self {
151        self._add_attos((n as i128).saturating_mul(ATTOS_PER_SEC_I128))
152    }
153
154    /// Adds (or subtracts) whole minutes. Negative values subtract.
155    #[inline]
156    pub const fn add_min(&self, n: i64) -> Self {
157        let delta = (n as i128)
158            .saturating_mul(60)
159            .saturating_mul(ATTOS_PER_SEC_I128);
160        self._add_attos(delta)
161    }
162
163    /// Adds (or subtracts) whole hours. Negative values subtract.
164    #[inline]
165    pub const fn add_hr(&self, n: i64) -> Self {
166        let delta = (n as i128)
167            .saturating_mul(3600)
168            .saturating_mul(ATTOS_PER_SEC_I128);
169        self._add_attos(delta)
170    }
171
172    /// Returns the year component.
173    #[inline]
174    pub const fn yr(&self) -> i64 {
175        self.yr
176    }
177
178    /// Returns the month component (1–12).
179    #[inline]
180    pub const fn mo(&self) -> u8 {
181        self.mo
182    }
183
184    /// Returns the day-of-month component (1–31, depending on month/year).
185    #[inline]
186    pub const fn day(&self) -> u8 {
187        self.day
188    }
189
190    /// Returns the hour component (0–23).
191    #[inline]
192    pub const fn hr(&self) -> u8 {
193        self.hr
194    }
195
196    /// Returns the minute component (0–59).
197    #[inline]
198    pub const fn min(&self) -> u8 {
199        self.min
200    }
201
202    /// Returns the second component (0–60). The value 60 only occurs during
203    /// a positive leap second on `Scale::UTC` / `UtcSpice` / `UtcHist`.
204    #[inline]
205    pub const fn sec(&self) -> u8 {
206        self.sec
207    }
208
209    /// Returns the attosecond (sub-second) component (0 ≤ attos < 10¹⁸).
210    #[inline]
211    pub const fn attos(&self) -> u64 {
212        self.attos
213    }
214
215    /// The time scale that the object was created on.
216    #[inline]
217    pub const fn scale(&self) -> Scale {
218        self.scale
219    }
220
221    /// Returns the **ISO week year** (can differ from the calendar year near
222    /// January 1 / December 31).
223    #[inline]
224    pub const fn iso_yr(&self) -> i64 {
225        let (iso_yr, _, _) = Dt::_to_iso_wk_date(self.yr, self.mo, self.day);
226        iso_yr
227    }
228
229    /// Returns the **ISO week number** (1–53). Weeks start on Monday; week 1
230    /// is the week containing the first Thursday of the year.
231    #[inline]
232    pub const fn iso_wk(&self) -> u8 {
233        let (_, iso_wk, _) = Dt::_to_iso_wk_date(self.yr, self.mo, self.day);
234        iso_wk
235    }
236
237    /// Returns the **day of the year** (ordinal date), 1-based (Jan 1 = 1,
238    /// Dec 31 = 365 or 366 in leap years).
239    #[inline]
240    pub const fn day_of_yr(&self) -> u16 {
241        Dt::_day_of_yr(self.yr, self.mo, self.day)
242    }
243
244    /// Returns the **weekday** number according to [`Dt::jd_to_wkday`]
245    /// (typically 0 = Sunday … 6 = Saturday; exact convention is defined
246    /// by the Julian Day helper).
247    #[inline]
248    pub const fn wkday(&self) -> u8 {
249        let jd = Dt::ymd_to_jd(self.yr, self.mo, self.day);
250        Dt::jd_to_wkday(jd)
251    }
252
253    /// Returns the **week of year** number when weeks are considered to start
254    /// on Sunday (US-style numbering).
255    #[inline]
256    pub const fn wk_of_yr_sun(&self) -> u8 {
257        Dt::_wk_sun(self.yr, self.day_of_yr())
258    }
259
260    /// Returns the **week of year** number when weeks are considered to start
261    /// on Monday.
262    #[inline]
263    pub const fn wk_of_yr_mon(&self) -> u8 {
264        Dt::_wk_mon(self.yr, self.day_of_yr())
265    }
266}
267
268#[cfg(feature = "jiff-tz")]
269impl YmdHms {
270    /// Adds the given number of years in the specified IANA timezone,
271    /// respecting timezone rules (including DST) and proper calendar arithmetic.
272    ///
273    /// ## Errors
274    ///
275    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
276    ///   this range will return a [`DtErr`].
277    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
278    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
279    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
280    pub fn add_yr_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
281        let zoned = self
282            .to_jiff_zoned(tz)?
283            .checked_add(jiff::Span::new().years(n))
284            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
285        Ok(self.from_jiff_zoned(zoned))
286    }
287
288    /// Adds the given number of months in the specified IANA timezone,
289    /// respecting timezone rules and calendar month-end clamping.
290    ///
291    /// ## Errors
292    ///
293    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
294    ///   this range will return a [`DtErr`].
295    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
296    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
297    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
298    pub fn add_mo_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
299        let zoned = self
300            .to_jiff_zoned(tz)?
301            .checked_add(jiff::Span::new().months(n))
302            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
303        Ok(self.from_jiff_zoned(zoned))
304    }
305
306    /// Adds the given number of weeks in the specified IANA timezone.
307    ///
308    /// ## Errors
309    ///
310    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
311    ///   this range will return a [`DtErr`].
312    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
313    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
314    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
315    #[inline]
316    pub fn add_wk_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
317        self.add_days_tz(n.saturating_mul(7), tz)
318    }
319
320    /// Adds the given number of calendar days in the specified IANA timezone.
321    ///
322    /// ## Errors
323    ///
324    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
325    ///   this range will return a [`DtErr`].
326    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
327    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
328    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
329    pub fn add_days_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
330        let zoned = self
331            .to_jiff_zoned(tz)?
332            .checked_add(jiff::Span::new().days(n))
333            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
334        Ok(self.from_jiff_zoned(zoned))
335    }
336
337    /// Adds the given number of hours in the specified IANA timezone,
338    /// respecting timezone rules (including DST).
339    ///
340    /// ## Errors
341    ///
342    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
343    ///   this range will return a [`DtErr`].
344    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
345    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
346    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
347    pub fn add_hr_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
348        let new_zoned = self
349            .to_jiff_zoned(tz)?
350            .checked_add(jiff::Span::new().hours(n))
351            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
352        Ok(self.from_jiff_zoned(new_zoned))
353    }
354
355    /// Adds the given number of minutes in the specified IANA timezone,
356    /// respecting timezone rules (including DST).
357    ///
358    /// ## Errors
359    ///
360    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
361    ///   this range will return a [`DtErr`].
362    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
363    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
364    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
365    pub fn add_min_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
366        let zoned = self
367            .to_jiff_zoned(tz)?
368            .checked_add(jiff::Span::new().minutes(n))
369            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
370        Ok(self.from_jiff_zoned(zoned))
371    }
372
373    /// Adds the given number of seconds in the specified IANA timezone.
374    ///
375    /// ## Errors
376    ///
377    /// - Jiff only supports years in the range `-9999..=9999`. Years outside
378    ///   this range will return a [`DtErr`].
379    /// - If Jiff cannot find the timezone name or if applying the timezone would cause
380    ///   the [`jiff::Zoned`] to be outside the `-9999..=9999` year range then a
381    ///   [`DtErr`] with [`DtErrKind::InvalidTimezoneOffset`] is returned.
382    pub fn add_sec_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
383        let zoned = self
384            .to_jiff_zoned(tz)?
385            .checked_add(jiff::Span::new().seconds(n))
386            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
387        Ok(self.from_jiff_zoned(zoned))
388    }
389
390    // helpers
391
392    fn to_jiff_zoned(&self, tz: &str) -> Result<jiff::Zoned, DtErr> {
393        use jiff::civil;
394
395        if !(-9999..=9999).contains(&self.yr) {
396            return Err(an_err!(
397                DtErrKind::OutOfRange,
398                "yr {} is outside Jiff's supported range (-9999..=9999)",
399                self.yr
400            ));
401        }
402
403        let hr: i8 = self
404            .hr
405            .try_into()
406            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "hr: {} u8 -> i8", self.hr))?;
407        let min: i8 = self
408            .min
409            .try_into()
410            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "min: {} u8 -> i8", self.min))?;
411
412        let sec_for_jiff: i8 = if self.sec == 60 {
413            59
414        } else {
415            self.sec
416                .try_into()
417                .map_err(|_| an_err!(DtErrKind::InvalidNumber, "sec: {} u8 -> i8", self.sec))?
418        };
419
420        let mo: i8 = self
421            .mo
422            .try_into()
423            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "mo: {} u8 -> i8", self.mo))?;
424        let day: i8 = self
425            .day
426            .try_into()
427            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "day: {} u8 -> i8", self.day))?;
428
429        let civil_time = civil::date(self.yr as i16, mo, day).at(hr, min, sec_for_jiff, 0);
430
431        civil_time
432            .in_tz(tz)
433            .map_err(|e| an_err!(DtErrKind::InvalidTimezoneOffset, "{}", e))
434    }
435
436    fn from_jiff_zoned(&self, zoned: jiff::Zoned) -> Self {
437        let civil = zoned.datetime();
438
439        Self::reconstruct(
440            civil.year() as i64,
441            civil.month() as u8,
442            civil.day() as u8,
443            civil.hour() as u8,
444            civil.minute() as u8,
445            civil.second() as u8,
446            self.attos,
447            self.scale,
448        )
449    }
450}