Times read as people say them, and old ones as their date: every TimeAgo across the site (283 of them) said "1176d ago" for a commit three years old, since it only knew days, hours and minutes. It now says "just now", "5m ago", "3h ago", "yesterday", "4d ago" and "3w ago", then the date, "Sep 30" this year and "Jul 24, 2023" before, from lib/time-ago.ts, and the moment in full ("Oct 13, 2026, 3:04 PM UTC") shows in the site's tooltip on hover. A deployment's status no longer wraps its time in a second hint of its own. A time that can't be read shows nothing rather than "NaN". Checked: the site's typecheck, its 893 tests (new: time-ago's wording for each span, a clock slightly ahead, an unreadable time and the full moment) and a production build.
4 files+107−200/4 viewed
| 5 | 5 | import { cn } from "../../lib/cn"; | |
| 6 | 6 | import { linkPaths } from "../../lib/compute"; | |
| 7 | 7 | import { type Submission, isPending } from "../../lib/pending"; | |
| 8 | + | import { exactTime, timeAgo } from "../../lib/time-ago"; | |
| 8 | 9 | import { Button, type ButtonSize, type ButtonVariant, buttonVariants } from "./button"; | |
| 10 | + | import { Hint } from "./hint"; | |
| 9 | 11 | ||
| 10 | 12 | export function Field({ | |
| 11 | 13 | label, | |
| ⋯ | |||
| 298 | 300 | ); | |
| 299 | 301 | } | |
| 300 | 302 | ||
| 301 | − | const UNITS: [string, number][] = [ | |
| 302 | − | ["d", 86_400_000], | |
| 303 | − | ["h", 3_600_000], | |
| 304 | − | ["m", 60_000], | |
| 305 | − | ]; | |
| 306 | − | ||
| 307 | − | /** Compact relative time such as "5m ago". */ | |
| 303 | + | /** | |
| 304 | + | * Compact relative time such as "5m ago", or the date once it is weeks | |
| 305 | + | * old (lib/time-ago.ts), with the moment in full on hover. | |
| 306 | + | */ | |
| 308 | 307 | export function TimeAgo({ at }: { at: string | number }) { | |
| 309 | 308 | // Timestamps are RFC 3339 strings; a few older ones are still numbers. | |
| 310 | − | const elapsed = Date.now() - new Date(at).getTime(); | |
| 311 | − | const unit = UNITS.find(([, size]) => elapsed >= size); | |
| 312 | − | const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now"; | |
| 309 | + | const time = new Date(at); | |
| 310 | + | if (Number.isNaN(time.getTime())) return null; | |
| 313 | 311 | return ( | |
| 314 | − | // The server and the browser render at slightly different times. | |
| 315 | − | <time dateTime={new Date(at).toISOString()} suppressHydrationWarning> | |
| 316 | − | {label} | |
| 317 | − | </time> | |
| 312 | + | <Hint label={<span suppressHydrationWarning>{exactTime(at)}</span>}> | |
| 313 | + | {/* The server and the browser render at slightly different times, and zones. */} | |
| 314 | + | <time dateTime={time.toISOString()} suppressHydrationWarning> | |
| 315 | + | {timeAgo(at)} | |
| 316 | + | </time> | |
| 317 | + | </Hint> | |
| 318 | 318 | ); | |
| 319 | 319 | } | |
| 320 | 320 | ||
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { exactTime, timeAgo } from "./time-ago.ts"; | |
| 5 | + | ||
| 6 | + | const NOW = Date.parse("2026-10-13T12:00:00.000Z"); | |
| 7 | + | const before = (ms: number) => new Date(NOW - ms).toISOString(); | |
| 8 | + | const MINUTE = 60_000; | |
| 9 | + | const HOUR = 60 * MINUTE; | |
| 10 | + | const DAY = 24 * HOUR; | |
| 11 | + | ||
| 12 | + | test("a moment within the last week is said in minutes, hours or days", () => { | |
| 13 | + | assert.equal(timeAgo(before(10_000), NOW, "UTC"), "just now"); | |
| 14 | + | assert.equal(timeAgo(before(-30_000), NOW, "UTC"), "just now"); | |
| 15 | + | assert.equal(timeAgo(before(50_000), NOW, "UTC"), "1m ago"); | |
| 16 | + | assert.equal(timeAgo(before(5 * MINUTE), NOW, "UTC"), "5m ago"); | |
| 17 | + | assert.equal(timeAgo(before(3 * HOUR), NOW, "UTC"), "3h ago"); | |
| 18 | + | assert.equal(timeAgo(before(30 * HOUR), NOW, "UTC"), "yesterday"); | |
| 19 | + | assert.equal(timeAgo(before(4 * DAY), NOW, "UTC"), "4d ago"); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("a moment weeks ago is said in weeks, and older ones by their date", () => { | |
| 23 | + | assert.equal(timeAgo(before(10 * DAY), NOW, "UTC"), "1w ago"); | |
| 24 | + | assert.equal(timeAgo(before(27 * DAY), NOW, "UTC"), "3w ago"); | |
| 25 | + | assert.equal(timeAgo("2026-09-01T09:00:00.000Z", NOW, "UTC"), "Sep 1"); | |
| 26 | + | assert.equal(timeAgo("2023-07-24T09:00:00.000Z", NOW, "UTC"), "Jul 24, 2023"); | |
| 27 | + | assert.equal(timeAgo(Date.parse("2023-07-24T09:00:00.000Z"), NOW, "UTC"), "Jul 24, 2023"); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("a moment that can't be read says nothing", () => { | |
| 31 | + | assert.equal(timeAgo("not a date", NOW), ""); | |
| 32 | + | assert.equal(exactTime("not a date"), ""); | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | test("the hint gives the moment in full", () => { | |
| 36 | + | assert.equal(exactTime("2026-10-13T15:04:00.000Z", "UTC"), "Oct 13, 2026, 3:04 PM UTC"); | |
| 37 | + | }); |
| 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 | + | } |
| 9 | 9 | import { TimeAgo } from "../../components/ui"; | |
| 10 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 11 | 11 | import { Card } from "../../components/ui/card"; | |
| 12 | − | import { Hint } from "../../components/ui/hint"; | |
| 13 | 12 | import { DeployLink, host } from "../../components/deploy"; | |
| 14 | 13 | import { DeploymentStateBadge, DeploymentStateIcon, STATE_TONE, sourceLabel } from "../../components/deployments-panel"; | |
| 15 | 14 | import { deployments } from "../../lib/services.server"; | |
| ⋯ | |||
| 223 | 222 | <span className={`font-medium ${over && status.state === "in_progress" ? "text-muted" : STATE_TONE[status.state]}`}>{STATE_WORD[status.state]}</span> | |
| 224 | 223 | <span className="text-xs text-faint"> | |
| 225 | 224 | {status.creator} ·{" "} | |
| 226 | − | <Hint label={new Date(status.created_at).toUTCString()}> | |
| 227 | − | <span tabIndex={0}> | |
| 228 | − | <TimeAgo at={status.created_at} /> | |
| 229 | − | </span> | |
| 230 | − | </Hint> | |
| 225 | + | <TimeAgo at={status.created_at} /> | |
| 231 | 226 | </span> | |
| 232 | 227 | </p> | |
| 233 | 228 | {status.description && <p className="mt-0.5 text-sm text-muted wrap-anywhere">{status.description}</p>} | |