diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index 51c90c3..e974a66 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -30,6 +30,7 @@ import { BetsService } from '../../domains/betting/bets.service'; import { ContentService } from '../../domains/operations/content/content.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service'; import { DepositService } from '../../domains/deposit/deposit.service'; +import { isInLocalTodayMatchWindow } from '@thebet365/shared'; import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator'; import { Type } from 'class-transformer'; @@ -104,17 +105,6 @@ function safeTimeZone(input?: string): string { } } -function dayKeyInTimeZone(date: Date, timeZone: string): string { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(date); - const map = new Map(parts.map((part) => [part.type, part.value])); - return `${map.get('year')}-${map.get('month')}-${map.get('day')}`; -} - @ApiTags('Player') @Controller('player') @UseGuards(JwtAuthGuard, PlayerGuard) @@ -191,11 +181,11 @@ export class PlayerController { this.matches.listPublished(locale, undefined, { includeMarkets: false }), ]); const timeZone = safeTimeZone(headerTimeZone); - const todayKey = dayKeyInTimeZone(new Date(), timeZone); + const now = new Date(); const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot); const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) => { const kickoff = new Date(m.startTime); - return !Number.isNaN(kickoff.getTime()) && dayKeyInTimeZone(kickoff, timeZone) === todayKey; + return !Number.isNaN(kickoff.getTime()) && isInLocalTodayMatchWindow(kickoff, now, timeZone); }); return jsonResponse({ banners, diff --git a/apps/api/src/shared/match-time.spec.ts b/apps/api/src/shared/match-time.spec.ts index 7dfd5a7..d7da6b1 100644 --- a/apps/api/src/shared/match-time.spec.ts +++ b/apps/api/src/shared/match-time.spec.ts @@ -1,7 +1,7 @@ import { formatLocalMatchDateTime, - isAfterLocalToday, - isInLocalToday, + isAfterLocalTodayMatchWindow, + isInLocalTodayMatchWindow, isoToPlatformPickerDateTime, platformPickerDateTimeToIso, } from '@thebet365/shared'; @@ -32,10 +32,17 @@ describe('match time helpers', () => { expect(malaysia).not.toBe(newYork); }); - it('uses the player local day for today and early buckets', () => { + it('keeps early next-day matches in the player today bucket until local noon', () => { const now = new Date('2026-06-11T15:00:00-04:00'); - expect(isInLocalToday('2026-06-12T02:00:00.000Z', now, 'America/New_York')).toBe(true); - expect(isAfterLocalToday('2026-06-12T05:00:00.000Z', now, 'America/New_York')).toBe(true); + expect(isInLocalTodayMatchWindow('2026-06-12T02:00:00.000Z', now, 'America/New_York')).toBe( + true, + ); + expect(isInLocalTodayMatchWindow('2026-06-12T15:59:59.000Z', now, 'America/New_York')).toBe( + true, + ); + expect(isAfterLocalTodayMatchWindow('2026-06-12T16:00:00.000Z', now, 'America/New_York')).toBe( + true, + ); }); }); diff --git a/apps/player/src/utils/matchDay.ts b/apps/player/src/utils/matchDay.ts index f77c41b..7f6276b 100644 --- a/apps/player/src/utils/matchDay.ts +++ b/apps/player/src/utils/matchDay.ts @@ -1,5 +1,5 @@ export { - isAfterLocalToday as isAfterTodayMatchWindow, - isInLocalToday as isInTodayMatchWindow, + isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow, + isInLocalTodayMatchWindow as isInTodayMatchWindow, isSameLocalCalendarDay as isSameCalendarDay, } from '@thebet365/shared'; diff --git a/apps/player/src/views/FootballView.vue b/apps/player/src/views/FootballView.vue index 67ea80e..dd0d976 100644 --- a/apps/player/src/views/FootballView.vue +++ b/apps/player/src/views/FootballView.vue @@ -11,8 +11,8 @@ import GoldSpinner from '../components/GoldSpinner.vue'; import { usePullToRefresh } from '../composables/usePullToRefresh'; import type { MatchPhase } from '../utils/matchPhase'; import { - isAfterLocalToday as isAfterTodayMatchWindow, - isInLocalToday as isInTodayMatchWindow, + isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow, + isInLocalTodayMatchWindow as isInTodayMatchWindow, } from '@thebet365/shared'; type MainTab = 'matches' | 'outright'; diff --git a/packages/shared/src/match-time.ts b/packages/shared/src/match-time.ts index 1369f52..ba7f139 100644 --- a/packages/shared/src/match-time.ts +++ b/packages/shared/src/match-time.ts @@ -1,6 +1,7 @@ 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}))?$/; @@ -59,6 +60,63 @@ function offsetMinutesInTimeZone(date: Date, timeZone: string): number { 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; @@ -190,6 +248,28 @@ export function isAfterLocalToday(value: string | Date, now = new Date(), timeZo 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',