const MS_PER_DAY: i64 = 86_400_000;
const MAX_TIME: f64 = 8.64e15;
#[must_use]
pub fn js_time_clip(x: f64) -> f64 {
if !x.is_finite() || x.abs() > MAX_TIME {
return f64::NAN;
}
x.trunc() + 0.0
}
#[must_use]
pub fn js_date_parse(s: &str) -> f64 {
iso_time(s.as_bytes()).unwrap_or_else(|| {
panic!(
"ferrovue: the date {s:?} is not in ECMAScript's date time string format with a time \
zone, which every browser reads alike (see ferrovue::guide::dates_and_prices)"
)
})
}
#[must_use]
pub fn js_to_iso_string(t: f64) -> String {
let Some(t) = time(t) else {
panic!("RangeError: Invalid time value");
};
let (year, month, day) = civil(t.div_euclid(MS_PER_DAY));
let ms = t.rem_euclid(MS_PER_DAY);
let year = if (0..=9999).contains(&year) {
format!("{year:04}")
} else {
format!("{}{:06}", if year < 0 { '-' } else { '+' }, year.abs())
};
format!(
"{year}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
ms / 3_600_000,
ms / 60_000 % 60,
ms / 1000 % 60,
ms % 1000
)
}
#[must_use]
pub fn js_utc_full_year(t: f64) -> f64 {
date_part(t, |(year, _, _)| year)
}
#[must_use]
pub fn js_utc_month(t: f64) -> f64 {
date_part(t, |(_, month, _)| month - 1)
}
#[must_use]
pub fn js_utc_date(t: f64) -> f64 {
date_part(t, |(_, _, day)| day)
}
#[must_use]
pub fn js_utc_day(t: f64) -> f64 {
time(t).map_or(f64::NAN, |t| {
(t.div_euclid(MS_PER_DAY) + 4).rem_euclid(7) as f64
})
}
#[must_use]
pub fn js_utc_hours(t: f64) -> f64 {
time_part(t, 3_600_000, 24)
}
#[must_use]
pub fn js_utc_minutes(t: f64) -> f64 {
time_part(t, 60_000, 60)
}
#[must_use]
pub fn js_utc_seconds(t: f64) -> f64 {
time_part(t, 1000, 60)
}
#[must_use]
pub fn js_utc_milliseconds(t: f64) -> f64 {
time_part(t, 1, 1000)
}
fn time(t: f64) -> Option<i64> {
let t = js_time_clip(t);
(!t.is_nan()).then_some(t as i64)
}
fn date_part(t: f64, part: fn((i64, i64, i64)) -> i64) -> f64 {
time(t).map_or(f64::NAN, |t| part(civil(t.div_euclid(MS_PER_DAY))) as f64)
}
fn time_part(t: f64, unit: i64, count: i64) -> f64 {
time(t).map_or(f64::NAN, |t| {
(t.rem_euclid(MS_PER_DAY) / unit % count) as f64
})
}
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil(days: i64) -> (i64, i64, i64) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month, day)
}
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
struct Cursor<'s> {
s: &'s [u8],
at: usize,
}
impl Cursor<'_> {
fn eat(&mut self, c: u8) -> bool {
let found = self.s.get(self.at) == Some(&c);
self.at += usize::from(found);
found
}
fn digits(&mut self, n: usize) -> Option<i64> {
let run = self.s.get(self.at..self.at + n)?;
if !run.iter().all(u8::is_ascii_digit) {
return None;
}
self.at += n;
Some(run.iter().fold(0, |acc, d| acc * 10 + i64::from(d - b'0')))
}
fn sign(&mut self) -> Option<i64> {
if self.eat(b'+') {
Some(1)
} else if self.eat(b'-') {
Some(-1)
} else {
None
}
}
fn within(&mut self, n: usize, low: i64, high: i64) -> Option<i64> {
self.digits(n).filter(|v| (low..=high).contains(v))
}
}
fn iso_time(s: &[u8]) -> Option<f64> {
let mut c = Cursor { s, at: 0 };
let year = match c.sign() {
Some(sign) => {
let year = c.digits(6)?;
if sign == -1 && year == 0 {
return None;
}
sign * year
}
None => c.digits(4)?,
};
let mut month = 1;
let mut day = 1;
if c.eat(b'-') {
month = c.within(2, 1, 12)?;
if c.eat(b'-') {
day = c.within(2, 1, days_in_month(year, month))?;
}
}
let mut ms = days_from_civil(year, month, day) * MS_PER_DAY;
if c.eat(b'T') {
let hours = c.within(2, 0, 23)?;
c.eat(b':').then_some(())?;
let minutes = c.within(2, 0, 59)?;
let mut seconds = 0;
let mut millis = 0;
if c.eat(b':') {
seconds = c.within(2, 0, 59)?;
if c.eat(b'.') {
millis = c.digits(3)?;
}
}
ms += ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis;
if !c.eat(b'Z') {
let sign = c.sign()?;
let hours = c.within(2, 0, 23)?;
c.eat(b':').then_some(())?;
let fields = ms as f64;
ms -= sign * (hours * 60 + c.within(2, 0, 59)?) * 60_000;
if js_time_clip(fields).is_nan() && !js_time_clip(ms as f64).is_nan() {
return None;
}
}
}
if c.at != s.len() {
return None;
}
Some(js_time_clip(ms as f64))
}
#[cfg(test)]
mod tests;