1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3use chrono::{DateTime, Datelike, Local};
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum DurationFormat {
11 Clock,
13 Dhm,
15 Hm,
17 M,
19 Natural,
21 #[default]
23 Text,
24}
25
26impl DurationFormat {
27 pub fn from_config(s: &str) -> Self {
31 match s.trim().to_lowercase().as_str() {
32 "clock" => Self::Clock,
33 "dhm" => Self::Dhm,
34 "hm" => Self::Hm,
35 "m" => Self::M,
36 "natural" => Self::Natural,
37 _ => Self::Text,
38 }
39 }
40}
41
42#[derive(Clone, Debug)]
44pub struct FormattedDuration {
45 days: i64,
46 format: DurationFormat,
47 hours: i64,
48 minutes: i64,
49 seconds: i64,
50}
51
52impl FormattedDuration {
53 pub fn new(duration: chrono::Duration, format: DurationFormat) -> Self {
55 let total_seconds = duration.num_seconds();
56 let total_minutes = total_seconds / 60;
57 let total_hours = total_minutes / 60;
58
59 let days = total_hours / 24;
60 let hours = total_hours % 24;
61 let minutes = total_minutes % 60;
62 let seconds = total_seconds % 60;
63
64 Self {
65 days,
66 format,
67 hours,
68 minutes,
69 seconds,
70 }
71 }
72}
73
74impl Display for FormattedDuration {
75 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
76 match self.format {
77 DurationFormat::Clock => {
78 let total_hours = self.days * 24 + self.hours;
79 write!(f, "{:02}:{:02}:{:02}", total_hours, self.minutes, self.seconds)
80 }
81 DurationFormat::Dhm => {
82 let mut parts = Vec::new();
83 if self.days > 0 {
84 parts.push(format!("{}d", self.days));
85 }
86 if self.hours > 0 {
87 parts.push(format!("{}h", self.hours));
88 }
89 if self.minutes > 0 || parts.is_empty() {
90 parts.push(format!("{}m", self.minutes));
91 }
92 write!(f, "{}", parts.join(" "))
93 }
94 DurationFormat::Hm => {
95 let total_hours = self.days * 24 + self.hours;
96 write!(f, "{:02}:{:02}", total_hours, self.minutes)
97 }
98 DurationFormat::M => {
99 let total = self.days * 24 * 60 + self.hours * 60 + self.minutes;
100 write!(f, "{total}")
101 }
102 DurationFormat::Natural => {
103 let total = self.days * 24 * 60 + self.hours * 60 + self.minutes;
104 write!(f, "{}", natural_duration(total))
105 }
106 DurationFormat::Text => {
107 let mut parts = Vec::new();
108 if self.days > 0 {
109 parts.push(pluralize(self.days, "day"));
110 }
111 if self.hours > 0 {
112 parts.push(pluralize(self.hours, "hour"));
113 }
114 if self.minutes > 0 || parts.is_empty() {
115 parts.push(pluralize(self.minutes, "minute"));
116 }
117 write!(f, "{}", parts.join(" "))
118 }
119 }
120 }
121}
122
123#[derive(Clone, Debug)]
125pub struct FormattedShortdate {
126 formatted: String,
127}
128
129impl FormattedShortdate {
130 pub fn new(datetime: DateTime<Local>, config: &ShortdateFormatConfig) -> Self {
136 let now = Local::now();
137 let today = now.date_naive();
138
139 let fmt = if datetime.date_naive() == today {
140 &config.today
141 } else if datetime.date_naive() > today - chrono::Duration::days(7) {
142 &config.this_week
143 } else if datetime.year() == today.year() {
144 &config.this_month
145 } else {
146 &config.older
147 };
148
149 Self {
150 formatted: datetime.format(fmt).to_string(),
151 }
152 }
153}
154
155impl Display for FormattedShortdate {
156 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
157 write!(f, "{}", self.formatted)
158 }
159}
160
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
163#[serde(default)]
164pub struct ShortdateFormatConfig {
165 pub older: String,
166 pub this_month: String,
167 pub this_week: String,
168 pub today: String,
169}
170
171impl Default for ShortdateFormatConfig {
172 fn default() -> Self {
173 Self {
174 older: "%m/%d/%y %_I:%M%P".into(),
175 this_month: "%m/%d %_I:%M%P".into(),
176 this_week: "%a %_I:%M%P".into(),
177 today: "%_I:%M%P".into(),
178 }
179 }
180}
181
182pub fn format_tag_total(duration: chrono::Duration) -> String {
184 let total_minutes = duration.num_minutes();
185 let total_hours = total_minutes / 60;
186
187 let days = total_hours / 24;
188 let hours = total_hours % 24;
189 let minutes = total_minutes % 60;
190
191 format!("{days:02}:{hours:02}:{minutes:02}")
192}
193
194fn natural_duration(total_minutes: i64) -> String {
195 if total_minutes == 0 {
196 return "0 minutes".into();
197 }
198
199 let hours = total_minutes / 60;
200 let minutes = total_minutes % 60;
201 let days = hours / 24;
202 let remaining_hours = hours % 24;
203
204 if days > 0 {
205 if remaining_hours == 0 && minutes == 0 {
206 return if days == 1 {
207 "about a day".into()
208 } else {
209 format!("about {days} days")
210 };
211 }
212 if remaining_hours >= 12 {
213 return format!("about {} days", days + 1);
214 }
215 return format!("about {days} and a half days");
216 }
217
218 if remaining_hours > 0 {
219 if minutes == 0 {
220 return if remaining_hours == 1 {
221 "about an hour".into()
222 } else {
223 format!("about {remaining_hours} hours")
224 };
225 }
226 if minutes <= 15 {
227 return if remaining_hours == 1 {
228 "about an hour".into()
229 } else {
230 format!("about {remaining_hours} hours")
231 };
232 }
233 if minutes >= 45 {
234 let rounded = remaining_hours + 1;
235 return format!("about {rounded} hours");
236 }
237 return if remaining_hours == 1 {
238 "about an hour and a half".into()
239 } else {
240 format!("about {remaining_hours} and a half hours")
241 };
242 }
243
244 if minutes == 1 {
245 "about a minute".into()
246 } else if minutes < 5 {
247 "a few minutes".into()
248 } else if minutes < 15 {
249 format!("about {minutes} minutes")
250 } else if minutes < 25 {
251 "about 15 minutes".into()
252 } else if minutes < 35 {
253 "about half an hour".into()
254 } else if minutes < 50 {
255 "about 45 minutes".into()
256 } else {
257 "about an hour".into()
258 }
259}
260
261fn pluralize(count: i64, word: &str) -> String {
262 if count == 1 {
263 format!("{count} {word}")
264 } else {
265 format!("{count} {word}s")
266 }
267}
268
269#[cfg(test)]
270mod test {
271 use chrono::Duration;
272
273 use super::*;
274
275 mod duration_format {
276 use pretty_assertions::assert_eq;
277
278 use super::*;
279
280 #[test]
281 fn it_defaults_unknown_to_text() {
282 assert_eq!(DurationFormat::from_config("unknown"), DurationFormat::Text);
283 }
284
285 #[test]
286 fn it_is_case_insensitive() {
287 assert_eq!(DurationFormat::from_config("CLOCK"), DurationFormat::Clock);
288 }
289
290 #[test]
291 fn it_parses_clock_from_config() {
292 assert_eq!(DurationFormat::from_config("clock"), DurationFormat::Clock);
293 }
294
295 #[test]
296 fn it_parses_dhm_from_config() {
297 assert_eq!(DurationFormat::from_config("dhm"), DurationFormat::Dhm);
298 }
299
300 #[test]
301 fn it_parses_hm_from_config() {
302 assert_eq!(DurationFormat::from_config("hm"), DurationFormat::Hm);
303 }
304
305 #[test]
306 fn it_parses_m_from_config() {
307 assert_eq!(DurationFormat::from_config("m"), DurationFormat::M);
308 }
309
310 #[test]
311 fn it_parses_natural_from_config() {
312 assert_eq!(DurationFormat::from_config("natural"), DurationFormat::Natural);
313 }
314
315 #[test]
316 fn it_parses_text_from_config() {
317 assert_eq!(DurationFormat::from_config("text"), DurationFormat::Text);
318 }
319 }
320
321 mod format_tag_total {
322 use pretty_assertions::assert_eq;
323
324 use super::*;
325
326 #[test]
327 fn it_formats_zero() {
328 assert_eq!(format_tag_total(Duration::zero()), "00:00:00");
329 }
330
331 #[test]
332 fn it_formats_hours_and_minutes() {
333 assert_eq!(format_tag_total(Duration::seconds(5400)), "00:01:30");
334 }
335
336 #[test]
337 fn it_formats_days_hours_minutes() {
338 let duration = Duration::seconds(93600 + 1800);
339
340 assert_eq!(format_tag_total(duration), "01:02:30");
341 }
342 }
343
344 mod formatted_duration {
345 use pretty_assertions::assert_eq;
346
347 use super::*;
348
349 #[test]
350 fn it_formats_clock() {
351 let fd = FormattedDuration::new(Duration::seconds(93600), DurationFormat::Clock);
352
353 assert_eq!(fd.to_string(), "26:00:00");
354 }
355
356 #[test]
357 fn it_formats_clock_with_minutes() {
358 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::Clock);
359
360 assert_eq!(fd.to_string(), "01:30:00");
361 }
362
363 #[test]
364 fn it_formats_clock_with_seconds() {
365 let fd = FormattedDuration::new(Duration::seconds(3661), DurationFormat::Clock);
366
367 assert_eq!(fd.to_string(), "01:01:01");
368 }
369
370 #[test]
371 fn it_formats_dhm() {
372 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Dhm);
373
374 assert_eq!(fd.to_string(), "1d 2h 30m");
375 }
376
377 #[test]
378 fn it_formats_dhm_hours_only() {
379 let fd = FormattedDuration::new(Duration::hours(3), DurationFormat::Dhm);
380
381 assert_eq!(fd.to_string(), "3h");
382 }
383
384 #[test]
385 fn it_formats_dhm_zero_duration() {
386 let fd = FormattedDuration::new(Duration::zero(), DurationFormat::Dhm);
387
388 assert_eq!(fd.to_string(), "0m");
389 }
390
391 #[test]
392 fn it_formats_hm() {
393 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Hm);
394
395 assert_eq!(fd.to_string(), "26:30");
396 }
397
398 #[test]
399 fn it_formats_m() {
400 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::M);
401
402 assert_eq!(fd.to_string(), "90");
403 }
404
405 #[test]
406 fn it_formats_natural_about_hours() {
407 let fd = FormattedDuration::new(Duration::hours(3), DurationFormat::Natural);
408
409 assert_eq!(fd.to_string(), "about 3 hours");
410 }
411
412 #[test]
413 fn it_formats_natural_few_minutes() {
414 let fd = FormattedDuration::new(Duration::minutes(3), DurationFormat::Natural);
415
416 assert_eq!(fd.to_string(), "a few minutes");
417 }
418
419 #[test]
420 fn it_formats_natural_half_hour() {
421 let fd = FormattedDuration::new(Duration::minutes(30), DurationFormat::Natural);
422
423 assert_eq!(fd.to_string(), "about half an hour");
424 }
425
426 #[test]
427 fn it_formats_natural_hour_and_half() {
428 let fd = FormattedDuration::new(Duration::minutes(90), DurationFormat::Natural);
429
430 assert_eq!(fd.to_string(), "about an hour and a half");
431 }
432
433 #[test]
434 fn it_formats_text() {
435 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::Text);
436
437 assert_eq!(fd.to_string(), "1 hour 30 minutes");
438 }
439
440 #[test]
441 fn it_formats_text_plural() {
442 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Text);
443
444 assert_eq!(fd.to_string(), "1 day 2 hours 30 minutes");
445 }
446
447 #[test]
448 fn it_formats_text_singular() {
449 let fd = FormattedDuration::new(Duration::hours(1), DurationFormat::Text);
450
451 assert_eq!(fd.to_string(), "1 hour");
452 }
453
454 #[test]
455 fn it_formats_text_zero_duration() {
456 let fd = FormattedDuration::new(Duration::zero(), DurationFormat::Text);
457
458 assert_eq!(fd.to_string(), "0 minutes");
459 }
460 }
461
462 mod formatted_shortdate {
463 use chrono::TimeZone;
464 use pretty_assertions::assert_eq;
465
466 use super::*;
467
468 fn config() -> ShortdateFormatConfig {
469 ShortdateFormatConfig {
470 today: "%H:%M".into(),
471 this_week: "%a %H:%M".into(),
472 this_month: "%m/%d %H:%M".into(),
473 older: "%m/%d/%y %H:%M".into(),
474 }
475 }
476
477 #[test]
478 fn it_formats_older() {
479 let datetime = Local.with_ymd_and_hms(2020, 6, 15, 14, 30, 0).unwrap();
480
481 let result = FormattedShortdate::new(datetime, &config());
482
483 assert_eq!(result.to_string(), "06/15/20 14:30");
484 }
485
486 #[test]
487 fn it_formats_this_month() {
488 let old = Local::now() - Duration::days(20);
489 let datetime = Local
490 .with_ymd_and_hms(old.year(), old.month(), old.day(), 14, 30, 0)
491 .unwrap();
492
493 let result = FormattedShortdate::new(datetime, &config());
494
495 let expected = datetime.format("%m/%d %H:%M").to_string();
496 assert_eq!(result.to_string(), expected);
497 }
498
499 #[test]
500 fn it_formats_this_week() {
501 let yesterday = Local::now() - Duration::days(2);
502 let datetime = Local
503 .with_ymd_and_hms(yesterday.year(), yesterday.month(), yesterday.day(), 14, 30, 0)
504 .unwrap();
505
506 let result = FormattedShortdate::new(datetime, &config());
507
508 let expected = datetime.format("%a %H:%M").to_string();
509 assert_eq!(result.to_string(), expected);
510 }
511
512 #[test]
513 fn it_formats_cross_year_dates_as_older() {
514 let now = Local::now();
515 let last_year = now.year() - 1;
516 let datetime = Local.with_ymd_and_hms(last_year, 11, 15, 14, 30, 0).unwrap();
517
518 let result = FormattedShortdate::new(datetime, &config());
519
520 assert_eq!(result.to_string(), format!("11/15/{} 14:30", last_year % 100));
521 }
522
523 #[test]
524 fn it_formats_today() {
525 let now = Local::now();
526 let datetime = Local
527 .with_ymd_and_hms(now.year(), now.month(), now.day(), 14, 30, 0)
528 .unwrap();
529
530 let result = FormattedShortdate::new(datetime, &config());
531
532 assert_eq!(result.to_string(), "14:30");
533 }
534 }
535}