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 fn total_minutes(&self) -> i64 {
75 self.days * 24 * 60 + self.hours * 60 + self.minutes
76 }
77}
78
79impl Display for FormattedDuration {
80 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
81 match self.format {
82 DurationFormat::Clock => {
83 let total_hours = self.days * 24 + self.hours;
84 write!(f, "{:02}:{:02}:{:02}", total_hours, self.minutes, self.seconds)
85 }
86 DurationFormat::Dhm => write!(
87 f,
88 "{}",
89 format_parts(self.days, self.hours, self.minutes, dhm_component)
90 ),
91 DurationFormat::Hm => {
92 let total_hours = self.days * 24 + self.hours;
93 write!(f, "{:02}:{:02}", total_hours, self.minutes)
94 }
95 DurationFormat::M => write!(f, "{}", self.total_minutes()),
96 DurationFormat::Natural => write!(f, "{}", natural_duration(self.total_minutes())),
97 DurationFormat::Text => write!(
98 f,
99 "{}",
100 format_parts(self.days, self.hours, self.minutes, text_component)
101 ),
102 }
103 }
104}
105
106#[derive(Clone, Debug)]
108pub struct FormattedShortdate {
109 formatted: String,
110}
111
112impl FormattedShortdate {
113 pub fn new(datetime: DateTime<Local>, config: &ShortdateFormatConfig) -> Self {
119 let now = Local::now();
120 let today = now.date_naive();
121
122 let fmt = if datetime.date_naive() == today {
123 &config.today
124 } else if datetime.date_naive() > today - chrono::Duration::days(7) {
125 &config.this_week
126 } else if datetime.year() == today.year() {
127 &config.this_month
128 } else {
129 &config.older
130 };
131
132 Self {
133 formatted: datetime.format(fmt).to_string(),
134 }
135 }
136}
137
138impl Display for FormattedShortdate {
139 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
140 write!(f, "{}", self.formatted)
141 }
142}
143
144#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
146#[serde(default)]
147pub struct ShortdateFormatConfig {
148 pub older: String,
149 pub this_month: String,
150 pub this_week: String,
151 pub today: String,
152}
153
154impl Default for ShortdateFormatConfig {
155 fn default() -> Self {
156 Self {
157 older: "%m/%d/%y %_I:%M%P".into(),
158 this_month: "%m/%d %_I:%M%P".into(),
159 this_week: "%a %_I:%M%P".into(),
160 today: "%_I:%M%P".into(),
161 }
162 }
163}
164
165pub fn format_tag_total(duration: chrono::Duration) -> String {
167 let total_minutes = duration.num_minutes();
168 let total_hours = total_minutes / 60;
169
170 let days = total_hours / 24;
171 let hours = total_hours % 24;
172 let minutes = total_minutes % 60;
173
174 format!("{days:02}:{hours:02}:{minutes:02}")
175}
176
177fn dhm_component(value: i64, _unit: &str, suffix: &str) -> String {
178 format!("{value}{suffix}")
179}
180
181fn format_parts(days: i64, hours: i64, minutes: i64, fmt: fn(i64, &str, &str) -> String) -> String {
182 let mut parts = Vec::new();
183 if days > 0 {
184 parts.push(fmt(days, "day", "d"));
185 }
186 if hours > 0 {
187 parts.push(fmt(hours, "hour", "h"));
188 }
189 if minutes > 0 || parts.is_empty() {
190 parts.push(fmt(minutes, "minute", "m"));
191 }
192 parts.join(" ")
193}
194
195fn natural_duration(total_minutes: i64) -> String {
196 if total_minutes == 0 {
197 return "0 minutes".into();
198 }
199
200 let hours = total_minutes / 60;
201 let minutes = total_minutes % 60;
202 let days = hours / 24;
203 let remaining_hours = hours % 24;
204
205 if days > 0 {
206 if remaining_hours == 0 && minutes == 0 {
207 return if days == 1 {
208 "about a day".into()
209 } else {
210 format!("about {days} days")
211 };
212 }
213 if remaining_hours >= 12 {
214 return format!("about {} days", days + 1);
215 }
216 return format!("about {days} and a half days");
217 }
218
219 if remaining_hours > 0 {
220 if minutes <= 15 {
221 return if remaining_hours == 1 {
222 "about an hour".into()
223 } else {
224 format!("about {remaining_hours} hours")
225 };
226 }
227 if minutes >= 45 {
228 let rounded = remaining_hours + 1;
229 return format!("about {rounded} hours");
230 }
231 return if remaining_hours == 1 {
232 "about an hour and a half".into()
233 } else {
234 format!("about {remaining_hours} and a half hours")
235 };
236 }
237
238 if minutes == 1 {
239 "about a minute".into()
240 } else if minutes < 5 {
241 "a few minutes".into()
242 } else if minutes < 15 {
243 format!("about {minutes} minutes")
244 } else if minutes < 18 {
245 "about 15 minutes".into()
246 } else if minutes < 23 {
247 "about 20 minutes".into()
248 } else if minutes < 35 {
249 "about half an hour".into()
250 } else if minutes < 50 {
251 "about 45 minutes".into()
252 } else {
253 "about an hour".into()
254 }
255}
256
257fn pluralize(count: i64, word: &str) -> String {
258 if count == 1 {
259 format!("{count} {word}")
260 } else {
261 format!("{count} {word}s")
262 }
263}
264
265fn text_component(value: i64, unit: &str, _suffix: &str) -> String {
266 pluralize(value, unit)
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_about_20_minutes() {
414 let fd = FormattedDuration::new(Duration::minutes(18), DurationFormat::Natural);
415
416 assert_eq!(fd.to_string(), "about 20 minutes");
417 }
418
419 #[test]
420 fn it_formats_natural_few_minutes() {
421 let fd = FormattedDuration::new(Duration::minutes(3), DurationFormat::Natural);
422
423 assert_eq!(fd.to_string(), "a few minutes");
424 }
425
426 #[test]
427 fn it_formats_natural_half_hour() {
428 let fd = FormattedDuration::new(Duration::minutes(30), DurationFormat::Natural);
429
430 assert_eq!(fd.to_string(), "about half an hour");
431 }
432
433 #[test]
434 fn it_formats_natural_hour_and_half() {
435 let fd = FormattedDuration::new(Duration::minutes(90), DurationFormat::Natural);
436
437 assert_eq!(fd.to_string(), "about an hour and a half");
438 }
439
440 #[test]
441 fn it_formats_text() {
442 let fd = FormattedDuration::new(Duration::seconds(5400), DurationFormat::Text);
443
444 assert_eq!(fd.to_string(), "1 hour 30 minutes");
445 }
446
447 #[test]
448 fn it_formats_text_plural() {
449 let fd = FormattedDuration::new(Duration::seconds(93600 + 1800), DurationFormat::Text);
450
451 assert_eq!(fd.to_string(), "1 day 2 hours 30 minutes");
452 }
453
454 #[test]
455 fn it_formats_text_singular() {
456 let fd = FormattedDuration::new(Duration::hours(1), DurationFormat::Text);
457
458 assert_eq!(fd.to_string(), "1 hour");
459 }
460
461 #[test]
462 fn it_formats_text_zero_duration() {
463 let fd = FormattedDuration::new(Duration::zero(), DurationFormat::Text);
464
465 assert_eq!(fd.to_string(), "0 minutes");
466 }
467 }
468
469 mod formatted_shortdate {
470 use chrono::TimeZone;
471 use pretty_assertions::assert_eq;
472
473 use super::*;
474
475 fn config() -> ShortdateFormatConfig {
476 ShortdateFormatConfig {
477 today: "%H:%M".into(),
478 this_week: "%a %H:%M".into(),
479 this_month: "%m/%d %H:%M".into(),
480 older: "%m/%d/%y %H:%M".into(),
481 }
482 }
483
484 #[test]
485 fn it_formats_older() {
486 let datetime = Local.with_ymd_and_hms(2020, 6, 15, 14, 30, 0).unwrap();
487
488 let result = FormattedShortdate::new(datetime, &config());
489
490 assert_eq!(result.to_string(), "06/15/20 14:30");
491 }
492
493 #[test]
494 fn it_formats_this_month() {
495 let old = Local::now() - Duration::days(20);
496 let datetime = Local
497 .with_ymd_and_hms(old.year(), old.month(), old.day(), 14, 30, 0)
498 .unwrap();
499
500 let result = FormattedShortdate::new(datetime, &config());
501
502 let expected = datetime.format("%m/%d %H:%M").to_string();
503 assert_eq!(result.to_string(), expected);
504 }
505
506 #[test]
507 fn it_formats_this_week() {
508 let yesterday = Local::now() - Duration::days(2);
509 let datetime = Local
510 .with_ymd_and_hms(yesterday.year(), yesterday.month(), yesterday.day(), 14, 30, 0)
511 .unwrap();
512
513 let result = FormattedShortdate::new(datetime, &config());
514
515 let expected = datetime.format("%a %H:%M").to_string();
516 assert_eq!(result.to_string(), expected);
517 }
518
519 #[test]
520 fn it_formats_cross_year_dates_as_older() {
521 let now = Local::now();
522 let last_year = now.year() - 1;
523 let datetime = Local.with_ymd_and_hms(last_year, 11, 15, 14, 30, 0).unwrap();
524
525 let result = FormattedShortdate::new(datetime, &config());
526
527 assert_eq!(result.to_string(), format!("11/15/{} 14:30", last_year % 100));
528 }
529
530 #[test]
531 fn it_formats_today() {
532 let now = Local::now();
533 let datetime = Local
534 .with_ymd_and_hms(now.year(), now.month(), now.day(), 14, 30, 0)
535 .unwrap();
536
537 let result = FormattedShortdate::new(datetime, &config());
538
539 assert_eq!(result.to_string(), "14:30");
540 }
541 }
542}