Files
thebet365/packages/shared/src/match-time.ts
wchino 21dd9957f0 refactor: update match time utilities and improve local match window logic
- Introduced new functions `isInLocalTodayMatchWindow` and `isAfterLocalTodayMatchWindow` to enhance match time checks.
- Replaced the deprecated `dayKeyInTimeZone` function with `isInLocalTodayMatchWindow` in the PlayerController.
- Updated related tests and utility exports to reflect the new naming conventions.
- Improved handling of local match windows in various components for better accuracy.
2026-06-13 18:20:26 +08:00

317 lines
9.2 KiB
TypeScript

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)}`;
}