118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import dayjs from 'dayjs';
|
|||
|
|
|
||
|
|
/**
|
||
|
|
* [Gitea #139] One place that turns a timestamp into words, honouring the
|
||
|
|
* user's 12/24 h clock and date-format preferences everywhere.
|
||
|
|
*
|
||
|
|
* Rules for the `auto` styles, relative to `now`:
|
||
|
|
* today → "21:14"
|
||
|
|
* yesterday → "Yesterday 21:14"
|
||
|
|
* tomorrow → "Tomorrow 21:14" (scheduled sends, reminders)
|
||
|
|
* last 6 days → "Mon 21:14"
|
||
|
|
* older / further → "5 Sep 2026 21:14" (date per `dateFormatString`)
|
||
|
|
*/
|
||
|
|
export type TimestampPrefs = {
|
||
|
|
hour24Clock: boolean;
|
||
|
|
dateFormatString: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type TimestampStyle =
|
||
|
|
/** Time for today, day word + time for nearby days, date + time otherwise. */
|
||
|
|
| 'auto'
|
||
|
|
/** Like `auto` but never the time: "Today", "Yesterday", "Mon", "5 Sep 2026". */
|
||
|
|
| 'autoDate'
|
||
|
|
/** Always the clock time. */
|
||
|
|
| 'time'
|
||
|
|
/** Always the date per `dateFormatString`. */
|
||
|
|
| 'date'
|
||
|
|
/** Always date + time. */
|
||
|
|
| 'dateTime';
|
||
|
|
|
||
|
|
const DEFAULT_DATE_FORMAT = 'D MMM YYYY';
|
||
|
|
|
||
|
|
const dateFormat = (prefs: TimestampPrefs) => prefs.dateFormatString || DEFAULT_DATE_FORMAT;
|
||
|
|
|
||
|
|
export const formatTime = (ts: number, prefs: Pick<TimestampPrefs, 'hour24Clock'>): string =>
|
||
|
|
dayjs(ts).format(prefs.hour24Clock ? 'HH:mm' : 'hh:mm A');
|
||
|
|
|
||
|
|
export const formatDate = (ts: number, prefs: TimestampPrefs): string =>
|
||
|
|
dayjs(ts).format(dateFormat(prefs));
|
||
|
|
|
||
|
|
/** Whole days between the start of `ts`'s day and the start of `now`'s day (negative = future). */
|
||
|
|
export const dayDistance = (ts: number, now: number): number =>
|
||
|
|
dayjs(now).startOf('day').diff(dayjs(ts).startOf('day'), 'day');
|
||
|
|
|
||
|
|
/** "Today" / "Yesterday" / "Tomorrow" / "Mon" for nearby days, else undefined. */
|
||
|
|
export const dayWord = (ts: number, now: number): string | undefined => {
|
||
|
|
const d = dayDistance(ts, now);
|
||
|
|
if (d === 0) return 'Today';
|
||
|
|
if (d === 1) return 'Yesterday';
|
||
|
|
if (d === -1) return 'Tomorrow';
|
||
|
|
if (d > 1 && d < 7) return dayjs(ts).format('ddd');
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function formatTimestamp(
|
||
|
|
ts: number,
|
||
|
|
prefs: TimestampPrefs,
|
||
|
|
style: TimestampStyle = 'auto',
|
||
|
|
now: number = Date.now(),
|
||
|
|
): string {
|
||
|
|
const time = formatTime(ts, prefs);
|
||
|
|
if (style === 'time') return time;
|
||
|
|
const date = formatDate(ts, prefs);
|
||
|
|
if (style === 'date') return date;
|
||
|
|
if (style === 'dateTime') return `${date} ${time}`;
|
||
|
|
|
||
|
|
const word = dayWord(ts, now);
|
||
|
|
if (style === 'autoDate') return word ?? date;
|
||
|
|
if (word === 'Today') return time;
|
||
|
|
return `${word ?? date} ${time}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Short relative age for dense lists: "now", "5m", "3h", "Yesterday", "Mon", "5 Sep". */
|
||
|
|
export function formatShortAge(
|
||
|
|
ts: number,
|
||
|
|
prefs: TimestampPrefs,
|
||
|
|
now: number = Date.now(),
|
||
|
|
): string {
|
||
|
|
const diffMin = Math.floor((now - ts) / 60_000);
|
||
|
|
if (diffMin < 60) return diffMin < 1 ? 'now' : `${diffMin}m`;
|
||
|
|
const diffHours = Math.floor(diffMin / 60);
|
||
|
|
if (diffHours < 24 && dayDistance(ts, now) === 0) return `${diffHours}h`;
|
||
|
|
const word = dayWord(ts, now);
|
||
|
|
if (word && word !== 'Today') return word;
|
||
|
|
// Same year: drop the year from the user's format for brevity.
|
||
|
|
const sameYear = dayjs(ts).year() === dayjs(now).year();
|
||
|
|
const fmt = dateFormat(prefs);
|
||
|
|
return dayjs(ts).format(sameYear ? fmt.replace(/[-/.\s]*Y{2,4}[-/.\s]*/, ' ').trim() : fmt);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Conversational age for list rows: "just now", "5m ago", "3h ago", "yesterday", "3d ago", then the date. */
|
||
|
|
export function formatRelativeAge(
|
||
|
|
ts: number,
|
||
|
|
prefs: TimestampPrefs,
|
||
|
|
now: number = Date.now(),
|
||
|
|
): string {
|
||
|
|
const diffMin = Math.floor((now - ts) / 60_000);
|
||
|
|
if (diffMin < 1) return 'just now';
|
||
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||
|
|
const diffHours = Math.floor(diffMin / 60);
|
||
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||
|
|
const days = dayDistance(ts, now);
|
||
|
|
if (days === 1) return 'yesterday';
|
||
|
|
if (days < 7) return `${days}d ago`;
|
||
|
|
return formatDate(ts, prefs);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Timeline day divider: "Today", "Yesterday", "Monday" (last 6 days), else the date. */
|
||
|
|
export function formatDayDivider(
|
||
|
|
ts: number,
|
||
|
|
prefs: TimestampPrefs,
|
||
|
|
now: number = Date.now(),
|
||
|
|
): string {
|
||
|
|
const d = dayDistance(ts, now);
|
||
|
|
if (d > 1 && d < 7) return dayjs(ts).format('dddd');
|
||
|
|
return formatTimestamp(ts, prefs, 'autoDate', now);
|
||
|
|
}
|