use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time};
use time_tz::{OffsetDateTimeExt as _, OffsetResult, PrimitiveDateTimeExt 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))
}
#[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 {
#[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 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;
};
let naive = PrimitiveDateTime::new(shifted, local.time());
match naive.assume_timezone(berlin()) {
OffsetResult::Some(t) => t,
OffsetResult::Ambiguous(first, _) => first,
OffsetResult::None => naive.assume_timezone_utc(berlin()),
}
.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 local_midnight_utc(day: Date) -> OffsetDateTime {
let naive = PrimitiveDateTime::new(day, Time::MIDNIGHT);
match naive.assume_timezone(berlin()) {
OffsetResult::Some(t) => t,
OffsetResult::Ambiguous(first, _) => first,
OffsetResult::None => naive.assume_timezone_utc(berlin()),
}
}
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::Custom(420)),
None,
"420 s does not divide 86 400 s"
);
assert_eq!(
intervals_in_day(date!(2026 - 06 - 01), IntervalResolution::Custom(0)),
None
);
}
#[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 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);
}
}