use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
use icalendar::{CalendarDateTime, DatePerhapsTime};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum EventTime {
Date(NaiveDate),
DateTimeUtc(DateTime<Utc>),
DateTimeFloating(NaiveDateTime),
DateTimeZoned {
datetime: NaiveDateTime,
tzid: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum NormalizedEventTime {
Date(NaiveDate),
Instant(DateTime<Utc>),
Floating(NaiveDateTime),
}
impl EventTime {
pub fn to_local_tz<Tz: TimeZone>(&self, tz: &Tz) -> DateTime<Tz> {
match self {
EventTime::Date(date) => resolve_local(
date.and_hms_opt(0, 0, 0)
.expect("midnight should be a valid NaiveDateTime"),
tz,
),
EventTime::DateTimeFloating(datetime) => resolve_local(*datetime, tz),
EventTime::DateTimeUtc(datetime) => datetime.with_timezone(tz),
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(event_tz) => resolve_local(*datetime, &event_tz).with_timezone(tz),
None => resolve_local(*datetime, tz),
},
}
}
pub fn to_utc(&self) -> DateTime<Utc> {
match self {
EventTime::Date(date) => date
.and_hms_opt(0, 0, 0)
.expect("midnight should be a valid NaiveDateTime")
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
EventTime::DateTimeFloating(datetime) => datetime
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
EventTime::DateTimeUtc(datetime) => *datetime,
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(event_tz) => datetime
.and_local_timezone(event_tz)
.unwrap()
.with_timezone(&Utc),
None => datetime
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
},
}
}
pub fn is_date(&self) -> bool {
matches!(self, EventTime::Date(_))
}
pub(crate) fn normalized(&self) -> NormalizedEventTime {
match self {
EventTime::Date(date) => NormalizedEventTime::Date(*date),
EventTime::DateTimeUtc(datetime) => NormalizedEventTime::Instant(*datetime),
EventTime::DateTimeFloating(datetime) => NormalizedEventTime::Floating(*datetime),
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(tz) => {
NormalizedEventTime::Instant(resolve_local(*datetime, &tz).with_timezone(&Utc))
}
None => NormalizedEventTime::Floating(*datetime),
},
}
}
}
fn parse_tzid(tzid: &str) -> Option<chrono_tz::Tz> {
tzid.parse().ok()
}
fn resolve_local<Tz: TimeZone>(naive: NaiveDateTime, tz: &Tz) -> DateTime<Tz> {
if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
return dt;
}
let bumped = naive + chrono::Duration::hours(1);
tz.from_local_datetime(&bumped)
.earliest()
.unwrap_or_else(|| tz.from_utc_datetime(&naive))
}
impl From<&EventTime> for DatePerhapsTime {
fn from(value: &EventTime) -> Self {
match value {
EventTime::Date(date) => (*date).into(),
EventTime::DateTimeUtc(datetime) => (*datetime).into(),
EventTime::DateTimeFloating(datetime) => (*datetime).into(),
EventTime::DateTimeZoned { datetime, tzid } => CalendarDateTime::WithTimezone {
date_time: *datetime,
tzid: tzid.to_string(),
}
.into(),
}
}
}
impl From<EventTime> for DatePerhapsTime {
fn from(value: EventTime) -> Self {
DatePerhapsTime::from(&value)
}
}
impl From<DatePerhapsTime> for EventTime {
fn from(value: DatePerhapsTime) -> Self {
match value {
DatePerhapsTime::Date(date) => EventTime::Date(date),
DatePerhapsTime::DateTime(CalendarDateTime::Floating(datetime)) => {
EventTime::DateTimeFloating(datetime)
}
DatePerhapsTime::DateTime(CalendarDateTime::Utc(datetime)) => {
EventTime::DateTimeUtc(datetime)
}
DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid }) => {
EventTime::DateTimeZoned {
datetime: date_time,
tzid: super::windows_tz::normalize(tzid),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_local_converts_utc_event_to_target_timezone() {
let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let event_time = &EventTime::DateTimeUtc(utc);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1300");
}
#[test]
fn to_local_converts_zoned_event_to_target_timezone() {
let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = &EventTime::DateTimeZoned {
datetime: date_time,
tzid: "America/New_York".to_string(),
};
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1800");
}
#[test]
fn to_local_treats_unknown_zoned_event_as_floating() {
let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime: date_time,
tzid: "Bogus/Zone".to_string(),
};
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1200");
}
#[test]
fn to_local_handles_dst_spring_forward_gap() {
let naive = NaiveDate::from_ymd_opt(2024, 3, 31)
.unwrap()
.and_hms_opt(2, 30, 0)
.unwrap();
let event_time = EventTime::DateTimeFloating(naive);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-03-31T0330");
}
#[test]
fn to_local_handles_dst_fall_back_overlap() {
let naive = NaiveDate::from_ymd_opt(2024, 10, 27)
.unwrap()
.and_hms_opt(2, 30, 0)
.unwrap();
let event_time = EventTime::DateTimeFloating(naive);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-10-27T0230");
}
#[test]
fn to_utc_passes_through_utc_event() {
let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let event_time = EventTime::DateTimeUtc(utc);
assert_eq!(event_time.to_utc(), utc);
}
#[test]
fn to_utc_converts_zoned_event_using_tzid() {
let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime,
tzid: "America/New_York".to_string(),
};
let utc = event_time.to_utc();
assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1700");
}
#[test]
fn to_utc_respects_dst_for_zoned_event() {
let datetime = NaiveDate::from_ymd_opt(2024, 7, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime,
tzid: "America/New_York".to_string(),
};
let utc = event_time.to_utc();
assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-07-01T1600");
}
}