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      || (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/// Date format strings for relative time display.
164#[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
184/// Format a tag total duration as `DD:HH:MM`.
185pub 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}