348 lines
9.9 KiB
TypeScript
348 lines
9.9 KiB
TypeScript
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<T = number> = (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<Unit extends string = UseTimeAgoUnitName> {
|
|
/**
|
|
* 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<string>;
|
|
|
|
/**
|
|
* Wraps a future fragment (e.g. `'3 minutes'` -> `'in 3 minutes'`)
|
|
*/
|
|
future: string | UseTimeAgoFormatter<string>;
|
|
|
|
/**
|
|
* 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<UnitNames extends string = UseTimeAgoUnitName>
|
|
= UseTimeAgoMessagesBuiltIn & Record<UnitNames, string | UseTimeAgoFormatter<number>>;
|
|
|
|
/**
|
|
* Options shared by the pure `formatTimeAgo` and the reactive `useTimeAgo`.
|
|
*/
|
|
export interface FormatTimeAgoOptions<UnitNames extends string = UseTimeAgoUnitName> {
|
|
/**
|
|
* 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<UnitNames>;
|
|
|
|
/**
|
|
* 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<UseTimeAgoUnit<UnitNames>>;
|
|
}
|
|
|
|
/**
|
|
* Options for `useTimeAgo`.
|
|
*/
|
|
export interface UseTimeAgoOptions<Controls extends boolean, UnitNames extends string = UseTimeAgoUnitName>
|
|
extends FormatTimeAgoOptions<UnitNames> {
|
|
/**
|
|
* 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<string>;
|
|
|
|
/**
|
|
* Whether the update interval is currently active
|
|
*/
|
|
isActive: Readonly<Ref<boolean>>;
|
|
}
|
|
|
|
export type UseTimeAgoReturn<Controls extends boolean = false>
|
|
= Controls extends true ? UseTimeAgoControls : ComputedRef<string>;
|
|
|
|
const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
|
|
{ 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 = /\d/;
|
|
|
|
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
|
|
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<UnitNames extends string = UseTimeAgoUnitName>(
|
|
from: Date,
|
|
options: FormatTimeAgoOptions<UnitNames> = {},
|
|
now: Date | number = Date.now(),
|
|
): string {
|
|
const {
|
|
max,
|
|
messages = DEFAULT_MESSAGES as UseTimeAgoMessages<UnitNames>,
|
|
fullDateFormatter = defaultFullDateFormatter,
|
|
units = DEFAULT_UNITS as Array<UseTimeAgoUnit<UnitNames>>,
|
|
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<UnitNames>): 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<UnitNames>): 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<Date | number | string>} time The instant to describe (reactive)
|
|
* @param {UseTimeAgoOptions} [options={}] Options
|
|
* @returns {ComputedRef<string> | 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<UnitNames extends string = UseTimeAgoUnitName>(
|
|
time: MaybeRefOrGetter<Date | number | string>,
|
|
options?: UseTimeAgoOptions<false, UnitNames>,
|
|
): ComputedRef<string>;
|
|
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
|
time: MaybeRefOrGetter<Date | number | string>,
|
|
options: UseTimeAgoOptions<true, UnitNames>,
|
|
): UseTimeAgoControls;
|
|
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
|
time: MaybeRefOrGetter<Date | number | string>,
|
|
options: UseTimeAgoOptions<boolean, UnitNames> = {},
|
|
): ComputedRef<string> | 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;
|
|
}
|