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): 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); }