import { getI18n } from '../i18n' function getLocale(): string { try { const { locale } = getI18n().global return typeof locale === 'string' ? locale : locale.value } catch { if (typeof navigator === 'undefined') return 'en-US' return (navigator.languages && navigator.languages.length) ? navigator.languages[0] : navigator.language } } // ─── Types ──────────────────────────────────────────────────────────────────── export type DateInput = Date | number | string // ─── Date convention ──────────────────────────────────────────────────────── // // Server → Client: UTC (defaults to UTC when no TZ info present) // Client → Server: Local time with TZ offset, OR UTC with Z suffix // Server accepts: Anything with TZ info; defaults to UTC when absent // Client accepts: Anything with TZ info; defaults to UTC when absent // Display: Always device-local time (browser's Intl / Date methods) // // ── utc() — parse server dates ────────────────────────────────────────────── // // utc(input) → Date (interpreted as UTC when no TZ info) // utc.iso(input) → normalized ISO string with Z // utc.date(input) → "YYYY-MM-DD" in UTC // utc.ms(input) → epoch milliseconds // // Handles: // • Missing "Z" suffix or TZ offset → assumes UTC // • Space instead of "T" → normalizes // • Strings with explicit TZ offset → respects it // • Already a Date / number → passes through // // ── local() — format for server submission ────────────────────────────────── // // local.iso(date) → ISO string with local TZ offset, e.g. "2026-05-05T11:00:00.000+03:00" // local.offset() → current device TZ offset string, e.g. "+03:00" // function _parseUTC(input: DateInput): Date { if (input instanceof Date) return input if (typeof input === 'number') return new Date(input) // Normalize: "2026-05-05 08:00:00" → "2026-05-05T08:00:00Z" let s = input.trim().replace(' ', 'T') // If no TZ info at all, assume UTC if (!s.endsWith('Z') && !s.includes('+') && !/[+-]\d{2}:\d{2}$/.test(s)) s += 'Z' return new Date(s) } export const utc = Object.assign(_parseUTC, { /** Parse and return normalized ISO string with Z */ iso(input: DateInput): string { return _parseUTC(input).toISOString() }, /** Parse and return UTC date-only string "YYYY-MM-DD" */ date(input: DateInput): string { return _parseUTC(input).toISOString().slice(0, 10) }, /** Parse and return epoch ms */ ms(input: DateInput): number { return _parseUTC(input).getTime() }, }) // ── local — format dates with device TZ for server submission ─────────────── function _tzOffset(date: Date): string { const off = -date.getTimezoneOffset() const sign = off >= 0 ? '+' : '-' const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0') const m = String(Math.abs(off) % 60).padStart(2, '0') return `${sign}${h}:${m}` } function _localISO(date: Date): string { const y = date.getFullYear() const mo = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') const h = String(date.getHours()).padStart(2, '0') const mi = String(date.getMinutes()).padStart(2, '0') const s = String(date.getSeconds()).padStart(2, '0') const ms = String(date.getMilliseconds()).padStart(3, '0') return `${y}-${mo}-${d}T${h}:${mi}:${s}.${ms}${_tzOffset(date)}` } export const local = { /** Format a Date as ISO string with local TZ offset, e.g. "2026-05-05T11:00:00.000+03:00" */ iso(date: Date = new Date()): string { return _localISO(date) }, /** Current device TZ offset string, e.g. "+03:00" or "-05:00" */ offset(date: Date = new Date()): string { return _tzOffset(date) }, } export type TimeDeltaUnit = 'days' | 'hours' | 'minutes' | 'seconds' | 'weeks' | 'months' | 'years' export interface TimeDeltaOptions { days?: number hours?: number minutes?: number seconds?: number weeks?: number months?: number years?: number } export type DateDiffUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' export type DayFormatTypes = 'DD' | 'DDD' | 'DDDD' export type MonthFormatTypes = 'MM' | 'MMM' | 'MMMM' export type YearFormatTypes = 'YY' | 'YYYY' export type HourFormatTypes = 'HH' export type MinuteFormatTypes = 'mm' export type SecondFormatTypes = 'ss' export type MillisecondFormatTypes = 'sss' export type AmPmFormatTypes = 'AmPm' export type DateFormatSeparatorTypes = '/' | '-' | ' ' | ':' | '.' export type CommonDateFormats = | `${DayFormatTypes}${DateFormatSeparatorTypes}${MonthFormatTypes}${DateFormatSeparatorTypes}${YearFormatTypes}` | `${MonthFormatTypes}${DateFormatSeparatorTypes}${YearFormatTypes}` | 'DD.MM.YY' | 'DD.MM.YYYY' | 'DD/MM/YY' | 'DD/MM/YYYY' | 'MM.DD.YY' | 'MM.DD.YYYY' | 'MM/DD/YY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' | 'YY-MM-DD' | 'DD MMM YYYY' | 'DD MMMM YYYY' | 'DDD, DD MMM' | 'DDDD, DD MMMM' | 'MMM DD' | 'MMMM DD' export type CommonTimeFormats = | 'HH:mm' | 'HH:mm:ss' | 'HH:mm:ss:sss' | 'HH:mm AmPm' export type CommonDateTimeFormats = | `${CommonDateFormats} ${CommonTimeFormats}` | `${CommonTimeFormats}, ${CommonDateFormats}` | 'YYYY-MM-DD HH:MM' export type NamedFormats = 'ISO' | 'ISO8601' | 'UTC' | 'RFC2822' | 'RFC3339' | 'UNIX' | 'TIMESTAMP' export type DateTimeAcceptedFormats = | CommonDateFormats | CommonTimeFormats | CommonDateTimeFormats | DayFormatTypes | MonthFormatTypes | YearFormatTypes | HourFormatTypes | MinuteFormatTypes | SecondFormatTypes | MillisecondFormatTypes | NamedFormats export interface FormatDateOptions extends Partial> { format?: DateTimeAcceptedFormats locale?: Intl.LocalesArgument tz?: string } // ─── Time constants ──────────────────────────────────────────────────────────── export const MS = { SECOND: 1_000, MINUTE: 60_000, HOUR: 3_600_000, DAY: 86_400_000, WEEK: 604_800_000, } as const export const { SECOND } = MS export const { MINUTE } = MS export const { HOUR } = MS export const { DAY } = MS export const { WEEK } = MS // ─── Core date arithmetic ────────────────────────────────────────────────────── export function timeDelta(date: DateInput, options: TimeDeltaOptions): Date { const d = new Date(date) if (options.days) d.setDate(d.getDate() + options.days) if (options.hours) d.setHours(d.getHours() + options.hours) if (options.minutes) d.setMinutes(d.getMinutes() + options.minutes) if (options.seconds) d.setSeconds(d.getSeconds() + options.seconds) if (options.weeks) d.setDate(d.getDate() + options.weeks * 7) if (options.months) d.setMonth(d.getMonth() + options.months) if (options.years) d.setFullYear(d.getFullYear() + options.years) return d } const _MS_PER_UNIT: Record = { seconds: 1_000, minutes: 60_000, hours: 3_600_000, days: 86_400_000, weeks: 604_800_000, months: 0, } export function dateDiff(a: DateInput, b: DateInput, unit: DateDiffUnit = 'days'): number { const da = new Date(a) const db = new Date(b) if (unit === 'months') { const years = db.getFullYear() - da.getFullYear() return years * 12 + (db.getMonth() - da.getMonth()) } return Math.floor((db.getTime() - da.getTime()) / _MS_PER_UNIT[unit]) } // ─── DateChain — fluent wrapper ──────────────────────────────────────────────── export class DateChain { private _date: Date constructor(date: DateInput) { this._date = new Date(date) } add(n: number, unit: TimeDeltaUnit = 'days'): DateChain { return new DateChain(timeDelta(this._date, { [unit]: n })) } subtract(n: number, unit: TimeDeltaUnit = 'days'): DateChain { return new DateChain(timeDelta(this._date, { [unit]: -n })) } diff(other: DateInput, unit: DateDiffUnit = 'days'): number { return dateDiff(this._date, other, unit) } isBefore(other: DateInput): boolean { return this._date.getTime() < new Date(other).getTime() } isAfter(other: DateInput): boolean { return this._date.getTime() > new Date(other).getTime() } isSameDay(other: DateInput): boolean { const o = new Date(other) return ( this._date.getFullYear() === o.getFullYear() && this._date.getMonth() === o.getMonth() && this._date.getDate() === o.getDate() ) } toDate(): Date { return new Date(this._date) } valueOf(): number { return this._date.getTime() } toString(): string { return this._date.toISOString() } } // ─── d — blended callable + static namespace ────────────────────────────────── function _d(date?: DateInput): DateChain { return new DateChain(date !== undefined ? date : new Date()) } export const d = Object.assign(_d, { // ── Relative to now ────────────────────────────────────────── get now(): DateChain { return new DateChain(new Date()) }, get today(): DateChain { const t = new Date() t.setHours(0, 0, 0, 0) return new DateChain(t) }, get tomorrow(): DateChain { return new DateChain(timeDelta(new Date(), { days: 1 })) }, get yesterday(): DateChain { return new DateChain(timeDelta(new Date(), { days: -1 })) }, get nextWeek(): DateChain { return new DateChain(timeDelta(new Date(), { weeks: 1 })) }, get lastWeek(): DateChain { return new DateChain(timeDelta(new Date(), { weeks: -1 })) }, // ── Current month ──────────────────────────────────────────── get firstOfMonth(): DateChain { const t = new Date() return new DateChain(new Date(t.getFullYear(), t.getMonth(), 1)) }, get lastOfMonth(): DateChain { const t = new Date() return new DateChain(new Date(t.getFullYear(), t.getMonth() + 1, 0)) }, // ── Current week (Sunday start) ────────────────────────────── get firstOfWeek(): DateChain { const t = new Date() return new DateChain(new Date(t.setDate(t.getDate() - t.getDay()))) }, get lastOfWeek(): DateChain { const t = new Date() return new DateChain(new Date(t.setDate(t.getDate() + (6 - t.getDay())))) }, // ── Next month ─────────────────────────────────────────────── get firstOfNextMonth(): DateChain { const t = new Date() return new DateChain(new Date(t.getFullYear(), t.getMonth() + 1, 1)) }, get lastOfNextMonth(): DateChain { const t = new Date() return new DateChain(new Date(t.getFullYear(), t.getMonth() + 2, 0)) }, // ── Next year ──────────────────────────────────────────────── get firstOfNextYear(): DateChain { return new DateChain(new Date(new Date().getFullYear() + 1, 0, 1)) }, // ── Methods ────────────────────────────────────────────────── in: (n: number, unit: TimeDeltaUnit = 'days'): DateChain => new DateChain(timeDelta(new Date(), { [unit]: n })), ago: (n: number, unit: TimeDeltaUnit = 'days'): DateChain => new DateChain(timeDelta(new Date(), { [unit]: -n })), diff: (a: DateInput, b: DateInput, unit: DateDiffUnit = 'days'): number => dateDiff(a, b, unit), isBefore: (a: DateInput, b: DateInput): boolean => new Date(a).getTime() < new Date(b).getTime(), isAfter: (a: DateInput, b: DateInput): boolean => new Date(a).getTime() > new Date(b).getTime(), isSameDay: (a: DateInput, b: DateInput): boolean => { const da = new Date(a) const db = new Date(b) return ( da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate() ) }, }) // ─── Timezone + formatting ───────────────────────────────────────────────────── export function handleTimezone(date: Date, intFmtOpt: Intl.DateTimeFormatOptions): Date { if (intFmtOpt.timeZone === 'UTC') { const utcDate = new Date(date.getTime()) utcDate.setMinutes(utcDate.getMinutes() + date.getTimezoneOffset()) return utcDate } try { const formatter = new Intl.DateTimeFormat('en-US', { ...intFmtOpt, month: 'numeric' }) const formattedParts = formatter.formatToParts(date) const parts: Record = {} for (const part of formattedParts) { if (part.type !== 'literal' && part.type !== 'timeZoneName') parts[part.type] = Number.parseInt(part.value, 10) } return new Date( parts.year, (parts.month || 1) - 1, parts.day, parts.hour || 0, parts.minute || 0, parts.second || 0, ) } catch (error) { console.warn(`Error handling timezone ${intFmtOpt.timeZone}:`, error) return date } } export function getDatePartsMap(date: Date, locale: Intl.LocalesArgument, intFmtOpt?: Intl.DateTimeFormatOptions) { const d = intFmtOpt?.timeZone ? handleTimezone(date, intFmtOpt) : date const year = d.getFullYear().toString() return { AmPm: d.toLocaleString(locale, { hour: 'numeric', hour12: true, minute: 'numeric' }).split(' ')[1], DD: String(d.getDate()).padStart(2, '0'), DDD: d.toLocaleString(locale, { weekday: 'short' }), DDDD: d.toLocaleString(locale, { weekday: 'long' }), HH: String(d.getHours()).padStart(2, '0'), mm: String(d.getMinutes()).padStart(2, '0'), MM: String(d.getMonth() + 1).padStart(2, '0'), MMM: d.toLocaleString(locale, { month: 'short' }), MMMM: d.toLocaleString(locale, { month: 'long' }), ss: String(d.getSeconds()).padStart(2, '0'), sss: String(d.getMilliseconds()).padStart(3, '0'), YY: year.slice(-2), YYYY: year, } } // Precomputed token regex (module-level, avoid re-creating on every call) const _sampleMap = getDatePartsMap(new Date(), 'en-US') const _orderedTokens = (Object.keys(_sampleMap).sort((a, b) => b.length - a.length)) as (keyof typeof _sampleMap)[] const _tokenRegex = new RegExp(_orderedTokens.join('|'), 'g') export function formatDate(date?: DateInput, format?: DateTimeAcceptedFormats): string export function formatDate(date?: DateInput, opts?: FormatDateOptions): string export function formatDate( date?: DateInput, formatOrOpts?: DateTimeAcceptedFormats | FormatDateOptions, ): string { if (!date) return '' let format: DateTimeAcceptedFormats | undefined let locale: Intl.LocalesArgument | undefined let timeZone: string | undefined let rest: Partial> = {} if (typeof formatOrOpts === 'string') { format = formatOrOpts } else if (formatOrOpts && typeof formatOrOpts === 'object') { format = formatOrOpts.format locale = formatOrOpts.locale timeZone = formatOrOpts.tz rest = formatOrOpts } if (format === 'ISO' || format === 'ISO8601') { const parsed = new Date(date) return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString() } format = format || 'DD.MM.YY' locale = locale || getLocale() try { let parsed = new Date(date) // Handle DD.MM.YY(YY) and DD/MM/YY(YY) strings that new Date() can't parse if (typeof date === 'string' && Number.isNaN(parsed.getTime())) { const parts = date.split(/[/.-]/) if (parts.length === 3) { const [d, m, y] = parts.map(p => Number.parseInt(p, 10)) if (!Number.isNaN(d) && !Number.isNaN(m) && !Number.isNaN(y)) { const fullYear = y < 100 ? 2000 + y : y parsed = new Date(fullYear, m - 1, d) } } } if (Number.isNaN(parsed.getTime())) { console.warn('Invalid date provided to formatDate:', date) return '' } const intFmtOpt: Intl.DateTimeFormatOptions = { day: 'numeric', hour: '2-digit', hour12: rest.hour12 === undefined ? true : rest.hour12, minute: '2-digit', month: 'long', second: '2-digit', timeZone, weekday: 'long', year: 'numeric', } const datePartsMap = getDatePartsMap(parsed, locale, intFmtOpt) const formattedParts = new Intl.DateTimeFormat(locale, intFmtOpt).formatToParts(parsed) const partsMap: Partial> = {} for (const part of formattedParts) { if (part.type !== 'literal') partsMap[part.type] = part.value } if (partsMap.month) { datePartsMap.MMM = partsMap.month.substring(0, 3) datePartsMap.MMMM = partsMap.month } if (partsMap.weekday) { datePartsMap.DDD = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone }).format(parsed) datePartsMap.DDDD = partsMap.weekday } if (partsMap.dayPeriod) { datePartsMap.AmPm = partsMap.dayPeriod } return format.replace(_tokenRegex, match => datePartsMap[match as keyof typeof datePartsMap]) } catch (error) { console.warn(`Error formatting date: ${date} with format: ${format}`, error) return '' } } export const fmtDate = formatDate