Skip to main content

kanbrain_core/
period.rs

1//! Calendar periods used by reports: an hour, a day, an ISO week (Monday–Sunday) or a month.
2
3use crate::enums::named_enum;
4use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike};
5
6named_enum! {
7    /// The length of a report period. `next()` cycles through them in this order (`tab`).
8    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
9    pub enum Unit {
10        /// One clock hour, e.g. 14:00–15:00.
11        Hour => "hour",
12        /// One calendar day.
13        Day => "day",
14        /// One ISO week, Monday to Sunday.
15        Week => "week",
16        /// One calendar month.
17        Month => "month",
18    }
19}
20
21/// A calendar-aligned span of local time: one hour, day, ISO week (Mon–Sun) or month.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct Period {
24    /// How long the period is.
25    pub unit: Unit,
26    /// Its first moment, in local time (e.g. Monday 00:00 for a week).
27    pub start: NaiveDateTime,
28}
29
30impl Period {
31    /// The period of length `unit` that contains `at`.
32    pub fn containing(unit: Unit, at: NaiveDateTime) -> Self {
33        let d = at.date();
34        let day = match unit {
35            Unit::Hour | Unit::Day => d,
36            Unit::Week => d - Duration::days(i64::from(d.weekday().num_days_from_monday())),
37            Unit::Month => d.with_day(1).expect("day 1 exists"),
38        };
39        let hour = if unit == Unit::Hour { at.hour() } else { 0 };
40        Self { unit, start: day.and_hms_opt(hour, 0, 0).expect("valid hour") }
41    }
42
43    /// The period of length `unit` that contains now.
44    pub fn current(unit: Unit) -> Self {
45        Self::containing(unit, Local::now().naive_local())
46    }
47
48    /// The first moment after the period (the start of the next one).
49    pub fn end(self) -> NaiveDateTime {
50        match self.unit {
51            Unit::Hour => self.start + Duration::hours(1),
52            Unit::Day => self.start + Duration::days(1),
53            Unit::Week => self.start + Duration::days(7),
54            // The first day of the following month (December rolls over to January).
55            Unit::Month => {
56                let d = self.start.date();
57                let (y, m) = if d.month() == 12 { (d.year() + 1, 1) } else { (d.year(), d.month() + 1) };
58                NaiveDate::from_ymd_opt(y, m, 1).expect("valid month").and_time(NaiveTime::MIN)
59            }
60        }
61    }
62
63    /// The following period of the same length.
64    pub fn next(self) -> Self {
65        Self { unit: self.unit, start: self.end() }
66    }
67
68    /// The preceding period of the same length.
69    pub fn prev(self) -> Self {
70        Self::containing(self.unit, self.start - Duration::seconds(1))
71    }
72
73    /// Whether `at` falls inside the period.
74    pub fn contains(self, at: NaiveDateTime) -> bool {
75        self.start <= at && at < self.end()
76    }
77
78    /// Switches unit, staying around "now" if this period contains it.
79    pub fn with_unit(self, unit: Unit) -> Self {
80        let now = Local::now().naive_local();
81        Self::containing(unit, if self.contains(now) { now } else { self.start })
82    }
83
84    /// The period as a `[start, end)` range of local times.
85    pub fn bounds(self) -> (DateTime<Local>, DateTime<Local>) {
86        (to_local(self.start), to_local(self.end()))
87    }
88
89    /// Human-readable name for titles, e.g. `week 39 · Sep 21 – Sep 27 2026`.
90    pub fn label(self) -> String {
91        match self.unit {
92            Unit::Hour => format!(
93                "{} {}–{}",
94                self.start.format("%a %Y-%m-%d"),
95                self.start.format("%H:%M"),
96                self.end().format("%H:%M")
97            ),
98            Unit::Day => self.start.format("%A %Y-%m-%d").to_string(),
99            Unit::Week => format!(
100                "week {} · {} – {}",
101                self.start.iso_week().week(),
102                self.start.format("%b %d"),
103                (self.end() - Duration::days(1)).format("%b %d %Y")
104            ),
105            Unit::Month => self.start.format("%B %Y").to_string(),
106        }
107    }
108
109    /// Sub-periods used as breakdown columns: days of a week, weeks of a month.
110    /// Weeks that cross the month edge are included; callers clip them to the month.
111    pub fn columns(self) -> Vec<(String, Period)> {
112        let sub = match self.unit {
113            Unit::Week => Unit::Day,
114            Unit::Month => Unit::Week,
115            Unit::Hour | Unit::Day => return Vec::new(),
116        };
117        let mut cols = Vec::new();
118        let mut p = Period::containing(sub, self.start);
119        while p.start < self.end() {
120            let label = match sub {
121                Unit::Day => p.start.format("%a %d").to_string(),
122                _ => format!("W{}", p.start.iso_week().week()),
123            };
124            cols.push((label, p));
125            p = p.next();
126        }
127        cols
128    }
129}
130
131/// Local time for `naive`. A time skipped by a DST change doesn't exist; then use the first
132/// valid quarter hour after it (DST gaps are at most an hour).
133fn to_local(naive: NaiveDateTime) -> DateTime<Local> {
134    (0..=4)
135        .find_map(|quarter| Local.from_local_datetime(&(naive + Duration::minutes(15 * quarter))).earliest())
136        .unwrap_or_else(|| Local.from_utc_datetime(&naive))
137}
138
139#[cfg(test)]
140#[path = "../tests/period.rs"]
141mod tests;