use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use jiff::tz::{Offset, TimeZone};
use crate::js::{self, date_utc, iso_string};
use crate::re::{js_re, test};
pub const PERIODS: [&str; 10] =
["today", "yesterday", "7d", "30d", "90d", "month", "last_month", "year", "12mo", "all"];
pub const INTERVALS: [&str; 4] = ["hour", "day", "week", "month"];
const MAX_BUCKETS: usize = 1000;
const MAX_HOURS: usize = 744;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Range {
pub from: i64,
pub to: i64,
pub from_date: String,
pub to_date: String,
pub interval: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Bucket {
pub start: i64,
pub end: i64,
}
const ALIASES: &[(&str, &str)] = &[
("cet", "Europe/Brussels"),
("eet", "Europe/Athens"),
("est", "America/Panama"),
("gmt", "UTC"),
("gmt+0", "UTC"),
("gmt-0", "UTC"),
("hst", "Pacific/Honolulu"),
("met", "Europe/Brussels"),
("mst", "America/Phoenix"),
("uct", "UTC"),
("wet", "Europe/Lisbon"),
("act", "Australia/Darwin"),
("aet", "Australia/Sydney"),
("agt", "America/Argentina/Buenos_Aires"),
("art", "Africa/Cairo"),
("ast", "America/Anchorage"),
("bet", "America/Sao_Paulo"),
("bst", "Asia/Dhaka"),
("cat", "Africa/Maputo"),
("cnt", "America/St_Johns"),
("cst", "America/Chicago"),
("ctt", "Asia/Shanghai"),
("eat", "Africa/Nairobi"),
("ect", "Europe/Paris"),
("iet", "America/Indiana/Indianapolis"),
("ist", "Asia/Kolkata"),
("jst", "Asia/Tokyo"),
("mit", "Pacific/Apia"),
("net", "Asia/Yerevan"),
("nst", "Pacific/Auckland"),
("plt", "Asia/Karachi"),
("pnt", "America/Phoenix"),
("prt", "America/Puerto_Rico"),
("pst", "America/Los_Angeles"),
("sst", "Pacific/Guadalcanal"),
("vst", "Asia/Ho_Chi_Minh"),
("systemv/ast4", "Etc/GMT+4"),
("systemv/ast4adt", "America/Halifax"),
("systemv/est5", "Etc/GMT+5"),
("systemv/est5edt", "America/New_York"),
("systemv/cst6", "Etc/GMT+6"),
("systemv/cst6cdt", "America/Chicago"),
("systemv/mst7", "Etc/GMT+7"),
("systemv/mst7mdt", "America/Denver"),
("systemv/pst8", "Etc/GMT+8"),
("systemv/pst8pdt", "America/Los_Angeles"),
("systemv/yst9", "Etc/GMT+9"),
("systemv/yst9ydt", "America/Anchorage"),
("systemv/hst10", "Etc/GMT+10"),
("canada/east-saskatchewan", "America/Regina"),
("us/pacific-new", "America/Los_Angeles"),
("africa/asmera", "Africa/Asmara"),
("america/argentina/comodrivadavia", "America/Argentina/Catamarca"),
("america/buenos_aires", "America/Argentina/Buenos_Aires"),
("america/catamarca", "America/Argentina/Catamarca"),
("america/cordoba", "America/Argentina/Cordoba"),
("america/fort_wayne", "America/Indiana/Indianapolis"),
("america/godthab", "America/Nuuk"),
("america/indianapolis", "America/Indiana/Indianapolis"),
("america/jujuy", "America/Argentina/Jujuy"),
("america/knox_in", "America/Indiana/Knox"),
("america/louisville", "America/Kentucky/Louisville"),
("america/mendoza", "America/Argentina/Mendoza"),
("america/rosario", "America/Argentina/Cordoba"),
("antarctica/south_pole", "Antarctica/McMurdo"),
("asia/ashkhabad", "Asia/Ashgabat"),
("asia/calcutta", "Asia/Kolkata"),
("asia/choibalsan", "Asia/Ulaanbaatar"),
("asia/chungking", "Asia/Shanghai"),
("asia/dacca", "Asia/Dhaka"),
("asia/katmandu", "Asia/Kathmandu"),
("asia/macao", "Asia/Macau"),
("asia/rangoon", "Asia/Yangon"),
("asia/saigon", "Asia/Ho_Chi_Minh"),
("asia/thimbu", "Asia/Thimphu"),
("asia/ujung_pandang", "Asia/Makassar"),
("asia/ulan_bator", "Asia/Ulaanbaatar"),
("atlantic/faeroe", "Atlantic/Faroe"),
("australia/act", "Australia/Sydney"),
("australia/lhi", "Australia/Lord_Howe"),
("australia/north", "Australia/Darwin"),
("australia/nsw", "Australia/Sydney"),
("australia/queensland", "Australia/Brisbane"),
("australia/south", "Australia/Adelaide"),
("australia/tasmania", "Australia/Hobart"),
("australia/victoria", "Australia/Melbourne"),
("australia/west", "Australia/Perth"),
("brazil/acre", "America/Rio_Branco"),
("brazil/denoronha", "America/Noronha"),
("brazil/east", "America/Sao_Paulo"),
("brazil/west", "America/Manaus"),
("canada/atlantic", "America/Halifax"),
("canada/central", "America/Winnipeg"),
("canada/eastern", "America/Toronto"),
("canada/mountain", "America/Edmonton"),
("canada/newfoundland", "America/St_Johns"),
("canada/pacific", "America/Vancouver"),
("canada/saskatchewan", "America/Regina"),
("canada/yukon", "America/Whitehorse"),
("chile/continental", "America/Santiago"),
("chile/easterisland", "Pacific/Easter"),
("cst6cdt", "America/Chicago"),
("cuba", "America/Havana"),
("egypt", "Africa/Cairo"),
("eire", "Europe/Dublin"),
("est5edt", "America/New_York"),
("europe/kiev", "Europe/Kyiv"),
("europe/uzhgorod", "Europe/Kyiv"),
("europe/zaporozhye", "Europe/Kyiv"),
("gb", "Europe/London"),
("gb-eire", "Europe/London"),
("gmt0", "Etc/GMT"),
("greenwich", "Etc/GMT"),
("hongkong", "Asia/Hong_Kong"),
("iceland", "Atlantic/Reykjavik"),
("iran", "Asia/Tehran"),
("israel", "Asia/Jerusalem"),
("jamaica", "America/Jamaica"),
("japan", "Asia/Tokyo"),
("kwajalein", "Pacific/Kwajalein"),
("libya", "Africa/Tripoli"),
("mexico/bajanorte", "America/Tijuana"),
("mexico/bajasur", "America/Mazatlan"),
("mexico/general", "America/Mexico_City"),
("mst7mdt", "America/Denver"),
("navajo", "America/Denver"),
("nz", "Pacific/Auckland"),
("nz-chat", "Pacific/Chatham"),
("pacific/enderbury", "Pacific/Kanton"),
("pacific/ponape", "Pacific/Pohnpei"),
("pacific/truk", "Pacific/Chuuk"),
("poland", "Europe/Warsaw"),
("portugal", "Europe/Lisbon"),
("prc", "Asia/Shanghai"),
("pst8pdt", "America/Los_Angeles"),
("roc", "Asia/Taipei"),
("rok", "Asia/Seoul"),
("singapore", "Asia/Singapore"),
("turkey", "Europe/Istanbul"),
("universal", "Etc/UTC"),
("us/alaska", "America/Anchorage"),
("us/aleutian", "America/Adak"),
("us/arizona", "America/Phoenix"),
("us/central", "America/Chicago"),
("us/east-indiana", "America/Indiana/Indianapolis"),
("us/eastern", "America/New_York"),
("us/hawaii", "Pacific/Honolulu"),
("us/indiana-starke", "America/Indiana/Knox"),
("us/michigan", "America/Detroit"),
("us/mountain", "America/Denver"),
("us/pacific", "America/Los_Angeles"),
("us/samoa", "Pacific/Pago_Pago"),
("w-su", "Europe/Moscow"),
("zulu", "Etc/UTC"),
];
static ZONES: LazyLock<Mutex<HashMap<String, Option<TimeZone>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn zone(timezone: &str) -> Option<TimeZone> {
let mut zones = ZONES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = zones.get(timezone) {
return found.clone();
}
let opened = open(timezone);
zones.insert(timezone.to_string(), opened.clone());
opened
}
fn open(timezone: &str) -> Option<TimeZone> {
let offset = regex::Regex::new(r"^([+\-]|\x{2212})([01][0-9]|2[0-3])(?::?([0-5][0-9]))?$").ok()?;
if let Some(m) = offset.captures(timezone) {
let sign = if &m[1] == "+" { 1 } else { -1 };
let hours: i32 = m[2].parse().ok()?;
let minutes: i32 = m.get(3).map_or(Some(0), |x| x.as_str().parse().ok())?;
return Some(TimeZone::fixed(Offset::from_seconds(sign * (hours * 3600 + minutes * 60)).ok()?));
}
if !timezone.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
return None;
}
let key = timezone.to_ascii_lowercase();
if matches!(
key.as_str(),
"factory" | "localtime" | "posixrules" | "etc/unknown" | "asia/riyadh87" | "mideast/riyadh87"
) || key.starts_with("posix/")
|| key.starts_with("right/")
{
return None;
}
if let Some((_, name)) = ALIASES.iter().find(|(k, _)| *k == key) {
return TimeZone::get(name).ok();
}
TimeZone::get(timezone).ok()
}
pub fn is_timezone(value: &str) -> bool {
zone(value).is_some()
}
fn parts(ts: i64, timezone: &str) -> [i64; 6] {
let tz = zone(timezone).unwrap_or(TimeZone::UTC);
let seconds = ts.div_euclid(1000);
let Ok(at) = jiff::Timestamp::from_second(seconds) else {
return [1970, 1, 1, 0, 0, 0];
};
let dt = tz.to_datetime(at);
[
i64::from(dt.year()),
i64::from(dt.month()),
i64::from(dt.day()),
i64::from(dt.hour()),
i64::from(dt.minute()),
i64::from(dt.second()),
]
}
fn offset(ts: i64, timezone: &str) -> i64 {
let [y, mo, d, h, mi, s] = parts(ts, timezone);
date_utc(y, mo - 1, d, h, mi, s, 0) - (ts - ts % 1000)
}
fn ymd(date: &str) -> (i64, i64, i64) {
let mut it = date.split('-').map(|p| js::text_number(p) as i64);
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}
pub fn start_of(date: &str, timezone: &str, hour: i64) -> i64 {
let (y, m, d) = ymd(date);
let guess = date_utc(y, m - 1, d, hour, 0, 0, 0);
let first = guess - offset(guess, timezone);
let mut at = guess - offset(first, timezone);
for _ in 0..8 {
let [ly, lm, ld, lh, _, _] = parts(at, timezone);
if date_utc(ly, lm - 1, ld, lh, 0, 0, 0) >= guess {
break;
}
at += 15 * 60_000;
}
if offset(at - 3 * 3_600_000, timezone) == offset(at, timezone) {
return at;
}
for step in 0..12 {
let back = 3 * 60 - step * 15;
let [ly, lm, ld, lh, _, _] = parts(at - back * 60_000, timezone);
if date_utc(ly, lm - 1, ld, lh, 0, 0, 0) == guess {
return at - back * 60_000;
}
}
at
}
pub fn local_date(ts: i64, timezone: &str) -> String {
let [y, m, d, ..] = parts(ts, timezone);
format!("{y:04}-{m:02}-{d:02}")
}
pub fn add_days(date: &str, days: i64) -> String {
let (y, m, d) = ymd(date);
js::head16(&iso_string(date_utc(y, m - 1, d + days, 0, 0, 0, 0)), 10)
}
pub fn add_months(date: &str, months: i64) -> String {
let (y, m, _) = ymd(date);
js::head16(&iso_string(date_utc(y, m - 1 + months, 1, 0, 0, 0, 0)), 10)
}
pub fn is_date(value: &str) -> bool {
if !test(js_re!(r"^\d{4}-\d{2}-\d{2}$"), value) || !("1900".."9999").contains(&value) {
return false;
}
let (y, m, d) = ymd(value);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return false;
}
js::head16(&iso_string(date_utc(y, m - 1, d, 0, 0, 0, 0)), 10) == value
}
fn date_ms(date: &str) -> i64 {
let (y, m, d) = ymd(date);
date_utc(y, m - 1, d, 0, 0, 0, 0)
}
fn days_between(from: &str, to: &str) -> i64 {
js::round_i64((date_ms(to) - date_ms(from)) as f64 / 86_400_000.0)
}
fn default_interval(from_date: &str, to_date: &str) -> &'static str {
let days = days_between(from_date, to_date);
if days < 1 {
"hour"
} else if days <= 92 {
"day"
} else {
"month"
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RangeInput {
pub period: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub interval: Option<String>,
}
fn filled(v: &Option<String>) -> Option<&str> {
v.as_deref().filter(|s| !s.is_empty())
}
pub fn resolve_range(input: &RangeInput, timezone: &str, now: i64, first_date: Option<&str>) -> Option<Range> {
let today = local_date(now, timezone);
let (from_date, to_date);
if filled(&input.from).is_some() || filled(&input.to).is_some() {
let (from, to) = (filled(&input.from)?, filled(&input.to)?);
if !is_date(from) || !is_date(to) || from > to {
return None;
}
from_date = from.to_string();
to_date = to.to_string();
} else {
let period = input.period.as_deref().unwrap_or("30d");
let month_start = format!("{}01", js::head16(&today, 8));
(from_date, to_date) = match period {
"today" => (today.clone(), today.clone()),
"yesterday" => (add_days(&today, -1), add_days(&today, -1)),
"7d" => (add_days(&today, -6), today.clone()),
"30d" => (add_days(&today, -29), today.clone()),
"90d" => (add_days(&today, -89), today.clone()),
"month" => (month_start, today.clone()),
"last_month" => (add_months(&today, -1), add_days(&month_start, -1)),
"year" => (format!("{}-01-01", js::head16(&today, 4)), today.clone()),
"12mo" => (add_months(&today, -11), today.clone()),
"all" => (
match first_date {
Some(f) if !f.is_empty() && f < today.as_str() => f.to_string(),
_ => today.clone(),
},
today.clone(),
),
_ => return None,
};
}
let mut interval = INTERVALS
.iter()
.find(|i| input.interval.as_deref() == Some(**i))
.copied()
.unwrap_or_else(|| default_interval(&from_date, &to_date));
let from = start_of(&from_date, timezone, 0);
let to = start_of(&add_days(&to_date, 1), timezone, 0);
while bucket_count(from, to, &from_date, &to_date, interval)
> if interval == "hour" { MAX_HOURS } else { MAX_BUCKETS } as i64
{
interval = match interval {
"hour" => "day",
"day" => "week",
"week" => "month",
_ => return None,
};
}
Some(Range { from, to, from_date, to_date, interval })
}
fn bucket_count(from: i64, to: i64, from_date: &str, to_date: &str, interval: &str) -> i64 {
match interval {
"hour" => (to - from + 3_599_999).div_euclid(3_600_000),
"day" => days_between(from_date, to_date) + 1,
"week" => {
let weekday = (weekday_of(from_date) + 6) % 7;
days_between(&add_days(from_date, -weekday), to_date).div_euclid(7) + 1
}
_ => {
let (fy, fm, _) = ymd(from_date);
let (ty, tm, _) = ymd(to_date);
(ty - fy) * 12 + (tm - fm) + 1
}
}
}
fn add_years(date: &str, years: i64) -> String {
let (y, m, d) = ymd(date);
let mut shifted = date_utc(y + years, m - 1, d, 0, 0, 0, 0);
let (sy, sm, _) = js::civil_from_days(shifted.div_euclid(86_400_000));
if sm != m {
shifted = date_utc(sy, sm - 1, 0, 0, 0, 0, 0);
}
js::head16(&iso_string(shifted), 10)
}
pub fn compare_range(
range: &Range,
mode: &str,
timezone: &str,
custom_from: Option<&str>,
custom_to: Option<&str>,
) -> Option<Range> {
let (from_date, to_date);
match mode {
"off" => return None,
"year" => {
from_date = add_years(&range.from_date, -1);
to_date = add_years(&range.to_date, -1);
}
"custom" => {
let from = custom_from.filter(|s| !s.is_empty())?;
let to = custom_to.filter(|s| !s.is_empty())?;
if !is_date(from) || !is_date(to) || from > to {
return None;
}
from_date = from.to_string();
to_date = to.to_string();
}
_ => {
let days = days_between(&range.from_date, &range.to_date) + 1;
from_date = add_days(&range.from_date, -days);
to_date = add_days(&range.from_date, -1);
}
}
Some(Range {
from: start_of(&from_date, timezone, 0),
to: start_of(&add_days(&to_date, 1), timezone, 0),
from_date,
to_date,
interval: range.interval,
})
}
fn weekday_of(date: &str) -> i64 {
(date_ms(date).div_euclid(86_400_000) + 4).rem_euclid(7)
}
pub fn buckets(range: &Range, timezone: &str) -> Vec<Bucket> {
let mut starts: Vec<i64> = Vec::new();
let most = if range.interval == "hour" { MAX_HOURS } else { MAX_BUCKETS };
if range.interval == "hour" {
let mut t = range.from;
while t < range.to && starts.len() <= MAX_HOURS {
starts.push(t);
t += 3_600_000;
}
} else {
let mut date = range.from_date.clone();
if range.interval == "week" {
let weekday = (weekday_of(&date) + 6) % 7;
date = add_days(&date, -weekday);
} else if range.interval == "month" {
date = format!("{}01", js::head16(&date, 8));
}
while date <= range.to_date && starts.len() <= MAX_BUCKETS {
starts.push(start_of(&date, timezone, 0));
date = match range.interval {
"day" => add_days(&date, 1),
"week" => add_days(&date, 7),
_ => add_months(&date, 1),
};
}
}
(0..starts.len().min(most))
.map(|i| Bucket {
start: starts[i].max(range.from),
end: starts.get(i + 1).copied().unwrap_or(range.to).min(range.to),
})
.collect()
}
pub fn local_weekday_hour(ts: i64, timezone: &str) -> (i64, i64) {
let [y, m, d, h, ..] = parts(ts, timezone);
let days = date_utc(y, m - 1, d, 0, 0, 0, 0).div_euclid(86_400_000);
(((days + 4).rem_euclid(7) + 6) % 7, h)
}