ts-time-utils
Version:
A comprehensive TypeScript utility library for time, dates, durations, and calendar operations with full tree-shaking support
1,088 lines (1,087 loc) • 39.6 kB
JavaScript
/**
* Internationalization and localization utilities for time formatting
*/
// Default locale configurations
const DEFAULT_LOCALES = {
'en': {
locale: 'en',
dateFormats: {
short: 'M/d/yyyy',
medium: 'MMM d, yyyy',
long: 'MMMM d, yyyy',
full: 'EEEE, MMMM d, yyyy'
},
timeFormats: {
short: 'h:mm a',
medium: 'h:mm:ss a',
long: 'h:mm:ss a z',
full: 'h:mm:ss a zzzz'
},
relativeTime: {
future: 'in {0}',
past: '{0} ago',
units: {
second: 'second',
seconds: 'seconds',
minute: 'minute',
minutes: 'minutes',
hour: 'hour',
hours: 'hours',
day: 'day',
days: 'days',
week: 'week',
weeks: 'weeks',
month: 'month',
months: 'months',
year: 'year',
years: 'years'
}
},
calendar: {
weekStartsOn: 0,
monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
},
numbers: {
decimal: '.',
thousands: ','
}
},
'es': {
locale: 'es',
dateFormats: {
short: 'd/M/yyyy',
medium: 'd MMM yyyy',
long: 'd \'de\' MMMM \'de\' yyyy',
full: 'EEEE, d \'de\' MMMM \'de\' yyyy'
},
timeFormats: {
short: 'H:mm',
medium: 'H:mm:ss',
long: 'H:mm:ss z',
full: 'H:mm:ss zzzz'
},
relativeTime: {
future: 'en {0}',
past: 'hace {0}',
units: {
second: 'segundo',
seconds: 'segundos',
minute: 'minuto',
minutes: 'minutos',
hour: 'hora',
hours: 'horas',
day: 'día',
days: 'días',
week: 'semana',
weeks: 'semanas',
month: 'mes',
months: 'meses',
year: 'año',
years: 'años'
}
},
calendar: {
weekStartsOn: 1,
monthNames: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
monthNamesShort: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
dayNames: ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'],
dayNamesShort: ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb']
},
numbers: {
decimal: ',',
thousands: '.'
}
},
'fr': {
locale: 'fr',
dateFormats: {
short: 'dd/MM/yyyy',
medium: 'd MMM yyyy',
long: 'd MMMM yyyy',
full: 'EEEE d MMMM yyyy'
},
timeFormats: {
short: 'HH:mm',
medium: 'HH:mm:ss',
long: 'HH:mm:ss z',
full: 'HH:mm:ss zzzz'
},
relativeTime: {
future: 'dans {0}',
past: 'il y a {0}',
units: {
second: 'seconde',
seconds: 'secondes',
minute: 'minute',
minutes: 'minutes',
hour: 'heure',
hours: 'heures',
day: 'jour',
days: 'jours',
week: 'semaine',
weeks: 'semaines',
month: 'mois',
months: 'mois',
year: 'année',
years: 'années'
}
},
calendar: {
weekStartsOn: 1,
monthNames: ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
monthNamesShort: ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc'],
dayNames: ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'],
dayNamesShort: ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
},
numbers: {
decimal: ',',
thousands: ' '
}
},
'de': {
locale: 'de',
dateFormats: {
short: 'dd.MM.yyyy',
medium: 'd. MMM yyyy',
long: 'd. MMMM yyyy',
full: 'EEEE, d. MMMM yyyy'
},
timeFormats: {
short: 'HH:mm',
medium: 'HH:mm:ss',
long: 'HH:mm:ss z',
full: 'HH:mm:ss zzzz'
},
relativeTime: {
future: 'in {0}',
past: 'vor {0}',
units: {
second: 'Sekunde',
seconds: 'Sekunden',
minute: 'Minute',
minutes: 'Minuten',
hour: 'Stunde',
hours: 'Stunden',
day: 'Tag',
days: 'Tagen',
week: 'Woche',
weeks: 'Wochen',
month: 'Monat',
months: 'Monaten',
year: 'Jahr',
years: 'Jahren'
}
},
calendar: {
weekStartsOn: 1,
monthNames: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
monthNamesShort: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
dayNames: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
},
numbers: {
decimal: ',',
thousands: '.'
}
},
'zh': {
locale: 'zh',
dateFormats: {
short: 'yyyy/M/d',
medium: 'yyyy年M月d日',
long: 'yyyy年M月d日',
full: 'yyyy年M月d日 EEEE'
},
timeFormats: {
short: 'H:mm',
medium: 'H:mm:ss',
long: 'H:mm:ss z',
full: 'H:mm:ss zzzz'
},
relativeTime: {
future: '{0}后',
past: '{0}前',
units: {
second: '秒',
seconds: '秒',
minute: '分钟',
minutes: '分钟',
hour: '小时',
hours: '小时',
day: '天',
days: '天',
week: '周',
weeks: '周',
month: '个月',
months: '个月',
year: '年',
years: '年'
}
},
calendar: {
weekStartsOn: 1,
monthNames: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
dayNames: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
dayNamesShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
},
numbers: {
decimal: '.',
thousands: ','
}
},
'ja': {
locale: 'ja',
dateFormats: {
short: 'yyyy/MM/dd',
medium: 'yyyy年M月d日',
long: 'yyyy年M月d日',
full: 'yyyy年M月d日 EEEE'
},
timeFormats: {
short: 'H:mm',
medium: 'H:mm:ss',
long: 'H:mm:ss z',
full: 'H:mm:ss zzzz'
},
relativeTime: {
future: '{0}後',
past: '{0}前',
units: {
second: '秒',
seconds: '秒',
minute: '分',
minutes: '分',
hour: '時間',
hours: '時間',
day: '日',
days: '日',
week: '週間',
weeks: '週間',
month: 'ヶ月',
months: 'ヶ月',
year: '年',
years: '年'
}
},
calendar: {
weekStartsOn: 0,
monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
dayNames: ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'],
dayNamesShort: ['日', '月', '火', '水', '木', '金', '土']
},
numbers: {
decimal: '.',
thousands: ','
}
},
'fa': {
locale: 'fa',
dateFormats: {
short: 'yyyy/M/d',
medium: 'd MMM yyyy',
long: 'd MMMM yyyy',
full: 'EEEE، d MMMM yyyy'
},
timeFormats: {
short: 'H:mm',
medium: 'H:mm:ss',
long: 'H:mm:ss z',
full: 'H:mm:ss zzzz'
},
relativeTime: {
future: '{0} دیگر',
past: '{0} پیش',
units: {
second: 'ثانیه',
seconds: 'ثانیه',
minute: 'دقیقه',
minutes: 'دقیقه',
hour: 'ساعت',
hours: 'ساعت',
day: 'روز',
days: 'روز',
week: 'هفته',
weeks: 'هفته',
month: 'ماه',
months: 'ماه',
year: 'سال',
years: 'سال'
}
},
calendar: {
weekStartsOn: 6, // Saturday starts the week in Persian calendar
monthNames: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'],
monthNamesShort: ['فرو', 'ارد', 'خرد', 'تیر', 'مرد', 'شهر', 'مهر', 'آبا', 'آذر', 'دی', 'بهم', 'اسف'],
dayNames: ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'],
dayNamesShort: ['یک', 'دو', 'سه', 'چهار', 'پنج', 'جمع', 'شنب']
},
numbers: {
decimal: '.',
thousands: ','
}
},
'nl': {
locale: 'nl',
dateFormats: {
short: 'd-M-yyyy',
medium: 'd MMM yyyy',
long: 'd MMMM yyyy',
full: 'EEEE d MMMM yyyy'
},
timeFormats: {
short: 'HH:mm',
medium: 'HH:mm:ss',
long: 'HH:mm:ss z',
full: 'HH:mm:ss zzzz'
},
relativeTime: {
future: 'over {0}',
past: '{0} geleden',
units: {
second: 'seconde',
seconds: 'seconden',
minute: 'minuut',
minutes: 'minuten',
hour: 'uur',
hours: 'uur',
day: 'dag',
days: 'dagen',
week: 'week',
weeks: 'weken',
month: 'maand',
months: 'maanden',
year: 'jaar',
years: 'jaar'
}
},
calendar: {
weekStartsOn: 1, // Monday starts the week in Netherlands
monthNames: ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'],
monthNamesShort: ['jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec'],
dayNames: ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'],
dayNamesShort: ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za']
},
numbers: {
decimal: ',',
thousands: '.'
}
},
'it': {
locale: 'it',
dateFormats: {
short: 'dd/MM/yyyy',
medium: 'd MMM yyyy',
long: 'd MMMM yyyy',
full: 'EEEE d MMMM yyyy'
},
timeFormats: {
short: 'HH:mm',
medium: 'HH:mm:ss',
long: 'HH:mm:ss z',
full: 'HH:mm:ss zzzz'
},
relativeTime: {
future: 'tra {0}',
past: '{0} fa',
units: {
second: 'secondo',
seconds: 'secondi',
minute: 'minuto',
minutes: 'minuti',
hour: 'ora',
hours: 'ore',
day: 'giorno',
days: 'giorni',
week: 'settimana',
weeks: 'settimane',
month: 'mese',
months: 'mesi',
year: 'anno',
years: 'anni'
}
},
calendar: {
weekStartsOn: 1, // Monday starts the week in Italy
monthNames: ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'],
monthNamesShort: ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'],
dayNames: ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'],
dayNamesShort: ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab']
},
numbers: {
decimal: ',',
thousands: '.'
}
}
};
// Global locale registry
const localeRegistry = new Map(Object.entries(DEFAULT_LOCALES));
/**
* Register a custom locale configuration
*/
export function registerLocale(config) {
localeRegistry.set(config.locale, config);
// Also register base language if this is a region-specific locale
const baseLang = config.locale.split('-')[0];
if (baseLang && baseLang !== config.locale && !localeRegistry.has(baseLang)) {
localeRegistry.set(baseLang, config);
}
}
/**
* Get locale configuration, with fallback to base language or English
*/
export function getLocaleConfig(locale) {
// Try exact match first
if (localeRegistry.has(locale)) {
return localeRegistry.get(locale);
}
// Try base language (e.g., 'en' for 'en-US')
const baseLang = locale.split('-')[0];
if (baseLang && localeRegistry.has(baseLang)) {
return localeRegistry.get(baseLang);
}
// Fallback to English
return localeRegistry.get('en');
}
/**
* Get list of all registered locales
*/
export function getSupportedLocales() {
return Array.from(localeRegistry.keys());
}
/**
* Format relative time in the specified locale
*/
export function formatRelativeTime(date, options = {}) {
const { locale = 'en', maxUnit = 'years', minUnit = 'seconds', precision = 0, short = false, numeric = 'always', style = 'long' } = options;
const targetDate = normalizeDate(date);
if (!targetDate) {
throw new Error('Invalid date provided for relative time formatting');
}
const now = new Date();
const diffMs = targetDate.getTime() - now.getTime();
const isPast = diffMs < 0;
const absDiffMs = Math.abs(diffMs);
const config = getLocaleConfig(locale);
const units = getTimeUnits();
// Find the most appropriate unit
let selectedUnit = 'seconds';
let value = 0;
// Find maxUnit index to limit our search
const maxUnitIndex = units.findIndex(u => u.name === maxUnit);
const minUnitIndex = units.findIndex(u => u.name === minUnit);
// Find the most appropriate unit by iterating from largest to smallest
for (let i = 0; i < units.length; i++) {
const unit = units[i];
const unitValue = absDiffMs / unit.ms;
// Skip units larger than maxUnit
if (maxUnitIndex >= 0 && i < maxUnitIndex) {
continue;
}
// If this unit gives us a value >= 1, use it
if (unitValue >= 1) {
const roundedValue = precision > 0 ?
parseFloat(unitValue.toFixed(precision)) :
Math.round(unitValue);
selectedUnit = roundedValue === 1 ? unit.singular : unit.plural;
value = roundedValue;
break;
}
// If we've reached the minimum unit, use it even if value < 1
if (unit.name === minUnit) {
// For minimum unit, use floor to avoid rounding up very small values
const flooredValue = precision > 0 ?
parseFloat(unitValue.toFixed(precision)) :
Math.max(0, Math.floor(unitValue));
selectedUnit = flooredValue === 1 ? unit.singular : unit.plural;
value = flooredValue;
break;
}
// If we're at the last unit (seconds) and haven't broken yet, use it
if (i === units.length - 1) {
// For the last unit (seconds), use floor to be precise
const flooredValue = precision > 0 ?
parseFloat(unitValue.toFixed(precision)) :
Math.max(0, Math.floor(unitValue));
selectedUnit = flooredValue === 1 ? unit.singular : unit.plural;
value = flooredValue;
break;
}
}
// Handle special cases for numeric='auto'
if (numeric === 'auto' && Math.abs(value) <= 1) {
return getRelativeWords(selectedUnit, isPast, config, locale);
}
// Format the number
const formattedValue = formatNumber(value, config, precision > 0 ? precision : undefined);
// Get unit text
const unitText = getUnitText(selectedUnit, value, config, short, style);
// Combine value and unit - for Chinese/Japanese, no space between number and unit
const needsSpace = !short && !['zh', 'ja'].includes(locale.split('-')[0]);
const combined = needsSpace ? `${formattedValue} ${unitText}` : `${formattedValue}${unitText}`;
// Apply past/future template
const template = isPast ? config.relativeTime?.past : config.relativeTime?.future;
return template?.replace('{0}', combined) || combined;
}
/**
* Format date in locale-specific format
*/
export function formatDateLocale(date, locale = 'en', style = 'medium') {
const targetDate = normalizeDate(date);
if (!targetDate) {
throw new Error('Invalid date provided for locale formatting');
}
const config = getLocaleConfig(locale);
const pattern = config.dateFormats?.[style] || config.dateFormats?.medium || 'MMM d, yyyy';
return formatWithPattern(targetDate, pattern, config);
}
/**
* Format time in locale-specific format
*/
export function formatTimeLocale(date, locale = 'en', style = 'medium') {
const targetDate = normalizeDate(date);
if (!targetDate) {
throw new Error('Invalid date provided for time locale formatting');
}
const config = getLocaleConfig(locale);
const pattern = config.timeFormats?.[style] || config.timeFormats?.medium || 'h:mm:ss a';
return formatWithPattern(targetDate, pattern, config);
}
/**
* Format both date and time in locale-specific format
*/
export function formatDateTimeLocale(date, locale = 'en', dateStyle = 'medium', timeStyle = 'medium') {
const dateStr = formatDateLocale(date, locale, dateStyle);
const timeStr = formatTimeLocale(date, locale, timeStyle);
// Simple concatenation - could be made more sophisticated per locale
return `${dateStr} ${timeStr}`;
}
/**
* Get localized month names
*/
export function getMonthNames(locale = 'en', short = false) {
const config = getLocaleConfig(locale);
return short ?
(config.calendar?.monthNamesShort || config.calendar?.monthNames || []) :
(config.calendar?.monthNames || []);
}
/**
* Get localized day names
*/
export function getDayNames(locale = 'en', short = false) {
const config = getLocaleConfig(locale);
return short ?
(config.calendar?.dayNamesShort || config.calendar?.dayNames || []) :
(config.calendar?.dayNames || []);
}
/**
* Get the first day of week for a locale (0 = Sunday, 1 = Monday, etc.)
*/
export function getFirstDayOfWeek(locale = 'en') {
const config = getLocaleConfig(locale);
return config.calendar?.weekStartsOn ?? 0;
}
/**
* Check if a locale is supported
*/
export function isLocaleSupported(locale) {
return localeRegistry.has(locale) || localeRegistry.has(locale.split('-')[0]);
}
/**
* Get the best matching locale from a list of preferences
*/
export function getBestMatchingLocale(preferences, fallback = 'en') {
for (const pref of preferences) {
// Try exact match first
if (isLocaleSupported(pref)) {
// If it's a region-specific locale that's not in our registry,
// but the base language is, return the base language
if (localeRegistry.has(pref)) {
return pref;
}
}
// Try base language
const baseLang = pref.split('-')[0];
if (baseLang && baseLang !== pref && isLocaleSupported(baseLang)) {
return baseLang;
}
}
return fallback;
}
/**
* Auto-detect locale from browser or system (if available)
*/
export function detectLocale(fallback = 'en') {
// In browser environment
if (typeof navigator !== 'undefined' && navigator.languages) {
return getBestMatchingLocale(Array.from(navigator.languages), fallback);
}
// Single language fallback
if (typeof navigator !== 'undefined' && navigator.language) {
return getBestMatchingLocale([navigator.language], fallback);
}
// Node.js environment
if (typeof globalThis !== 'undefined' &&
'process' in globalThis &&
typeof globalThis.process === 'object' &&
globalThis.process.env) {
const env = globalThis.process.env;
const envLocales = [
env.LC_ALL,
env.LC_MESSAGES,
env.LANG,
env.LANGUAGE
].filter(Boolean).map(loc => loc.split('.')[0]);
if (envLocales.length > 0) {
return getBestMatchingLocale(envLocales, fallback);
}
}
return fallback;
}
// Helper functions
function normalizeDate(date) {
if (date instanceof Date) {
return isNaN(date.getTime()) ? null : date;
}
if (typeof date === 'string' || typeof date === 'number') {
const parsed = new Date(date);
return isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
function getTimeUnits() {
return [
{ name: 'years', singular: 'year', plural: 'years', ms: 365.25 * 24 * 60 * 60 * 1000 },
{ name: 'months', singular: 'month', plural: 'months', ms: 30.44 * 24 * 60 * 60 * 1000 },
{ name: 'weeks', singular: 'week', plural: 'weeks', ms: 7 * 24 * 60 * 60 * 1000 },
{ name: 'days', singular: 'day', plural: 'days', ms: 24 * 60 * 60 * 1000 },
{ name: 'hours', singular: 'hour', plural: 'hours', ms: 60 * 60 * 1000 },
{ name: 'minutes', singular: 'minute', plural: 'minutes', ms: 60 * 1000 },
{ name: 'seconds', singular: 'second', plural: 'seconds', ms: 1000 }
];
}
function formatNumber(value, config, precision) {
let str = value.toString();
// If precision is specified and value is a whole number that should show decimals
if (precision !== undefined && precision > 0 && Number.isInteger(value)) {
str = value.toFixed(precision);
}
const decimal = config.numbers?.decimal || '.';
const thousands = config.numbers?.thousands || ',';
const parts = str.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousands);
return parts.join(decimal);
}
function getUnitText(unit, value, config, short, style) {
const unitText = config.relativeTime?.units?.[unit] || unit;
if (short || style === 'short') {
// Return abbreviated form - this could be more sophisticated
const abbreviations = {
'second': 's', 'seconds': 's',
'minute': 'm', 'minutes': 'm',
'hour': 'h', 'hours': 'h',
'day': 'd', 'days': 'd',
'week': 'w', 'weeks': 'w',
'month': 'mo', 'months': 'mo',
'year': 'y', 'years': 'y'
};
return abbreviations[unit] || unitText;
}
return unitText;
}
function getRelativeWords(unit, isPast, config, locale) {
// Special relative words for common cases
const specialWords = {
'en': {
'day': { past: 'yesterday', future: 'tomorrow' },
'days': { past: 'yesterday', future: 'tomorrow' }
},
'es': {
'day': { past: 'ayer', future: 'mañana' },
'days': { past: 'ayer', future: 'mañana' }
},
'fr': {
'day': { past: 'hier', future: 'demain' },
'days': { past: 'hier', future: 'demain' }
},
'de': {
'day': { past: 'gestern', future: 'morgen' },
'days': { past: 'gestern', future: 'morgen' }
},
'fa': {
'day': { past: 'دیروز', future: 'فردا' },
'days': { past: 'دیروز', future: 'فردا' }
},
'nl': {
'day': { past: 'gisteren', future: 'morgen' },
'days': { past: 'gisteren', future: 'morgen' }
},
'it': {
'day': { past: 'ieri', future: 'domani' },
'days': { past: 'ieri', future: 'domani' }
}
};
const baseLang = locale.split('-')[0];
const words = specialWords[baseLang]?.[unit];
if (words) {
return isPast ? words.past : words.future;
}
// Fallback to regular format
const unitText = config.relativeTime?.units?.[unit] || unit;
const template = isPast ? config.relativeTime?.past : config.relativeTime?.future;
return template?.replace('{0}', `1 ${unitText}`) || `1 ${unitText}`;
}
function formatWithPattern(date, pattern, config) {
const formatMap = {
'yyyy': date.getFullYear().toString(),
'MMMM': config.calendar?.monthNames?.[date.getMonth()] || (date.getMonth() + 1).toString(),
'MMM': config.calendar?.monthNamesShort?.[date.getMonth()] || (date.getMonth() + 1).toString(),
'MM': (date.getMonth() + 1).toString().padStart(2, '0'),
'M': (date.getMonth() + 1).toString(),
'dd': date.getDate().toString().padStart(2, '0'),
'd': date.getDate().toString(),
'EEEE': config.calendar?.dayNames?.[date.getDay()] || date.getDay().toString(),
'HH': date.getHours().toString().padStart(2, '0'),
'H': date.getHours().toString(),
'h': ((date.getHours() % 12) || 12).toString(),
'mm': date.getMinutes().toString().padStart(2, '0'),
'ss': date.getSeconds().toString().padStart(2, '0'),
'a': date.getHours() < 12 ? 'AM' : 'PM'
};
// Sort by length (longest first) to handle overlapping tokens
const tokens = Object.keys(formatMap).sort((a, b) => b.length - a.length);
let result = pattern;
for (const token of tokens) {
// Replace token only when it appears as a complete token (not part of another)
// Use a more sophisticated approach to avoid conflicts
const tokenRegex = new RegExp(`(?<!\\w)${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`, 'g');
result = result.replace(tokenRegex, formatMap[token]);
}
return result;
}
// ========================================
// LOCALE CONVERSION UTILITIES
// ========================================
/**
* Convert a relative time string from one locale to another
* Attempts to parse the relative time and reformat in target locale
*/
export function convertRelativeTime(relativeTimeString, fromLocale, toLocale) {
if (fromLocale === toLocale) {
return relativeTimeString;
}
const parsedTime = parseRelativeTime(relativeTimeString, fromLocale);
if (!parsedTime) {
return null;
}
return formatRelativeTime(parsedTime.date, {
locale: toLocale,
maxUnit: parsedTime.unit,
precision: parsedTime.precision,
short: parsedTime.isShort,
numeric: parsedTime.numeric
});
}
/**
* Detect the locale of a formatted relative time string
* Returns the most likely locale or null if detection fails
*/
export function detectLocaleFromRelativeTime(relativeTimeString) {
const supportedLocales = getSupportedLocales();
for (const locale of supportedLocales) {
if (parseRelativeTime(relativeTimeString, locale)) {
return locale;
}
}
return null;
}
/**
* Convert a date format pattern from one locale's convention to another
*/
export function convertFormatPattern(pattern, fromLocale, toLocale, style) {
if (fromLocale === toLocale) {
return pattern;
}
const toConfig = getLocaleConfig(toLocale);
// If style is specified, return the target locale's pattern for that style
if (style && toConfig.dateFormats?.[style]) {
return toConfig.dateFormats[style];
}
// Try to map common patterns between locales
const patternMappings = {
'en': {
'M/d/yyyy': 'short',
'MMM d, yyyy': 'medium',
'MMMM d, yyyy': 'long',
'EEEE, MMMM d, yyyy': 'full'
},
'es': {
'd/M/yyyy': 'short',
'd MMM yyyy': 'medium',
'd \'de\' MMMM \'de\' yyyy': 'long',
'EEEE, d \'de\' MMMM \'de\' yyyy': 'full'
},
'fr': {
'dd/MM/yyyy': 'short',
'd MMM yyyy': 'medium',
'd MMMM yyyy': 'long',
'EEEE d MMMM yyyy': 'full'
},
'de': {
'd.M.yyyy': 'short',
'd. MMM yyyy': 'medium',
'd. MMMM yyyy': 'long',
'EEEE, d. MMMM yyyy': 'full'
}
};
// Find matching style from source pattern
const fromMappings = patternMappings[fromLocale];
if (fromMappings) {
const matchedStyle = fromMappings[pattern];
if (matchedStyle && toConfig.dateFormats?.[matchedStyle]) {
return toConfig.dateFormats[matchedStyle];
}
}
// Fallback: return target locale's medium format
return toConfig.dateFormats?.medium || pattern;
}
/**
* Convert a formatted date string from one locale to another
* Attempts to parse the date and reformat in target locale
*/
export function convertFormattedDate(formattedDate, fromLocale, toLocale, targetStyle) {
if (fromLocale === toLocale) {
return formattedDate;
}
const parsedDate = parseFormattedDate(formattedDate, fromLocale);
if (!parsedDate) {
return null;
}
return formatDateLocale(parsedDate, toLocale, targetStyle || 'medium');
}
/**
* Bulk convert an array of relative time strings to a different locale
*/
export function convertRelativeTimeArray(relativeTimeStrings, fromLocale, toLocale) {
return relativeTimeStrings.map(str => convertRelativeTime(str, fromLocale, toLocale));
}
/**
* Get format pattern differences between two locales
*/
export function compareLocaleFormats(locale1, locale2) {
const config1 = getLocaleConfig(locale1);
const config2 = getLocaleConfig(locale2);
const result = {
dateFormats: {},
timeFormats: {},
weekStartsOn: {
locale1: config1.calendar?.weekStartsOn || 0,
locale2: config2.calendar?.weekStartsOn || 0
}
};
// Compare date formats
const styles = ['short', 'medium', 'long', 'full'];
for (const style of styles) {
if (config1.dateFormats?.[style] || config2.dateFormats?.[style]) {
result.dateFormats[style] = {
locale1: config1.dateFormats?.[style] || 'N/A',
locale2: config2.dateFormats?.[style] || 'N/A'
};
}
}
// Compare time formats
for (const style of styles) {
if (config1.timeFormats?.[style] || config2.timeFormats?.[style]) {
result.timeFormats[style] = {
locale1: config1.timeFormats?.[style] || 'N/A',
locale2: config2.timeFormats?.[style] || 'N/A'
};
}
}
return result;
}
// ========================================
// HELPER FUNCTIONS FOR CONVERSIONS
// ========================================
/**
* Parse a relative time string and extract its components
*/
function parseRelativeTime(relativeTimeString, locale) {
const config = getLocaleConfig(locale);
const trimmed = relativeTimeString.trim();
// Try simple patterns first: "2 hours ago", "hace 2 horas", etc.
const pastTemplate = config.relativeTime?.past || '{0} ago';
const futureTemplate = config.relativeTime?.future || 'in {0}';
// Check if it matches past pattern
const pastPrefix = pastTemplate.split('{0}')[0];
const pastSuffix = pastTemplate.split('{0}')[1] || '';
const futurePrefix = futureTemplate.split('{0}')[0];
const futureSuffix = futureTemplate.split('{0}')[1] || '';
let valueAndUnit = '';
let isPast = false;
// Try to extract the value and unit part
if (pastPrefix && trimmed.startsWith(pastPrefix.trim())) {
const remaining = trimmed.substring(pastPrefix.trim().length).trim();
if (!pastSuffix || remaining.endsWith(pastSuffix.trim())) {
valueAndUnit = pastSuffix ? remaining.substring(0, remaining.length - pastSuffix.trim().length).trim() : remaining;
isPast = true;
}
}
else if (pastSuffix && trimmed.endsWith(pastSuffix.trim())) {
const remaining = trimmed.substring(0, trimmed.length - pastSuffix.trim().length).trim();
if (!pastPrefix || remaining.startsWith(pastPrefix.trim())) {
valueAndUnit = pastPrefix ? remaining.substring(pastPrefix.trim().length).trim() : remaining;
isPast = true;
}
}
else if (futurePrefix && trimmed.startsWith(futurePrefix.trim())) {
const remaining = trimmed.substring(futurePrefix.trim().length).trim();
if (!futureSuffix || remaining.endsWith(futureSuffix.trim())) {
valueAndUnit = futureSuffix ? remaining.substring(0, remaining.length - futureSuffix.trim().length).trim() : remaining;
isPast = false;
}
}
else if (futureSuffix && trimmed.endsWith(futureSuffix.trim())) {
const remaining = trimmed.substring(0, trimmed.length - futureSuffix.trim().length).trim();
if (!futurePrefix || remaining.startsWith(futurePrefix.trim())) {
valueAndUnit = futurePrefix ? remaining.substring(futurePrefix.trim().length).trim() : remaining;
isPast = false;
}
}
if (!valueAndUnit)
return null;
// Extract number and unit from something like "2 hours" or "2h"
const match = valueAndUnit.match(/^(\d+(?:\.\d+)?)\s*(.+)$/);
if (!match)
return null;
const value = parseFloat(match[1]);
const unitText = match[2].trim();
if (isNaN(value))
return null;
// Find matching unit
const unit = findRelativeTimeUnit(unitText, config);
if (!unit)
return null;
// Calculate the date
const now = new Date();
const unitMs = getUnitMilliseconds(unit);
const offsetMs = value * unitMs * (isPast ? -1 : 1);
const date = new Date(now.getTime() + offsetMs);
return {
date,
unit,
precision: value % 1 === 0 ? 0 : 1,
isShort: unitText.length <= 2, // heuristic for short format like "h", "m", "d"
numeric: 'always'
};
}
/**
* Parse a formatted date string using locale-specific patterns
*/
function parseFormattedDate(formattedDate, locale) {
const config = getLocaleConfig(locale);
const trimmed = formattedDate.trim();
// Try different date format patterns
const patterns = Object.values(config.dateFormats || {});
for (const pattern of patterns) {
const date = tryParseWithPattern(trimmed, pattern, config);
if (date) {
return date;
}
}
return null;
}
/**
* Find a RelativeTimeUnit from unit text
*/
function findRelativeTimeUnit(unitText, config) {
const units = config.relativeTime?.units;
if (!units)
return null;
// Check exact matches first
for (const [key, value] of Object.entries(units)) {
if (value === unitText) {
return key;
}
}
// Check abbreviations for English and other common cases
const abbreviations = {
// English abbreviations
's': 'seconds',
'sec': 'seconds',
'secs': 'seconds',
'm': 'minutes',
'min': 'minutes',
'mins': 'minutes',
'h': 'hours',
'hr': 'hours',
'hrs': 'hours',
'd': 'days',
'day': 'day',
'days': 'days',
'w': 'weeks',
'wk': 'weeks',
'wks': 'weeks',
'mo': 'months',
'mos': 'months',
'y': 'years',
'yr': 'years',
'yrs': 'years',
// Persian abbreviations
'ث': 'seconds',
'د': 'minutes',
'س': 'hours',
'ر': 'days',
'ه': 'weeks',
'م': 'months',
'ل': 'years'
};
return abbreviations[unitText] || null;
}
/**
* Get milliseconds for a time unit
*/
function getUnitMilliseconds(unit) {
const unitMap = {
'second': 1000,
'seconds': 1000,
'minute': 60 * 1000,
'minutes': 60 * 1000,
'hour': 60 * 60 * 1000,
'hours': 60 * 60 * 1000,
'day': 24 * 60 * 60 * 1000,
'days': 24 * 60 * 60 * 1000,
'week': 7 * 24 * 60 * 60 * 1000,
'weeks': 7 * 24 * 60 * 60 * 1000,
'month': 30.44 * 24 * 60 * 60 * 1000,
'months': 30.44 * 24 * 60 * 60 * 1000,
'year': 365.25 * 24 * 60 * 60 * 1000,
'years': 365.25 * 24 * 60 * 60 * 1000
};
return unitMap[unit] || 1000;
}
/**
* Try to parse a date string with a specific pattern
*/
function tryParseWithPattern(dateString, pattern, config) {
// This is a simplified parser - could be made more sophisticated
// For now, try common patterns
if (pattern === 'M/d/yyyy' || pattern === 'd/M/yyyy') {
const parts = dateString.split('/');
if (parts.length === 3) {
const [first, second, year] = parts.map(p => parseInt(p, 10));
if (!isNaN(first) && !isNaN(second) && !isNaN(year)) {
const month = pattern === 'M/d/yyyy' ? first - 1 : second - 1;
const day = pattern === 'M/d/yyyy' ? second : first;
return new Date(year, month, day);
}
}
}
// Try ISO date parsing as fallback
const isoDate = new Date(dateString);
return isNaN(isoDate.getTime()) ? null : isoDate;
}