ca244e2168
- Use the fork glyph for branch and a sine wave for read aloud (all one lib now) - Extract compact "2h ago" into formatAgo() in lib/time.ts (+ ageDays locale string) - Cover formatAgo with a unit test
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
// Canonical time/date formatting. Shared `Intl` instances (created once, not
|
|
// per-render) + relative-time helpers. Every surface that shows a timestamp or
|
|
// an age pulls from here so the rendered strings stay consistent app-wide.
|
|
|
|
export const SECOND = 1000
|
|
export const MINUTE = 60_000
|
|
export const HOUR = 3_600_000
|
|
export const DAY = 86_400_000
|
|
|
|
// ── Absolute date/time formatters ──────────────────────────────────────────
|
|
// `hh:mm` clock (thread today/yesterday lines).
|
|
export const fmtClock = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
|
|
// Compact "day + clock", no year/seconds (artifacts, thread fallback, cron runs).
|
|
export const fmtDayTime = new Intl.DateTimeFormat(undefined, {
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
month: 'short'
|
|
})
|
|
|
|
// Medium date + short time (command center session detail).
|
|
export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
|
|
|
// Date only, "5 Jun 2026" (starmap tooltip).
|
|
export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
|
|
// ── Relative time ──────────────────────────────────────────────────────────
|
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
|
|
|
// Localized bidirectional "in 5 min" / "2 hr ago" — coarsest sensible unit so a
|
|
// daily job reads "in 14 hr", not "in 840 min".
|
|
export function relativeTime(targetMs: number, nowMs = Date.now()): string {
|
|
const diff = targetMs - nowMs
|
|
const abs = Math.abs(diff)
|
|
const sign = diff < 0 ? -1 : 1
|
|
|
|
if (abs < MINUTE) {
|
|
return rtf.format(sign * Math.round(abs / SECOND), 'second')
|
|
}
|
|
|
|
if (abs < HOUR) {
|
|
return rtf.format(sign * Math.round(abs / MINUTE), 'minute')
|
|
}
|
|
|
|
if (abs < DAY) {
|
|
return rtf.format(sign * Math.round(abs / HOUR), 'hour')
|
|
}
|
|
|
|
return rtf.format(sign * Math.round(abs / DAY), 'day')
|
|
}
|
|
|
|
export type ElapsedUnit = 'day' | 'hour' | 'minute' | 'second'
|
|
|
|
// Coarsest elapsed bucket for a (clamped-nonnegative) duration, floored. The
|
|
// caller owns rendering — compact "5m", "5m ago", etc. — so no format is baked
|
|
// in here.
|
|
export function coarseElapsed(deltaMs: number): { unit: ElapsedUnit; value: number } {
|
|
const ms = Math.max(0, deltaMs)
|
|
|
|
if (ms >= DAY) {
|
|
return { unit: 'day', value: Math.floor(ms / DAY) }
|
|
}
|
|
|
|
if (ms >= HOUR) {
|
|
return { unit: 'hour', value: Math.floor(ms / HOUR) }
|
|
}
|
|
|
|
if (ms >= MINUTE) {
|
|
return { unit: 'minute', value: Math.floor(ms / MINUTE) }
|
|
}
|
|
|
|
return { unit: 'second', value: Math.floor(ms / SECOND) }
|
|
}
|
|
|
|
// Localized strings for `formatAgo`; shaped to accept `t.agents` directly.
|
|
export interface AgoLabels {
|
|
ageNow: string
|
|
ageSeconds: (seconds: number) => string
|
|
ageMinutes: (minutes: number) => string
|
|
ageHours: (hours: number) => string
|
|
ageDays: (days: number) => string
|
|
}
|
|
|
|
// Compact localized "2h ago" / "3m ago" / "now" for a past timestamp, bucketed
|
|
// via `coarseElapsed` so every age label reads consistently.
|
|
export function formatAgo(fromMs: number, labels: AgoLabels, nowMs = Date.now()): string {
|
|
const { unit, value } = coarseElapsed(nowMs - fromMs)
|
|
|
|
if (unit === 'second') {
|
|
return value < 2 ? labels.ageNow : labels.ageSeconds(value)
|
|
}
|
|
|
|
const by = { day: labels.ageDays, hour: labels.ageHours, minute: labels.ageMinutes }
|
|
|
|
return by[unit](value)
|
|
}
|