use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
use time_tz::{
Offset as _, OffsetDateTimeExt as _, OffsetResult, PrimitiveDateTimeExt as _, TimeZone as _,
Tz, timezones,
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::time::resolution::Resolution;
pub const FIRST_YEAR: i32 = 1900;
pub const LAST_YEAR: i32 = 9998;
#[must_use]
pub fn berlin() -> &'static Tz {
timezones::db::europe::BERLIN
}
#[must_use]
pub fn to_berlin(instant: OffsetDateTime) -> OffsetDateTime {
instant.to_timezone(berlin())
}
fn supported(year: i32) -> bool {
(FIRST_YEAR..=LAST_YEAR).contains(&year)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DayBoundary {
Strom,
Gas,
}
impl DayBoundary {
pub const ALL: [Self; 2] = [Self::Strom, Self::Gas];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Strom => "STROM",
Self::Gas => "GAS",
}
}
#[must_use]
pub const fn local_start(self) -> Time {
match self {
Self::Strom => Time::MIDNIGHT,
Self::Gas => time::macros::time!(6:00),
}
}
fn start_of(self, date: Date) -> Option<OffsetDateTime> {
if !supported(date.year()) {
return None;
}
Some(resolve_local(PrimitiveDateTime::new(
date,
self.local_start(),
)))
}
fn period(self, first: Date, next: Date) -> Option<Period> {
let start = self.start_of(first)?;
let end = resolve_local(PrimitiveDateTime::new(next, self.local_start()));
Some(Period {
start,
end,
boundary: self,
})
}
#[must_use]
pub fn day(self, date: Date) -> Option<Period> {
self.period(date, date.next_day()?)
}
#[must_use]
pub fn month(self, year: i32, month: Month) -> Option<Period> {
let first = Date::from_calendar_date(year, month, 1).ok()?;
let next = if month == Month::December {
Date::from_calendar_date(year.checked_add(1)?, Month::January, 1).ok()?
} else {
Date::from_calendar_date(year, month.next(), 1).ok()?
};
self.period(first, next)
}
#[must_use]
pub fn year(self, year: i32) -> Option<Period> {
let first = Date::from_calendar_date(year, Month::January, 1).ok()?;
let next = Date::from_calendar_date(year.checked_add(1)?, Month::January, 1).ok()?;
self.period(first, next)
}
#[must_use]
pub fn day_of(self, instant: OffsetDateTime) -> Option<Date> {
let local = to_berlin(instant);
let date = if local.time() < self.local_start() {
local.date().previous_day()?
} else {
local.date()
};
supported(date.year()).then_some(date)
}
#[must_use]
pub fn days_between(self, from: OffsetDateTime, to: OffsetDateTime) -> Option<i64> {
Some((self.day_of(to)? - self.day_of(from)?).whole_days())
}
#[must_use]
pub fn bucket(self, instant: OffsetDateTime, resolution: Resolution) -> Option<Period> {
let date = self.day_of(instant)?;
match resolution {
Resolution::Day => self.day(date),
Resolution::Month => self.month(date.year(), date.month()),
Resolution::Year => self.year(date.year()),
Resolution::Minutes(_) | Resolution::Hour => {
let day = self.day(date)?;
let step = i64::from(resolution.fixed_seconds()?);
let offset = (instant - day.start).whole_seconds();
let start = day.start + Duration::seconds(offset.div_euclid(step) * step);
Some(Period {
start,
end: start + Duration::seconds(step),
boundary: self,
})
}
}
}
}
crate::ids::codes::string_codes! {
DayKind;
DayBoundary;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Period {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
start: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
end: OffsetDateTime,
boundary: DayBoundary,
}
impl Period {
#[must_use]
pub const fn start(&self) -> OffsetDateTime {
self.start
}
#[must_use]
pub const fn end(&self) -> OffsetDateTime {
self.end
}
#[must_use]
pub const fn boundary(&self) -> DayBoundary {
self.boundary
}
#[must_use]
pub const fn range(&self) -> (OffsetDateTime, OffsetDateTime) {
(self.start, self.end)
}
#[must_use]
pub fn duration(&self) -> Duration {
self.end - self.start
}
#[must_use]
pub fn contains(&self, instant: OffsetDateTime) -> bool {
self.start <= instant && instant < self.end
}
#[must_use]
pub fn count(&self, resolution: Resolution) -> Option<u32> {
if let Some(step) = resolution.fixed_seconds() {
let secs = self.duration().whole_seconds();
let step = i64::from(step);
return (secs >= 0 && secs % step == 0)
.then(|| u32::try_from(secs / step).ok())
.flatten();
}
let b = self.boundary;
let first = b.day_of(self.start)?;
let last = b.day_of(self.end - Duration::nanoseconds(1))?;
let n = match resolution {
Resolution::Day => (b.day(first)?.start == self.start && b.day(last)?.end == self.end)
.then(|| (last - first).whole_days() + 1)?,
Resolution::Month => {
let aligned = b.month(first.year(), first.month())?.start == self.start
&& b.month(last.year(), last.month())?.end == self.end;
aligned.then(|| {
i64::from(last.year() - first.year()) * 12 + i64::from(last.month() as u8)
- i64::from(first.month() as u8)
+ 1
})?
}
Resolution::Year => {
let aligned = b.year(first.year())?.start == self.start
&& b.year(last.year())?.end == self.end;
aligned.then(|| i64::from(last.year() - first.year()) + 1)?
}
Resolution::Minutes(_) | Resolution::Hour => return None,
};
u32::try_from(n).ok()
}
#[must_use]
pub fn kind(&self) -> DayKind {
let wall = |t: OffsetDateTime| {
let l = to_berlin(t);
PrimitiveDateTime::new(l.date(), l.time())
};
let wall_span = wall(self.end) - wall(self.start);
match self.duration().cmp(&wall_span) {
std::cmp::Ordering::Less => DayKind::ShortDay,
std::cmp::Ordering::Greater => DayKind::LongDay,
std::cmp::Ordering::Equal => DayKind::Normal,
}
}
#[must_use]
pub fn transition(&self) -> Option<OffsetDateTime> {
let offset = |t: OffsetDateTime| to_berlin(t).offset();
let first = offset(self.start);
let mut cursor = self.start;
while cursor < self.end {
let next = cursor + Duration::hours(1);
if next < self.end && offset(next) != first {
return Some(next);
}
cursor = next;
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
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 const fn is_dst_transition(self) -> bool {
!matches!(self, Self::Normal)
}
}
#[must_use]
pub fn shift_back_days(instant: OffsetDateTime, days: i64) -> Option<OffsetDateTime> {
let local = to_berlin(instant);
let date = local.date().checked_sub(Duration::days(days))?;
supported(date.year()).then(|| resolve_local(PrimitiveDateTime::new(date, local.time())))
}
#[must_use]
pub fn shift_back_one_year(instant: OffsetDateTime) -> Option<OffsetDateTime> {
let local = to_berlin(instant);
let date = local.date();
let target_year = date.year().checked_sub(1)?;
if !supported(target_year) {
return None;
}
let day = date
.day()
.min(time::util::days_in_month(date.month(), target_year));
let shifted = Date::from_calendar_date(target_year, date.month(), day).ok()?;
Some(resolve_local(PrimitiveDateTime::new(shifted, local.time())))
}
fn resolve_local(naive: PrimitiveDateTime) -> OffsetDateTime {
let resolved = 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())
}
};
resolved.to_offset(UtcOffset::UTC)
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
const STROM: DayBoundary = DayBoundary::Strom;
const GAS: DayBoundary = DayBoundary::Gas;
fn hours(p: Option<Period>) -> i64 {
p.unwrap().duration().whole_hours()
}
#[test]
fn dst_transition_days_are_23_and_25_hours() {
assert_eq!(hours(STROM.day(date!(2026 - 03 - 29))), 23);
assert_eq!(hours(STROM.day(date!(2026 - 07 - 20))), 24);
assert_eq!(hours(STROM.day(date!(2026 - 10 - 25))), 25);
let q = Resolution::QUARTER_HOUR;
assert_eq!(STROM.day(date!(2026 - 03 - 29)).unwrap().count(q), Some(92));
assert_eq!(
STROM.day(date!(2026 - 10 - 25)).unwrap().count(q),
Some(100)
);
assert_eq!(
STROM.day(date!(2026 - 03 - 29)).unwrap().kind(),
DayKind::ShortDay
);
assert_eq!(
STROM.day(date!(2026 - 10 - 25)).unwrap().kind(),
DayKind::LongDay
);
assert_eq!(
STROM.day(date!(2026 - 07 - 20)).unwrap().kind(),
DayKind::Normal
);
}
#[test]
fn months_and_years_count_quarter_hours_and_calendar_units() {
let q = Resolution::QUARTER_HOUR;
assert_eq!(
STROM.month(2026, Month::March).unwrap().count(q),
Some(2_972)
);
assert_eq!(
STROM.month(2026, Month::October).unwrap().count(q),
Some(2_980)
);
assert_eq!(
STROM
.month(2026, Month::March)
.unwrap()
.count(Resolution::Day),
Some(31)
);
let y = STROM.year(2028).unwrap();
assert_eq!(y.count(Resolution::Day), Some(366));
assert_eq!(y.count(Resolution::Month), Some(12));
assert_eq!(y.count(Resolution::Year), Some(1));
assert_eq!(y.kind(), DayKind::Normal);
assert_eq!(
STROM.year(2026).unwrap().count(Resolution::Hour),
Some(365 * 24)
);
assert_eq!(
STROM
.day(date!(2026 - 01 - 01))
.unwrap()
.count(Resolution::Month),
None
);
}
#[test]
fn day_boundaries_are_local_not_utc() {
assert_eq!(
STROM.day(date!(2026 - 01 - 15)).unwrap().start(),
datetime!(2026-01-14 23:00 UTC)
);
assert_eq!(
STROM.day(date!(2026 - 07 - 15)).unwrap().start(),
datetime!(2026-07-14 22:00 UTC)
);
assert_eq!(
GAS.day(date!(2026 - 07 - 15)).unwrap().start(),
datetime!(2026-07-15 4:00 UTC)
);
}
#[test]
fn days_tile_the_year_without_gaps() {
for b in DayBoundary::ALL {
let mut d = date!(2026 - 01 - 01);
let mut prev_end = b.day(d).unwrap().start();
let mut total = Duration::ZERO;
while d.year() == 2026 {
let p = b.day(d).unwrap();
assert_eq!(p.start(), prev_end, "{b} {d}");
prev_end = p.end();
total += p.duration();
d = d.next_day().unwrap();
}
assert_eq!(total, b.year(2026).unwrap().duration());
}
}
#[test]
fn the_gastag_shifts_the_transition_to_saturday() {
assert_eq!(hours(GAS.day(date!(2026 - 10 - 24))), 25);
assert_eq!(hours(GAS.day(date!(2026 - 10 - 25))), 24);
assert_eq!(hours(GAS.day(date!(2026 - 03 - 28))), 23);
let m = GAS.month(2026, Month::March).unwrap();
assert_eq!(m.start(), datetime!(2026-03-01 5:00 UTC));
assert_eq!(m.end(), datetime!(2026-04-01 4:00 UTC));
}
#[test]
fn day_of_follows_the_boundary() {
assert_eq!(
STROM.day_of(datetime!(2026-07-14 23:30 UTC)),
Some(date!(2026 - 07 - 15))
);
assert_eq!(
GAS.day_of(datetime!(2026-07-15 4:30 UTC)),
Some(date!(2026 - 07 - 15))
);
assert_eq!(
GAS.day_of(datetime!(2026-07-15 3:30 UTC)),
Some(date!(2026 - 07 - 14))
);
}
#[test]
fn unsupported_years_answer_none_rather_than_a_substitute() {
assert!(STROM.day(date!(1899 - 12 - 31)).is_none());
assert!(STROM.day(date!(9999 - 01 - 01)).is_none());
assert!(STROM.year(9998).is_some());
assert!(STROM.year(9999).is_none());
assert!(STROM.month(1899, Month::December).is_none());
assert!(STROM.day_of(datetime!(1850-06-01 0:00 UTC)).is_none());
assert!(shift_back_days(datetime!(1900-01-02 12:00 UTC), 5).is_none());
assert!(shift_back_one_year(datetime!(1900-06-01 12:00 UTC)).is_none());
}
#[test]
fn buckets_snap_to_the_local_day_start_not_the_epoch() {
let b = GAS
.bucket(datetime!(2026-01-15 5:30 UTC), Resolution::Hour)
.unwrap();
assert_eq!(b.start(), datetime!(2026-01-15 5:00 UTC));
let day = STROM.day(date!(2026 - 10 - 25)).unwrap();
let mut t = day.start();
let mut n = 0;
while t < day.end() {
let b = STROM.bucket(t, Resolution::QUARTER_HOUR).unwrap();
assert_eq!(b.start(), t);
t = b.end();
n += 1;
}
assert_eq!(n, 100);
assert_eq!(
STROM.bucket(datetime!(2026-03-15 12:00 UTC), Resolution::Month),
STROM.month(2026, Month::March)
);
}
#[test]
fn transitions_are_located_to_the_instant() {
assert_eq!(
STROM.day(date!(2026 - 03 - 29)).unwrap().transition(),
Some(datetime!(2026-03-29 1:00 UTC))
);
assert_eq!(
STROM.day(date!(2026 - 10 - 25)).unwrap().transition(),
Some(datetime!(2026-10-25 1:00 UTC))
);
assert_eq!(STROM.day(date!(2026 - 07 - 20)).unwrap().transition(), None);
}
#[test]
fn shifting_back_keeps_the_local_clock_time() {
assert_eq!(
shift_back_one_year(datetime!(2026-07-15 10:00 UTC)),
Some(datetime!(2025-07-15 10:00 UTC))
);
let leap = STROM.day(date!(2028 - 02 - 29)).unwrap().start();
let back = shift_back_one_year(leap).unwrap();
assert_eq!(STROM.day_of(back), Some(date!(2027 - 02 - 28)));
assert_eq!(
shift_back_days(datetime!(2026-10-28 11:00 UTC), 7),
Some(datetime!(2026-10-21 10:00 UTC))
);
}
#[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).unwrap();
assert_eq!(back, datetime!(2026-03-29 1:30 UTC));
assert_eq!(to_berlin(back).hour(), 3);
}
#[test]
fn an_ambiguous_local_time_resolves_to_the_earlier_pass() {
let back = shift_back_days(datetime!(2026-10-26 1:30 UTC), 1).unwrap();
assert_eq!(back, datetime!(2026-10-25 0:30 UTC));
}
}