use anyhow::{Error, Result}; use chrono::{DateTime, Datelike, Utc}; use diesel::{ backend::Backend, deserialize::{self, FromSql, FromSqlRow}, expression::AsExpression, serialize::{self, ToSql}, sql_types::Text, sqlite::Sqlite, }; use serde::{Deserialize, Serialize}; #[derive( Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Clone, Debug, AsExpression, FromSqlRow, )] #[diesel(sql_type=Text)] pub struct DateTimeUtc { chrono_datetime_utc: DateTime, } impl DateTimeUtc { pub fn day_diff(&self, other: &DateTimeUtc) -> u32 { self.chrono_datetime_utc .num_days_from_ce() .abs_diff(other.chrono_datetime_utc.num_days_from_ce()) } pub fn new(chrono_datetime_utc: &DateTime) -> Self { Self { chrono_datetime_utc: chrono_datetime_utc.clone(), } } pub fn now() -> Self { Self::new(&Utc::now()) } /// Constructs DateTimeUtc from a day string, YYYY-MM-DD, in UTC /// Example: "2024-10-24" -> Midnight 24 Oct 2024 UTC pub fn from_day_str(day_str: &str) -> Result { if !Self::is_day_str(day_str) { Err(Error::msg(format!("{} is not a day string in YYYY-MM-DD format", day_str))) } else { let mut date_str = String::from(day_str); date_str.push_str("T00:00:00.000Z"); Ok(Self::from_date_str(&date_str)?) } } /// Constructs DateTimeUtc from a date string in ISO format, in UTC /// Example: "2024-10-24T10:30:00.000" -> 10.30am 24 Oct 2024 UTC pub fn from_date_str(date_time_utc: &str) -> Result { let chrono_datetime_utc = date_time_utc.parse::>()?; Ok(Self { chrono_datetime_utc, }) } /// Converts to a day string, YYYY-MM-DD, in UTC /// Example: 10.30am 24 Oct 2024 UTC -> "2024-10-24" pub fn to_day_str(&self) -> String { let date_str = self.to_date_str(); let day_str = date_str .split_at_checked(10) .unwrap() .0; String::from(day_str) } /// Converts to a date string in ISO format, in UTC /// Example: 10.30am 24 Oct 2024 UTC -> "2024-10-24T10:30:00.000Z" pub fn to_date_str(&self) -> String { self.chrono_datetime_utc.to_rfc3339() } fn is_day_str(str: &str) -> bool { if str.len() != 10 { return false; } for (ix, ch) in str.chars().enumerate() { if ix == 4 || ix == 7 { if ch != '-' { return false; } } else { if ch < '0' || ch > '9' { return false; } } } return true; } } impl ToSql for DateTimeUtc { fn to_sql<'b>( &'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>, ) -> serialize::Result { out.set_value(self.to_date_str()); Ok(serialize::IsNull::No) } } impl FromSql for DateTimeUtc { fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { let str = >::from_sql(bytes)?; Ok(Self::from_date_str(&str)?) } } #[cfg(test)] mod test { use chrono::TimeZone; use super::*; #[test] fn given_midnight_when_day_diff_next_midnight_less_1s_then_return_0() { let start = DateTimeUtc::new(&Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().to_utc()); let end = DateTimeUtc::new( &Utc.with_ymd_and_hms(2000, 1, 1, 23, 59, 59) .unwrap() .to_utc(), ); let day_diff = start.day_diff(&end); let neg_day_diff = end.day_diff(&start); assert_eq!(day_diff, 0); assert_eq!(day_diff, neg_day_diff); } #[test] fn given_midnight_when_day_diff_next_midnight_then_return_1() { let start = DateTimeUtc::new(&Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().to_utc()); let end = DateTimeUtc::new(&Utc.with_ymd_and_hms(2000, 1, 2, 0, 0, 0).unwrap().to_utc()); let day_diff = start.day_diff(&end); let neg_day_diff = end.day_diff(&start); assert_eq!(day_diff, 1); assert_eq!(day_diff, neg_day_diff); } #[test] fn given_midnight_less_1s_when_day_diff_midnight_then_return_1() { let start = DateTimeUtc::new( &Utc.with_ymd_and_hms(2000, 1, 1, 23, 59, 59) .unwrap() .to_utc(), ); let end = DateTimeUtc::new(&Utc.with_ymd_and_hms(2000, 1, 2, 0, 0, 0).unwrap().to_utc()); let day_diff = start.day_diff(&end); let neg_day_diff = end.day_diff(&start); assert_eq!(day_diff, 1); assert_eq!(day_diff, neg_day_diff); } #[test] fn given_23_nov_1970_when_to_day_str_then_return_iso_day_str() { let date_time_utc = DateTimeUtc::new( &Utc.with_ymd_and_hms(1970, 11, 23, 0, 0, 0) .unwrap() .to_utc(), ); assert_eq!(date_time_utc.to_day_str(), "1970-11-23"); } }