use std::sync::OnceLock;
use chrono::{DateTime, LocalResult, NaiveDate, NaiveDateTime, TimeZone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BucketTimezone {
Local,
Named(chrono_tz::Tz),
}
static BUCKET_TZ: OnceLock<BucketTimezone> = OnceLock::new();
pub fn set_bucket_timezone(tz: BucketTimezone) {
let _ = BUCKET_TZ.set(tz);
}
pub fn bucket_timezone() -> BucketTimezone {
BUCKET_TZ.get().copied().unwrap_or(BucketTimezone::Local)
}
pub fn parse_bucket_timezone(name: &str) -> Option<BucketTimezone> {
name.parse::<chrono_tz::Tz>()
.ok()
.map(BucketTimezone::Named)
}
impl BucketTimezone {
pub fn date_of_ms(&self, timestamp_ms: i64) -> String {
match self {
BucketTimezone::Local => fmt_date(&chrono::Local, timestamp_ms),
BucketTimezone::Named(tz) => fmt_date(tz, timestamp_ms),
}
}
pub fn date_hour_of_ms(&self, timestamp_ms: i64) -> Option<String> {
match self {
BucketTimezone::Local => fmt_hour(&chrono::Local, timestamp_ms),
BucketTimezone::Named(tz) => fmt_hour(tz, timestamp_ms),
}
}
pub fn naive_datetime_of_ms(&self, timestamp_ms: i64) -> Option<NaiveDateTime> {
match self {
BucketTimezone::Local => naive_datetime(&chrono::Local, timestamp_ms),
BucketTimezone::Named(tz) => naive_datetime(tz, timestamp_ms),
}
}
pub fn now_naive(&self) -> NaiveDateTime {
match self {
BucketTimezone::Local => chrono::Local::now().naive_local(),
BucketTimezone::Named(tz) => chrono::Utc::now().with_timezone(tz).naive_local(),
}
}
pub fn today(&self) -> NaiveDate {
match self {
BucketTimezone::Local => chrono::Local::now().date_naive(),
BucketTimezone::Named(tz) => chrono::Utc::now().with_timezone(tz).date_naive(),
}
}
pub fn midnight_today_ms(&self) -> Option<i64> {
self.start_of_day_ms(self.today())
}
pub fn start_of_day_ms(&self, date: NaiveDate) -> Option<i64> {
match self {
BucketTimezone::Local => {
start_of_day_ms_with(date, |wall| chrono::Local.from_local_datetime(wall))
}
BucketTimezone::Named(tz) => {
start_of_day_ms_with(date, |wall| tz.from_local_datetime(wall))
}
}
}
}
fn fmt_date<Tz>(tz: &Tz, timestamp_ms: i64) -> String
where
Tz: TimeZone,
Tz::Offset: std::fmt::Display,
{
match tz.timestamp_millis_opt(timestamp_ms) {
LocalResult::Single(dt) => dt.format("%Y-%m-%d").to_string(),
_ => String::new(),
}
}
fn fmt_hour<Tz>(tz: &Tz, timestamp_ms: i64) -> Option<String>
where
Tz: TimeZone,
Tz::Offset: std::fmt::Display,
{
match tz.timestamp_millis_opt(timestamp_ms) {
LocalResult::Single(dt) => Some(dt.format("%Y-%m-%d %H:00").to_string()),
_ => None,
}
}
fn naive_datetime<Tz>(tz: &Tz, timestamp_ms: i64) -> Option<NaiveDateTime>
where
Tz: TimeZone,
{
match tz.timestamp_millis_opt(timestamp_ms) {
LocalResult::Single(dt) => Some(dt.naive_local()),
_ => None,
}
}
fn start_of_day_ms_with<Tz, F>(date: NaiveDate, resolve: F) -> Option<i64>
where
Tz: TimeZone,
F: Fn(&NaiveDateTime) -> LocalResult<DateTime<Tz>>,
{
let mut wall = date.and_hms_opt(0, 0, 0)?;
for _ in 0..=(24 * 60) {
match resolve(&wall) {
LocalResult::Single(dt) | LocalResult::Ambiguous(dt, _) => {
return Some(dt.timestamp_millis());
}
LocalResult::None => wall += chrono::Duration::minutes(1),
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn utc_ms(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
Utc.with_ymd_and_hms(year, month, day, hour, minute, 0)
.single()
.unwrap()
.timestamp_millis()
}
#[test]
fn named_timezone_uses_pinned_iana_spring_forward_rules() {
let tz = parse_bucket_timezone("America/New_York").unwrap();
assert_eq!(
tz.date_hour_of_ms(utc_ms(2024, 3, 10, 6, 59)),
Some("2024-03-10 01:00".to_string())
);
assert_eq!(
tz.date_hour_of_ms(utc_ms(2024, 3, 10, 7, 0)),
Some("2024-03-10 03:00".to_string())
);
}
#[test]
fn named_timezone_uses_pinned_iana_fall_back_rules() {
let tz = parse_bucket_timezone("America/New_York").unwrap();
assert_eq!(
tz.date_hour_of_ms(utc_ms(2024, 11, 3, 5, 30)),
Some("2024-11-03 01:00".to_string())
);
assert_eq!(
tz.date_hour_of_ms(utc_ms(2024, 11, 3, 6, 30)),
Some("2024-11-03 01:00".to_string())
);
}
#[test]
fn named_timezone_exposes_pinned_wall_clock_components() {
let tz = parse_bucket_timezone("Pacific/Kiritimati").unwrap();
let wall = tz
.naive_datetime_of_ms(utc_ms(2026, 7, 31, 12, 30))
.unwrap();
assert_eq!(
wall.format("%Y-%m-%d %H:%M").to_string(),
"2026-08-01 02:30"
);
}
#[test]
fn named_timezone_start_of_day_uses_pinned_offset() {
let tz = parse_bucket_timezone("Pacific/Kiritimati").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 8, 1).unwrap();
assert_eq!(tz.start_of_day_ms(date), Some(utc_ms(2026, 7, 31, 10, 0)));
}
#[test]
fn start_of_day_walks_forward_across_a_midnight_gap() {
let date = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let one_am = chrono::NaiveTime::from_hms_opt(1, 0, 0).unwrap();
let result = start_of_day_ms_with::<chrono::Utc, _>(date, |wall| {
if wall.time() < one_am {
LocalResult::None
} else {
chrono::Utc.from_local_datetime(wall)
}
});
assert_eq!(
result,
Some(
chrono::Utc
.from_utc_datetime(&date.and_hms_opt(1, 0, 0).unwrap())
.timestamp_millis()
)
);
}
}