/ src / utils / formatBriefTimestamp.ts
formatBriefTimestamp.ts
 1  /**
 2   * Format an ISO timestamp for the brief/chat message label line.
 3   *
 4   * Display scales with age (like a messaging app):
 5   *   - same day:      "1:30 PM" or "13:30" (locale-dependent)
 6   *   - within 6 days: "Sunday, 4:15 PM" (locale-dependent)
 7   *   - older:         "Sunday, Feb 20, 4:30 PM" (locale-dependent)
 8   *
 9   * Respects POSIX locale env vars (LC_ALL > LC_TIME > LANG) for time format
10   * (12h/24h), weekday names, month names, and overall structure.
11   * Bun/V8's `toLocaleString(undefined)` ignores these on macOS, so we
12   * convert them to BCP 47 tags ourselves.
13   *
14   * `now` is injectable for tests.
15   */
16  export function formatBriefTimestamp(
17    isoString: string,
18    now: Date = new Date(),
19  ): string {
20    const d = new Date(isoString)
21    if (Number.isNaN(d.getTime())) {
22      return ''
23    }
24  
25    const locale = getLocale()
26    const dayDiff = startOfDay(now) - startOfDay(d)
27    const daysAgo = Math.round(dayDiff / 86_400_000)
28  
29    if (daysAgo === 0) {
30      return d.toLocaleTimeString(locale, {
31        hour: 'numeric',
32        minute: '2-digit',
33      })
34    }
35  
36    if (daysAgo > 0 && daysAgo < 7) {
37      return d.toLocaleString(locale, {
38        weekday: 'long',
39        hour: 'numeric',
40        minute: '2-digit',
41      })
42    }
43  
44    return d.toLocaleString(locale, {
45      weekday: 'long',
46      month: 'short',
47      day: 'numeric',
48      hour: 'numeric',
49      minute: '2-digit',
50    })
51  }
52  
53  /**
54   * Derive a BCP 47 locale tag from POSIX env vars.
55   * LC_ALL > LC_TIME > LANG, falls back to undefined (system default).
56   * Converts POSIX format (en_GB.UTF-8) to BCP 47 (en-GB).
57   */
58  function getLocale(): string | undefined {
59    const raw =
60      process.env.LC_ALL || process.env.LC_TIME || process.env.LANG || ''
61    if (!raw || raw === 'C' || raw === 'POSIX') {
62      return undefined
63    }
64    // Strip codeset (.UTF-8) and modifier (@euro), replace _ with -
65    const base = raw.split('.')[0]!.split('@')[0]!
66    if (!base) {
67      return undefined
68    }
69    const tag = base.replaceAll('_', '-')
70    // Validate by trying to construct an Intl locale — invalid tags throw
71    try {
72      new Intl.DateTimeFormat(tag)
73      return tag
74    } catch {
75      return undefined
76    }
77  }
78  
79  function startOfDay(d: Date): number {
80    return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
81  }