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