mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user