format_distance.rs

  1use chrono::{DateTime, Local, NaiveDateTime};
  2
  3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  4pub enum DateTimeType {
  5    Naive(NaiveDateTime),
  6    Local(DateTime<Local>),
  7}
  8
  9impl DateTimeType {
 10    /// Converts the DateTimeType to a NaiveDateTime.
 11    ///
 12    /// If the DateTimeType is already a NaiveDateTime, it will be returned as is.
 13    /// If the DateTimeType is a DateTime<Local>, it will be converted to a NaiveDateTime.
 14    pub fn to_naive(&self) -> NaiveDateTime {
 15        match self {
 16            DateTimeType::Naive(naive) => *naive,
 17            DateTimeType::Local(local) => local.naive_local(),
 18        }
 19    }
 20}
 21
 22pub struct FormatDistance {
 23    date: DateTimeType,
 24    base_date: DateTimeType,
 25    include_seconds: bool,
 26    add_suffix: bool,
 27    hide_prefix: bool,
 28}
 29
 30impl FormatDistance {
 31    pub fn new(date: DateTimeType, base_date: DateTimeType) -> Self {
 32        Self {
 33            date,
 34            base_date,
 35            include_seconds: false,
 36            add_suffix: false,
 37            hide_prefix: false,
 38        }
 39    }
 40
 41    pub fn from_now(date: DateTimeType) -> Self {
 42        Self::new(date, DateTimeType::Local(Local::now()))
 43    }
 44
 45    pub fn include_seconds(mut self, include_seconds: bool) -> Self {
 46        self.include_seconds = include_seconds;
 47        self
 48    }
 49
 50    pub fn add_suffix(mut self, add_suffix: bool) -> Self {
 51        self.add_suffix = add_suffix;
 52        self
 53    }
 54
 55    pub fn hide_prefix(mut self, hide_prefix: bool) -> Self {
 56        self.hide_prefix = hide_prefix;
 57        self
 58    }
 59}
 60
 61/// Calculates the distance in seconds between two NaiveDateTime objects.
 62/// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative.
 63///
 64/// ## Arguments
 65///
 66/// * `date` - A NaiveDateTime object representing the date of interest
 67/// * `base_date` - A NaiveDateTime object representing the base date against which the comparison is made
 68fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
 69    let duration = date.signed_duration_since(base_date);
 70    -duration.num_seconds()
 71}
 72
 73/// Generates a string describing the time distance between two dates in a human-readable way.
 74fn distance_string(
 75    distance: i64,
 76    include_seconds: bool,
 77    add_suffix: bool,
 78    hide_prefix: bool,
 79) -> String {
 80    let suffix = if distance < 0 { " from now" } else { " ago" };
 81
 82    let distance = distance.abs();
 83
 84    let minutes = distance / 60;
 85    let hours = distance / 3_600;
 86    let days = distance / 86_400;
 87    let months = distance / 2_592_000;
 88
 89    let string = if distance < 5 && include_seconds {
 90        if hide_prefix {
 91            "5 seconds"
 92        } else {
 93            "less than 5 seconds"
 94        }
 95        .to_string()
 96    } else if distance < 10 && include_seconds {
 97        if hide_prefix {
 98            "10 seconds"
 99        } else {
100            "less than 10 seconds"
101        }
102        .to_string()
103    } else if distance < 20 && include_seconds {
104        if hide_prefix {
105            "20 seconds"
106        } else {
107            "less than 20 seconds"
108        }
109        .to_string()
110    } else if distance < 40 && include_seconds {
111        if hide_prefix {
112            "half a minute"
113        } else {
114            "half a minute"
115        }
116        .to_string()
117    } else if distance < 60 && include_seconds {
118        if hide_prefix {
119            "a minute"
120        } else {
121            "less than a minute"
122        }
123        .to_string()
124    } else if distance < 90 && include_seconds {
125        "1 minute".to_string()
126    } else if distance < 30 {
127        if hide_prefix {
128            "a minute"
129        } else {
130            "less than a minute"
131        }
132        .to_string()
133    } else if distance < 90 {
134        "1 minute".to_string()
135    } else if distance < 2_700 {
136        format!("{} minutes", minutes)
137    } else if distance < 5_400 {
138        if hide_prefix {
139            "1 hour"
140        } else {
141            "about 1 hour"
142        }
143        .to_string()
144    } else if distance < 86_400 {
145        if hide_prefix {
146            format!("{} hours", hours)
147        } else {
148            format!("about {} hours", hours)
149        }
150        .to_string()
151    } else if distance < 172_800 {
152        "1 day".to_string()
153    } else if distance < 2_592_000 {
154        format!("{} days", days)
155    } else if distance < 5_184_000 {
156        if hide_prefix {
157            "1 month"
158        } else {
159            "about 1 month"
160        }
161        .to_string()
162    } else if distance < 7_776_000 {
163        if hide_prefix {
164            "2 months"
165        } else {
166            "about 2 months"
167        }
168        .to_string()
169    } else if distance < 31_540_000 {
170        format!("{} months", months)
171    } else if distance < 39_425_000 {
172        if hide_prefix {
173            "1 year"
174        } else {
175            "about 1 year"
176        }
177        .to_string()
178    } else if distance < 55_195_000 {
179        if hide_prefix { "1 year" } else { "over 1 year" }.to_string()
180    } else if distance < 63_080_000 {
181        if hide_prefix {
182            "2 years"
183        } else {
184            "almost 2 years"
185        }
186        .to_string()
187    } else {
188        let years = distance / 31_536_000;
189        let remaining_months = (distance % 31_536_000) / 2_592_000;
190
191        if remaining_months < 3 {
192            if hide_prefix {
193                format!("{} years", years)
194            } else {
195                format!("about {} years", years)
196            }
197            .to_string()
198        } else if remaining_months < 9 {
199            if hide_prefix {
200                format!("{} years", years)
201            } else {
202                format!("over {} years", years)
203            }
204            .to_string()
205        } else {
206            if hide_prefix {
207                format!("{} years", years + 1)
208            } else {
209                format!("almost {} years", years + 1)
210            }
211            .to_string()
212        }
213    };
214
215    if add_suffix {
216        format!("{}{}", string, suffix)
217    } else {
218        string
219    }
220}
221
222/// Get the time difference between two dates into a relative human readable string.
223///
224/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
225///
226/// Use [naive_format_distance_from_now] to compare a NaiveDateTime against now.
227///
228/// # Arguments
229///
230/// * `date` - The NaiveDateTime to compare.
231/// * `base_date` - The NaiveDateTime to compare against.
232/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed
233/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future
234///
235/// # Example
236///
237/// ```rust
238/// use chrono::DateTime;
239/// use ui2::utils::naive_format_distance;
240///
241/// fn time_between_moon_landings() -> String {
242///     let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local();
243///     let base_date = DateTime::parse_from_rfc3339("1972-12-14T00:00:00Z").unwrap().naive_local();
244///     format!("There was {} between the first and last crewed moon landings.", naive_format_distance(date, base_date, false, false))
245/// }
246/// ```
247///
248/// Output: `"There was about 3 years between the first and last crewed moon landings."`
249pub fn format_distance(
250    date: DateTimeType,
251    base_date: NaiveDateTime,
252    include_seconds: bool,
253    add_suffix: bool,
254    hide_prefix: bool,
255) -> String {
256    let distance = distance_in_seconds(date.to_naive(), base_date);
257
258    distance_string(distance, include_seconds, add_suffix, hide_prefix)
259}
260
261/// Get the time difference between a date and now as relative human readable string.
262///
263/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
264///
265/// # Arguments
266///
267/// * `datetime` - The NaiveDateTime to compare with the current time.
268/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed
269/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future
270///
271/// # Example
272///
273/// ```rust
274/// use chrono::DateTime;
275/// use ui2::utils::naive_format_distance_from_now;
276///
277/// fn time_since_first_moon_landing() -> String {
278///     let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local();
279///     format!("It's been {} since Apollo 11 first landed on the moon.", naive_format_distance_from_now(date, false, false))
280/// }
281/// ```
282///
283/// Output: `It's been over 54 years since Apollo 11 first landed on the moon.`
284pub fn format_distance_from_now(
285    datetime: DateTimeType,
286    include_seconds: bool,
287    add_suffix: bool,
288    hide_prefix: bool,
289) -> String {
290    let now = chrono::offset::Local::now().naive_local();
291
292    format_distance(datetime, now, include_seconds, add_suffix, hide_prefix)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use chrono::NaiveDateTime;
299
300    #[test]
301    fn test_format_distance() {
302        let date = DateTimeType::Naive(
303            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
304        );
305        let base_date = DateTimeType::Naive(
306            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
307        );
308
309        assert_eq!(
310            "about 2 hours",
311            format_distance(date, base_date.to_naive(), false, false, false)
312        );
313    }
314
315    #[test]
316    fn test_format_distance_with_suffix() {
317        let date = DateTimeType::Naive(
318            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
319        );
320        let base_date = DateTimeType::Naive(
321            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
322        );
323
324        assert_eq!(
325            "about 2 hours from now",
326            format_distance(date, base_date.to_naive(), false, true, false)
327        );
328    }
329
330    #[test]
331    fn test_format_distance_from_now() {
332        let date = DateTimeType::Naive(
333            NaiveDateTime::parse_from_str("1969-07-20T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
334                .expect("Invalid NaiveDateTime for date"),
335        );
336
337        assert_eq!(
338            "over 54 years ago",
339            format_distance_from_now(date, false, true, false)
340        );
341    }
342
343    #[test]
344    fn test_format_distance_string() {
345        assert_eq!(
346            distance_string(3, false, false, false),
347            "less than a minute"
348        );
349        assert_eq!(
350            distance_string(7, false, false, false),
351            "less than a minute"
352        );
353        assert_eq!(
354            distance_string(13, false, false, false),
355            "less than a minute"
356        );
357        assert_eq!(
358            distance_string(21, false, false, false),
359            "less than a minute"
360        );
361        assert_eq!(distance_string(45, false, false, false), "1 minute");
362        assert_eq!(distance_string(61, false, false, false), "1 minute");
363        assert_eq!(distance_string(1920, false, false, false), "32 minutes");
364        assert_eq!(distance_string(3902, false, false, false), "about 1 hour");
365        assert_eq!(distance_string(18002, false, false, false), "about 5 hours");
366        assert_eq!(distance_string(86470, false, false, false), "1 day");
367        assert_eq!(distance_string(345880, false, false, false), "4 days");
368        assert_eq!(
369            distance_string(2764800, false, false, false),
370            "about 1 month"
371        );
372        assert_eq!(
373            distance_string(5184000, false, false, false),
374            "about 2 months"
375        );
376        assert_eq!(distance_string(10368000, false, false, false), "4 months");
377        assert_eq!(
378            distance_string(34694000, false, false, false),
379            "about 1 year"
380        );
381        assert_eq!(
382            distance_string(47310000, false, false, false),
383            "over 1 year"
384        );
385        assert_eq!(
386            distance_string(61503000, false, false, false),
387            "almost 2 years"
388        );
389        assert_eq!(
390            distance_string(160854000, false, false, false),
391            "about 5 years"
392        );
393        assert_eq!(
394            distance_string(236550000, false, false, false),
395            "over 7 years"
396        );
397        assert_eq!(
398            distance_string(249166000, false, false, false),
399            "almost 8 years"
400        );
401    }
402
403    #[test]
404    fn test_format_distance_string_include_seconds() {
405        assert_eq!(
406            distance_string(3, true, false, false),
407            "less than 5 seconds"
408        );
409        assert_eq!(
410            distance_string(7, true, false, false),
411            "less than 10 seconds"
412        );
413        assert_eq!(
414            distance_string(13, true, false, false),
415            "less than 20 seconds"
416        );
417        assert_eq!(distance_string(21, true, false, false), "half a minute");
418        assert_eq!(
419            distance_string(45, true, false, false),
420            "less than a minute"
421        );
422        assert_eq!(distance_string(61, true, false, false), "1 minute");
423    }
424}