use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, macros::time};
use time_tz::{
Offset as _, OffsetDateTimeExt as _, OffsetResult, PrimitiveDateTimeExt as _, TimeZone as _,
Tz, timezones,
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::resolution::IntervalResolution;
#[must_use]
pub fn berlin() -> &'static Tz {
timezones::db::europe::BERLIN
}
#[must_use]
pub fn to_berlin(instant: OffsetDateTime) -> OffsetDateTime {
instant.to_timezone(berlin())
}
#[must_use]
pub fn local_day(instant: OffsetDateTime) -> Date {
to_berlin(instant).date()
}
#[must_use]
pub fn local_month(instant: OffsetDateTime) -> Date {
first_of_month(local_day(instant))
}
#[must_use]
pub fn local_year(instant: OffsetDateTime) -> i32 {
local_day(instant).year()
}
#[must_use]
pub fn day_start_utc(day: Date) -> OffsetDateTime {
local_midnight_utc(day).to_offset(time::UtcOffset::UTC)
}
#[must_use]
pub fn day_end_utc(day: Date) -> OffsetDateTime {
day_start_utc(day.next_day().unwrap_or(day))
}
const GAS_DAY_START: Time = time!(6:00);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum DayBoundary {
#[default]
Midnight,
Gastag,
}
impl DayBoundary {
pub const ALL: [Self; 2] = [Self::Midnight, Self::Gastag];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Midnight => "MIDNIGHT",
Self::Gastag => "GASTAG",
}
}
#[must_use]
pub fn day_start_utc(self, day: Date) -> OffsetDateTime {
match self {
Self::Midnight => day_start_utc(day),
Self::Gastag => gas_day_start_utc(day),
}
}
#[must_use]
pub fn day_end_utc(self, day: Date) -> OffsetDateTime {
match self {
Self::Midnight => day_end_utc(day),
Self::Gastag => gas_day_end_utc(day),
}
}
#[must_use]
pub fn local_day(self, instant: OffsetDateTime) -> Date {
match self {
Self::Midnight => local_day(instant),
Self::Gastag => local_gas_day(instant),
}
}
#[must_use]
pub fn day_length(self, day: Date) -> Duration {
self.day_end_utc(day) - self.day_start_utc(day)
}
#[must_use]
pub fn intervals_in_day(self, day: Date, resolution: IntervalResolution) -> Option<u32> {
match resolution {
IntervalResolution::Day => Some(1),
IntervalResolution::Month | IntervalResolution::Year => None,
other => divide(self.day_length(day), other),
}
}
#[must_use]
pub fn local_month(self, instant: OffsetDateTime) -> Date {
first_of_month(self.local_day(instant))
}
#[must_use]
pub fn month_start_utc(self, day: Date) -> OffsetDateTime {
self.day_start_utc(first_of_month(day))
}
#[must_use]
pub fn month_end_utc(self, day: Date) -> OffsetDateTime {
self.day_start_utc(first_of_next_month(day))
}
#[must_use]
pub fn local_year(self, instant: OffsetDateTime) -> i32 {
self.local_day(instant).year()
}
#[must_use]
pub fn year_start_utc(self, year: i32) -> OffsetDateTime {
self.day_start_utc(jan_first(year))
}
#[must_use]
pub fn year_end_utc(self, year: i32) -> OffsetDateTime {
self.day_start_utc(jan_first(year + 1))
}
}
#[must_use]
pub fn gas_day_start_utc(day: Date) -> OffsetDateTime {
resolve_local(PrimitiveDateTime::new(day, GAS_DAY_START)).to_offset(time::UtcOffset::UTC)
}
#[must_use]
pub fn gas_day_end_utc(day: Date) -> OffsetDateTime {
gas_day_start_utc(day.next_day().unwrap_or(day))
}
#[must_use]
pub fn local_gas_day(instant: OffsetDateTime) -> Date {
let local = to_berlin(instant);
let day = local.date();
if local.time() < GAS_DAY_START {
day.previous_day().unwrap_or(day)
} else {
day
}
}
#[must_use]
pub fn month_start_utc(day: Date) -> OffsetDateTime {
day_start_utc(first_of_month(day))
}
#[must_use]
pub fn month_end_utc(day: Date) -> OffsetDateTime {
day_start_utc(first_of_next_month(day))
}
#[must_use]
pub fn year_start_utc(year: i32) -> OffsetDateTime {
day_start_utc(jan_first(year))
}
#[must_use]
pub fn year_end_utc(year: i32) -> OffsetDateTime {
day_start_utc(jan_first(year + 1))
}
#[must_use]
pub fn day_length(day: Date) -> Duration {
day_end_utc(day) - day_start_utc(day)
}
#[must_use]
pub fn month_length(day: Date) -> Duration {
month_end_utc(day) - month_start_utc(day)
}
#[must_use]
pub fn year_length(year: i32) -> Duration {
year_end_utc(year) - year_start_utc(year)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum DayKind {
Normal,
ShortDay,
LongDay,
}
impl DayKind {
pub const ALL: [Self; 3] = [Self::Normal, Self::ShortDay, Self::LongDay];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::ShortDay => "SHORT_DAY",
Self::LongDay => "LONG_DAY",
}
}
#[must_use]
pub fn is_dst_transition(self) -> bool {
!matches!(self, Self::Normal)
}
}
#[must_use]
pub fn day_kind(day: Date) -> DayKind {
match day_length(day).whole_hours() {
23 => DayKind::ShortDay,
25 => DayKind::LongDay,
_ => DayKind::Normal,
}
}
#[must_use]
pub fn dst_transition_utc(day: Date) -> Option<OffsetDateTime> {
let start = day_start_utc(day);
let end = day_end_utc(day);
let first_offset = to_berlin(start).offset();
if to_berlin(end - Duration::seconds(1)).offset() == first_offset {
return None;
}
let mut cursor = start;
while cursor < end {
let next = cursor + Duration::hours(1);
if to_berlin(next).offset() != to_berlin(cursor).offset() {
return Some(next);
}
cursor = next;
}
None
}
crate::codes::string_codes! {
DayKind;
DayBoundary;
}
#[must_use]
pub fn intervals_in_day(day: Date, resolution: IntervalResolution) -> Option<u32> {
match resolution {
IntervalResolution::Day => Some(1),
IntervalResolution::Month | IntervalResolution::Year => None,
other => divide(day_length(day), other),
}
}
#[must_use]
pub fn intervals_in_month(day: Date, resolution: IntervalResolution) -> Option<u32> {
match resolution {
IntervalResolution::Day => Some(u32::from(days_in_month(day))),
IntervalResolution::Month => Some(1),
IntervalResolution::Year => None,
other => divide(month_length(day), other),
}
}
#[must_use]
pub fn intervals_in_year(year: i32, resolution: IntervalResolution) -> Option<u32> {
match resolution {
IntervalResolution::Day => Some(u32::from(days_in_year(year))),
IntervalResolution::Month => Some(12),
IntervalResolution::Year => Some(1),
other => divide(year_length(year), other),
}
}
#[must_use]
pub fn intervals_between(
from: OffsetDateTime,
to: OffsetDateTime,
resolution: IntervalResolution,
) -> Option<u32> {
if to < from {
return None;
}
divide(to - from, resolution)
}
#[must_use]
pub fn days_between(from: OffsetDateTime, to: OffsetDateTime) -> i64 {
(local_day(to) - local_day(from)).whole_days()
}
#[must_use]
pub fn shift_back_one_year(instant: OffsetDateTime) -> OffsetDateTime {
let local = to_berlin(instant);
let date = local.date();
let target_year = date.year() - 1;
let day = date
.day()
.min(time::util::days_in_month(date.month(), target_year));
let Ok(shifted) = Date::from_calendar_date(target_year, date.month(), day) else {
return instant;
};
resolve_local(PrimitiveDateTime::new(shifted, local.time())).to_offset(time::UtcOffset::UTC)
}
#[must_use]
pub fn shift_back_days(instant: OffsetDateTime, days: i64) -> OffsetDateTime {
let local = to_berlin(instant);
let Some(date) = local.date().checked_sub(Duration::days(days)) else {
return instant;
};
resolve_local(PrimitiveDateTime::new(date, local.time())).to_offset(time::UtcOffset::UTC)
}
#[must_use]
pub fn days_in_month(day: Date) -> u8 {
time::util::days_in_month(day.month(), day.year())
}
#[must_use]
pub fn days_in_year(year: i32) -> u16 {
time::util::days_in_year(year)
}
#[must_use]
pub fn is_leap_year(year: i32) -> bool {
time::util::is_leap_year(year)
}
fn resolve_local(naive: PrimitiveDateTime) -> OffsetDateTime {
match naive.assume_timezone(berlin()) {
OffsetResult::Some(t) => t,
OffsetResult::Ambiguous(first, _) => first,
OffsetResult::None => {
let before = naive.assume_utc() - Duration::days(1);
naive.assume_offset(berlin().get_offset_utc(&before).to_utc())
}
}
}
fn local_midnight_utc(day: Date) -> OffsetDateTime {
resolve_local(PrimitiveDateTime::new(day, Time::MIDNIGHT))
}
fn divide(span: Duration, resolution: IntervalResolution) -> Option<u32> {
let secs = resolution.fixed_seconds()?;
let span_secs = span.whole_seconds();
if span_secs < 0 {
return None;
}
let secs = i64::from(secs);
if span_secs % secs != 0 {
return None;
}
u32::try_from(span_secs / secs).ok()
}
fn first_of_month(day: Date) -> Date {
Date::from_calendar_date(day.year(), day.month(), 1).unwrap_or(day)
}
fn first_of_next_month(day: Date) -> Date {
let (year, month) = if day.month() == Month::December {
(day.year() + 1, Month::January)
} else {
(day.year(), day.month().next())
};
Date::from_calendar_date(year, month, 1).unwrap_or(day)
}
fn jan_first(year: i32) -> Date {
Date::from_calendar_date(year, Month::January, 1).unwrap_or(Date::MIN)
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
#[test]
fn dst_transition_days_are_23_and_25_hours() {
let cases = [
(date!(2025 - 03 - 30), 23, 92, DayKind::ShortDay),
(date!(2025 - 10 - 26), 25, 100, DayKind::LongDay),
(date!(2026 - 03 - 29), 23, 92, DayKind::ShortDay),
(date!(2026 - 10 - 25), 25, 100, DayKind::LongDay),
(date!(2027 - 03 - 28), 23, 92, DayKind::ShortDay),
(date!(2027 - 10 - 31), 25, 100, DayKind::LongDay),
(date!(2026 - 07 - 20), 24, 96, DayKind::Normal),
(date!(2026 - 01 - 15), 24, 96, DayKind::Normal),
];
for (day, hours, quarters, kind) in cases {
assert_eq!(day_length(day).whole_hours(), hours, "{day} length");
assert_eq!(
intervals_in_day(day, IntervalResolution::QuarterHour),
Some(quarters),
"{day} quarter-hours"
);
assert_eq!(day_kind(day), kind, "{day} kind");
}
}
#[test]
fn dst_months_are_not_whole_days() {
let march = intervals_in_month(date!(2026 - 03 - 01), IntervalResolution::QuarterHour);
assert_eq!(
march,
Some(2_972),
"31 × 96 = 2976, minus 4 for the lost hour"
);
let october = intervals_in_month(date!(2026 - 10 - 01), IntervalResolution::QuarterHour);
assert_eq!(
october,
Some(2_980),
"31 × 96 = 2976, plus 4 for the extra hour"
);
let july = intervals_in_month(date!(2026 - 07 - 01), IntervalResolution::QuarterHour);
assert_eq!(july, Some(31 * 96));
let feb = intervals_in_month(date!(2028 - 02 - 01), IntervalResolution::QuarterHour);
assert_eq!(feb, Some(29 * 96));
}
#[test]
fn years_account_for_leap_days() {
assert_eq!(days_in_year(2026), 365);
assert_eq!(days_in_year(2028), 366);
assert!(!is_leap_year(2026));
assert!(is_leap_year(2028));
assert_eq!(
intervals_in_year(2026, IntervalResolution::QuarterHour),
Some(365 * 96)
);
assert_eq!(
intervals_in_year(2028, IntervalResolution::QuarterHour),
Some(366 * 96)
);
assert_eq!(intervals_in_year(2026, IntervalResolution::Day), Some(365));
assert_eq!(intervals_in_year(2026, IntervalResolution::Month), Some(12));
}
#[test]
fn day_boundaries_are_local_not_utc() {
assert_eq!(
day_start_utc(date!(2026 - 01 - 15)),
datetime!(2026-01-14 23:00 UTC)
);
assert_eq!(
day_start_utc(date!(2026 - 07 - 15)),
datetime!(2026-07-14 22:00 UTC)
);
assert_eq!(
local_day(datetime!(2026-07-14 23:30 UTC)),
date!(2026 - 07 - 15)
);
assert_eq!(
local_day(datetime!(2026-01-14 22:59 UTC)),
date!(2026 - 01 - 14)
);
}
#[test]
fn days_tile_the_year_without_gaps() {
let mut day = date!(2026 - 01 - 01);
let mut cursor = day_start_utc(day);
let mut quarters = 0u32;
while day.year() == 2026 {
assert_eq!(
day_start_utc(day),
cursor,
"{day} must start where the previous ended"
);
quarters += intervals_in_day(day, IntervalResolution::QuarterHour)
.unwrap_or_else(|| panic!("{day} must divide into quarter-hours"));
cursor = day_end_utc(day);
day = day.next_day().unwrap();
}
assert_eq!(cursor, year_end_utc(2026));
assert_eq!(
quarters,
365 * 96,
"the transitions cancel over a full year"
);
}
#[test]
fn month_and_year_boundaries_are_local() {
assert_eq!(
month_start_utc(date!(2026 - 03 - 17)),
datetime!(2026-02-28 23:00 UTC),
"March starts at 00:00 CET"
);
assert_eq!(
month_end_utc(date!(2026 - 03 - 17)),
datetime!(2026-03-31 22:00 UTC),
"April starts at 00:00 CEST"
);
assert_eq!(year_start_utc(2026), datetime!(2025-12-31 23:00 UTC));
assert_eq!(
local_month(datetime!(2026-02-28 23:30 UTC)),
date!(2026 - 03 - 01),
"23:30 UTC on 28 Feb is already 1 March in Berlin"
);
assert_eq!(local_year(datetime!(2025-12-31 23:30 UTC)), 2026);
}
#[test]
fn coarse_resolutions_have_no_sub_day_count() {
let day = date!(2026 - 06 - 01);
assert_eq!(intervals_in_day(day, IntervalResolution::Day), Some(1));
assert_eq!(intervals_in_day(day, IntervalResolution::Month), None);
assert_eq!(intervals_in_day(day, IntervalResolution::Year), None);
assert_eq!(intervals_in_month(day, IntervalResolution::Day), Some(30));
assert_eq!(intervals_in_month(day, IntervalResolution::Year), None);
}
#[test]
fn uneven_resolutions_do_not_divide() {
assert_eq!(
intervals_in_day(date!(2026 - 03 - 29), IntervalResolution::Hour),
Some(23)
);
assert_eq!(
intervals_in_day(
date!(2026 - 06 - 01),
IntervalResolution::from_seconds(420).unwrap()
),
None,
"420 s does not divide 86 400 s"
);
}
#[test]
fn intervals_between_is_fixed_resolution_only() {
let from = datetime!(2026-01-01 00:00 UTC);
let to = datetime!(2026-01-01 06:00 UTC);
assert_eq!(
intervals_between(from, to, IntervalResolution::QuarterHour),
Some(24)
);
assert_eq!(intervals_between(to, from, IntervalResolution::Hour), None);
assert_eq!(intervals_between(from, to, IntervalResolution::Month), None);
}
#[test]
fn days_between_counts_calendar_days_not_durations() {
let from = day_start_utc(date!(2026 - 03 - 23));
let to = day_start_utc(date!(2026 - 04 - 06));
assert_eq!((to - from).whole_days(), 13, "the naive count truncates");
assert_eq!(days_between(from, to), 14, "fourteen calendar days");
let autumn_from = day_start_utc(date!(2026 - 10 - 19));
let autumn_to = day_start_utc(date!(2026 - 11 - 02));
assert_eq!(days_between(autumn_from, autumn_to), 14);
assert_eq!(
days_between(
datetime!(2026-06-01 05:00 UTC),
datetime!(2026-06-08 23:00 UTC)
),
8,
"23:00 UTC on the 8th is already the 9th in Berlin"
);
assert_eq!(days_between(to, from), -14);
}
#[test]
fn shifting_back_a_year_keeps_the_local_clock_time() {
assert_eq!(
shift_back_one_year(datetime!(2026-07-15 10:00 UTC)), datetime!(2025-07-15 10:00 UTC) );
let winter = shift_back_one_year(datetime!(2026-01-15 23:30 UTC)); assert_eq!(local_day(winter), date!(2025 - 01 - 16));
assert_eq!(
to_berlin(winter).time(),
to_berlin(datetime!(2026-01-15 23:30 UTC)).time()
);
let leap_day = day_start_utc(date!(2028 - 02 - 29));
assert_eq!(
local_day(shift_back_one_year(leap_day)),
date!(2027 - 02 - 28)
);
let after_leap = day_start_utc(date!(2029 - 01 - 15));
assert_eq!(
local_day(shift_back_one_year(after_leap)),
date!(2028 - 01 - 15),
"the calendar shift lands on the anniversary"
);
assert_eq!(
local_day(after_leap - Duration::days(365)),
date!(2028 - 01 - 16),
"the naive shift drifts by the leap day"
);
}
#[test]
fn dst_transitions_are_located_to_the_instant() {
for (day, expected) in [
(date!(2026 - 03 - 29), Some(datetime!(2026-03-29 1:00 UTC))),
(date!(2026 - 10 - 25), Some(datetime!(2026-10-25 1:00 UTC))),
(date!(2025 - 10 - 26), Some(datetime!(2025-10-26 1:00 UTC))),
(date!(2026 - 07 - 20), None),
(date!(2026 - 01 - 15), None),
] {
assert_eq!(dst_transition_utc(day), expected, "{day}");
}
let t = dst_transition_utc(date!(2026 - 10 - 25)).unwrap();
let before = to_berlin(t - Duration::minutes(30));
let after = to_berlin(t + Duration::minutes(30));
assert_eq!(before.hour(), 2);
assert_eq!(after.hour(), 2);
assert_ne!(before.offset(), after.offset());
}
#[test]
fn historical_transitions_come_from_the_tz_database() {
assert_eq!(day_length(date!(1980 - 04 - 06)).whole_hours(), 23);
assert_eq!(day_length(date!(1980 - 03 - 30)).whole_hours(), 24);
}
#[test]
fn shifting_back_days_keeps_the_local_clock_time() {
let plain = datetime!(2026-06-17 10:00 UTC);
assert_eq!(plain - shift_back_days(plain, 7), Duration::hours(168));
let autumn = datetime!(2026-10-28 11:00 UTC); let back = shift_back_days(autumn, 7);
assert_eq!(back, datetime!(2026-10-21 10:00 UTC)); assert_eq!(autumn - back, Duration::hours(169));
assert_eq!(to_berlin(back).time(), to_berlin(autumn).time());
let spring = datetime!(2026-04-01 10:00 UTC); assert_eq!(spring - shift_back_days(spring, 7), Duration::hours(167));
assert_eq!(shift_back_days(plain, 0), plain);
}
#[test]
fn a_skipped_local_time_is_pushed_forward_by_the_gap() {
let back = shift_back_days(datetime!(2026-03-30 0:30 UTC), 1);
assert_eq!(back, datetime!(2026-03-29 1:30 UTC));
let local = to_berlin(back);
assert_eq!(local.date(), date!(2026 - 03 - 29));
assert_eq!(local.hour(), 3);
assert_eq!(local.minute(), 30);
let year = shift_back_one_year(datetime!(2027-03-29 0:30 UTC));
assert_eq!(year, datetime!(2026-03-29 1:30 UTC));
assert_eq!(to_berlin(year).hour(), 3, "{year}");
for minute in [0i64, 15, 30, 45] {
let src = datetime!(2026-03-30 0:00 UTC) + Duration::minutes(minute);
let shifted = shift_back_days(src, 1);
assert!(shifted < src, "the shift must go back a day");
assert!(
to_berlin(shifted).hour() >= 2,
"a skipped hour resolves forward, not back: {shifted}"
);
}
}
#[test]
fn an_ambiguous_local_time_resolves_to_the_earlier_pass() {
let back = shift_back_days(datetime!(2026-10-26 1:30 UTC), 1);
assert_eq!(back, datetime!(2026-10-25 0:30 UTC));
assert_eq!(
to_berlin(back).offset(),
time::UtcOffset::from_hms(2, 0, 0).unwrap()
);
}
#[test]
fn day_boundaries_cut_the_same_day_two_ways() {
let day = date!(2026 - 01 - 15);
assert_eq!(
DayBoundary::Midnight.day_start_utc(day),
datetime!(2026-01-14 23:00 UTC)
);
assert_eq!(
DayBoundary::Gastag.day_start_utc(day),
datetime!(2026-01-15 5:00 UTC)
);
assert_eq!(DayBoundary::default(), DayBoundary::Midnight);
for boundary in DayBoundary::ALL {
let mut cursor = boundary.day_start_utc(date!(2026 - 10 - 23));
let mut d = date!(2026 - 10 - 23);
while d < date!(2026 - 10 - 28) {
assert_eq!(boundary.day_start_utc(d), cursor, "{boundary:?} {d}");
assert_eq!(boundary.local_day(cursor), d, "{boundary:?} {d}");
cursor = boundary.day_end_utc(d);
d = d.next_day().unwrap();
}
}
assert_eq!(
DayBoundary::Midnight
.day_length(date!(2026 - 10 - 25))
.whole_hours(),
25
);
assert_eq!(
DayBoundary::Gastag
.day_length(date!(2026 - 10 - 24))
.whole_hours(),
25
);
assert_eq!(
DayBoundary::Gastag
.intervals_in_day(date!(2026 - 10 - 24), IntervalResolution::QuarterHour),
Some(100)
);
assert_eq!(
DayBoundary::Gastag.intervals_in_day(date!(2026 - 10 - 24), IntervalResolution::Day),
Some(1)
);
assert_eq!(
DayBoundary::Gastag.intervals_in_day(date!(2026 - 10 - 24), IntervalResolution::Year),
None
);
}
#[test]
fn gas_days_run_0600_to_0600_and_tile() {
assert_eq!(
gas_day_start_utc(date!(2026 - 01 - 15)),
datetime!(2026-01-15 5:00 UTC)
);
assert_eq!(
gas_day_start_utc(date!(2026 - 07 - 15)),
datetime!(2026-07-15 4:00 UTC)
);
let mut day = date!(2026 - 03 - 25);
let mut cursor = gas_day_start_utc(day);
while day < date!(2026 - 04 - 02) {
assert_eq!(gas_day_start_utc(day), cursor, "{day}");
cursor = gas_day_end_utc(day);
day = day.next_day().unwrap();
}
let sat = gas_day_end_utc(date!(2026 - 03 - 28)) - gas_day_start_utc(date!(2026 - 03 - 28));
let sun = gas_day_end_utc(date!(2026 - 03 - 29)) - gas_day_start_utc(date!(2026 - 03 - 29));
assert_eq!(sat.whole_hours(), 23);
assert_eq!(sun.whole_hours(), 24);
assert_eq!(
local_gas_day(datetime!(2026-07-15 3:30 UTC)), date!(2026 - 07 - 14)
);
assert_eq!(
local_gas_day(datetime!(2026-07-15 4:30 UTC)), date!(2026 - 07 - 15)
);
for ts in [
datetime!(2026-10-25 0:30 UTC),
datetime!(2026-10-25 4:59 UTC),
datetime!(2026-10-25 5:00 UTC),
datetime!(2026-03-29 0:30 UTC),
] {
let day = local_gas_day(ts);
assert!(
gas_day_start_utc(day) <= ts && ts < gas_day_end_utc(day),
"{ts}"
);
}
}
}