export const PLATFORM_TIME_ZONE = 'Asia/Kuala_Lumpur'; export const PLATFORM_TIME_ZONE_OFFSET_MINUTES = 8 * 60; export const PLATFORM_TIME_ZONE_OFFSET_LABEL = 'UTC+8'; export const MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR = 12; const PICKER_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/; function pad2(value: number): string { return String(value).padStart(2, '0'); } function validDate(value: string | Date | null | undefined): Date | null { if (value == null) return null; const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function formatDateTime( date: Date, locale: string, options: Intl.DateTimeFormatOptions, ): string { try { return new Intl.DateTimeFormat(locale || 'en-US', options).format(date); } catch { return new Intl.DateTimeFormat('en-US', options).format(date); } } function timeZoneParts(date: Date, timeZone: string) { const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }).formatToParts(date); return new Map(parts.map((part) => [part.type, part.value])); } function dayKeyInTimeZone(date: Date, timeZone: string): string { const parts = timeZoneParts(date, timeZone); return `${parts.get('year')}-${parts.get('month')}-${parts.get('day')}`; } function offsetMinutesInTimeZone(date: Date, timeZone: string): number { const parts = timeZoneParts(date, timeZone); const asUtc = Date.UTC( Number(parts.get('year')), Number(parts.get('month')) - 1, Number(parts.get('day')), Number(parts.get('hour')), Number(parts.get('minute')), Number(parts.get('second')), ); return Math.round((asUtc - date.getTime()) / 60000); } function zonedWallTimeToUtc( parts: { year: number; month: number; day: number; hour: number; minute?: number; second?: number }, timeZone: string, ): Date { const wallMs = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute ?? 0, parts.second ?? 0, ); let utcMs = wallMs; for (let i = 0; i < 3; i += 1) { utcMs = wallMs - offsetMinutesInTimeZone(new Date(utcMs), timeZone) * 60 * 1000; } return new Date(utcMs); } function localDayParts(date: Date, timeZone: string) { const parts = timeZoneParts(date, timeZone); return { year: Number(parts.get('year')), month: Number(parts.get('month')), day: Number(parts.get('day')), }; } function addDaysToParts(parts: { year: number; month: number; day: number }, days: number) { const d = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days)); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), }; } function localTodayMatchWindow(now = new Date(), timeZone?: string) { if (timeZone) { const today = localDayParts(now, timeZone); const tomorrow = addDaysToParts(today, 1); return { start: zonedWallTimeToUtc({ ...today, hour: 0 }, timeZone), end: zonedWallTimeToUtc( { ...tomorrow, hour: MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR }, timeZone, ), }; } const start = new Date(now); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); end.setHours(MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR, 0, 0, 0); return { start, end }; } function parsePickerParts(value: string) { const match = PICKER_DATETIME_RE.exec(value.trim()); if (!match) return null; const [, y, mo, d, h, mi, s = '00'] = match; const parts = { year: Number(y), month: Number(mo), day: Number(d), hour: Number(h), minute: Number(mi), second: Number(s), }; const wallAsUtc = new Date( Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second), ); if ( wallAsUtc.getUTCFullYear() !== parts.year || wallAsUtc.getUTCMonth() !== parts.month - 1 || wallAsUtc.getUTCDate() !== parts.day || wallAsUtc.getUTCHours() !== parts.hour || wallAsUtc.getUTCMinutes() !== parts.minute || wallAsUtc.getUTCSeconds() !== parts.second ) { return null; } return parts; } export function platformPickerDateTimeToIso(value: string): string { const trimmed = value.trim(); if (!trimmed) return ''; const parts = parsePickerParts(trimmed); if (!parts) return trimmed; const platformWallMs = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, ); return new Date(platformWallMs - PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000).toISOString(); } export function isoToPlatformPickerDateTime(value?: string | null): string { if (!value?.trim()) return ''; const date = validDate(value); if (!date) return value.slice(0, 19); const platform = new Date(date.getTime() + PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000); return [ platform.getUTCFullYear(), pad2(platform.getUTCMonth() + 1), pad2(platform.getUTCDate()), ].join('-') + `T${pad2(platform.getUTCHours())}:${pad2(platform.getUTCMinutes())}:${pad2(platform.getUTCSeconds())}`; } export function isValidIsoDateTime(value: string | Date | null | undefined): boolean { return validDate(value) !== null; } export function formatPlatformMatchDateTime( value: string | Date | null | undefined, locale = 'en-US', ): string { const date = validDate(value); if (!date) return ''; const formatted = formatDateTime(date, locale, { timeZone: PLATFORM_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); return `${formatted} ${PLATFORM_TIME_ZONE_OFFSET_LABEL}`; } export function getLocalGmtOffsetLabel(value: string | Date = new Date(), timeZone?: string): string { const date = validDate(value) ?? new Date(); const offsetMinutes = timeZone ? offsetMinutesInTimeZone(date, timeZone) : -date.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? '+' : '-'; const abs = Math.abs(offsetMinutes); const hours = Math.floor(abs / 60); const minutes = abs % 60; return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${pad2(minutes)}`; } export function isSameLocalCalendarDay( value: string | Date, now = new Date(), timeZone?: string, ): boolean { const date = validDate(value); if (!date) return false; if (timeZone) { return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone); } return ( date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() ); } export function isInLocalToday(value: string | Date, now = new Date(), timeZone?: string): boolean { const date = validDate(value); if (!date) return false; if (timeZone) { return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone); } const start = new Date(now); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return date >= start && date < end; } export function isAfterLocalToday(value: string | Date, now = new Date(), timeZone?: string): boolean { const date = validDate(value); if (!date) return false; if (timeZone) { return dayKeyInTimeZone(date, timeZone) > dayKeyInTimeZone(now, timeZone); } const end = new Date(now); end.setHours(0, 0, 0, 0); end.setDate(end.getDate() + 1); return date >= end; } export function isInLocalTodayMatchWindow( value: string | Date, now = new Date(), timeZone?: string, ): boolean { const date = validDate(value); if (!date) return false; const { start, end } = localTodayMatchWindow(now, timeZone); return date >= start && date < end; } export function isAfterLocalTodayMatchWindow( value: string | Date, now = new Date(), timeZone?: string, ): boolean { const date = validDate(value); if (!date) return false; const { end } = localTodayMatchWindow(now, timeZone); return date >= end; } export function formatLocalMatchDateTime( value: string | Date | null | undefined, locale = 'en-US', options: { variant?: 'compact' | 'full'; todayLabel?: string; includeSeconds?: boolean; includeTimeZone?: boolean; timeZone?: string; } = {}, ): string { const date = validDate(value); if (!date) return ''; const variant = options.variant ?? 'compact'; const time = formatDateTime(date, locale, { ...(options.timeZone ? { timeZone: options.timeZone } : {}), hour: '2-digit', minute: '2-digit', ...(options.includeSeconds ? { second: '2-digit' as const } : {}), }); let text: string; if (variant === 'compact') { if (options.todayLabel && isSameLocalCalendarDay(date, new Date(), options.timeZone)) { text = `${options.todayLabel} ${time}`; } else { const day = formatDateTime(date, locale, { ...(options.timeZone ? { timeZone: options.timeZone } : {}), month: 'numeric', day: 'numeric', }); text = `${day} ${time}`; } } else { const day = formatDateTime(date, locale, { ...(options.timeZone ? { timeZone: options.timeZone } : {}), year: 'numeric', month: '2-digit', day: '2-digit', }); text = `${day} ${time}`; } if (options.includeTimeZone === false) return text; return `${text} ${getLocalGmtOffsetLabel(date, options.timeZone)}`; }