Skip to main content

doing_time/
format.rs

1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3use chrono::{DateTime, Datelike, Local};
4use serde::{Deserialize, Serialize};
5
6/// Duration display format modes.
7///
8/// Determines how a `chrono::Duration` is rendered as a human-readable string.
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum DurationFormat {
11  /// `01:02:30` — zero-padded `HH:MM:SS` clock format.
12  Clock,
13  /// `1d 2h 30m` — abbreviated with spaces.
14  Dhm,
15  /// `02:30` — hours and minutes clock format (days folded into hours).
16  Hm,
17  /// `90` — total minutes as a plain number.
18  M,
19  /// `about an hour and a half` — fuzzy natural language approximation.
20  Natural,
21  /// `1 hour 30 minutes` — exact natural language.
22  #[default]
23  Text,
24}
25
26impl DurationFormat {
27  /// Parse a format name from a config string value.
28  ///
29  /// Unrecognized values fall back to [`DurationFormat::Text`].
30  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/// A formatted duration that implements [`Display`].
43#[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  /// Create a new formatted duration from a `chrono::Duration` and format mode.
54  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/// A formatted short date that implements [`Display`].
124#[derive(Clone, Debug)]
125pub struct FormattedShortdate {
126  formatted: String,
127}
128
129impl FormattedShortdate {
130  /// Format a datetime using config-driven relative date buckets.
131  ///
132  /// Dates from today use the `today` format, dates within the last week use
133  /// `this_week`, dates within the same year use `this_month`, and older dates
134  /// use the `older` format.
135  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/// Date format strings for relative time display.
162#[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
182/// Format a tag total duration as `DD:HH:MM`.
183pub 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}