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  /// Total duration expressed as whole minutes.
74  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/// A formatted short date that implements [`Display`].
107#[derive(Clone, Debug)]
108pub struct FormattedShortdate {
109  formatted: String,
110}
111
112impl FormattedShortdate {
113  /// Format a datetime using config-driven relative date buckets.
114  ///
115  /// Dates from today use the `today` format, dates within the last week use
116  /// `this_week`, dates within the same year use `this_month`, and older dates
117  /// use the `older` format.
118  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/// Date format strings for relative time display.
145#[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
165/// Format a tag total duration as `DD:HH:MM`.
166pub 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}