diff --git a/packages/web/src/booking/PublicBookingMonthGrid.tsx b/packages/web/src/booking/PublicBookingMonthGrid.tsx index 2530ddbdd5..4cf4dd42d3 100644 --- a/packages/web/src/booking/PublicBookingMonthGrid.tsx +++ b/packages/web/src/booking/PublicBookingMonthGrid.tsx @@ -178,7 +178,6 @@ export function PublicBookingMonthGrid({ } const { day } = cell; const isSelected = selectedDateKey === day.dateKey; - const label = formatBookingMonthDayLabel(day.dateKey, timeZone); if (!day.available) { return (
, + locale?: string, +): ((timeZone: string) => Intl.DateTimeFormat) => { + const cache = new Map(); + return (timeZone: string) => { + const cached = cache.get(timeZone); + if (cached) { + return cached; + } + const formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone }); + cache.set(timeZone, formatter); + return formatter; + }; +}; + +const monthHeadingFormatter = perTimeZoneFormatter({ + month: "long", + year: "numeric", +}); +const timeZoneNameFormatter = perTimeZoneFormatter({ timeZoneName: "long" }); +const slotLabelFormatter = perTimeZoneFormatter({ + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const slotTimeFormatter = perTimeZoneFormatter({ + hour: "numeric", + minute: "2-digit", +}); +const slotDateHeadingFormatter = perTimeZoneFormatter({ + weekday: "long", + month: "long", + day: "numeric", +}); +const monthDayLabelFormatter = perTimeZoneFormatter({ + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", +}); +const weekdayShortFormatter = perTimeZoneFormatter({ weekday: "short" }); +const weekdayLongFormatter = perTimeZoneFormatter({ weekday: "long" }); +/** `en-CA` prints ISO date order, which is what a YYYY-MM-DD key needs. */ +const dateKeyFormatter = perTimeZoneFormatter( + { year: "numeric", month: "2-digit", day: "2-digit" }, + "en-CA", +); + export const isBookingMonthKey = (value: string): boolean => MONTH_KEY_PATTERN.test(value); @@ -40,11 +97,7 @@ export function formatBookingMonthHeading( if (!monthStart) { return monthKey; } - return new Intl.DateTimeFormat(undefined, { - month: "long", - year: "numeric", - timeZone, - }).format(monthStart.toDate()); + return monthHeadingFormatter(timeZone).format(monthStart.toDate()); } function parseBookingMonthStart( @@ -104,10 +157,7 @@ export function isBookingMonthAvailable( export function formatGuestTimeZoneLabel(timeZone: string): string { try { - const parts = new Intl.DateTimeFormat(undefined, { - timeZone, - timeZoneName: "long", - }).formatToParts(new Date()); + const parts = timeZoneNameFormatter(timeZone).formatToParts(new Date()); const name = parts.find((part) => part.type === "timeZoneName")?.value; return name ?? timeZone; } catch { @@ -119,37 +169,21 @@ export function formatBookingSlotLabel( slotStart: string, timeZone: string, ): string { - return new Intl.DateTimeFormat(undefined, { - timeZone, - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(slotStart)); + return slotLabelFormatter(timeZone).format(new Date(slotStart)); } export function formatBookingSlotTime( slotStart: string, timeZone: string, ): string { - return new Intl.DateTimeFormat(undefined, { - timeZone, - hour: "numeric", - minute: "2-digit", - }).format(new Date(slotStart)); + return slotTimeFormatter(timeZone).format(new Date(slotStart)); } export function formatBookingSlotDateHeading( slotStart: string, timeZone: string, ): string { - return new Intl.DateTimeFormat(undefined, { - timeZone, - weekday: "long", - month: "long", - day: "numeric", - }).format(new Date(slotStart)); + return slotDateHeadingFormatter(timeZone).format(new Date(slotStart)); } export function formatDurationMinutes(minutes: number): string { @@ -165,25 +199,7 @@ export function formatDurationMinutes(minutes: number): string { return `${hourLabel} ${remainder} minutes`; } -/** Cache one formatter per timezone; constructing Intl is the expensive part. */ -const dateKeyFormatters = new Map(); - -const dateKeyFormatter = (timeZone: string): Intl.DateTimeFormat => { - const cached = dateKeyFormatters.get(timeZone); - if (cached) { - return cached; - } - const formatter = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - dateKeyFormatters.set(timeZone, formatter); - return formatter; -}; - -/** YYYY-MM-DD in `timeZone`. `en-CA` prints ISO date order. */ +/** YYYY-MM-DD in `timeZone`. */ export function formatBookingDateKey( instant: Date | string | Dayjs, timeZone: string, @@ -319,14 +335,8 @@ export function listBookingWeekdayHeadings( return Array.from({ length: 7 }, (_, index) => { const date = sunday.add(index, "day").toDate(); return { - short: new Intl.DateTimeFormat(undefined, { - weekday: "short", - timeZone, - }).format(date), - long: new Intl.DateTimeFormat(undefined, { - weekday: "long", - timeZone, - }).format(date), + short: weekdayShortFormatter(timeZone).format(date), + long: weekdayLongFormatter(timeZone).format(date), }; }); } @@ -335,13 +345,9 @@ export function formatBookingMonthDayLabel( dateKey: string, timeZone: string, ): string { - return new Intl.DateTimeFormat(undefined, { - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - timeZone, - }).format(dayjs.tz(dateKey, timeZone).toDate()); + return monthDayLabelFormatter(timeZone).format( + dayjs.tz(dateKey, timeZone).toDate(), + ); } export function listBookingAvailableDateKeysInMonth( diff --git a/packages/web/src/booking/public-booking.query.ts b/packages/web/src/booking/public-booking.query.ts index e72da5cf10..ee86687a16 100644 --- a/packages/web/src/booking/public-booking.query.ts +++ b/packages/web/src/booking/public-booking.query.ts @@ -26,23 +26,24 @@ const PUBLIC_BOOKING_SLOTS_STALE_TIME_MS = 60_000; export const publicBookingQueryKeys = { page: (slug: string) => ["public-booking", "page", slug] as const, + /** Prefix covering every month and timezone of one host's slots. */ + slotsAll: (slug: string) => ["public-booking", "slots", slug] as const, slots: (slug: string, monthKey: string, timeZone: string) => - ["public-booking", "slots", slug, monthKey, timeZone] as const, + [...publicBookingQueryKeys.slotsAll(slug), monthKey, timeZone] as const, reservation: (reservationId: string) => ["public-booking", "reservation", reservationId] as const, }; +/** A page or reservation that is gone stays gone; retry once for anything else. */ +const retryUnlessNotFound = (failureCount: number, error: Error): boolean => + !(error instanceof PublicBookingNotFoundError) && failureCount < 1; + function publicBookingPageQueryOptions(slug: string) { return queryOptions({ queryKey: publicBookingQueryKeys.page(slug), queryFn: () => PublicBookingApi.getPage(slug), staleTime: PUBLIC_BOOKING_PAGE_STALE_TIME_MS, - retry: (failureCount, error) => { - if (error instanceof PublicBookingNotFoundError) { - return false; - } - return failureCount < 1; - }, + retry: retryUnlessNotFound, }); } @@ -55,12 +56,7 @@ function publicBookingReservationQueryOptions(reservationId: string) { queryKey: publicBookingQueryKeys.reservation(reservationId), queryFn: () => PublicBookingApi.getReservation(reservationId), staleTime: PUBLIC_BOOKING_PAGE_STALE_TIME_MS, - retry: (failureCount, error) => { - if (error instanceof PublicBookingNotFoundError) { - return false; - } - return failureCount < 1; - }, + retry: retryUnlessNotFound, enabled: Boolean(reservationId), }); } @@ -243,7 +239,7 @@ export function useCreatePublicBookingReservationMutation(slug: string) { PublicBookingApi.createReservation(slug, input), onSuccess: () => { void queryClient.invalidateQueries({ - queryKey: ["public-booking", "slots", slug], + queryKey: publicBookingQueryKeys.slotsAll(slug), }); }, });