import { computed, shallowRef, toValue } from 'vue'; import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; import { isFunction, isNumber, isString } from '@robonen/stdlib'; import type { ResumableActions } from '@/types'; import { useIntervalFn } from '@/composables/animation/useIntervalFn'; /** * Formatter for a single unit value. Receives the rounded numeric value and * whether the instant is in the past, and returns the localized fragment. */ export type UseTimeAgoFormatter = (value: T, isPast: boolean) => string; /** * The default set of unit names recognized by `useTimeAgo`. */ export type UseTimeAgoUnitName = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; /** * A single time unit used while resolving the most appropriate granularity. */ export interface UseTimeAgoUnit { /** * Upper bound (exclusive) of the absolute diff (ms) this unit applies to */ max: number; /** * Length of one unit in milliseconds */ value: number; /** * Unit name; used to look up the matching message formatter */ name: Unit; } /** * Built-in (non-unit) message slots. */ export interface UseTimeAgoMessagesBuiltIn { /** * Shown when the diff is below the smallest displayed unit */ justNow: string; /** * Wraps a past fragment (e.g. `'3 minutes'` -> `'3 minutes ago'`) */ past: string | UseTimeAgoFormatter; /** * Wraps a future fragment (e.g. `'3 minutes'` -> `'in 3 minutes'`) */ future: string | UseTimeAgoFormatter; /** * Shown when the provided time cannot be parsed into a valid date */ invalid: string; } /** * Full message map: the built-in slots plus a formatter per unit name. */ export type UseTimeAgoMessages = UseTimeAgoMessagesBuiltIn & Record>; /** * Options shared by the pure `formatTimeAgo` and the reactive `useTimeAgo`. */ export interface FormatTimeAgoOptions { /** * Maximum unit (or absolute ms diff) to display before falling back to * `fullDateFormatter`. */ max?: UnitNames | number; /** * Formatter applied when the diff exceeds `max`. * * @default (date) => date.toISOString().slice(0, 10) */ fullDateFormatter?: (date: Date) => string; /** * Localized messages. */ messages?: UseTimeAgoMessages; /** * Show seconds (i.e. allow sub-minute granularity) instead of `justNow`. * * @default false */ showSecond?: boolean; /** * Rounding strategy applied to unit values. A string maps to the matching * `Math` method; a number rounds to that many decimal places. * * @default 'round' */ rounding?: 'round' | 'ceil' | 'floor' | number; /** * Custom ordered list of units (ascending by `value`). */ units?: Array>; } /** * Options for `useTimeAgo`. */ export interface UseTimeAgoOptions extends FormatTimeAgoOptions { /** * Expose pause/resume controls alongside the time string. * * @default false */ controls?: Controls; /** * Interval (ms) at which the relative string is recomputed. * * @default 30000 */ updateInterval?: number; /** * Start the update interval immediately. * * @default true */ immediate?: boolean; } /** * Controls returned when `controls: true`. */ export interface UseTimeAgoControls extends ResumableActions { /** * The reactive relative-time string */ timeAgo: ComputedRef; /** * Whether the update interval is currently active */ isActive: Readonly>; } export type UseTimeAgoReturn = Controls extends true ? UseTimeAgoControls : ComputedRef; const DEFAULT_UNITS: Array> = [ { max: 60000, value: 1000, name: 'second' }, { max: 2760000, value: 60000, name: 'minute' }, { max: 72000000, value: 3600000, name: 'hour' }, { max: 518400000, value: 86400000, name: 'day' }, { max: 2419200000, value: 604800000, name: 'week' }, { max: 28512000000, value: 2592000000, name: 'month' }, { max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' }, ]; const REGEX_DIGIT = /* #__PURE__ */ /\d/; const DEFAULT_MESSAGES: UseTimeAgoMessages = { justNow: 'just now', past: n => REGEX_DIGIT.test(n) ? `${n} ago` : n, future: n => REGEX_DIGIT.test(n) ? `in ${n}` : n, month: (n, past) => n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`, year: (n, past) => n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`, day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`, week: (n, past) => n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`, hour: n => `${n} hour${n > 1 ? 's' : ''}`, minute: n => `${n} minute${n > 1 ? 's' : ''}`, second: n => `${n} second${n > 1 ? 's' : ''}`, invalid: '', }; function defaultFullDateFormatter(date: Date): string { return date.toISOString().slice(0, 10); } /** * Pure (non-reactive) relative-time formatter. Useful on its own and reused by * `useTimeAgo` on every tick. * * @param {Date} from The instant to describe * @param {FormatTimeAgoOptions} [options={}] Formatting options * @param {Date | number} [now=Date.now()] The reference "now" * @returns {string} The localized relative-time string * * @example * formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago' * * @since 0.0.15 */ export function formatTimeAgo( from: Date, options: FormatTimeAgoOptions = {}, now: Date | number = Date.now(), ): string { const { max, messages = DEFAULT_MESSAGES as UseTimeAgoMessages, fullDateFormatter = defaultFullDateFormatter, units = DEFAULT_UNITS as Array>, showSecond = false, rounding = 'round', } = options; const fromMs = +from; if (Number.isNaN(fromMs)) return messages.invalid; const roundFn = isNumber(rounding) ? (n: number): number => +n.toFixed(rounding) : Math[rounding]; const diff = +now - fromMs; const absDiff = Math.abs(diff); function getValue(unit: UseTimeAgoUnit): number { return roundFn(absDiff / unit.value); } function applyFormat( name: UnitNames | keyof UseTimeAgoMessagesBuiltIn, val: number | string, isPast: boolean, ): string { const formatter = messages[name]; if (isFunction(formatter)) return formatter(val as never, isPast); return formatter.replace('{0}', val.toString()); } function format(unit: UseTimeAgoUnit): string { const val = getValue(unit); const past = diff > 0; const str = applyFormat(unit.name, val, past); return applyFormat(past ? 'past' : 'future', str, past); } if (absDiff < 60000 && !showSecond) return messages.justNow; if (isNumber(max) && absDiff > max) return fullDateFormatter(new Date(from)); if (isString(max)) { const unitMax = units.find(unit => unit.name === max)?.max; if (unitMax && absDiff > unitMax) return fullDateFormatter(new Date(from)); } for (let idx = 0; idx < units.length; idx++) { const unit = units[idx]!; const prev = units[idx - 1]; if (getValue(unit) <= 0 && prev) return format(prev); if (absDiff < unit.max) return format(unit); } return messages.invalid; } /** * @name useTimeAgo * @category Animation * @description Reactive relative time string (e.g. `'3 minutes ago'`) that * ticks on a fixed interval. Fully customizable messages (i18n), units, * rounding, and an automatic fallback to a full date once `max` is exceeded. * * @param {MaybeRefOrGetter} time The instant to describe (reactive) * @param {UseTimeAgoOptions} [options={}] Options * @returns {ComputedRef | UseTimeAgoControls} The reactive string, or controls when `controls: true` * * @example * const timeAgo = useTimeAgo(new Date(Date.now() - 60_000)); // '1 minute ago' * * @example * // With pause/resume controls and a custom update cadence * const { timeAgo, pause, resume } = useTimeAgo(date, { controls: true, updateInterval: 1000 }); * * @example * // i18n + full-date fallback past one month * const timeAgo = useTimeAgo(date, { * max: 'month', * messages: { ...customMessages }, * fullDateFormatter: d => d.toLocaleDateString('fr-FR'), * }); * * @since 0.0.15 */ export function useTimeAgo( time: MaybeRefOrGetter, options?: UseTimeAgoOptions, ): ComputedRef; export function useTimeAgo( time: MaybeRefOrGetter, options: UseTimeAgoOptions, ): UseTimeAgoControls; export function useTimeAgo( time: MaybeRefOrGetter, options: UseTimeAgoOptions = {}, ): ComputedRef | UseTimeAgoControls { const { controls = false, updateInterval = 30000, immediate = true, } = options; // A single ticking ref drives recomputation; the heavy formatting stays in // a computed so it only runs when `now` or `time` actually change. const now = shallowRef(Date.now()); const resumable = useIntervalFn(() => { now.value = Date.now(); }, updateInterval, { immediate }); const timeAgo = computed(() => formatTimeAgo(new Date(toValue(time)), options, now.value)); if (controls) { return { timeAgo, isActive: resumable.isActive, pause: resumable.pause, resume: resumable.resume, toggle: resumable.toggle, }; } return timeAgo; }