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 || (datetime.year() + 1 == today.year() && datetime.month() > today.month())
145 {
146 &config.this_month
147 } else {
148 &config.older
149 };
150
151 Self {
152 formatted: datetime.format(fmt).to_string(),
153 }
154 }
155}
156
157impl Display for FormattedShortdate {
158 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
159 write!(f, "{}", self.formatted)
160 }
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165#[serde(default)]
166pub struct ShortdateFormatConfig {
167 pub older: String,
168 pub this_month: String,
169 pub this_week: String,
170 pub today: String,
171}
172
173impl Default for ShortdateFormatConfig {
174 fn default() -> Self {
175 Self {
176 older: "%m/%d/%y %_I:%M%P".into(),
177 this_month: "%m/%d %_I:%M%P".into(),
178 this_week: "%a %_I:%M%P".into(),
179 today: "%_I:%M%P".into(),
180 }
181 }
182}
183
184pub fn format_tag_total(duration: chrono::Duration) -> String {
186 let total_minutes = duration.num_minutes();
187 let total_hours = total_minutes / 60;
188
189 let days = total_hours / 24;
190 let hours = total_hours % 24;
191 let minutes = total_minutes % 60;
192
193 format!("{days:02}:{hours:02}:{minutes:02}")
194}
195
196fn natural_duration(total_minutes: i64) -> String {
197 if total_minutes == 0 {
198 return "0 minutes".into();
199 }
200
201 let hours = total_minutes / 60;
202 let minutes = total_minutes % 60;
203 let days = hours / 24;
204 let remaining_hours = hours % 24;
205
206 if days > 0 {
207 if remaining_hours == 0 && minutes == 0 {
208 return if days == 1 {
209 "about a day".into()
210 } else {
211 format!("about {days} days")
212 };
213 }
214 if remaining_hours >= 12 {
215 return format!("about {} days", days + 1);
216 }
217 return format!("about {days} and a half days");
218 }
219
220 if remaining_hours > 0 {
221 if minutes == 0 {
222 return if remaining_hours == 1 {
223 "about an hour".into()
224 } else {
225 format!("about {remaining_hours} hours")
226 };
227 }
228 if minutes <= 15 {
229 return if remaining_hours == 1 {
230 "about an hour".into()
231 } else {
232 format!("about {remaining_hours} hours")
233 };
234 }
235 if minutes >= 45 {
236 let rounded = remaining_hours + 1;
237 return format!("about {rounded} hours");
238 }
239 return if remaining_hours == 1 {
240 "about an hour and a half".into()
241 } else {
242 format!("about {remaining_hours} and a half hours")
243 };
244 }
245
246 if minutes == 1 {
247 "about a minute".into()
248 } else if minutes < 5 {
249 "a few minutes".into()
250 } else if minutes < 15 {
251 format!("about {minutes} minutes")
252 } else if minutes < 25 {
253 "about 15 minutes".into()
254 } else if minutes < 35 {
255 "about half an hour".into()
256 } else if minutes < 50 {
257 "about 45 minutes".into()
258 } else {
259 "about an hour".into()
260 }
261}
262
263fn pluralize(count: i64, word: &str) -> String {
264 if count == 1 {
265 format!("{count} {word}")
266 } else {
267 format!("{count} {word}s")
268 }
269}
270
271#[cfg(test)]
272mod test {
273 use chrono::Duration;
274
275 use super::*;
276
277 mod duration_format {
278 use pretty_assertions::assert_eq;
279
280 use super::*;
281
282 #[test]
283 fn it_parses_clock_from_config() {
284 assert_eq!(DurationFormat::from_config("clock"), DurationFormat::Clock);
285 }
286
287 #[test]
288 fn it_parses_dhm_from_config() {
289 assert_eq!(DurationFormat::from_config("dhm"), DurationFormat::Dhm);
290 }
291
292 #[test]
293 fn it_parses_hm_from_config() {
294 assert_eq!(DurationFormat::from_config("hm"), DurationFormat::Hm);
295 }
296
297 #[test]
298 fn it_parses_m_from_config() {
299 assert_eq!(DurationFormat::from_config("m"), DurationFormat::M);
300 }
301
302 #[test]
303 fn it_parses_natural_from_config() {
304 assert_eq!(DurationFormat::from_config("natural"), DurationFormat::Natural);
305 }
306
307 #[test]
308 fn it_parses_text_from_config() {
309 assert_eq!(DurationFormat::from_config("text"), DurationFormat::Text);
310 }
311
312 #[test]
313 fn it_defaults_unknown_to_text() {
314 assert_eq!(DurationFormat::from_config("unknown"), DurationFormat::Text);
315 }
316
317 #[test]
318 fn it_is_case_insensitive() {
319 assert_eq!(DurationFormat::from_config("CLOCK"), DurationFormat::Clock);
320 }
321 }
322
323 mod format_tag_total {
324 use pretty_assertions::assert_eq;
325
326 use super::*;
327
328 #[test]
329 fn it_formats_zero() {
330 assert_eq!(format_tag_total(Duration::zero()), "00:00:00");
331 }
332
333 #[test]
334 fn it_formats_hours_and_minutes() {
335 assert_eq!(format_tag_total(Duration::seconds(5400)), "00:01:30");
336 }
337
338 #[test]
339 fn it_formats_days_hours_minutes() {
340 let duration = Duration::seconds(93600 + 1800);
341
342 assert_eq!(format_tag_total(duration), "01:02:30");
343 }
344 }
345
346 mod formatted_duration {
347 use pretty_assertions::assert_eq;
348
349 use super::*;
350
351 #[test]
352 fn it_formats_clock() {
353 let fd = FormattedDuration::new(Duration::seconds(93600), DurationFormat::Clock);
354
355 assert_eq!(fd.to_string(), "26:00:00");
356 }
357
358 #[test]
359 fn it_formats_clock_with_minutes() {
360 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::Clock);
361
362 assert_eq!(fd.to_string(), "01:30:00");
363 }
364
365 #[test]
366 fn it_formats_clock_with_seconds() {
367 let fd = FormattedDuration::new(Duration::seconds(3661), DurationFormat::Clock);
368
369 assert_eq!(fd.to_string(), "01:01:01");
370 }
371
372 #[test]
373 fn it_formats_dhm() {
374 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Dhm);
375
376 assert_eq!(fd.to_string(), "1d 2h 30m");
377 }
378
379 #[test]
380 fn it_formats_dhm_hours_only() {
381 let fd = FormattedDuration::new(Duration::hours(3), DurationFormat::Dhm);
382
383 assert_eq!(fd.to_string(), "3h");
384 }
385
386 #[test]
387 fn it_formats_dhm_zero_duration() {
388 let fd = FormattedDuration::new(Duration::zero(), DurationFormat::Dhm);
389
390 assert_eq!(fd.to_string(), "0m");
391 }
392
393 #[test]
394 fn it_formats_hm() {
395 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Hm);
396
397 assert_eq!(fd.to_string(), "26:30");
398 }
399
400 #[test]
401 fn it_formats_m() {
402 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::M);
403
404 assert_eq!(fd.to_string(), "90");
405 }
406
407 #[test]
408 fn it_formats_text() {
409 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::Text);
410
411 assert_eq!(fd.to_string(), "1 hour 30 minutes");
412 }
413
414 #[test]
415 fn it_formats_text_plural() {
416 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Text);
417
418 assert_eq!(fd.to_string(), "1 day 2 hours 30 minutes");
419 }
420
421 #[test]
422 fn it_formats_text_singular() {
423 let fd = FormattedDuration::new(Duration::hours(1), DurationFormat::Text);
424
425 assert_eq!(fd.to_string(), "1 hour");
426 }
427
428 #[test]
429 fn it_formats_text_zero_duration() {
430 let fd = FormattedDuration::new(Duration::zero(), DurationFormat::Text);
431
432 assert_eq!(fd.to_string(), "0 minutes");
433 }
434
435 #[test]
436 fn it_formats_natural_half_hour() {
437 let fd = FormattedDuration::new(Duration::minutes(30), DurationFormat::Natural);
438
439 assert_eq!(fd.to_string(), "about half an hour");
440 }
441
442 #[test]
443 fn it_formats_natural_hour_and_half() {
444 let fd = FormattedDuration::new(Duration::minutes(90), DurationFormat::Natural);
445
446 assert_eq!(fd.to_string(), "about an hour and a half");
447 }
448
449 #[test]
450 fn it_formats_natural_few_minutes() {
451 let fd = FormattedDuration::new(Duration::minutes(3), DurationFormat::Natural);
452
453 assert_eq!(fd.to_string(), "a few minutes");
454 }
455
456 #[test]
457 fn it_formats_natural_about_hours() {
458 let fd = FormattedDuration::new(Duration::hours(3), DurationFormat::Natural);
459
460 assert_eq!(fd.to_string(), "about 3 hours");
461 }
462 }
463
464 mod formatted_shortdate {
465 use chrono::TimeZone;
466 use pretty_assertions::assert_eq;
467
468 use super::*;
469
470 fn config() -> ShortdateFormatConfig {
471 ShortdateFormatConfig {
472 today: "%H:%M".into(),
473 this_week: "%a %H:%M".into(),
474 this_month: "%m/%d %H:%M".into(),
475 older: "%m/%d/%y %H:%M".into(),
476 }
477 }
478
479 #[test]
480 fn it_formats_today() {
481 let now = Local::now();
482 let datetime = Local
483 .with_ymd_and_hms(now.year(), now.month(), now.day(), 14, 30, 0)
484 .unwrap();
485
486 let result = FormattedShortdate::new(datetime, &config());
487
488 assert_eq!(result.to_string(), "14:30");
489 }
490
491 #[test]
492 fn it_formats_this_week() {
493 let yesterday = Local::now() - Duration::days(2);
494 let datetime = Local
495 .with_ymd_and_hms(yesterday.year(), yesterday.month(), yesterday.day(), 14, 30, 0)
496 .unwrap();
497
498 let result = FormattedShortdate::new(datetime, &config());
499
500 let expected = datetime.format("%a %H:%M").to_string();
501 assert_eq!(result.to_string(), expected);
502 }
503
504 #[test]
505 fn it_formats_this_month() {
506 let old = Local::now() - Duration::days(20);
507 let datetime = Local
508 .with_ymd_and_hms(old.year(), old.month(), old.day(), 14, 30, 0)
509 .unwrap();
510
511 let result = FormattedShortdate::new(datetime, &config());
512
513 let expected = datetime.format("%m/%d %H:%M").to_string();
514 assert_eq!(result.to_string(), expected);
515 }
516
517 #[test]
518 fn it_formats_older() {
519 let datetime = Local.with_ymd_and_hms(2020, 6, 15, 14, 30, 0).unwrap();
520
521 let result = FormattedShortdate::new(datetime, &config());
522
523 assert_eq!(result.to_string(), "06/15/20 14:30");
524 }
525 }
526}