use core::fmt;
use time::{Date, Duration, OffsetDateTime};
pub const SLOT: Duration = Duration::minutes(15);
pub const SLOTS_PER_DAY: usize = 96;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Slot {
#[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339"))]
start: OffsetDateTime,
}
impl Slot {
#[must_use]
pub fn containing(instant: OffsetDateTime) -> Self {
let utc = instant.to_offset(time::UtcOffset::UTC);
let secs_into_hour = i64::from(utc.minute()) * 60 + i64::from(utc.second());
let secs_into_slot = secs_into_hour % SLOT.whole_seconds();
let start = utc
- Duration::seconds(secs_into_slot)
- Duration::nanoseconds(i64::from(utc.nanosecond()));
Self { start }
}
#[must_use]
pub fn starting_at(instant: OffsetDateTime) -> Option<Self> {
let slot = Self::containing(instant);
(slot.start == instant.to_offset(time::UtcOffset::UTC)).then_some(slot)
}
#[must_use]
pub const fn start(&self) -> OffsetDateTime {
self.start
}
#[must_use]
pub fn end(&self) -> OffsetDateTime {
self.start + SLOT
}
#[must_use]
pub fn contains(&self, instant: OffsetDateTime) -> bool {
let utc = instant.to_offset(time::UtcOffset::UTC);
utc >= self.start && utc < self.end()
}
#[must_use]
pub fn next(&self) -> Self {
Self {
start: self.start + SLOT,
}
}
#[must_use]
pub fn prev(&self) -> Self {
Self {
start: self.start - SLOT,
}
}
#[must_use]
pub fn offset(&self, n: i64) -> Self {
Self {
start: self.start + SLOT * i32::try_from(n).unwrap_or(i32::MAX),
}
}
#[must_use]
pub fn distance_to(&self, other: Self) -> i64 {
(other.start - self.start).whole_seconds() / SLOT.whole_seconds()
}
#[must_use]
pub fn local_date(&self) -> Date {
metering::calendar::local_day(self.start)
}
#[must_use]
pub fn local_minute_of_day(&self) -> u16 {
let local = metering::calendar::to_berlin(self.start);
u16::from(local.hour()) * 60 + u16::from(local.minute())
}
#[must_use]
pub fn index_in_local_day(&self) -> u32 {
let day_start = metering::calendar::day_start_utc(self.local_date());
u32::try_from((self.start - day_start).whole_seconds() / SLOT.whole_seconds()).unwrap_or(0)
}
}
impl fmt::Display for Slot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let local = metering::calendar::to_berlin(self.start);
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02} local",
local.year(),
u8::from(local.month()),
local.day(),
local.hour(),
local.minute()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Horizon {
pub first: Slot,
pub len: usize,
}
impl Horizon {
#[must_use]
pub fn new(from: OffsetDateTime, len: usize) -> Self {
Self {
first: Slot::containing(from),
len,
}
}
#[must_use]
pub fn two_days(from: OffsetDateTime) -> Self {
Self::new(from, 2 * SLOTS_PER_DAY)
}
pub fn slots(&self) -> impl ExactSizeIterator<Item = Slot> + '_ {
let first = self.first;
(0..self.len).map(move |i| first.offset(i as i64))
}
#[must_use]
pub fn get(&self, i: usize) -> Option<Slot> {
(i < self.len).then(|| self.first.offset(i as i64))
}
#[must_use]
pub fn index_of(&self, slot: Slot) -> Option<usize> {
let d = self.first.distance_to(slot);
(d >= 0 && (d as usize) < self.len).then_some(d as usize)
}
#[must_use]
pub fn end(&self) -> OffsetDateTime {
self.first.start() + SLOT * i32::try_from(self.len).unwrap_or(i32::MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn containing_rounds_down_to_the_quarter_hour() {
let s = Slot::containing(datetime!(2026-03-15 10:07:42.5 UTC));
assert_eq!(s.start(), datetime!(2026-03-15 10:00:00 UTC));
assert_eq!(s.end(), datetime!(2026-03-15 10:15:00 UTC));
assert!(s.contains(datetime!(2026-03-15 10:14:59 UTC)));
assert!(!s.contains(datetime!(2026-03-15 10:15:00 UTC)));
}
#[test]
fn containing_normalises_a_non_utc_input() {
let berlin_noon = datetime!(2026-06-01 12:07:00 +2);
assert_eq!(
Slot::containing(berlin_noon).start(),
datetime!(2026-06-01 10:00:00 UTC)
);
}
#[test]
fn starting_at_rejects_an_unaligned_instant() {
assert!(Slot::starting_at(datetime!(2026-03-15 10:00:00 UTC)).is_some());
assert!(Slot::starting_at(datetime!(2026-03-15 10:01:00 UTC)).is_none());
}
#[test]
fn local_minute_of_day_follows_the_wall_clock_across_dst() {
let winter = Slot::containing(datetime!(2026-01-15 16:00:00 UTC));
assert_eq!(winter.local_minute_of_day(), 17 * 60);
let summer = Slot::containing(datetime!(2026-07-15 15:00:00 UTC));
assert_eq!(summer.local_minute_of_day(), 17 * 60);
}
#[test]
fn the_short_spring_day_has_92_slots_and_the_long_autumn_day_100() {
for (day, expected) in [
(time::macros::date!(2026 - 03 - 29), 92),
(time::macros::date!(2026 - 10 - 25), 100),
] {
let mut s = Slot::containing(metering::calendar::day_start_utc(day));
let mut n = 0;
while s.local_date() == day {
n += 1;
s = s.next();
}
assert_eq!(n, expected, "slots in {day}");
}
}
#[test]
fn the_repeated_hour_maps_to_the_same_wall_clock_window_twice() {
let first = Slot::containing(datetime!(2026-10-25 00:15:00 UTC));
let second = Slot::containing(datetime!(2026-10-25 01:15:00 UTC));
assert_eq!(first.local_minute_of_day(), 2 * 60 + 15);
assert_eq!(second.local_minute_of_day(), 2 * 60 + 15);
assert_ne!(first, second);
assert_ne!(first.index_in_local_day(), second.index_in_local_day());
}
#[test]
fn horizon_indexes_round_trip() {
let h = Horizon::two_days(datetime!(2026-05-01 08:03:00 UTC));
assert_eq!(h.len, 192);
assert_eq!(h.slots().count(), 192);
let s = h.get(100).unwrap();
assert_eq!(h.index_of(s), Some(100));
assert_eq!(h.index_of(h.first.prev()), None);
assert_eq!(h.end(), datetime!(2026-05-03 08:00:00 UTC));
}
}