Add `useGetTimeAgo` and utils (#4556)
* Create a testable version of ago() and re-enable the disabled test (#4364) * Enable the test of ago() * Use test cases This puts the input and the expected values next to each other. * Create dateDiff function This is a copy of ago(), but with the ability to specify the second date instead of using Date.now(). * Let ago() use dateDiff() * Move constants close to usage * Test dateDiff instead of ago This makes it possible to test the dates without being forced to rely on what the current date is. The commented out tests do not yet pass. This is fixed in later commits. * Update dateDiff and enable the remaining tests * Split up tests, use date-fns as helpers * Remove old test * Add long format * Add hook * Migrate to hooks * Delete old code * Or equal to * Update comment --------- Co-authored-by: Jan Aagaard <jan@aagaard.net>zio/stable
parent
08cfb09589
commit
443beda741
|
@ -6,7 +6,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||||
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||||
import {ago} from '../../src/lib/strings/time'
|
|
||||||
import {
|
import {
|
||||||
makeRecordUri,
|
makeRecordUri,
|
||||||
toNiceDomain,
|
toNiceDomain,
|
||||||
|
@ -142,79 +141,6 @@ describe('makeRecordUri', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// FIXME: Reenable after fixing non-deterministic test.
|
|
||||||
describe.skip('ago', () => {
|
|
||||||
const oneYearDate = new Date(
|
|
||||||
new Date().setMonth(new Date().getMonth() - 11),
|
|
||||||
).setDate(new Date().getDate() - 28)
|
|
||||||
|
|
||||||
const inputs = [
|
|
||||||
1671461038,
|
|
||||||
'04 Dec 1995 00:12:00 GMT',
|
|
||||||
new Date(),
|
|
||||||
new Date().setSeconds(new Date().getSeconds() - 10),
|
|
||||||
new Date().setMinutes(new Date().getMinutes() - 10),
|
|
||||||
new Date().setHours(new Date().getHours() - 1),
|
|
||||||
new Date().setDate(new Date().getDate() - 1),
|
|
||||||
new Date().setDate(new Date().getDate() - 20),
|
|
||||||
new Date().setDate(new Date().getDate() - 25),
|
|
||||||
new Date().setDate(new Date().getDate() - 28),
|
|
||||||
new Date().setDate(new Date().getDate() - 29),
|
|
||||||
new Date().setDate(new Date().getDate() - 30),
|
|
||||||
new Date().setMonth(new Date().getMonth() - 1),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
|
||||||
new Date().getDate() - 20,
|
|
||||||
),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
|
||||||
new Date().getDate() - 25,
|
|
||||||
),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
|
||||||
new Date().getDate() - 28,
|
|
||||||
),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
|
||||||
new Date().getDate() - 29,
|
|
||||||
),
|
|
||||||
new Date().setMonth(new Date().getMonth() - 11),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
|
||||||
new Date().getDate() - 20,
|
|
||||||
),
|
|
||||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
|
||||||
new Date().getDate() - 25,
|
|
||||||
),
|
|
||||||
oneYearDate,
|
|
||||||
]
|
|
||||||
const outputs = [
|
|
||||||
new Date(1671461038).toLocaleDateString(),
|
|
||||||
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
|
|
||||||
'now',
|
|
||||||
'10s',
|
|
||||||
'10m',
|
|
||||||
'1h',
|
|
||||||
'1d',
|
|
||||||
'20d',
|
|
||||||
'25d',
|
|
||||||
'28d',
|
|
||||||
'29d',
|
|
||||||
'1mo',
|
|
||||||
'1mo',
|
|
||||||
'1mo',
|
|
||||||
'1mo',
|
|
||||||
'2mo',
|
|
||||||
'2mo',
|
|
||||||
'11mo',
|
|
||||||
'11mo',
|
|
||||||
'11mo',
|
|
||||||
new Date(oneYearDate).toLocaleDateString(),
|
|
||||||
]
|
|
||||||
|
|
||||||
it('correctly calculates how much time passed, in a string', () => {
|
|
||||||
for (let i = 0; i < inputs.length; i++) {
|
|
||||||
const result = ago(inputs[i])
|
|
||||||
expect(result).toEqual(outputs[i])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('makeValidHandle', () => {
|
describe('makeValidHandle', () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'test-handle-123',
|
'test-handle-123',
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
it(`works with strings`, () => {
|
||||||
|
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
|
||||||
|
})
|
||||||
|
it(`works with dates`, () => {
|
||||||
|
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`equal values return now`, () => {
|
||||||
|
expect(dateDiff(base, base, {lingui})).toEqual('now')
|
||||||
|
})
|
||||||
|
it(`future dates return now`, () => {
|
||||||
|
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 5 seconds ago return now`, () => {
|
||||||
|
const then = subSeconds(base, 4)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('now')
|
||||||
|
})
|
||||||
|
it(`values >= 5 seconds ago return seconds`, () => {
|
||||||
|
const then = subSeconds(base, 5)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('5s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 1 min return seconds`, () => {
|
||||||
|
const then = subSeconds(base, 59)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('59s')
|
||||||
|
})
|
||||||
|
it(`values >= 1 min return minutes`, () => {
|
||||||
|
const then = subSeconds(base, 60)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||||
|
})
|
||||||
|
it(`minutes round down`, () => {
|
||||||
|
const then = subSeconds(base, 119)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 1 hour return minutes`, () => {
|
||||||
|
const then = subMinutes(base, 59)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('59m')
|
||||||
|
})
|
||||||
|
it(`values >= 1 hour return hours`, () => {
|
||||||
|
const then = subMinutes(base, 60)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||||
|
})
|
||||||
|
it(`hours round down`, () => {
|
||||||
|
const then = subMinutes(base, 119)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 1 day return hours`, () => {
|
||||||
|
const then = subHours(base, 23)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('23h')
|
||||||
|
})
|
||||||
|
it(`values >= 1 day return days`, () => {
|
||||||
|
const then = subHours(base, 24)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||||
|
})
|
||||||
|
it(`days round down`, () => {
|
||||||
|
const then = subHours(base, 47)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 30 days return days`, () => {
|
||||||
|
const then = subDays(base, 29)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('29d')
|
||||||
|
})
|
||||||
|
it(`values >= 30 days return months`, () => {
|
||||||
|
const then = subDays(base, 30)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||||
|
})
|
||||||
|
it(`months round down`, () => {
|
||||||
|
const then = subDays(base, 59)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||||
|
})
|
||||||
|
it(`values are rounded by increments of 30`, () => {
|
||||||
|
const then = subDays(base, 61)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`values < 360 days return months`, () => {
|
||||||
|
const then = subDays(base, 359)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
|
||||||
|
})
|
||||||
|
it(`values >= 360 days return the earlier value`, () => {
|
||||||
|
const then = subDays(base, 360)
|
||||||
|
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,95 @@
|
||||||
|
import {useCallback, useMemo} from 'react'
|
||||||
|
import {msg, plural} from '@lingui/macro'
|
||||||
|
import {I18nContext, useLingui} from '@lingui/react'
|
||||||
|
import {differenceInSeconds} from 'date-fns'
|
||||||
|
|
||||||
|
export type TimeAgoOptions = {
|
||||||
|
lingui: I18nContext['_']
|
||||||
|
format?: 'long' | 'short'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetTimeAgo() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
return useCallback(
|
||||||
|
(
|
||||||
|
date: number | string | Date,
|
||||||
|
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||||
|
) => {
|
||||||
|
return dateDiff(date, Date.now(), {lingui: _, format: options?.format})
|
||||||
|
},
|
||||||
|
[_],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimeAgo(
|
||||||
|
date: number | string | Date,
|
||||||
|
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||||
|
): string {
|
||||||
|
const timeAgo = useGetTimeAgo()
|
||||||
|
return useMemo(() => {
|
||||||
|
return timeAgo(date, {...options})
|
||||||
|
}, [date, options, timeAgo])
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* - 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))
|
||||||
|
|
||||||
|
if (diffSeconds < NOW) {
|
||||||
|
return _(msg`now`)
|
||||||
|
} else if (diffSeconds < MINUTE) {
|
||||||
|
return `${diffSeconds}${
|
||||||
|
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
|
||||||
|
}`
|
||||||
|
} else if (diffSeconds < HOUR) {
|
||||||
|
const diff = Math.floor(diffSeconds / MINUTE)
|
||||||
|
return `${diff}${
|
||||||
|
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
|
||||||
|
}`
|
||||||
|
} else if (diffSeconds < DAY) {
|
||||||
|
const diff = Math.floor(diffSeconds / HOUR)
|
||||||
|
return `${diff}${
|
||||||
|
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
|
||||||
|
}`
|
||||||
|
} else if (diffSeconds < MONTH_30) {
|
||||||
|
const diff = Math.floor(diffSeconds / DAY)
|
||||||
|
return `${diff}${
|
||||||
|
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
|
||||||
|
}`
|
||||||
|
} 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()
|
||||||
|
|
||||||
|
if (long) {
|
||||||
|
return _(msg`on ${str}`)
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,45 +1,3 @@
|
||||||
const NOW = 5
|
|
||||||
const MINUTE = 60
|
|
||||||
const HOUR = MINUTE * 60
|
|
||||||
const DAY = HOUR * 24
|
|
||||||
const MONTH_30 = DAY * 30
|
|
||||||
const MONTH = DAY * 30.41675 // This results in 365.001 days in a year, which is close enough for nearly all cases
|
|
||||||
export function ago(date: number | string | Date): string {
|
|
||||||
let ts: number
|
|
||||||
if (typeof date === 'string') {
|
|
||||||
ts = Number(new Date(date))
|
|
||||||
} else if (date instanceof Date) {
|
|
||||||
ts = Number(date)
|
|
||||||
} else {
|
|
||||||
ts = date
|
|
||||||
}
|
|
||||||
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
|
|
||||||
if (diffSeconds < NOW) {
|
|
||||||
return `now`
|
|
||||||
} else if (diffSeconds < MINUTE) {
|
|
||||||
return `${diffSeconds}s`
|
|
||||||
} else if (diffSeconds < HOUR) {
|
|
||||||
return `${Math.floor(diffSeconds / MINUTE)}m`
|
|
||||||
} else if (diffSeconds < DAY) {
|
|
||||||
return `${Math.floor(diffSeconds / HOUR)}h`
|
|
||||||
} else if (diffSeconds < MONTH_30) {
|
|
||||||
return `${Math.round(diffSeconds / DAY)}d`
|
|
||||||
} else {
|
|
||||||
let months = diffSeconds / MONTH
|
|
||||||
if (months % 1 >= 0.9) {
|
|
||||||
months = Math.ceil(months)
|
|
||||||
} else {
|
|
||||||
months = Math.floor(months)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (months < 12) {
|
|
||||||
return `${months}mo`
|
|
||||||
} else {
|
|
||||||
return new Date(ts).toLocaleDateString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function niceDate(date: number | string | Date) {
|
export function niceDate(date: number | string | Date) {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
return `${d.toLocaleDateString('en-us', {
|
return `${d.toLocaleDateString('en-us', {
|
||||||
|
|
|
@ -1,26 +1,26 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {useTickEveryMinute} from '#/state/shell'
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
import {ago} from 'lib/strings/time'
|
|
||||||
|
|
||||||
export function TimeElapsed({
|
export function TimeElapsed({
|
||||||
timestamp,
|
timestamp,
|
||||||
children,
|
children,
|
||||||
timeToString = ago,
|
timeToString,
|
||||||
}: {
|
}: {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
|
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
|
||||||
timeToString?: (timeElapsed: string) => string
|
timeToString?: (timeElapsed: string) => string
|
||||||
}) {
|
}) {
|
||||||
|
const ago = useGetTimeAgo()
|
||||||
|
const format = timeToString ?? ago
|
||||||
const tick = useTickEveryMinute()
|
const tick = useTickEveryMinute()
|
||||||
const [timeElapsed, setTimeAgo] = React.useState(() =>
|
const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp))
|
||||||
timeToString(timestamp),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [prevTick, setPrevTick] = React.useState(tick)
|
const [prevTick, setPrevTick] = React.useState(tick)
|
||||||
if (prevTick !== tick) {
|
if (prevTick !== tick) {
|
||||||
setPrevTick(tick)
|
setPrevTick(tick)
|
||||||
setTimeAgo(timeToString(timestamp))
|
setTimeAgo(format(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
return children({timeElapsed})
|
return children({timeElapsed})
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ScrollView} from '../com/util/Views'
|
|
||||||
import {s} from 'lib/styles'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {Text} from '../com/util/text/Text'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {getEntries} from '#/logger/logDump'
|
|
||||||
import {ago} from 'lib/strings/time'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
|
import {getEntries} from '#/logger/logDump'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {Text} from '../com/util/text/Text'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
import {ScrollView} from '../com/util/Views'
|
||||||
|
|
||||||
export function LogScreen({}: NativeStackScreenProps<
|
export function LogScreen({}: NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
|
@ -22,6 +23,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const [expanded, setExpanded] = React.useState<string[]>([])
|
const [expanded, setExpanded] = React.useState<string[]>([])
|
||||||
|
const timeAgo = useGetTimeAgo()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
|
@ -70,7 +72,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Text type="sm" style={[styles.ts, pal.textLight]}>
|
<Text type="sm" style={[styles.ts, pal.textLight]}>
|
||||||
{ago(entry.timestamp)}
|
{timeAgo(entry.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{expanded.includes(entry.id) ? (
|
{expanded.includes(entry.id) ? (
|
||||||
|
|
Loading…
Reference in New Issue