Files
hermes-agent/apps/desktop/src/lib/time.ts
T
Brooklyn Nicholson ca244e2168 refine inline message actions: fork icon, sine-wave read-aloud, shared age util
- 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
2026-07-22 12:18:59 -05:00

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