about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/hooks/__tests__/useTimeAgo.test.ts161
-rw-r--r--src/lib/hooks/useTimeAgo.ts192
-rw-r--r--src/lib/strings/time.ts17
3 files changed, 286 insertions, 84 deletions
diff --git a/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts
index e74f9c62d..68eb6e43a 100644
--- a/src/lib/hooks/__tests__/useTimeAgo.test.ts
+++ b/src/lib/hooks/__tests__/useTimeAgo.test.ts
@@ -1,102 +1,213 @@
 import {describe, expect, it} from '@jest/globals'
-import {MessageDescriptor} from '@lingui/core'
 import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
 
 import {dateDiff} from '../useTimeAgo'
 
-const lingui: any = (obj: MessageDescriptor) => obj.message
-
 const base = new Date('2024-06-17T00:00:00Z')
 
 describe('dateDiff', () => {
   it(`works with numbers`, () => {
-    expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
+    const earlier = subDays(base, 3)
+    expect(dateDiff(earlier, Number(base))).toEqual({
+      value: 3,
+      unit: 'day',
+      earlier,
+      later: base,
+    })
   })
   it(`works with strings`, () => {
-    expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
+    const earlier = subDays(base, 3)
+    expect(dateDiff(earlier, base.toString())).toEqual({
+      value: 3,
+      unit: 'day',
+      earlier,
+      later: base,
+    })
   })
   it(`works with dates`, () => {
-    expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
+    const earlier = subDays(base, 3)
+    expect(dateDiff(earlier, base)).toEqual({
+      value: 3,
+      unit: 'day',
+      earlier,
+      later: base,
+    })
   })
 
   it(`equal values return now`, () => {
-    expect(dateDiff(base, base, {lingui})).toEqual('now')
+    expect(dateDiff(base, base)).toEqual({
+      value: 0,
+      unit: 'now',
+      earlier: base,
+      later: base,
+    })
   })
   it(`future dates return now`, () => {
-    expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
+    const earlier = addDays(base, 3)
+    expect(dateDiff(earlier, base)).toEqual({
+      value: 0,
+      unit: 'now',
+      earlier,
+      later: base,
+    })
   })
 
   it(`values < 5 seconds ago return now`, () => {
     const then = subSeconds(base, 4)
-    expect(dateDiff(then, base, {lingui})).toEqual('now')
+    expect(dateDiff(then, base)).toEqual({
+      value: 0,
+      unit: 'now',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 5 seconds ago return seconds`, () => {
     const then = subSeconds(base, 5)
-    expect(dateDiff(then, base, {lingui})).toEqual('5s')
+    expect(dateDiff(then, base)).toEqual({
+      value: 5,
+      unit: 'second',
+      earlier: then,
+      later: base,
+    })
   })
 
   it(`values < 1 min return seconds`, () => {
     const then = subSeconds(base, 59)
-    expect(dateDiff(then, base, {lingui})).toEqual('59s')
+    expect(dateDiff(then, base)).toEqual({
+      value: 59,
+      unit: 'second',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 1 min return minutes`, () => {
     const then = subSeconds(base, 60)
-    expect(dateDiff(then, base, {lingui})).toEqual('1m')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'minute',
+      earlier: then,
+      later: base,
+    })
   })
   it(`minutes round down`, () => {
     const then = subSeconds(base, 119)
-    expect(dateDiff(then, base, {lingui})).toEqual('1m')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'minute',
+      earlier: then,
+      later: base,
+    })
   })
 
   it(`values < 1 hour return minutes`, () => {
     const then = subMinutes(base, 59)
-    expect(dateDiff(then, base, {lingui})).toEqual('59m')
+    expect(dateDiff(then, base)).toEqual({
+      value: 59,
+      unit: 'minute',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 1 hour return hours`, () => {
     const then = subMinutes(base, 60)
-    expect(dateDiff(then, base, {lingui})).toEqual('1h')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'hour',
+      earlier: then,
+      later: base,
+    })
   })
   it(`hours round down`, () => {
     const then = subMinutes(base, 119)
-    expect(dateDiff(then, base, {lingui})).toEqual('1h')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'hour',
+      earlier: then,
+      later: base,
+    })
   })
 
   it(`values < 1 day return hours`, () => {
     const then = subHours(base, 23)
-    expect(dateDiff(then, base, {lingui})).toEqual('23h')
+    expect(dateDiff(then, base)).toEqual({
+      value: 23,
+      unit: 'hour',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 1 day return days`, () => {
     const then = subHours(base, 24)
-    expect(dateDiff(then, base, {lingui})).toEqual('1d')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'day',
+      earlier: then,
+      later: base,
+    })
   })
   it(`days round down`, () => {
     const then = subHours(base, 47)
-    expect(dateDiff(then, base, {lingui})).toEqual('1d')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'day',
+      earlier: then,
+      later: base,
+    })
   })
 
   it(`values < 30 days return days`, () => {
     const then = subDays(base, 29)
-    expect(dateDiff(then, base, {lingui})).toEqual('29d')
+    expect(dateDiff(then, base)).toEqual({
+      value: 29,
+      unit: 'day',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 30 days return months`, () => {
     const then = subDays(base, 30)
-    expect(dateDiff(then, base, {lingui})).toEqual('1mo')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'month',
+      earlier: then,
+      later: base,
+    })
   })
   it(`months round down`, () => {
     const then = subDays(base, 59)
-    expect(dateDiff(then, base, {lingui})).toEqual('1mo')
+    expect(dateDiff(then, base)).toEqual({
+      value: 1,
+      unit: 'month',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values are rounded by increments of 30`, () => {
     const then = subDays(base, 61)
-    expect(dateDiff(then, base, {lingui})).toEqual('2mo')
+    expect(dateDiff(then, base)).toEqual({
+      value: 2,
+      unit: 'month',
+      earlier: then,
+      later: base,
+    })
   })
 
   it(`values < 360 days return months`, () => {
     const then = subDays(base, 359)
-    expect(dateDiff(then, base, {lingui})).toEqual('11mo')
+    expect(dateDiff(then, base)).toEqual({
+      value: 11,
+      unit: 'month',
+      earlier: then,
+      later: base,
+    })
   })
   it(`values >= 360 days return the earlier value`, () => {
     const then = subDays(base, 360)
-    expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
+    expect(dateDiff(then, base)).toEqual({
+      value: 12,
+      unit: 'month',
+      earlier: then,
+      later: base,
+    })
   })
 })
diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts
index efcb4754b..3a8bf49bc 100644
--- a/src/lib/hooks/useTimeAgo.ts
+++ b/src/lib/hooks/useTimeAgo.ts
@@ -1,86 +1,178 @@
 import {useCallback} from 'react'
-import {msg, plural} from '@lingui/macro'
-import {I18nContext, useLingui} from '@lingui/react'
+import {I18n} from '@lingui/core'
+import {defineMessage, msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {differenceInSeconds} from 'date-fns'
 
-export type TimeAgoOptions = {
-  lingui: I18nContext['_']
-  format?: 'long' | 'short'
+export type DateDiffFormat = 'long' | 'short'
+
+type DateDiff = {
+  value: number
+  unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
+  earlier: Date
+  later: Date
 }
 
+const NOW = 5
+const MINUTE = 60
+const HOUR = MINUTE * 60
+const DAY = HOUR * 24
+const MONTH_30 = DAY * 30
+
 export function useGetTimeAgo() {
-  const {_} = useLingui()
+  const {i18n} = useLingui()
   return useCallback(
     (
       earlier: number | string | Date,
       later: number | string | Date,
-      options?: Omit<TimeAgoOptions, 'lingui'>,
+      options?: {format: DateDiffFormat},
     ) => {
-      return dateDiff(earlier, later, {lingui: _, format: options?.format})
+      const diff = dateDiff(earlier, later)
+      return formatDateDiff({diff, i18n, format: options?.format})
     },
-    [_],
+    [i18n],
   )
 }
 
-const NOW = 5
-const MINUTE = 60
-const HOUR = MINUTE * 60
-const DAY = HOUR * 24
-const MONTH_30 = DAY * 30
-
 /**
- * Returns the difference between `earlier` and `later` dates, formatted as a
- * natural language string.
+ * Returns the difference between `earlier` and `later` dates, based on
+ * opinionated rules.
  *
  * - All month are considered exactly 30 days.
  * - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
- * - Differences >= 360 days are returned as the "M/D/YYYY" string
  * - All values round down
  */
 export function dateDiff(
   earlier: number | string | Date,
   later: number | string | Date,
-  options: TimeAgoOptions,
-): string {
-  const _ = options.lingui
-  const format = options?.format || 'short'
-  const long = format === 'long'
-  const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
+): DateDiff {
+  let diff = {
+    value: 0,
+    unit: 'now' as DateDiff['unit'],
+  }
+  const e = new Date(earlier)
+  const l = new Date(later)
+  const diffSeconds = differenceInSeconds(l, e)
 
   if (diffSeconds < NOW) {
-    return _(msg`now`)
+    diff = {
+      value: 0,
+      unit: 'now' as DateDiff['unit'],
+    }
   } else if (diffSeconds < MINUTE) {
-    return `${diffSeconds}${
-      long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
-    }`
+    diff = {
+      value: diffSeconds,
+      unit: 'second' as DateDiff['unit'],
+    }
   } else if (diffSeconds < HOUR) {
-    const diff = Math.floor(diffSeconds / MINUTE)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
-    }`
+    const value = Math.floor(diffSeconds / MINUTE)
+    diff = {
+      value,
+      unit: 'minute' as DateDiff['unit'],
+    }
   } else if (diffSeconds < DAY) {
-    const diff = Math.floor(diffSeconds / HOUR)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
-    }`
+    const value = Math.floor(diffSeconds / HOUR)
+    diff = {
+      value,
+      unit: 'hour' as DateDiff['unit'],
+    }
   } else if (diffSeconds < MONTH_30) {
-    const diff = Math.floor(diffSeconds / DAY)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
-    }`
+    const value = Math.floor(diffSeconds / DAY)
+    diff = {
+      value,
+      unit: 'day' as DateDiff['unit'],
+    }
   } else {
-    const diff = Math.floor(diffSeconds / MONTH_30)
-    if (diff < 12) {
-      return `${diff}${
-        long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
-      }`
-    } else {
-      const str = new Date(earlier).toLocaleDateString()
+    const value = Math.floor(diffSeconds / MONTH_30)
+    diff = {
+      value,
+      unit: 'month' as DateDiff['unit'],
+    }
+  }
+
+  return {
+    ...diff,
+    earlier: e,
+    later: l,
+  }
+}
+
+/**
+ * Accepts a `DateDiff` and teturns the difference between `earlier` and
+ * `later` dates, formatted as a natural language string.
+ *
+ * - All month are considered exactly 30 days.
+ * - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
+ * - Differences >= 360 days are returned as the "M/D/YYYY" string
+ * - All values round down
+ */
+export function formatDateDiff({
+  diff,
+  format = 'short',
+  i18n,
+}: {
+  diff: DateDiff
+  format?: DateDiffFormat
+  i18n: I18n
+}): string {
+  const long = format === 'long'
 
-      if (long) {
-        return _(msg`on ${str}`)
+  switch (diff.unit) {
+    case 'now': {
+      return i18n._(msg`now`)
+    }
+    case 'second': {
+      return long
+        ? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
+        : i18n._(
+            defineMessage({
+              message: `${diff.value}s`,
+              comment: `How many seconds have passed, displayed in a narrow form`,
+            }),
+          )
+    }
+    case 'minute': {
+      return long
+        ? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
+        : i18n._(
+            defineMessage({
+              message: `${diff.value}m`,
+              comment: `How many minutes have passed, displayed in a narrow form`,
+            }),
+          )
+    }
+    case 'hour': {
+      return long
+        ? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
+        : i18n._(
+            defineMessage({
+              message: `${diff.value}h`,
+              comment: `How many hours have passed, displayed in a narrow form`,
+            }),
+          )
+    }
+    case 'day': {
+      return long
+        ? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
+        : i18n._(
+            defineMessage({
+              message: `${diff.value}d`,
+              comment: `How many days have passed, displayed in a narrow form`,
+            }),
+          )
+    }
+    case 'month': {
+      if (diff.value < 12) {
+        return long
+          ? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
+          : i18n._(
+              defineMessage({
+                message: `${diff.value}mo`,
+                comment: `How many months have passed, displayed in a narrow form`,
+              }),
+            )
       }
-      return str
+      return i18n.date(new Date(diff.earlier))
     }
   }
 }
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index bfefea9bc..e505b7892 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -1,13 +1,12 @@
-export function niceDate(date: number | string | Date) {
+import {I18n} from '@lingui/core'
+
+export function niceDate(i18n: I18n, date: number | string | Date) {
   const d = new Date(date)
-  return `${d.toLocaleDateString('en-us', {
-    year: 'numeric',
-    month: 'short',
-    day: 'numeric',
-  })} at ${d.toLocaleTimeString(undefined, {
-    hour: 'numeric',
-    minute: '2-digit',
-  })}`
+
+  return i18n.date(d, {
+    dateStyle: 'long',
+    timeStyle: 'short',
+  })
 }
 
 export function getAge(birthDate: Date): number {