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}