1use crate::config::settings::{BudgetPeriodType, Settings};
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::BudgetPeriod;
9use chrono::{Datelike, Duration, Local, NaiveDate};
10
11pub struct PeriodService<'a> {
13 settings: &'a Settings,
14}
15
16impl<'a> PeriodService<'a> {
17 pub fn new(settings: &'a Settings) -> Self {
19 Self { settings }
20 }
21
22 pub fn current_period(&self) -> BudgetPeriod {
24 let today = Local::now().date_naive();
25 self.period_for_date(today)
26 }
27
28 pub fn period_for_date(&self, date: NaiveDate) -> BudgetPeriod {
30 match self.settings.budget_period_type {
31 BudgetPeriodType::Monthly => BudgetPeriod::monthly(date.year(), date.month()),
32 BudgetPeriodType::Weekly => {
33 BudgetPeriod::weekly(date.iso_week().year(), date.iso_week().week())
34 }
35 BudgetPeriodType::BiWeekly => {
36 let anchor = self.get_biweekly_anchor(date.year());
39 let days_since_anchor = (date - anchor).num_days();
40 let periods_since_anchor = days_since_anchor / 14;
41 let period_start = anchor + Duration::days(periods_since_anchor * 14);
42 BudgetPeriod::bi_weekly(period_start)
43 }
44 }
45 }
46
47 pub fn next_period(&self, period: &BudgetPeriod) -> BudgetPeriod {
49 period.next()
50 }
51
52 pub fn previous_period(&self, period: &BudgetPeriod) -> BudgetPeriod {
54 period.prev()
55 }
56
57 fn get_biweekly_anchor(&self, year: i32) -> NaiveDate {
59 let jan_1 = NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
60 let days_until_monday = (7 - jan_1.weekday().num_days_from_monday()) % 7;
61 jan_1 + Duration::days(days_until_monday as i64)
62 }
63
64 pub fn parse_or_current(&self, period_str: Option<&str>) -> EnvelopeResult<BudgetPeriod> {
66 match period_str {
67 Some(s) => self.parse(s),
68 None => Ok(self.current_period()),
69 }
70 }
71
72 pub fn parse(&self, s: &str) -> EnvelopeResult<BudgetPeriod> {
79 let s_lower = s.trim().to_lowercase();
80
81 if s_lower == "current" || s_lower == "now" || s_lower == "this" {
83 return Ok(self.current_period());
84 }
85
86 if s_lower == "last" || s_lower == "previous" || s_lower == "prev" {
87 return Ok(self.previous_period(&self.current_period()));
88 }
89
90 if s_lower == "next" {
91 return Ok(self.next_period(&self.current_period()));
92 }
93
94 if let Some(period) = self.parse_month_name(&s_lower) {
96 return Ok(period);
97 }
98
99 BudgetPeriod::parse(s.trim())
101 .map_err(|_| EnvelopeError::Validation(format!("Invalid period format: {}", s)))
102 }
103
104 fn parse_month_name(&self, s: &str) -> Option<BudgetPeriod> {
106 let months = [
107 ("january", 1),
108 ("jan", 1),
109 ("february", 2),
110 ("feb", 2),
111 ("march", 3),
112 ("mar", 3),
113 ("april", 4),
114 ("apr", 4),
115 ("may", 5),
116 ("june", 6),
117 ("jun", 6),
118 ("july", 7),
119 ("jul", 7),
120 ("august", 8),
121 ("aug", 8),
122 ("september", 9),
123 ("sep", 9),
124 ("sept", 9),
125 ("october", 10),
126 ("oct", 10),
127 ("november", 11),
128 ("nov", 11),
129 ("december", 12),
130 ("dec", 12),
131 ];
132
133 for (name, month) in months {
134 if let Some(stripped) = s.strip_prefix(name) {
135 let rest = stripped.trim();
137 let year = if rest.is_empty() {
138 let today = Local::now().date_naive();
140 if month > today.month() {
141 today.year() - 1
142 } else {
143 today.year()
144 }
145 } else {
146 rest.parse().ok()?
147 };
148
149 return Some(BudgetPeriod::monthly(year, month));
150 }
151 }
152
153 None
154 }
155
156 pub fn recent_periods(&self, count: usize) -> Vec<BudgetPeriod> {
158 let mut periods = Vec::with_capacity(count);
159 let mut current = self.current_period();
160
161 for _ in 0..count {
162 periods.push(current.clone());
163 current = self.previous_period(¤t);
164 }
165
166 periods.reverse();
167 periods
168 }
169
170 pub fn upcoming_periods(&self, count: usize) -> Vec<BudgetPeriod> {
172 let mut periods = Vec::with_capacity(count);
173 let mut current = self.current_period();
174
175 for _ in 0..count {
176 periods.push(current.clone());
177 current = self.next_period(¤t);
178 }
179
180 periods
181 }
182
183 pub fn format_period(&self, period: &BudgetPeriod) -> String {
185 period.to_string()
186 }
187
188 pub fn format_period_friendly(&self, period: &BudgetPeriod) -> String {
190 match period {
191 BudgetPeriod::Monthly { year, month } => {
192 let month_names = [
193 "January",
194 "February",
195 "March",
196 "April",
197 "May",
198 "June",
199 "July",
200 "August",
201 "September",
202 "October",
203 "November",
204 "December",
205 ];
206 let month_name = month_names[(*month - 1) as usize];
207 format!("{} {}", month_name, year)
208 }
209 BudgetPeriod::Weekly { year, week } => {
210 format!("Week {} of {}", week, year)
211 }
212 BudgetPeriod::BiWeekly { start_date } => {
213 let end_date = *start_date + Duration::days(13);
214 format!(
215 "{} - {}",
216 start_date.format("%b %d"),
217 end_date.format("%b %d, %Y")
218 )
219 }
220 BudgetPeriod::Custom { start, end } => {
221 format!("{} to {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d"))
222 }
223 }
224 }
225
226 pub fn is_current(&self, period: &BudgetPeriod) -> bool {
228 *period == self.current_period()
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn default_settings() -> Settings {
237 Settings::default()
238 }
239
240 #[test]
241 fn test_current_period() {
242 let settings = default_settings();
243 let service = PeriodService::new(&settings);
244
245 let period = service.current_period();
246 let today = Local::now().date_naive();
247
248 assert!(period.contains(today));
250 }
251
252 #[test]
253 fn test_period_navigation() {
254 let settings = default_settings();
255 let service = PeriodService::new(&settings);
256
257 let jan = BudgetPeriod::monthly(2025, 1);
258 let feb = service.next_period(&jan);
259 let dec = service.previous_period(&jan);
260
261 assert_eq!(feb, BudgetPeriod::monthly(2025, 2));
262 assert_eq!(dec, BudgetPeriod::monthly(2024, 12));
263 }
264
265 #[test]
266 fn test_parse_relative() {
267 let settings = default_settings();
268 let service = PeriodService::new(&settings);
269
270 let current = service.current_period();
271
272 assert_eq!(service.parse("current").unwrap(), current);
273 assert_eq!(service.parse("now").unwrap(), current);
274 assert_eq!(
275 service.parse("last").unwrap(),
276 service.previous_period(¤t)
277 );
278 assert_eq!(
279 service.parse("next").unwrap(),
280 service.next_period(¤t)
281 );
282 }
283
284 #[test]
285 fn test_parse_standard() {
286 let settings = default_settings();
287 let service = PeriodService::new(&settings);
288
289 assert_eq!(
290 service.parse("2025-01").unwrap(),
291 BudgetPeriod::monthly(2025, 1)
292 );
293 assert_eq!(
294 service.parse("2025-W03").unwrap(),
295 BudgetPeriod::weekly(2025, 3)
296 );
297 }
298
299 #[test]
300 fn test_parse_month_name() {
301 let settings = default_settings();
302 let service = PeriodService::new(&settings);
303
304 let jan2025 = service.parse("January 2025").unwrap();
305 assert_eq!(jan2025, BudgetPeriod::monthly(2025, 1));
306
307 let mar2025 = service.parse("Mar 2025").unwrap();
308 assert_eq!(mar2025, BudgetPeriod::monthly(2025, 3));
309 }
310
311 #[test]
312 fn test_recent_periods() {
313 let settings = default_settings();
314 let service = PeriodService::new(&settings);
315
316 let recent = service.recent_periods(3);
317 assert_eq!(recent.len(), 3);
318
319 assert!(recent[0] < recent[1]);
321 assert!(recent[1] < recent[2]);
322
323 assert!(service.is_current(&recent[2]));
325 }
326
327 #[test]
328 fn test_format_period_friendly() {
329 let settings = default_settings();
330 let service = PeriodService::new(&settings);
331
332 let jan = BudgetPeriod::monthly(2025, 1);
333 assert_eq!(service.format_period_friendly(&jan), "January 2025");
334
335 let week3 = BudgetPeriod::weekly(2025, 3);
336 assert_eq!(service.format_period_friendly(&week3), "Week 3 of 2025");
337 }
338}