| 1 | /** |
| 2 | * How long ago something happened, in a few characters, as `TimeAgo` |
| 3 | * shows it: "just now", "5m ago", "3h ago", "yesterday", "4d ago", |
| 4 | * "3w ago", then the date itself, "Sep 30" this year and "Sep 30, 2023" |
| 5 | * before, since "1176d ago" says less than the date does. Only plain |
| 6 | * functions, so it is tested on its own. |
| 7 | */ |
| 8 | |
| 9 | const MINUTE = 60_000; |
| 10 | const HOUR = 60 * MINUTE; |
| 11 | const DAY = 24 * HOUR; |
| 12 | |
| 13 | /** A moment as a number, from an RFC 3339 string or (for a few older ones) a number. */ |
| 14 | function timeOf(at: string | number): number { |
| 15 | return typeof at === "number" ? at : Date.parse(at); |
| 16 | } |
| 17 | |
| 18 | export function timeAgo(at: string | number, now: number = Date.now(), timeZone?: string): string { |
| 19 | const time = timeOf(at); |
| 20 | if (!Number.isFinite(time)) return ""; |
| 21 | // A moment slightly ahead is a clock that differs, not the future. |
| 22 | const elapsed = Math.max(0, now - time); |
| 23 | if (elapsed < 45_000) return "just now"; |
| 24 | if (elapsed < HOUR) return `${Math.max(1, Math.round(elapsed / MINUTE))}m ago`; |
| 25 | if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; |
| 26 | if (elapsed < 2 * DAY) return "yesterday"; |
| 27 | if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`; |
| 28 | if (elapsed < 28 * DAY) return `${Math.floor(elapsed / (7 * DAY))}w ago`; |
| 29 | const sameYear = yearIn(time, timeZone) === yearIn(now, timeZone); |
| 30 | return new Date(time).toLocaleDateString("en-US", { |
| 31 | month: "short", |
| 32 | day: "numeric", |
| 33 | ...(sameYear ? {} : { year: "numeric" }), |
| 34 | timeZone, |
| 35 | }); |
| 36 | } |
| 37 | |
| 38 | /** The moment in full, for the hint over a `TimeAgo`: "Oct 13, 2026, 3:04 PM". */ |
| 39 | export function exactTime(at: string | number, timeZone?: string): string { |
| 40 | const time = timeOf(at); |
| 41 | if (!Number.isFinite(time)) return ""; |
| 42 | return new Date(time).toLocaleString("en-US", { |
| 43 | month: "short", |
| 44 | day: "numeric", |
| 45 | year: "numeric", |
| 46 | hour: "numeric", |
| 47 | minute: "2-digit", |
| 48 | timeZone, |
| 49 | timeZoneName: "short", |
| 50 | }); |
| 51 | } |
| 52 | |
| 53 | function yearIn(time: number, timeZone?: string): string { |
| 54 | return new Date(time).toLocaleDateString("en-US", { year: "numeric", timeZone }); |
| 55 | } |