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