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..6929c62ab67bc --- /dev/null +++ b/apps/meteor/client/lib/utils/dateFormat.spec.ts @@ -0,0 +1,43 @@ +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 a'); + }); + + 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('preserves empty literal blocks', () => { + expect(momentFormatToDateFns('[] LT')).toBe("'' p"); + }); +}); + +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('formats date with year-month-day token string', () => { + expect(formatDate(sample, 'YYYY-MM-DD')).toBe('2026-04-24'); + }); +}); diff --git a/apps/meteor/client/lib/utils/dateFormat.ts b/apps/meteor/client/lib/utils/dateFormat.ts index f09bea095a887..2e8d88d099c16 100644 --- a/apps/meteor/client/lib/utils/dateFormat.ts +++ b/apps/meteor/client/lib/utils/dateFormat.ts @@ -6,6 +6,7 @@ export type DateInput = string | Date | number; /** * Map moment-style locale format tokens to date-fns format string. * Used for Message_DateFormat and Message_TimeFormat settings (defaults: LL, LT). + * Moment's `[literal]` escape syntax is translated to date-fns' `'literal'` syntax. */ export const momentFormatToDateFns = (momentFormat: string): string => { const tokenMap: Record = { @@ -39,12 +40,25 @@ export const momentFormatToDateFns = (momentFormat: string): string => { A: 'a', a: 'a', }; - 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); - } - return out; + + const replaceTokens = (input: string): string => { + let out = input; + for (const [mom, df] of entries) { + out = out.replace(new RegExp(mom.replace(/([.*+?^${}()|[\]\\])/g, '\\$1'), 'g'), df); + } + return out; + }; + + return momentFormat + .split(/(\[[^\]]*\])/g) + .map((part) => { + if (part.startsWith('[') && part.endsWith(']')) { + return `'${part.slice(1, -1).replace(/'/g, "''")}'`; + } + return replaceTokens(part); + }) + .join(''); }; export const formatDate = (date: DateInput, formatStr: string, locale?: Locale): string => {