Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
263 lines
7.4 KiB
TypeScript
263 lines
7.4 KiB
TypeScript
import { format, formatDistanceToNow, formatDuration, intervalToDuration, differenceInCalendarDays, parse } from 'date-fns';
|
|
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<readonly [moment: string, dateFns: string]> = (
|
|
[
|
|
// 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]/;
|
|
|
|
/**
|
|
* 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 => {
|
|
let out = '';
|
|
let literal = '';
|
|
let i = 0;
|
|
|
|
const flushLiteral = () => {
|
|
if (literal) {
|
|
out += `'${literal.replace(/'/g, "''")}'`;
|
|
literal = '';
|
|
}
|
|
};
|
|
|
|
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 DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* Normalize a date input to a Date.
|
|
*
|
|
* Calendar-date-only strings (`yyyy-MM-dd`, e.g. produced by an `<input type="date">`)
|
|
* are parsed in LOCAL time, the way Moment.js did before the date-fns migration.
|
|
* `new Date('2026-07-02')` instead parses as UTC midnight, which renders as the
|
|
* previous day in negative-offset timezones (e.g. UTC-3 shows "Jul 1"). Full ISO
|
|
* datetimes, numbers and Date objects keep their original instant semantics.
|
|
*/
|
|
export const toDate = (date: DateInput): Date => {
|
|
if (date instanceof Date) {
|
|
return date;
|
|
}
|
|
if (typeof date === 'string' && DATE_ONLY_RE.test(date)) {
|
|
return parse(date, 'yyyy-MM-dd', new Date());
|
|
}
|
|
return new Date(date);
|
|
};
|
|
|
|
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 => {
|
|
return safeFormat(toDate(date), formatStr, locale);
|
|
};
|
|
|
|
export const formatTimeAgo = (
|
|
date: DateInput,
|
|
options: {
|
|
yesterdayLabel: string;
|
|
sameDayFormat: string;
|
|
lastDayFormat?: string; // if set, show "Yesterday" + format(time, lastDayFormat)
|
|
lastWeekFormat: string;
|
|
otherFormat: string;
|
|
otherYearFormat: string;
|
|
},
|
|
locale?: Locale,
|
|
): string => {
|
|
const d = toDate(date);
|
|
const now = new Date();
|
|
const diffDays = differenceInCalendarDays(now, d);
|
|
|
|
if (diffDays === 0) {
|
|
return safeFormat(d, options.sameDayFormat, locale);
|
|
}
|
|
if (diffDays === 1) {
|
|
if (options.lastDayFormat) {
|
|
return `${options.yesterdayLabel} ${safeFormat(d, options.lastDayFormat, locale)}`;
|
|
}
|
|
return options.yesterdayLabel;
|
|
}
|
|
if (diffDays > 1 && diffDays < 7) {
|
|
return safeFormat(d, options.lastWeekFormat, locale);
|
|
}
|
|
const diffYears = now.getFullYear() - d.getFullYear();
|
|
const fmt = diffYears !== 0 ? options.otherYearFormat : options.otherFormat;
|
|
return safeFormat(d, fmt, locale);
|
|
};
|
|
|
|
export const formatFromNow = (date: DateInput, addSuffix: boolean, locale?: Locale): string => {
|
|
return formatDistanceToNow(toDate(date), { addSuffix, locale });
|
|
};
|
|
|
|
export const formatDurationMs = (timeMs: number, locale?: Locale): string => {
|
|
const duration = intervalToDuration({ start: 0, end: timeMs });
|
|
return formatDuration(duration, { locale });
|
|
};
|