1use chrono::{DateTime, Datelike, Duration, Local, NaiveTime};
2use std::str::FromStr;
3
4#[derive(Debug, Clone)]
5pub enum Period {
6 Today,
7 Yesterday,
8 Hours(u32),
9 Days(u32),
10 Week,
11}
12
13pub struct TimeRange {
14 pub since: DateTime<Local>,
15 pub until: Option<DateTime<Local>>,
16}
17
18impl FromStr for Period {
19 type Err = String;
20
21 fn from_str(s: &str) -> Result<Self, Self::Err> {
22 match s {
23 "today" => Ok(Period::Today),
24 "yesterday" => Ok(Period::Yesterday),
25 "week" => Ok(Period::Week),
26 other => {
27 if let Some(h) = other.strip_suffix('h') {
28 h.parse::<u32>()
29 .map(Period::Hours)
30 .map_err(|_| format!("Invalid hours: {other}"))
31 } else if let Some(d) = other.strip_suffix('d') {
32 d.parse::<u32>()
33 .map(Period::Days)
34 .map_err(|_| format!("Invalid days: {other}"))
35 } else {
36 Err(format!(
37 "Unknown period: {other}. Use: today, yesterday, 24h, 3d, 7d, week"
38 ))
39 }
40 }
41 }
42 }
43}
44
45impl Period {
46 pub fn to_time_range(&self) -> TimeRange {
47 let now = Local::now();
48 let start_of_today = now
49 .date_naive()
50 .and_time(NaiveTime::MIN)
51 .and_local_timezone(Local)
52 .single()
53 .unwrap_or(now);
54
55 match self {
56 Period::Today => TimeRange {
57 since: start_of_today,
58 until: None,
59 },
60 Period::Yesterday => {
61 let yesterday_start = start_of_today - Duration::days(1);
62 TimeRange {
63 since: yesterday_start,
64 until: Some(start_of_today),
65 }
66 }
67 Period::Hours(h) => TimeRange {
68 since: now - Duration::hours(i64::from(*h)),
69 until: None,
70 },
71 Period::Days(d) => TimeRange {
72 since: now - Duration::days(i64::from(*d)),
73 until: None,
74 },
75 Period::Week => {
76 let days_since_monday = now.weekday().num_days_from_monday() as i64;
77 let monday = start_of_today - Duration::days(days_since_monday);
78 TimeRange {
79 since: monday,
80 until: None,
81 }
82 }
83 }
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use chrono::{Timelike, Weekday};
91
92 #[test]
93 fn parse_today() {
94 assert!(matches!(Period::from_str("today"), Ok(Period::Today)));
95 }
96
97 #[test]
98 fn parse_yesterday() {
99 assert!(matches!(
100 Period::from_str("yesterday"),
101 Ok(Period::Yesterday)
102 ));
103 }
104
105 #[test]
106 fn parse_week() {
107 assert!(matches!(Period::from_str("week"), Ok(Period::Week)));
108 }
109
110 #[test]
111 fn parse_hours() {
112 let period = Period::from_str("24h");
113 assert!(matches!(period, Ok(Period::Hours(24))));
114 }
115
116 #[test]
117 fn parse_days() {
118 let period = Period::from_str("7d");
119 assert!(matches!(period, Ok(Period::Days(7))));
120 }
121
122 #[test]
123 fn parse_custom_days() {
124 let period = Period::from_str("14d");
125 assert!(matches!(period, Ok(Period::Days(14))));
126 }
127
128 #[test]
129 fn parse_invalid_returns_error() {
130 assert!(Period::from_str("invalid").is_err());
131 }
132
133 #[test]
134 fn parse_invalid_number_returns_error() {
135 assert!(Period::from_str("abch").is_err());
136 }
137
138 #[test]
139 fn today_range_starts_at_midnight() {
140 let range = Period::Today.to_time_range();
141 assert_eq!(range.since.time().hour(), 0);
142 assert_eq!(range.since.time().minute(), 0);
143 assert!(range.until.is_none());
144 }
145
146 #[test]
147 fn yesterday_range_has_both_bounds() {
148 let range = Period::Yesterday.to_time_range();
149 assert!(range.until.is_some());
150 let until = range.until.as_ref().unwrap_or(&range.since);
151 assert!(range.since < *until);
152 }
153
154 #[test]
155 fn hours_range_is_in_past() {
156 let range = Period::Hours(24).to_time_range();
157 assert!(range.since < Local::now());
158 assert!(range.until.is_none());
159 }
160
161 #[test]
162 fn week_range_starts_on_monday() {
163 let range = Period::Week.to_time_range();
164 assert_eq!(range.since.weekday(), Weekday::Mon);
165 }
166}