fix(utils): preserve colon-containing values in tagsToRecord; align invalidFallback; add formatRelativeTime (#156)

* fix(utils): preserve colon-containing values in tagsToRecord; align invalidFallback across date formatters; add formatRelativeTime

**key-value-tags: fix value truncation on tags with colons**

`tagsToRecord` used `tag.split(':')` with array destructuring, so any
value containing `:` (e.g. a webhook URL `https://example.com/hook`)
was silently truncated to just the scheme.  Switch to `indexOf` so the
split happens only on the first colon, preserving the full value.

Example (before → after):
  `tagsToRecord(['hook:https://api.example.com/cb'])`
  before: `{ hook: 'https' }`  ← bug
  after:  `{ hook: 'https://api.example.com/cb' }`

Add `key-value-tags.test.ts` covering: simple pairs, URL values,
multi-colon values, empty key/value, round-trip with `recordToTags`.

**date-time: honour `invalidFallback` consistently**

`FormatDateOptions` declares `invalidFallback` but only
`formatDateTimeSeconds` ever read it — `formatDateTime` and `formatDate`
both collapsed a present-but-invalid date string into `fallback ?? ''`,
making it impossible for callers to distinguish "nothing was passed" from
"a bad string was passed".

Extract a shared `resolveInvalid(value, options)` helper (prefers
`invalidFallback`, then `fallback`, then the raw value) and apply it
uniformly.  Also refactor `formatDateTimeSeconds` to use the existing
`parseDate` helper, eliminating the duplicated `new Date` + `isNaN`
guard.  No externally visible behaviour change for previously valid
combinations; callers that relied on invalid dates falling through to
`fallback` keep working since `resolveInvalid` falls through to
`fallback` when `invalidFallback` is absent.

**date-time: add `formatRelativeTime`**

Chat and notification UIs commonly need relative timestamps ("3 minutes
ago", "yesterday").  The utility file has no such function.  Add
`formatRelativeTime(value, options?)` using `Intl.RelativeTimeFormat`
so the output respects the browser locale without hardcoded English
strings.  Thresholds: seconds < 60 s, minutes < 1 h, hours < 24 h,
days < 7 d, beyond that falls back to `toLocaleDateString()`.  Accepts
both ISO strings and `Date` objects.

Add `date-time.test.ts` covering all four exported functions including
`vi.useFakeTimers` assertions for `formatRelativeTime`.

* fix(utils): clean up formatRelativeTime after merge

Made-with: Cursor
This commit is contained in:
RoomWithOutRoof
2026-03-03 15:49:02 +08:00
committed by GitHub
parent ea719f7ca7
commit 450cc30a9f
4 changed files with 228 additions and 15 deletions
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { formatDateTime, formatDate, formatDateTimeSeconds, formatRelativeTime } from './date-time'
// ─── formatDateTime ───────────────────────────────────────────────────────────
describe('formatDateTime', () => {
it('returns empty string for null', () => {
expect(formatDateTime(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(formatDateTime(undefined)).toBe('')
})
it('returns fallback for missing value', () => {
expect(formatDateTime(null, { fallback: '' })).toBe('')
})
it('returns fallback for invalid date when no invalidFallback set', () => {
expect(formatDateTime('not-a-date', { fallback: '' })).toBe('')
})
it('returns invalidFallback for invalid date when set', () => {
expect(formatDateTime('not-a-date', { invalidFallback: 'bad date' })).toBe('bad date')
})
it('formats a valid ISO date string', () => {
const result = formatDateTime('2026-03-01T10:00:00Z')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
// ─── formatDate ───────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('returns empty string for null', () => {
expect(formatDate(null)).toBe('')
})
it('returns fallback for missing value', () => {
expect(formatDate(undefined, { fallback: 'n/a' })).toBe('n/a')
})
it('returns invalidFallback for invalid date', () => {
expect(formatDate('garbage', { invalidFallback: '?' })).toBe('?')
})
it('falls back to fallback when invalidFallback not set and date is invalid', () => {
expect(formatDate('garbage', { fallback: 'fallback' })).toBe('fallback')
})
it('formats a valid date string', () => {
const result = formatDate('2026-03-01')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
// ─── formatDateTimeSeconds ────────────────────────────────────────────────────
describe('formatDateTimeSeconds', () => {
it('returns empty string for null', () => {
expect(formatDateTimeSeconds(null)).toBe('')
})
it('returns fallback for missing value', () => {
expect(formatDateTimeSeconds(undefined, { fallback: '' })).toBe('')
})
it('returns invalidFallback for invalid date', () => {
expect(formatDateTimeSeconds('bad', { invalidFallback: 'invalid' })).toBe('invalid')
})
it('falls back to fallback when invalidFallback not set', () => {
expect(formatDateTimeSeconds('bad', { fallback: 'fb' })).toBe('fb')
})
it('returns raw value when neither fallback option is set and date is invalid', () => {
expect(formatDateTimeSeconds('raw-garbage')).toBe('raw-garbage')
})
it('formats a known UTC timestamp to YYYY-MM-DD HH:mm:ss in local time', () => {
// Pin to a specific local date to avoid TZ flakiness.
const d = new Date(2026, 2, 1, 10, 5, 9) // 2026-03-01 10:05:09 local
const result = formatDateTimeSeconds(d.toISOString())
// We can only assert the shape since local TZ varies in CI.
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
})
})
// ─── formatRelativeTime ───────────────────────────────────────────────────────
describe('formatRelativeTime', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns empty string for null', () => {
expect(formatRelativeTime(null)).toBe('')
})
it('returns fallback for missing value', () => {
expect(formatRelativeTime(undefined, { fallback: '' })).toBe('')
})
it('returns invalidFallback for invalid date string', () => {
expect(formatRelativeTime('not-a-date', { invalidFallback: '?' })).toBe('?')
})
it('accepts a Date object', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'))
const past = new Date('2026-03-01T11:55:00Z') // 5 minutes ago
const result = formatRelativeTime(past)
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('accepts an ISO string', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'))
const result = formatRelativeTime('2026-03-01T11:55:00Z')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('falls back to toLocaleDateString for dates older than 7 days', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'))
const old = new Date('2026-02-01T12:00:00Z') // 28 days ago
const result = formatRelativeTime(old)
// Should be a date string, not a relative string
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
+20 -14
View File
@@ -11,14 +11,24 @@ function parseDate(value: string | null | undefined): Date | null {
return Number.isNaN(parsed.getTime()) ? null : parsed
}
/**
* Resolves the display string when a date value is non-null but could not be
* parsed. `invalidFallback` takes precedence over `fallback`; when neither is
* supplied the raw input value is returned so callers can see what arrived.
*/
function resolveInvalid(value: string, options: FormatDateOptions): string {
if (options.invalidFallback !== undefined) return options.invalidFallback
if (options.fallback !== undefined) return options.fallback
return value
}
export function formatDateTime(
value: string | null | undefined,
options: FormatDateOptions = {},
): string {
if (!value) return options.fallback ?? ''
const parsed = parseDate(value)
if (!parsed) {
return options.fallback ?? ''
}
if (!parsed) return resolveInvalid(value, options)
return parsed.toLocaleString()
}
@@ -26,10 +36,9 @@ export function formatDate(
value: string | null | undefined,
options: FormatDateOptions = {},
): string {
if (!value) return options.fallback ?? ''
const parsed = parseDate(value)
if (!parsed) {
return options.fallback ?? ''
}
if (!parsed) return resolveInvalid(value, options)
return parsed.toLocaleDateString()
}
@@ -37,13 +46,9 @@ export function formatDateTimeSeconds(
value: string | null | undefined,
options: FormatDateOptions = {},
): string {
if (!value) {
return options.fallback ?? ''
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return options.invalidFallback ?? value
}
if (!value) return options.fallback ?? ''
const parsed = parseDate(value)
if (!parsed) return resolveInvalid(value, options)
const year = parsed.getFullYear()
const month = String(parsed.getMonth() + 1).padStart(2, '0')
@@ -68,7 +73,7 @@ export function formatRelativeTime(
): string {
if (!value) return options.fallback ?? ''
const date = value instanceof Date ? value : parseDate(value)
if (!date) return options.fallback ?? ''
if (!date) return resolveInvalid(value as string, options)
const diffMs = date.getTime() - Date.now()
const absDiffSec = Math.abs(diffMs) / 1000
@@ -79,5 +84,6 @@ export function formatRelativeTime(
if (absDiffSec < 86_400) return rtf.format(Math.round(diffMs / 3_600_000), 'hour')
if (absDiffSec < 604_800) return rtf.format(Math.round(diffMs / 86_400_000), 'day')
// Beyond a week: absolute date is more readable than "34 days ago"
return date.toLocaleDateString()
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { tagsToRecord, recordToTags } from './key-value-tags'
describe('tagsToRecord', () => {
it('converts simple key:value pairs', () => {
expect(tagsToRecord(['env:production', 'tier:free'])).toEqual({
env: 'production',
tier: 'free',
})
})
it('preserves colons inside values (e.g. URLs)', () => {
expect(tagsToRecord(['webhook:https://example.com/hook'])).toEqual({
webhook: 'https://example.com/hook',
})
})
it('preserves multiple colons in value', () => {
expect(tagsToRecord(['ts:2026-03-01T10:00:00Z'])).toEqual({
ts: '2026-03-01T10:00:00Z',
})
})
it('skips entries without a colon', () => {
expect(tagsToRecord(['nocoion'])).toEqual({})
})
it('skips entries with an empty key (leading colon)', () => {
expect(tagsToRecord([':value'])).toEqual({})
})
it('skips entries with an empty value (trailing colon)', () => {
expect(tagsToRecord(['key:'])).toEqual({})
})
it('returns empty record for empty array', () => {
expect(tagsToRecord([])).toEqual({})
})
it('last writer wins on duplicate keys', () => {
expect(tagsToRecord(['env:staging', 'env:production'])).toEqual({ env: 'production' })
})
})
describe('recordToTags', () => {
it('converts a record back to tags', () => {
const tags = recordToTags({ env: 'production', tier: 'free' })
expect(tags).toContain('env:production')
expect(tags).toContain('tier:free')
expect(tags).toHaveLength(2)
})
it('returns empty array for null', () => {
expect(recordToTags(null)).toEqual([])
})
it('returns empty array for undefined', () => {
expect(recordToTags(undefined)).toEqual([])
})
it('round-trips tags containing colons in values', () => {
const original = { webhook: 'https://example.com/hook' }
const tags = recordToTags(original)
expect(tagsToRecord(tags)).toEqual(original)
})
})
+5 -1
View File
@@ -1,7 +1,11 @@
export function tagsToRecord(tags: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (const tag of tags) {
const [key, value] = tag.split(':')
// Use indexOf so that values containing ':' (e.g. URLs) are preserved intact.
const sep = tag.indexOf(':')
if (sep <= 0) continue
const key = tag.slice(0, sep)
const value = tag.slice(sep + 1)
if (key && value) {
out[key] = value
}