Skip to content

Commit 91e8262

Browse files
authored
Implement posix resolution for month-week-day (#214)
Phew, that was an adventure. This also adds a test that verifies that the mwd resolution works... on the thin tzdb that jiff bundles. The local UNIX database already has the year 2028 on its transition table, so in that case it just tests that the transition is correctly fetched.
1 parent 4cfa2da commit 91e8262

File tree

3 files changed

+119
-25
lines changed

3 files changed

+119
-25
lines changed

src/iso.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -997,13 +997,13 @@ fn balance_iso_year_month(year: i32, month: i32) -> (i32, u8) {
997997
/// Note: month is 1 based.
998998
#[inline]
999999
pub(crate) fn constrain_iso_day(year: i32, month: u8, day: u8) -> u8 {
1000-
let days_in_month = utils::iso_days_in_month(year, month.into());
1000+
let days_in_month = utils::iso_days_in_month(year, month);
10011001
day.clamp(1, days_in_month)
10021002
}
10031003

10041004
#[inline]
10051005
pub(crate) fn is_valid_iso_day(year: i32, month: u8, day: u8) -> bool {
1006-
let days_in_month = utils::iso_days_in_month(year, month.into());
1006+
let days_in_month = utils::iso_days_in_month(year, month);
10071007
(1..=days_in_month).contains(&day)
10081008
}
10091009

src/tzdb.rs

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -364,35 +364,73 @@ fn resolve_posix_tz_string_for_epoch_seconds(
364364

365365
let transition =
366366
compute_tz_for_epoch_seconds(is_transition_day, transition, seconds, dst_variant);
367-
let offset = match transition {
368-
TransitionType::Dst => {
369-
LocalTimeRecord::from_daylight_savings_time(&dst_variant.variant_info)
370-
}
371-
TransitionType::Std => LocalTimeRecord::from_standard_time(&posix_tz_string.std_info),
367+
let std_offset = LocalTimeRecord::from_standard_time(&posix_tz_string.std_info).offset;
368+
let dst_offset = LocalTimeRecord::from_daylight_savings_time(&dst_variant.variant_info).offset;
369+
let (old_offset, new_offset) = match transition {
370+
TransitionType::Dst => (std_offset, dst_offset),
371+
TransitionType::Std => (dst_offset, std_offset),
372372
};
373373
let transition = match transition {
374374
TransitionType::Dst => start,
375375
TransitionType::Std => end,
376376
};
377-
let year = utils::epoch_time_to_epoch_year(seconds);
377+
let year = utils::epoch_time_to_epoch_year(seconds * 1000);
378378
let year_epoch = utils::epoch_days_for_year(year) * 86400;
379-
let leap_days = utils::mathematical_days_in_year(year) - 365;
379+
let leap_day = utils::mathematical_in_leap_year(seconds * 1000) as u16;
380380

381381
let days = match transition.day {
382-
TransitionDay::NoLeap(day) if day > 59 => i32::from(day) - 1 + leap_days,
383-
TransitionDay::NoLeap(day) => i32::from(day) - 1,
384-
TransitionDay::WithLeap(day) => i32::from(day),
385-
TransitionDay::Mwd(_month, _week, _day) => {
386-
// TODO: build transition epoch from month, week and day.
387-
return Ok(TimeZoneOffset {
388-
offset: offset.offset,
389-
transition_epoch: None,
390-
});
382+
TransitionDay::NoLeap(day) if day > 59 => day - 1 + leap_day,
383+
TransitionDay::NoLeap(day) => day - 1,
384+
TransitionDay::WithLeap(day) => day,
385+
TransitionDay::Mwd(month, week, day) => {
386+
let days_to_month = utils::month_to_day((month - 1) as u8, leap_day);
387+
let days_in_month = u16::from(utils::iso_days_in_month(year, month as u8) - 1);
388+
389+
// Month starts in the day...
390+
let day_offset =
391+
(u16::from(utils::epoch_seconds_to_day_of_week(i64::from(year_epoch)))
392+
+ days_to_month)
393+
.rem_euclid(7);
394+
395+
// EXAMPLE:
396+
//
397+
// 0 1 2 3 4 5 6
398+
// sun mon tue wed thu fri sat
399+
// - - - 0 1 2 3
400+
// 4 5 6 7 8 9 10
401+
// 11 12 13 14 15 16 17
402+
// 18 19 20 21 22 23 24
403+
// 25 26 27 28 29 30 -
404+
//
405+
// The day_offset = 3, since the month starts on a wednesday.
406+
//
407+
// We're looking for the second friday of the month. Thus, since the month started before
408+
// a friday, we need to start counting from week 0:
409+
//
410+
// day_of_month = (week - u16::from(day_offset <= day)) * 7 + day - day_offset = (2 - 1) * 7 + 5 - 3 = 9
411+
//
412+
// This works if the month started on a day before the day we want (day_offset <= day). However, if that's not the
413+
// case, we need to start counting on week 1. For example, calculate the day of the month for the third monday
414+
// of the month:
415+
//
416+
// day_of_month = (week - u16::from(day_offset <= day)) * 7 + day - day_offset = (3 - 0) * 7 + 1 - 3 = 19
417+
let mut day_of_month = (week - u16::from(day_offset <= day)) * 7 + day - day_offset;
418+
419+
// If we're on week 5, we need to clamp to the last valid day.
420+
if day_of_month > days_in_month - 1 {
421+
day_of_month -= 7
422+
}
423+
424+
days_to_month + day_of_month
391425
}
392426
};
393-
let transition_epoch = i64::from(year_epoch) + i64::from(days) * 3600 + transition.time.0;
427+
428+
// Transition time is on local time, so we need to add the UTC offset to get the correct UTC timestamp
429+
// for the transition.
430+
let transition_epoch =
431+
i64::from(year_epoch) + i64::from(days) * 86400 + transition.time.0 - old_offset;
394432
Ok(TimeZoneOffset {
395-
offset: offset.offset,
433+
offset: new_offset,
396434
transition_epoch: Some(transition_epoch),
397435
})
398436
}
@@ -490,7 +528,7 @@ impl Mwd {
490528
let day_of_month = utils::epoch_seconds_to_day_of_month(seconds);
491529
let week_of_month = day_of_month / 7 + 1;
492530
let day_of_week = utils::epoch_seconds_to_day_of_week(seconds);
493-
Self(month, week_of_month, day_of_week)
531+
Self(month, week_of_month, u16::from(day_of_week))
494532
}
495533
}
496534

@@ -1033,4 +1071,60 @@ mod tests {
10331071
let locals = sydney.v2_estimate_tz_pair(&Seconds(seconds)).unwrap();
10341072
assert!(matches!(locals, LocalTimeRecordResult::Single(_)));
10351073
}
1074+
1075+
#[test]
1076+
fn mwd_transition_epoch() {
1077+
#[cfg(not(target_os = "windows"))]
1078+
let tzif = Tzif::read_tzif("Europe/Berlin").unwrap();
1079+
#[cfg(target_os = "windows")]
1080+
let tzif = Tzif::from_bytes(jiff_tzdb::get("Europe/Berlin").unwrap().1).unwrap();
1081+
1082+
let start_date = crate::iso::IsoDate {
1083+
year: 2028,
1084+
month: 3,
1085+
day: 30,
1086+
};
1087+
let start_time = crate::iso::IsoTime {
1088+
hour: 6,
1089+
minute: 0,
1090+
second: 0,
1091+
millisecond: 0,
1092+
microsecond: 0,
1093+
nanosecond: 0,
1094+
};
1095+
let start_dt = IsoDateTime::new(start_date, start_time).unwrap();
1096+
let start_dt_secs = (start_dt.as_nanoseconds().unwrap().0 / 1_000_000_000) as i64;
1097+
1098+
let start_seconds = &Seconds(start_dt_secs);
1099+
1100+
assert_eq!(
1101+
tzif.get(start_seconds).unwrap().transition_epoch.unwrap(),
1102+
// Sun, Mar 26 at 2:00 am
1103+
1837645200
1104+
);
1105+
1106+
let end_date = crate::iso::IsoDate {
1107+
year: 2028,
1108+
month: 10,
1109+
day: 29,
1110+
};
1111+
let end_time = crate::iso::IsoTime {
1112+
hour: 6,
1113+
minute: 0,
1114+
second: 0,
1115+
millisecond: 0,
1116+
microsecond: 0,
1117+
nanosecond: 0,
1118+
};
1119+
let end_dt = IsoDateTime::new(end_date, end_time).unwrap();
1120+
let end_dt_secs = (end_dt.as_nanoseconds().unwrap().0 / 1_000_000_000) as i64;
1121+
1122+
let end_seconds = &Seconds(end_dt_secs);
1123+
1124+
assert_eq!(
1125+
tzif.get(end_seconds).unwrap().transition_epoch.unwrap(),
1126+
// Sun, Oct 29 at 3:00 am
1127+
1856394000
1128+
);
1129+
}
10361130
}

src/utils.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ pub(crate) fn ymd_from_epoch_milliseconds(epoch_milliseconds: i64) -> (i32, u8,
9595
}
9696

9797
#[cfg(feature = "tzdb")]
98-
fn month_to_day(m: u8, leap_day: u16) -> u16 {
98+
pub(crate) fn month_to_day(m: u8, leap_day: u16) -> u16 {
9999
match m {
100100
0 => 0,
101101
1 => 31,
@@ -126,8 +126,8 @@ pub(crate) fn epoch_time_to_day_in_year(t: i64) -> i32 {
126126
}
127127

128128
#[cfg(feature = "tzdb")]
129-
pub(crate) fn epoch_seconds_to_day_of_week(t: i64) -> u16 {
130-
(((t / 86_400) + 4) % 7) as u16
129+
pub(crate) fn epoch_seconds_to_day_of_week(t: i64) -> u8 {
130+
((t / 86_400) + 4).rem_euclid(7) as u8
131131
}
132132

133133
#[cfg(feature = "tzdb")]
@@ -150,7 +150,7 @@ pub(crate) fn epoch_seconds_to_day_of_month(t: i64) -> u16 {
150150
/// 12.2.31 `ISODaysInMonth ( year, month )`
151151
///
152152
/// NOTE: month is 1 based
153-
pub(crate) fn iso_days_in_month(year: i32, month: i32) -> u8 {
153+
pub(crate) fn iso_days_in_month(year: i32, month: u8) -> u8 {
154154
match month {
155155
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
156156
4 | 6 | 9 | 11 => 30,

0 commit comments

Comments
 (0)