/** * Date formatting — how timestamps render throughout the app (spec §07 → * Formatting). Governs the Snippet Library list dates and the metadata panel's * Created/Modified, driven by the user's `formatting.dateFormat` setting. * * Portable core: no browser APIs, no React. `Intl` is deliberately avoided for * the custom tokens so output is locale-stable and unit-testable; month/day * names are the fixed English set. Pure: an ISO string in, a display string out. * * - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a * full locale date for older items). * - iso → a full ISO 8601 timestamp. * - custom → the user's format string (token grammar below); falls back to ISO * when the pattern is empty. */ /** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */ export type DateFormatMode = 'smart' | 'iso' | 'custom'; const DAY_MS = 24 * 60 * 60 * 1000; const MONTHS_SHORT = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const MONTHS_LONG = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; /** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */ function pad(n: number): string { return String(n).padStart(2, '0'); } /** * Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today", * one calendar day back → "Yesterday", under a week → "Nd ago", else the full * locale date. Comparison is by calendar day (local), so "Yesterday" doesn't * flip on the exact hour. `now` is injectable for deterministic tests. */ export function formatSmart(date: Date, now: Date): string { const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS); if (days <= 0) return 'Today'; if (days === 1) return 'Yesterday'; if (days < 7) return `${days}d ago`; return date.toLocaleDateString(); } /** Full ISO 8601 timestamp (spec §07 → ISO 8601). */ export function formatIso(date: Date): string { return date.toISOString(); } // Tokens longest-first so MMMM matches before MMM before MM before M, etc. const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g; /** * Format a date with a date-fns-style token pattern, in **local** time * (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h * mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is * enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that * happens to be a token (e.g. a `d` in prose) would be substituted — the field * is a power-user affordance, so this stays simple and predictable. */ export function formatCustom(date: Date, pattern: string): string { const h24 = date.getHours(); const h12 = h24 % 12 === 0 ? 12 : h24 % 12; const map: Record = { yyyy: String(date.getFullYear()), yy: pad(date.getFullYear() % 100), MMMM: MONTHS_LONG[date.getMonth()], MMM: MONTHS_SHORT[date.getMonth()], MM: pad(date.getMonth() + 1), M: String(date.getMonth() + 1), dd: pad(date.getDate()), d: String(date.getDate()), HH: pad(h24), H: String(h24), hh: pad(h12), h: String(h12), mm: pad(date.getMinutes()), m: String(date.getMinutes()), ss: pad(date.getSeconds()), s: String(date.getSeconds()), a: h24 < 12 ? 'AM' : 'PM', }; return pattern.replace(TOKEN, (t) => map[t] ?? t); } /** * Render an ISO timestamp per the user's date-format setting (spec §07). An * unparseable timestamp is returned verbatim rather than rendered as "Invalid * Date", so a malformed stored value degrades gracefully. `customFormat` is used * only in `custom` mode and falls back to ISO when blank. `now` (for `smart`) * defaults to the current time; inject it in tests. */ export function formatDate( iso: string, mode: DateFormatMode, customFormat = '', now: Date = new Date(), ): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return iso; switch (mode) { case 'iso': return formatIso(date); case 'custom': return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat); case 'smart': default: return formatSmart(date, now); } }