use chrono::{Local, NaiveDate, TimeZone};
const MS_PER_MIN: i64 = 60_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Span {
H1,
H3,
H6,
H12,
H24,
}
impl Span {
const ORDER: [Span; 5] = [Span::H1, Span::H3, Span::H6, Span::H12, Span::H24];
pub fn minutes(self) -> i64 {
match self {
Span::H1 => 60,
Span::H3 => 180,
Span::H6 => 360,
Span::H12 => 720,
Span::H24 => 1440,
}
}
pub fn label(self) -> &'static str {
match self {
Span::H1 => "1h",
Span::H3 => "3h",
Span::H6 => "6h",
Span::H12 => "12h",
Span::H24 => "24h",
}
}
pub fn wider(self) -> Self {
let i = Self::ORDER.iter().position(|&s| s == self).unwrap_or(0);
Self::ORDER[(i + 1).min(Self::ORDER.len() - 1)]
}
pub fn narrower(self) -> Self {
let i = Self::ORDER.iter().position(|&s| s == self).unwrap_or(0);
Self::ORDER[i.saturating_sub(1)]
}
pub fn fetch_count(self) -> usize {
(self.minutes() as usize + 60) * 2
}
}
#[derive(Debug, Clone, Copy)]
pub struct View {
pub span: Span,
pub end: Option<i64>,
}
impl Default for View {
fn default() -> Self {
Self {
span: Span::H3,
end: None,
}
}
}
impl View {
pub fn is_live(self) -> bool {
self.end.is_none()
}
pub fn bounds(self, now_ms: i64) -> (i64, i64) {
let end = self.end.unwrap_or(now_ms);
(end - self.span.minutes() * MS_PER_MIN, end)
}
fn step(self) -> i64 {
self.span.minutes() * MS_PER_MIN / 2
}
pub fn pan_back(&mut self, now_ms: i64) {
let end = self.end.unwrap_or(now_ms);
self.end = Some(end - self.step());
}
pub fn pan_forward(&mut self, now_ms: i64) {
if let Some(end) = self.end {
let next = end + self.step();
self.end = if next >= now_ms { None } else { Some(next) };
}
}
pub fn follow(&mut self) {
self.end = None;
}
pub fn zoom_out(&mut self) {
self.span = self.span.wider();
}
pub fn zoom_in(&mut self) {
self.span = self.span.narrower();
}
pub fn jump_to(&mut self, date: NaiveDate, now_ms: i64) {
self.span = Span::H24;
let next_midnight = date
.succ_opt()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.and_then(|dt| Local.from_local_datetime(&dt).single())
.map(|dt| dt.timestamp_millis());
self.end = match next_midnight {
Some(ms) if ms < now_ms => Some(ms),
_ => None,
};
}
}
pub fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_700_000_000_000; const HOUR: i64 = 3_600_000;
#[test]
fn live_window_ends_at_now() {
let v = View::default();
let (start, end) = v.bounds(NOW);
assert_eq!(end, NOW);
assert_eq!(start, NOW - 3 * HOUR); }
#[test]
fn pan_back_anchors_and_shifts_by_half_span() {
let mut v = View::default();
v.pan_back(NOW);
assert!(!v.is_live());
assert_eq!(v.end, Some(NOW - 90 * 60_000));
}
#[test]
fn pan_forward_snaps_back_to_live() {
let mut v = View::default();
v.pan_back(NOW); v.pan_forward(NOW); assert!(v.is_live());
}
#[test]
fn zoom_cycles_and_clamps() {
let mut v = View::default(); v.zoom_in(); assert_eq!(v.span, Span::H1);
v.zoom_in(); assert_eq!(v.span, Span::H1);
v.zoom_out(); v.zoom_out();
v.zoom_out();
v.zoom_out(); v.zoom_out(); assert_eq!(v.span, Span::H24);
}
#[test]
fn jump_to_past_day_uses_24h_and_anchors() {
let mut v = View::default();
let date = parse_date("2000-01-01").unwrap();
v.jump_to(date, NOW);
assert_eq!(v.span, Span::H24);
assert!(!v.is_live());
let (start, end) = v.bounds(NOW);
assert_eq!(end - start, 24 * HOUR);
}
#[test]
fn jump_to_future_day_falls_back_to_live() {
let mut v = View::default();
let date = parse_date("2099-01-01").unwrap();
v.jump_to(date, NOW);
assert!(v.is_live());
}
#[test]
fn parse_date_rejects_garbage() {
assert!(parse_date("not-a-date").is_none());
assert!(parse_date("2026-13-40").is_none());
assert!(parse_date("2026-07-16").is_some());
}
}