diff --git a/apps/meteor/client/lib/utils/dateFormat.spec.ts b/apps/meteor/client/lib/utils/dateFormat.spec.ts new file mode 100644 index 0000000000000..bc5a4b1824168 --- /dev/null +++ b/apps/meteor/client/lib/utils/dateFormat.spec.ts @@ -0,0 +1,143 @@ +import { formatDate, momentFormatToDateFns } from './dateFormat'; + +describe('momentFormatToDateFns', () => { + it('maps locale tokens', () => { + expect(momentFormatToDateFns('L')).toBe('P'); + expect(momentFormatToDateFns('LT')).toBe('p'); + expect(momentFormatToDateFns('LTS')).toBe('pp'); + expect(momentFormatToDateFns('LL')).toBe('PPP'); + expect(momentFormatToDateFns('LLL')).toBe('PPP p'); + expect(momentFormatToDateFns('LLLL')).toBe('EEEE, PPP p'); + }); + + it('maps common tokens', () => { + expect(momentFormatToDateFns('YYYY-MM-DD HH:mm:ss')).toBe('yyyy-MM-dd HH:mm:ss'); + expect(momentFormatToDateFns('MMMM Do YYYY, h:mm:ss a')).toBe('MMMM do yyyy, h:mm:ss aaa'); + }); + + it('preserves AM/PM casing (Moment A = uppercase, a = lowercase)', () => { + // date-fns `a` is always uppercase; `aaa` is lowercase. So Moment `a` → `aaa`. + expect(momentFormatToDateFns('A')).toBe('a'); + expect(momentFormatToDateFns('a')).toBe('aaa'); + }); + + it('translates moment [literal] escape to date-fns single-quoted literal', () => { + expect(momentFormatToDateFns('[Today at] LT')).toBe("'Today at' p"); + expect(momentFormatToDateFns('[Session started at] HH:mm [on] LL')).toBe("'Session started at' HH:mm 'on' PPP"); + }); + + it("escapes embedded single quotes inside literals as ''", () => { + expect(momentFormatToDateFns("[it's] LT")).toBe("'it''s' p"); + }); + + it('drops empty literal blocks since date-fns has no empty-string syntax', () => { + // In date-fns, '' represents a literal apostrophe, not an empty string. + expect(momentFormatToDateFns('[] LT')).toBe(' p'); + }); + + it('quotes letters that are not Moment tokens (T in ISO 8601 separator)', () => { + // In Moment, T is a literal; in date-fns T = ms timestamp. Must quote. + expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ss')).toBe("yyyy-MM-dd'T'HH:mm:ss"); + }); + + it('maps day-of-year tokens (DDD, DDDo, DDDD)', () => { + expect(momentFormatToDateFns('DDD')).toBe('D'); + expect(momentFormatToDateFns('DDDo')).toBe('Do'); + expect(momentFormatToDateFns('DDDD')).toBe('DDD'); + }); + + it('maps day-of-week ordinal (do)', () => { + expect(momentFormatToDateFns('do')).toBe('io'); + }); + + it('maps the 6-digit padded year (YYYYYY)', () => { + expect(momentFormatToDateFns('YYYYYY')).toBe('yyyyyy'); + }); + + it('maps timezone abbreviation tokens (z, zz)', () => { + expect(momentFormatToDateFns('z')).toBe('zzz'); + expect(momentFormatToDateFns('zz')).toBe('zzz'); + }); + + it('maps extended fractional-second tokens (SSSS…SSSSSSSSS)', () => { + expect(momentFormatToDateFns('SSSS')).toBe('SSSS'); + expect(momentFormatToDateFns('SSSSSSSSS')).toBe('SSSSSSSSS'); + }); + + it('maps era year (y) and era name (N…NNNNN)', () => { + expect(momentFormatToDateFns('y')).toBe('y'); + expect(momentFormatToDateFns('N')).toBe('G'); + expect(momentFormatToDateFns('NN')).toBe('GG'); + expect(momentFormatToDateFns('NNN')).toBe('GGG'); + expect(momentFormatToDateFns('NNNN')).toBe('GGGG'); + expect(momentFormatToDateFns('NNNNN')).toBe('GGGGG'); + }); + + it('maps lowercase locale variants (l, ll, lll, llll)', () => { + expect(momentFormatToDateFns('l')).toBe('P'); + expect(momentFormatToDateFns('ll')).toBe('PP'); + expect(momentFormatToDateFns('lll')).toBe('PP p'); + expect(momentFormatToDateFns('llll')).toBe('EEE, PP p'); + }); + + it('preserves tokens that are identical between Moment and date-fns (kk, Q, ww)', () => { + // Regression: these used to pass through unchanged; tokenizer now must list them + // explicitly so they aren't quoted as literals. + expect(momentFormatToDateFns('kk:mm')).toBe('kk:mm'); + expect(momentFormatToDateFns('k:mm')).toBe('k:mm'); + expect(momentFormatToDateFns('Q')).toBe('Q'); + expect(momentFormatToDateFns('ww')).toBe('ww'); + }); + + it('maps ISO week-of-year tokens (W, WW, Wo)', () => { + expect(momentFormatToDateFns('W')).toBe('I'); + expect(momentFormatToDateFns('WW')).toBe('II'); + expect(momentFormatToDateFns('Wo')).toBe('Io'); + }); + + it('maps week-year tokens (gg/gggg locale, GG/GGGG ISO)', () => { + expect(momentFormatToDateFns('gg')).toBe('YY'); + expect(momentFormatToDateFns('gggg')).toBe('YYYY'); + expect(momentFormatToDateFns('GG')).toBe('RR'); + expect(momentFormatToDateFns('GGGG')).toBe('RRRR'); + }); + + it('maps Moment timezone offset tokens to date-fns equivalents', () => { + expect(momentFormatToDateFns('Z')).toBe('xxx'); + expect(momentFormatToDateFns('ZZ')).toBe('xx'); + expect(momentFormatToDateFns('Z ZZ')).toBe('xxx xx'); + expect(momentFormatToDateFns('LT Z')).toBe('p xxx'); + expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ssZ')).toBe("yyyy-MM-dd'T'HH:mm:ssxxx"); + }); +}); + +describe('formatDate', () => { + const sample = new Date('2026-04-24T20:30:45'); + + it('formats literal blocks with locale tokens without throwing', () => { + expect(() => formatDate(sample, '[Today at] LT')).not.toThrow(); + expect(formatDate(sample, '[Today at] LT')).toMatch(/^Today at /); + }); + + it('keeps the ISO 8601 T as a literal instead of inserting a ms timestamp', () => { + expect(formatDate(sample, 'YYYY-MM-DDTHH:mm:ss')).toBe('2026-04-24T20:30:45'); + }); + + it('does not throw on Moment timezone tokens', () => { + expect(() => formatDate(sample, 'LT Z')).not.toThrow(); + expect(() => formatDate(sample, 'Z ZZ')).not.toThrow(); + expect(() => formatDate(sample, 'YYYY-MM-DDTHH:mm:ssZ')).not.toThrow(); + }); + + it('falls back instead of crashing on a malformed format', () => { + // Unterminated bracket — translator buffers but date-fns may still refuse. + expect(() => formatDate(sample, '[unterminated')).not.toThrow(); + }); + + it('formats week-year tokens without throwing on date-fns Y warning', () => { + // date-fns refuses Y/YY/YYYY without useAdditionalWeekYearTokens — verify the + // option is wired through. + expect(() => formatDate(sample, 'gggg')).not.toThrow(); + expect(formatDate(sample, 'gggg')).toMatch(/^\d{4}$/); + }); +}); diff --git a/apps/meteor/client/lib/utils/dateFormat.ts b/apps/meteor/client/lib/utils/dateFormat.ts index f09bea095a887..fdcb91dde3344 100644 --- a/apps/meteor/client/lib/utils/dateFormat.ts +++ b/apps/meteor/client/lib/utils/dateFormat.ts @@ -3,54 +3,200 @@ import type { Locale } from 'date-fns'; export type DateInput = string | Date | number; +const FALLBACK_FORMAT = 'PPP p'; // date-fns equivalent of moment's LLL + +const MOMENT_TO_DATE_FNS_TOKENS: ReadonlyArray = ( + [ + // Locale formats + ['LLLL', 'EEEE, PPP p'], + ['LTS', 'pp'], + ['LLL', 'PPP p'], + ['LL', 'PPP'], + ['LT', 'p'], + ['L', 'P'], + // Locale formats — short variants (Moment lowercase l = no zero-padding; + // date-fns has no equivalent without zero-padding, so PP/P is the closest match) + ['llll', 'EEE, PP p'], + ['lll', 'PP p'], + ['ll', 'PP'], + ['l', 'P'], + // Year + ['YYYYYY', 'yyyyyy'], // 6-digit padded; Moment includes a +/- sign that date-fns omits + ['YYYY', 'yyyy'], + ['YY', 'yy'], + ['Y', 'yyyy'], + ['y', 'y'], // Moment lowercase y = era year (always positive); equivalent to calendar year for AD dates + // Era — Moment N/NN/NNN are abbreviated, NNNN wide, NNNNN narrow + ['NNNNN', 'GGGGG'], + ['NNNN', 'GGGG'], + ['NNN', 'GGG'], + ['NN', 'GG'], + ['N', 'G'], + // Month + ['MMMM', 'MMMM'], + ['MMM', 'MMM'], + ['MM', 'MM'], + ['Mo', 'Mo'], + ['M', 'M'], + // Day of month + ['Do', 'do'], + ['DD', 'dd'], + ['D', 'd'], + // Day of year — needs `useAdditionalDayOfYearTokens` on date-fns + ['DDDD', 'DDD'], + ['DDDo', 'Do'], + ['DDD', 'D'], + // Day of week + ['dddd', 'EEEE'], + ['ddd', 'EEE'], + ['dd', 'EEEEEE'], + // Numeric day of week — semantics shift: Moment d/e produce 0-6 (Sun=0), + // date-fns i/c produce 1-7. No exact equivalent exists. + ['do', 'io'], + ['d', 'i'], + ['e', 'c'], + // ISO week of year + ['WW', 'II'], + ['Wo', 'Io'], + ['W', 'I'], + // Week-numbering year — Moment gg/gggg = locale, GG/GGGG = ISO. + // date-fns Y/YY/YYYY (locale) requires `useAdditionalWeekYearTokens`. + ['gggg', 'YYYY'], + ['gg', 'YY'], + ['GGGG', 'RRRR'], + ['GG', 'RR'], + // Hour (H = 0-23, h = 1-12, k = 1-24) + ['HH', 'HH'], + ['H', 'H'], + ['hh', 'hh'], + ['h', 'h'], + ['kk', 'kk'], + ['k', 'k'], + // Minute + ['mm', 'mm'], + ['m', 'm'], + // Second + ['ss', 'ss'], + ['s', 's'], + // Fractional second — JS Date only has ms precision; SSSS+ pad with zeros in both + ['SSSSSSSSS', 'SSSSSSSSS'], + ['SSSSSSSS', 'SSSSSSSS'], + ['SSSSSSS', 'SSSSSSS'], + ['SSSSSS', 'SSSSSS'], + ['SSSSS', 'SSSSS'], + ['SSSS', 'SSSS'], + ['SSS', 'SSS'], + ['SS', 'SS'], + ['S', 'S'], + // AM/PM — date-fns `a`/`aa` are always uppercase (AM/PM); `aaa` is lowercase (am/pm) + ['A', 'a'], + ['a', 'aaa'], + // Quarter + ['QQQQ', 'QQQQ'], + ['QQQ', 'QQQ'], + ['QQ', 'QQ'], + ['Qo', 'Qo'], + ['Q', 'Q'], + // Week of year (locale) + ['ww', 'ww'], + ['wo', 'wo'], + ['w', 'w'], + // ISO day of week (Moment E = 1-7 → date-fns i) + ['E', 'i'], + // Timezone offset (Moment Z = +05:00, ZZ = +0500) + ['ZZ', 'xx'], + ['Z', 'xxx'], + // Timezone abbreviated name (Moment z/zz = "EST"). date-fns has no + // real abbreviation; zzz produces "GMT-3" in browsers. Closest available. + ['zz', 'zzz'], + ['z', 'zzz'], + // Unix timestamp (Moment X = seconds, x = milliseconds) + ['X', 't'], + ['x', 'T'], + ] as Array<[moment: string, dateFns: string]> +).sort((a, b) => b[0].length - a[0].length); + +const LITERAL_LETTER = /[a-zA-Z]/; + /** - * Map moment-style locale format tokens to date-fns format string. - * Used for Message_DateFormat and Message_TimeFormat settings (defaults: LL, LT). + * Translate a Moment.js format string to a date-fns format string. + * + * The two libraries diverge in two important ways that this function bridges: + * 1. Moment treats unrecognized letters as literals (so `T` in `YYYY-MM-DDTHH:mm:ss` + * prints as a literal `T`); date-fns reserves every letter as a token, so an + * unmapped letter either produces wrong output (`T` = ms timestamp) or throws. + * 2. Moment uses `Z`/`ZZ` for timezone offsets; date-fns has no `Z` token at all. + * + * The translator tokenizes left-to-right: it recognizes Moment's `[literal]` escape + * syntax, longest-matches a known Moment token, and quotes any other letter as a + * date-fns literal so admin-configured formats keep working after the moment→date-fns + * migration. Used by Message_DateFormat / Message_TimeFormat / Message_TimeAndDateFormat. */ export const momentFormatToDateFns = (momentFormat: string): string => { - const tokenMap: Record = { - L: 'P', // 09/04/1986 - LT: 'p', // 8:30 PM - LTS: 'pp', // 8:30:00 PM - LL: 'PPP', // September 4, 1986 - LLL: 'PPP p', // September 4, 1986 8:30 PM - LLLL: 'EEEE, PPP p', - // Common tokens - YYYY: 'yyyy', - YY: 'yy', - Y: 'yyyy', - MMMM: 'MMMM', - MMM: 'MMM', - MM: 'MM', - M: 'M', - Do: 'do', // 4th - DD: 'dd', - D: 'd', - dddd: 'EEEE', - ddd: 'EEE', - HH: 'HH', - H: 'H', - hh: 'hh', - h: 'h', - mm: 'mm', - m: 'm', - ss: 'ss', - s: 's', - A: 'a', - a: 'a', + let out = ''; + let literal = ''; + let i = 0; + + const flushLiteral = () => { + if (literal) { + out += `'${literal.replace(/'/g, "''")}'`; + literal = ''; + } }; - let out = momentFormat; - const entries = Object.entries(tokenMap).sort(([a], [b]) => b.length - a.length); - for (const [mom, df] of entries) { - out = out.replace(new RegExp(mom.replace(/([.*+?^${}()|[\]\\])/g, '\\$1'), 'g'), df); + + while (i < momentFormat.length) { + const ch = momentFormat[i]; + + if (ch === '[') { + const end = momentFormat.indexOf(']', i + 1); + if (end !== -1) { + literal += momentFormat.slice(i + 1, end); + i = end + 1; + continue; + } + } + + let matched = false; + for (const [mom, df] of MOMENT_TO_DATE_FNS_TOKENS) { + if (momentFormat.startsWith(mom, i)) { + flushLiteral(); + out += df; + i += mom.length; + matched = true; + break; + } + } + if (matched) continue; + + if (LITERAL_LETTER.test(ch)) { + literal += ch; + } else { + flushLiteral(); + out += ch; + } + i++; } + + flushLiteral(); return out; }; +const safeFormat = (d: Date, momentFormat: string, locale?: Locale): string => { + const options = { + ...(locale && { locale }), + useAdditionalWeekYearTokens: true, + useAdditionalDayOfYearTokens: true, + }; + try { + return format(d, momentFormatToDateFns(momentFormat), options); + } catch { + return format(d, FALLBACK_FORMAT, options); + } +}; + export const formatDate = (date: DateInput, formatStr: string, locale?: Locale): string => { const d = typeof date === 'object' && date instanceof Date ? date : new Date(date); - const dfFormat = momentFormatToDateFns(formatStr); - return format(d, dfFormat, locale ? { locale } : undefined); + return safeFormat(d, formatStr, locale); }; export const formatTimeAgo = ( @@ -70,20 +216,20 @@ export const formatTimeAgo = ( const diffDays = differenceInCalendarDays(now, d); if (diffDays === 0) { - return format(d, momentFormatToDateFns(options.sameDayFormat), locale ? { locale } : undefined); + return safeFormat(d, options.sameDayFormat, locale); } if (diffDays === 1) { if (options.lastDayFormat) { - return `${options.yesterdayLabel} ${format(d, momentFormatToDateFns(options.lastDayFormat), locale ? { locale } : undefined)}`; + return `${options.yesterdayLabel} ${safeFormat(d, options.lastDayFormat, locale)}`; } return options.yesterdayLabel; } if (diffDays > 1 && diffDays < 7) { - return format(d, momentFormatToDateFns(options.lastWeekFormat), locale ? { locale } : undefined); + return safeFormat(d, options.lastWeekFormat, locale); } const diffYears = now.getFullYear() - d.getFullYear(); const fmt = diffYears !== 0 ? options.otherYearFormat : options.otherFormat; - return format(d, momentFormatToDateFns(fmt), locale ? { locale } : undefined); + return safeFormat(d, fmt, locale); }; export const formatFromNow = (date: DateInput, addSuffix: boolean, locale?: Locale): string => {