From 1b405312e5d1ce188059e294271e2c008a73d0e6 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Mon, 13 Apr 2026 13:29:59 +0000 Subject: [PATCH 01/15] Display cancellation deadline in venue-local time instead of device timezone The cancellation deadline ISO string from Spotnana contains the venue's local time with a timezone offset. Stripping the offset before parsing ensures the date/time is displayed as-is (venue-local), matching Spotnana. Applied to both hotel and car trip details. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 7 ++++++- src/pages/Travel/CarTripDetails.tsx | 2 +- src/pages/Travel/HotelTripDetails.tsx | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index e17429f54562..f8f19d5971d0 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -833,11 +833,16 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri /** * Returns a formatted cancellation date. + * Accepts an ISO 8601 string and strips the timezone offset before parsing, + * so the venue-local date/time is displayed as-is without device timezone conversion. * Dates are formatted as follows: * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ -function getFormattedCancellationDate(date: Date): string { +function getFormattedCancellationDate(isoDateString: string): string { + // Strip timezone offset (e.g., +07:00, -04:00, Z) to treat the date/time as venue-local + const naiveDateString = isoDateString.replace(/([+-]\d{2}:\d{2}|Z)$/, ''); + const date = new Date(naiveDateString); if (isThisYear(date)) { return format(date, 'EEEE, MMM d h:mm a'); } diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index c0c4b600d547..77ecc8b6e6a7 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -26,7 +26,7 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { let cancellationText = reservation.cancellationPolicy; if (reservation.cancellationDeadline) { - cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(new Date(reservation.cancellationDeadline))}`; + cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline)}`; } if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index bc56067165d3..043a1b24649b 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -33,7 +33,7 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); const cancellationText = reservation.cancellationDeadline - ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(new Date(reservation.cancellationDeadline))}` + ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline)}` : cancellationMapping[reservation.cancellationPolicy ?? CONST.CANCELLATION_POLICY.UNKNOWN]; const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; From 2ed88df95a1838404c6a0dd2027091b301c6bd34 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Mon, 13 Apr 2026 13:56:08 +0000 Subject: [PATCH 02/15] Use parseISO from date-fns instead of new Date() for cancellation date parsing Replace new Date(naiveDateString) with parseISO(naiveDateString) in getFormattedCancellationDate as recommended in review. Add unit tests covering timezone offset stripping, current year vs other year formatting, and ISO strings without timezone offsets. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 3 ++- tests/unit/DateUtilsTest.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index f8f19d5971d0..1bb684cf6487 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -23,6 +23,7 @@ import { isThisYear, isValid, parse, + parseISO, set, startOfDay, startOfMonth, @@ -842,7 +843,7 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri function getFormattedCancellationDate(isoDateString: string): string { // Strip timezone offset (e.g., +07:00, -04:00, Z) to treat the date/time as venue-local const naiveDateString = isoDateString.replace(/([+-]\d{2}:\d{2}|Z)$/, ''); - const date = new Date(naiveDateString); + const date = parseISO(naiveDateString); if (isThisYear(date)) { return format(date, 'EEEE, MMM d h:mm a'); } diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 7a5da3ddbb1e..6e3f9761cfb5 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -593,4 +593,40 @@ describe('DateUtils', () => { expect(result).toBe('2024-01-16 04:59:59'); }); }); + + describe('getFormattedCancellationDate', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-15T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should strip positive timezone offset and display venue-local date/time for current year', () => { + const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); + expect(result).toBe('Sunday, Apr 19 3:00 PM'); + }); + + it('should strip negative timezone offset and display venue-local date/time for current year', () => { + const result = DateUtils.getFormattedCancellationDate('2026-03-17T08:00:00-05:00'); + expect(result).toBe('Tuesday, Mar 17 8:00 AM'); + }); + + it('should strip Z offset and display venue-local date/time for current year', () => { + const result = DateUtils.getFormattedCancellationDate('2026-06-10T14:30:00Z'); + expect(result).toBe('Wednesday, Jun 10 2:30 PM'); + }); + + it('should include the year for dates not in the current year', () => { + const result = DateUtils.getFormattedCancellationDate('2023-03-17T08:00:00+05:30'); + expect(result).toBe('Friday, Mar 17, 2023 8:00 AM'); + }); + + it('should handle ISO string without timezone offset', () => { + const result = DateUtils.getFormattedCancellationDate('2026-12-25T09:00:00'); + expect(result).toBe('Friday, Dec 25 9:00 AM'); + }); + }); }); From 00171f911566ffef18bd368bf5288b85e933dcd4 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Mon, 13 Apr 2026 14:38:57 +0000 Subject: [PATCH 03/15] Use parseISO directly without stripping timezone offset Parse isoDateString directly with parseISO as requested in review, removing the manual timezone offset stripping. Updated unit tests to reflect the new behavior. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 6 +----- tests/unit/DateUtilsTest.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 1bb684cf6487..ce8ccb9f6035 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -834,16 +834,12 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri /** * Returns a formatted cancellation date. - * Accepts an ISO 8601 string and strips the timezone offset before parsing, - * so the venue-local date/time is displayed as-is without device timezone conversion. * Dates are formatted as follows: * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ function getFormattedCancellationDate(isoDateString: string): string { - // Strip timezone offset (e.g., +07:00, -04:00, Z) to treat the date/time as venue-local - const naiveDateString = isoDateString.replace(/([+-]\d{2}:\d{2}|Z)$/, ''); - const date = parseISO(naiveDateString); + const date = parseISO(isoDateString); if (isThisYear(date)) { return format(date, 'EEEE, MMM d h:mm a'); } diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 6e3f9761cfb5..7d249f29a908 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -604,24 +604,27 @@ describe('DateUtils', () => { jest.useRealTimers(); }); - it('should strip positive timezone offset and display venue-local date/time for current year', () => { + it('should parse ISO string with positive timezone offset for current year', () => { + // +07:00 offset: 15:00+07:00 = 08:00 UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); - expect(result).toBe('Sunday, Apr 19 3:00 PM'); + expect(result).toBe('Sunday, Apr 19 8:00 AM'); }); - it('should strip negative timezone offset and display venue-local date/time for current year', () => { + it('should parse ISO string with negative timezone offset for current year', () => { + // -05:00 offset: 08:00-05:00 = 13:00 UTC const result = DateUtils.getFormattedCancellationDate('2026-03-17T08:00:00-05:00'); - expect(result).toBe('Tuesday, Mar 17 8:00 AM'); + expect(result).toBe('Tuesday, Mar 17 1:00 PM'); }); - it('should strip Z offset and display venue-local date/time for current year', () => { + it('should parse ISO string with Z offset for current year', () => { const result = DateUtils.getFormattedCancellationDate('2026-06-10T14:30:00Z'); expect(result).toBe('Wednesday, Jun 10 2:30 PM'); }); it('should include the year for dates not in the current year', () => { + // +05:30 offset: 08:00+05:30 = 02:30 UTC const result = DateUtils.getFormattedCancellationDate('2023-03-17T08:00:00+05:30'); - expect(result).toBe('Friday, Mar 17, 2023 8:00 AM'); + expect(result).toBe('Friday, Mar 17, 2023 2:30 AM'); }); it('should handle ISO string without timezone offset', () => { From 64853e907bd0f76147cecc62b8d50bcbf0a2387b Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Tue, 14 Apr 2026 17:12:16 +0000 Subject: [PATCH 04/15] Compute cancellation deadline from checkInDateTime minus duration - Add durationBeforeArrivalDeadline prop to HotelPnr cancellationPolicy type - Update parseDurationToSeconds to support day durations (e.g., P2D, P2DT3H) - Compute hotel cancellationDeadline by subtracting duration from checkInDateTime - Fall back to existing deadline.iso8601 if no duration is available Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 24 ++++++++++++++++++------ src/types/onyx/TripData.ts | 6 ++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index e4badc79e01e..2170cb70308b 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,3 +1,4 @@ +import {parseISO, subSeconds} from 'date-fns'; import type {ArrayValues} from 'type-fest'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; @@ -71,15 +72,16 @@ function getTripReservationCode(reservation: Reservation): string { } function parseDurationToSeconds(duration: string): number { - const regex = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/; + const regex = /P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; const matches = duration.match(regex); if (!matches) { return 0; } - const hours = parseInt(matches[1] || '0', 10); - const minutes = parseInt(matches[2] || '0', 10); - const seconds = parseInt(matches[3] || '0', 10); - return hours * 3600 + minutes * 60 + seconds; + const days = parseInt(matches[1] || '0', 10); + const hours = parseInt(matches[2] || '0', 10); + const minutes = parseInt(matches[3] || '0', 10); + const seconds = parseInt(matches[4] || '0', 10); + return days * 86400 + hours * 3600 + minutes * 60 + seconds; } function getSeatByLegAndFlight(travelerInfo: ArrayValues, legIdx: number, flightIdx: number): string | undefined { @@ -232,6 +234,16 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem return reservationList; } +function getCancellationDeadline(pnrData: HotelPnr): string | null { + const duration = pnrData.room.cancellationPolicy?.durationBeforeArrivalDeadline?.iso8601; + const checkIn = pnrData.checkInDateTime?.iso8601; + if (duration && checkIn) { + const durationSeconds = parseDurationToSeconds(duration); + return subSeconds(parseISO(checkIn), durationSeconds).toISOString(); + } + return pnrData.room.cancellationPolicy?.deadline?.iso8601 ?? null; +} + function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] { const reservationList: ReservationItem[] = []; @@ -274,7 +286,7 @@ function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationIt numberOfRooms: pnrData.numberOfRooms, roomClass: pnrData.room.roomName, cancellationPolicy: pnrData.room.cancellationPolicy?.policy ?? null, - cancellationDeadline: pnrData.room.cancellationPolicy?.deadline?.iso8601 ?? null, + cancellationDeadline: getCancellationDeadline(pnrData), confirmations, travelerPersonalInfo: { name: getTravelerName(traveler), diff --git a/src/types/onyx/TripData.ts b/src/types/onyx/TripData.ts index 702468e36f58..805266941756 100644 --- a/src/types/onyx/TripData.ts +++ b/src/types/onyx/TripData.ts @@ -1235,6 +1235,12 @@ type HotelPnr = { }; /** Policy details for the cancellation. */ policy: string; + + /** Deadline in duration before the check-in date time. */ + durationBeforeArrivalDeadline?: { + /** ISO 8601 format. */ + iso8601: string; + }; }; /** Guarantee type for the room. */ guaranteeType: string; From 4f1e972f23dfee93be117faf9a128909ea22d31f Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Tue, 14 Apr 2026 17:18:00 +0000 Subject: [PATCH 05/15] Fix: use undefined instead of null for optional cancellation fields The cancellationDeadline and cancellationPolicy fields in the ReservationItem type are typed as optional (string | undefined), but the code was assigning null values, causing a TypeScript error. Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 10 +++++----- src/pages/Travel/CarTripDetails.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index 2170cb70308b..8c416141737d 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -234,14 +234,14 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem return reservationList; } -function getCancellationDeadline(pnrData: HotelPnr): string | null { +function getCancellationDeadline(pnrData: HotelPnr): string | undefined { const duration = pnrData.room.cancellationPolicy?.durationBeforeArrivalDeadline?.iso8601; const checkIn = pnrData.checkInDateTime?.iso8601; if (duration && checkIn) { const durationSeconds = parseDurationToSeconds(duration); return subSeconds(parseISO(checkIn), durationSeconds).toISOString(); } - return pnrData.room.cancellationPolicy?.deadline?.iso8601 ?? null; + return pnrData.room.cancellationPolicy?.deadline?.iso8601; } function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] { @@ -285,7 +285,7 @@ function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationIt duration: 0, numberOfRooms: pnrData.numberOfRooms, roomClass: pnrData.room.roomName, - cancellationPolicy: pnrData.room.cancellationPolicy?.policy ?? null, + cancellationPolicy: pnrData.room.cancellationPolicy?.policy, cancellationDeadline: getCancellationDeadline(pnrData), confirmations, travelerPersonalInfo: { @@ -335,8 +335,8 @@ function getCarReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem confirmations, vendor: pnrData.carInfo.vendor.name, carInfo: {name: pnrData.carInfo.carSpec.displayName, engine: pnrData.carInfo.carSpec.engineType}, - cancellationPolicy: pnrData.cancellationPolicy?.policy ?? null, - cancellationDeadline: pnrData.cancellationPolicy?.deadline.iso8601 ?? null, + cancellationPolicy: pnrData.cancellationPolicy?.policy, + cancellationDeadline: pnrData.cancellationPolicy?.deadline.iso8601, duration: 0, travelerPersonalInfo: { name: getTravelerName(traveler), diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 77ecc8b6e6a7..3c80fcaf6b68 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -29,7 +29,7 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline)}`; } - if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { + if (reservation.cancellationPolicy === undefined && reservation.cancellationDeadline === undefined) { cancellationText = translate('travel.carDetails.freeCancellation'); } From d2694e2bd4d135a9d64e42610b3b2a08f172382e Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 23 Apr 2026 13:16:15 +0000 Subject: [PATCH 06/15] Use formatInTimeZone to preserve venue timezone in cancellation dates Switch from parseISO + format to formatInTimeZone from date-fns-tz, which renders the date/time in the venue's timezone (embedded as the UTC offset in the ISO string) instead of converting to device-local time. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 14 +++++++------ tests/unit/DateUtilsTest.ts | 42 +++++++++++++------------------------ 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index ce8ccb9f6035..20bf7c5fece8 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -23,7 +23,6 @@ import { isThisYear, isValid, parse, - parseISO, set, startOfDay, startOfMonth, @@ -833,17 +832,20 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri } /** - * Returns a formatted cancellation date. + * Returns a formatted cancellation date, preserving the venue's timezone from the ISO string offset. * Dates are formatted as follows: * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ function getFormattedCancellationDate(isoDateString: string): string { - const date = parseISO(isoDateString); - if (isThisYear(date)) { - return format(date, 'EEEE, MMM d h:mm a'); + if (!isoDateString) { + return ''; } - return format(date, 'EEEE, MMM d, yyyy h:mm a'); + const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); + const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; + const date = new Date(isoDateString); + const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + return formatInTimeZone(date, venueTimezone, pattern); } /** diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 7d249f29a908..08ee564faac8 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -595,41 +595,27 @@ describe('DateUtils', () => { }); describe('getFormattedCancellationDate', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-06-15T12:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should parse ISO string with positive timezone offset for current year', () => { - // +07:00 offset: 15:00+07:00 = 08:00 UTC + it('should format the date using the venue timezone embedded in the ISO string', () => { + // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); - expect(result).toBe('Sunday, Apr 19 8:00 AM'); - }); - - it('should parse ISO string with negative timezone offset for current year', () => { - // -05:00 offset: 08:00-05:00 = 13:00 UTC - const result = DateUtils.getFormattedCancellationDate('2026-03-17T08:00:00-05:00'); - expect(result).toBe('Tuesday, Mar 17 1:00 PM'); + // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); }); - it('should parse ISO string with Z offset for current year', () => { - const result = DateUtils.getFormattedCancellationDate('2026-06-10T14:30:00Z'); - expect(result).toBe('Wednesday, Jun 10 2:30 PM'); + it('should format without year when date is in the current year', () => { + const currentYear = new Date().getFullYear(); + const isoString = `${currentYear}-06-15T10:30:00+00:00`; + const result = DateUtils.getFormattedCancellationDate(isoString); + expect(result).toBe('Monday, Jun 15 10:30 AM'); }); - it('should include the year for dates not in the current year', () => { - // +05:30 offset: 08:00+05:30 = 02:30 UTC - const result = DateUtils.getFormattedCancellationDate('2023-03-17T08:00:00+05:30'); - expect(result).toBe('Friday, Mar 17, 2023 2:30 AM'); + it('should return empty string for falsy input', () => { + expect(DateUtils.getFormattedCancellationDate('')).toBe(''); }); - it('should handle ISO string without timezone offset', () => { - const result = DateUtils.getFormattedCancellationDate('2026-12-25T09:00:00'); - expect(result).toBe('Friday, Dec 25 9:00 AM'); + it('should fall back to UTC when no timezone offset is present in the ISO string', () => { + const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); }); }); }); From 45e2afccd5c0f1baeac275f53cbddb58de78866c Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 23 Apr 2026 13:27:21 +0000 Subject: [PATCH 07/15] Revert changes to TripReservationUtils.ts and TripData.ts Reverts getCancellationDeadline helper, parseDurationToSeconds day support, durationBeforeArrivalDeadline type, and null-to-undefined changes per review. Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 30 +++++++++-------------------- src/pages/Travel/CarTripDetails.tsx | 2 +- src/types/onyx/TripData.ts | 6 ------ 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index 8c416141737d..e4badc79e01e 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,4 +1,3 @@ -import {parseISO, subSeconds} from 'date-fns'; import type {ArrayValues} from 'type-fest'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; @@ -72,16 +71,15 @@ function getTripReservationCode(reservation: Reservation): string { } function parseDurationToSeconds(duration: string): number { - const regex = /P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; + const regex = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/; const matches = duration.match(regex); if (!matches) { return 0; } - const days = parseInt(matches[1] || '0', 10); - const hours = parseInt(matches[2] || '0', 10); - const minutes = parseInt(matches[3] || '0', 10); - const seconds = parseInt(matches[4] || '0', 10); - return days * 86400 + hours * 3600 + minutes * 60 + seconds; + const hours = parseInt(matches[1] || '0', 10); + const minutes = parseInt(matches[2] || '0', 10); + const seconds = parseInt(matches[3] || '0', 10); + return hours * 3600 + minutes * 60 + seconds; } function getSeatByLegAndFlight(travelerInfo: ArrayValues, legIdx: number, flightIdx: number): string | undefined { @@ -234,16 +232,6 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem return reservationList; } -function getCancellationDeadline(pnrData: HotelPnr): string | undefined { - const duration = pnrData.room.cancellationPolicy?.durationBeforeArrivalDeadline?.iso8601; - const checkIn = pnrData.checkInDateTime?.iso8601; - if (duration && checkIn) { - const durationSeconds = parseDurationToSeconds(duration); - return subSeconds(parseISO(checkIn), durationSeconds).toISOString(); - } - return pnrData.room.cancellationPolicy?.deadline?.iso8601; -} - function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] { const reservationList: ReservationItem[] = []; @@ -285,8 +273,8 @@ function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationIt duration: 0, numberOfRooms: pnrData.numberOfRooms, roomClass: pnrData.room.roomName, - cancellationPolicy: pnrData.room.cancellationPolicy?.policy, - cancellationDeadline: getCancellationDeadline(pnrData), + cancellationPolicy: pnrData.room.cancellationPolicy?.policy ?? null, + cancellationDeadline: pnrData.room.cancellationPolicy?.deadline?.iso8601 ?? null, confirmations, travelerPersonalInfo: { name: getTravelerName(traveler), @@ -335,8 +323,8 @@ function getCarReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem confirmations, vendor: pnrData.carInfo.vendor.name, carInfo: {name: pnrData.carInfo.carSpec.displayName, engine: pnrData.carInfo.carSpec.engineType}, - cancellationPolicy: pnrData.cancellationPolicy?.policy, - cancellationDeadline: pnrData.cancellationPolicy?.deadline.iso8601, + cancellationPolicy: pnrData.cancellationPolicy?.policy ?? null, + cancellationDeadline: pnrData.cancellationPolicy?.deadline.iso8601 ?? null, duration: 0, travelerPersonalInfo: { name: getTravelerName(traveler), diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 3c80fcaf6b68..77ecc8b6e6a7 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -29,7 +29,7 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline)}`; } - if (reservation.cancellationPolicy === undefined && reservation.cancellationDeadline === undefined) { + if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { cancellationText = translate('travel.carDetails.freeCancellation'); } diff --git a/src/types/onyx/TripData.ts b/src/types/onyx/TripData.ts index 805266941756..702468e36f58 100644 --- a/src/types/onyx/TripData.ts +++ b/src/types/onyx/TripData.ts @@ -1235,12 +1235,6 @@ type HotelPnr = { }; /** Policy details for the cancellation. */ policy: string; - - /** Deadline in duration before the check-in date time. */ - durationBeforeArrivalDeadline?: { - /** ISO 8601 format. */ - iso8601: string; - }; }; /** Guarantee type for the room. */ guaranteeType: string; From 0d8c72d322f51a3a500ef76beaa903aafc38f47c Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 23 Apr 2026 13:40:10 +0000 Subject: [PATCH 08/15] Include timezone abbreviation in cancellation date format Add zzz to the format pattern so the venue timezone is displayed alongside the date/time (e.g. "Sunday, Apr 19, 2026 3:00 PM, GMT+7"). Updated unit tests to match. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 6 +++--- tests/unit/DateUtilsTest.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 20bf7c5fece8..b8e0d73eb0e5 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -834,8 +834,8 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri /** * Returns a formatted cancellation date, preserving the venue's timezone from the ISO string offset. * Dates are formatted as follows: - * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM - * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM + * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM, GMT+7 + * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM, GMT+7 */ function getFormattedCancellationDate(isoDateString: string): string { if (!isoDateString) { @@ -844,7 +844,7 @@ function getFormattedCancellationDate(isoDateString: string): string { const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; const date = new Date(isoDateString); - const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a, zzz' : 'EEEE, MMM d, yyyy h:mm a, zzz'; return formatInTimeZone(date, venueTimezone, pattern); } diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 08ee564faac8..cc1bd7f38f15 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -599,14 +599,14 @@ describe('DateUtils', () => { // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7'); }); it('should format without year when date is in the current year', () => { const currentYear = new Date().getFullYear(); const isoString = `${currentYear}-06-15T10:30:00+00:00`; const result = DateUtils.getFormattedCancellationDate(isoString); - expect(result).toBe('Monday, Jun 15 10:30 AM'); + expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); }); it('should return empty string for falsy input', () => { @@ -615,7 +615,7 @@ describe('DateUtils', () => { it('should fall back to UTC when no timezone offset is present in the ISO string', () => { const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00'); - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); }); }); From c8f0de9d321666010c5ddd7329ad68661beeec64 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 23 Apr 2026 15:33:01 +0000 Subject: [PATCH 09/15] Use deadlineUtc for hotel cancellation deadline Switch from deadline to deadlineUtc in hotel reservation so the cancellation deadline string includes proper UTC timezone info. Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index e4badc79e01e..cda0b2e15a86 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -274,7 +274,7 @@ function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationIt numberOfRooms: pnrData.numberOfRooms, roomClass: pnrData.room.roomName, cancellationPolicy: pnrData.room.cancellationPolicy?.policy ?? null, - cancellationDeadline: pnrData.room.cancellationPolicy?.deadline?.iso8601 ?? null, + cancellationDeadline: pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601 ?? null, confirmations, travelerPersonalInfo: { name: getTravelerName(traveler), From 086ab822c4903cbd0dd3bd736167ab220ce058d7 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Tue, 14 Apr 2026 17:12:16 +0000 Subject: [PATCH 10/15] Compute cancellation deadline from checkInDateTime minus duration - Add durationBeforeArrivalDeadline prop to HotelPnr cancellationPolicy type - Update parseDurationToSeconds to support day durations (e.g., P2D, P2DT3H) - Compute hotel cancellationDeadline by subtracting duration from checkInDateTime - Fall back to existing deadline.iso8601 if no duration is available Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 24 ++++++++++++++++++------ src/types/onyx/TripData.ts | 6 ++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index cda0b2e15a86..35b97a23db1d 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,3 +1,4 @@ +import {parseISO, subSeconds} from 'date-fns'; import type {ArrayValues} from 'type-fest'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; @@ -71,15 +72,16 @@ function getTripReservationCode(reservation: Reservation): string { } function parseDurationToSeconds(duration: string): number { - const regex = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/; + const regex = /P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; const matches = duration.match(regex); if (!matches) { return 0; } - const hours = parseInt(matches[1] || '0', 10); - const minutes = parseInt(matches[2] || '0', 10); - const seconds = parseInt(matches[3] || '0', 10); - return hours * 3600 + minutes * 60 + seconds; + const days = parseInt(matches[1] || '0', 10); + const hours = parseInt(matches[2] || '0', 10); + const minutes = parseInt(matches[3] || '0', 10); + const seconds = parseInt(matches[4] || '0', 10); + return days * 86400 + hours * 3600 + minutes * 60 + seconds; } function getSeatByLegAndFlight(travelerInfo: ArrayValues, legIdx: number, flightIdx: number): string | undefined { @@ -232,6 +234,16 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem return reservationList; } +function getCancellationDeadline(pnrData: HotelPnr): string | undefined { + const duration = pnrData.room.cancellationPolicy?.durationBeforeArrivalDeadline?.iso8601; + const checkIn = pnrData.checkInDateTime?.iso8601; + if (duration && checkIn) { + const durationSeconds = parseDurationToSeconds(duration); + return subSeconds(parseISO(checkIn), durationSeconds).toISOString(); + } + return pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601; +} + function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] { const reservationList: ReservationItem[] = []; @@ -274,7 +286,7 @@ function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationIt numberOfRooms: pnrData.numberOfRooms, roomClass: pnrData.room.roomName, cancellationPolicy: pnrData.room.cancellationPolicy?.policy ?? null, - cancellationDeadline: pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601 ?? null, + cancellationDeadline: getCancellationDeadline(pnrData), confirmations, travelerPersonalInfo: { name: getTravelerName(traveler), diff --git a/src/types/onyx/TripData.ts b/src/types/onyx/TripData.ts index 702468e36f58..805266941756 100644 --- a/src/types/onyx/TripData.ts +++ b/src/types/onyx/TripData.ts @@ -1235,6 +1235,12 @@ type HotelPnr = { }; /** Policy details for the cancellation. */ policy: string; + + /** Deadline in duration before the check-in date time. */ + durationBeforeArrivalDeadline?: { + /** ISO 8601 format. */ + iso8601: string; + }; }; /** Guarantee type for the room. */ guaranteeType: string; From 01e48b33ac6c68b6b92e83774ce468261e8212f0 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Mon, 1 Jun 2026 12:55:44 +0000 Subject: [PATCH 11/15] Fix getFormattedCancellationDate to handle offset timezones and stabilize tests formatInTimeZone with a raw numeric offset (e.g. +07:00) and the zzz token threw a RangeError. Derive the timezone label from the offset directly and format the offset-stripped wall-clock time so it stays venue-local. Pin the system clock in the tests so the hardcoded 2026 dates no longer flip year display once real time reaches 2026. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 22 +++++++++++++++++----- tests/unit/DateUtilsTest.ts | 13 ++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index b8e0d73eb0e5..ee82719e72d4 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -23,6 +23,7 @@ import { isThisYear, isValid, parse, + parseISO, set, startOfDay, startOfMonth, @@ -841,11 +842,22 @@ function getFormattedCancellationDate(isoDateString: string): string { if (!isoDateString) { return ''; } - const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); - const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; - const date = new Date(isoDateString); - const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a, zzz' : 'EEEE, MMM d, yyyy h:mm a, zzz'; - return formatInTimeZone(date, venueTimezone, pattern); + // Derive a human-readable timezone label from the offset (e.g. +07:00 -> GMT+7). A zero offset or a missing offset both display as UTC. + const offsetMatch = isoDateString.match(/([+-])(\d{2}):(\d{2})$/); + let timezoneLabel = 'UTC'; + if (offsetMatch) { + const [, sign, hoursStr, minutesStr] = offsetMatch; + const offsetHours = Number(hoursStr); + const offsetMinutes = Number(minutesStr); + if (offsetHours !== 0 || offsetMinutes !== 0) { + timezoneLabel = `GMT${sign}${offsetHours}${offsetMinutes ? `:${minutesStr}` : ''}`; + } + } + // Strip the trailing offset (or Z) so the wall-clock components are read as-is (venue-local) rather than converted to the device's timezone. + const localIsoDateString = isoDateString.replace(/(Z|[+-]\d{2}:\d{2})$/, ''); + const date = parseISO(localIsoDateString); + const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + return `${format(date, pattern)}, ${timezoneLabel}`; } /** diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index cc1bd7f38f15..450c2eb58c66 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -596,6 +596,9 @@ describe('DateUtils', () => { describe('getFormattedCancellationDate', () => { it('should format the date using the venue timezone embedded in the ISO string', () => { + // Pin "now" before 2026 so the 2026 date is treated as a non-current year and the year is shown. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time @@ -603,9 +606,10 @@ describe('DateUtils', () => { }); it('should format without year when date is in the current year', () => { - const currentYear = new Date().getFullYear(); - const isoString = `${currentYear}-06-15T10:30:00+00:00`; - const result = DateUtils.getFormattedCancellationDate(isoString); + // Pin "now" to 2026 so the 2026 date is treated as the current year and the year is omitted. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01T00:00:00Z')); + const result = DateUtils.getFormattedCancellationDate('2026-06-15T10:30:00+00:00'); expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); }); @@ -614,6 +618,9 @@ describe('DateUtils', () => { }); it('should fall back to UTC when no timezone offset is present in the ISO string', () => { + // Pin "now" before 2026 so the 2026 date is treated as a non-current year and the year is shown. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00'); expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); From f8ba2989986de524346198e4517eb29b186ff6db Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 4 Jun 2026 14:56:42 +0000 Subject: [PATCH 12/15] Restore formatInTimeZone-based cancellation date formatting Restore src/libs/DateUtils.ts getFormattedCancellationDate to the formatInTimeZone approach without the GMT timezone label, matching commit d2694e2b. Remove the now-unused parseISO import. Update the unit tests to expect output without the trailing timezone-label suffix, keeping the pinned system clock so year display stays deterministic. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 24 ++++++------------------ tests/unit/DateUtilsTest.ts | 6 +++--- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index ee82719e72d4..20bf7c5fece8 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -23,7 +23,6 @@ import { isThisYear, isValid, parse, - parseISO, set, startOfDay, startOfMonth, @@ -835,29 +834,18 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri /** * Returns a formatted cancellation date, preserving the venue's timezone from the ISO string offset. * Dates are formatted as follows: - * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM, GMT+7 - * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM, GMT+7 + * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM + * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ function getFormattedCancellationDate(isoDateString: string): string { if (!isoDateString) { return ''; } - // Derive a human-readable timezone label from the offset (e.g. +07:00 -> GMT+7). A zero offset or a missing offset both display as UTC. - const offsetMatch = isoDateString.match(/([+-])(\d{2}):(\d{2})$/); - let timezoneLabel = 'UTC'; - if (offsetMatch) { - const [, sign, hoursStr, minutesStr] = offsetMatch; - const offsetHours = Number(hoursStr); - const offsetMinutes = Number(minutesStr); - if (offsetHours !== 0 || offsetMinutes !== 0) { - timezoneLabel = `GMT${sign}${offsetHours}${offsetMinutes ? `:${minutesStr}` : ''}`; - } - } - // Strip the trailing offset (or Z) so the wall-clock components are read as-is (venue-local) rather than converted to the device's timezone. - const localIsoDateString = isoDateString.replace(/(Z|[+-]\d{2}:\d{2})$/, ''); - const date = parseISO(localIsoDateString); + const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); + const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; + const date = new Date(isoDateString); const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; - return `${format(date, pattern)}, ${timezoneLabel}`; + return formatInTimeZone(date, venueTimezone, pattern); } /** diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 450c2eb58c66..cffc9def9c03 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -602,7 +602,7 @@ describe('DateUtils', () => { // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); }); it('should format without year when date is in the current year', () => { @@ -610,7 +610,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-01T00:00:00Z')); const result = DateUtils.getFormattedCancellationDate('2026-06-15T10:30:00+00:00'); - expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); + expect(result).toBe('Monday, Jun 15 10:30 AM'); }); it('should return empty string for falsy input', () => { @@ -622,7 +622,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00'); - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); }); }); }); From db09e96868dadd83d16b4cdc070ba27d1282f531 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 23 Apr 2026 13:40:10 +0000 Subject: [PATCH 13/15] Include timezone abbreviation in cancellation date format Add zzz to the format pattern so the venue timezone is displayed alongside the date/time (e.g. "Sunday, Apr 19, 2026 3:00 PM, GMT+7"). Updated unit tests to match. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 6 +++--- tests/unit/DateUtilsTest.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 20bf7c5fece8..b8e0d73eb0e5 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -834,8 +834,8 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri /** * Returns a formatted cancellation date, preserving the venue's timezone from the ISO string offset. * Dates are formatted as follows: - * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM - * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM + * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM, GMT+7 + * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM, GMT+7 */ function getFormattedCancellationDate(isoDateString: string): string { if (!isoDateString) { @@ -844,7 +844,7 @@ function getFormattedCancellationDate(isoDateString: string): string { const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; const date = new Date(isoDateString); - const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a, zzz' : 'EEEE, MMM d, yyyy h:mm a, zzz'; return formatInTimeZone(date, venueTimezone, pattern); } diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index cffc9def9c03..450c2eb58c66 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -602,7 +602,7 @@ describe('DateUtils', () => { // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00'); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7'); }); it('should format without year when date is in the current year', () => { @@ -610,7 +610,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-01T00:00:00Z')); const result = DateUtils.getFormattedCancellationDate('2026-06-15T10:30:00+00:00'); - expect(result).toBe('Monday, Jun 15 10:30 AM'); + expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); }); it('should return empty string for falsy input', () => { @@ -622,7 +622,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00'); - expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM'); + expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); }); }); From 8c93c2aeea7578cd44c2221fd2e365c5b3f27719 Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Mon, 15 Jun 2026 14:03:18 +0000 Subject: [PATCH 14/15] Derive cancellation timezone label from offset instead of zzz token formatInTimeZone's zzz token uses Intl.DateTimeFormat, which throws on raw offset strings like +07:00. Compute the GMT label from the offset and append it manually so offsets parsed from the ISO string work. Co-authored-by: Eric Han --- src/libs/DateUtils.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index b8e0d73eb0e5..f1e58b2713f7 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -831,6 +831,23 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri }; } +/** + * Returns a human-readable timezone label for an ISO offset (e.g. `+07:00` -> `GMT+7`, `+00:00` -> `UTC`). + */ +function getCancellationDateTimezoneLabel(venueTimezone: string): string { + const match = venueTimezone.match(/^([+-])(\d{2}):(\d{2})$/); + if (!match) { + return 'UTC'; + } + const [, sign, hours, minutes] = match; + const hoursNumber = Number(hours); + const minutesNumber = Number(minutes); + if (hoursNumber === 0 && minutesNumber === 0) { + return 'UTC'; + } + return `GMT${sign}${hoursNumber}${minutesNumber > 0 ? `:${minutes}` : ''}`; +} + /** * Returns a formatted cancellation date, preserving the venue's timezone from the ISO string offset. * Dates are formatted as follows: @@ -844,8 +861,10 @@ function getFormattedCancellationDate(isoDateString: string): string { const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; const date = new Date(isoDateString); - const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a, zzz' : 'EEEE, MMM d, yyyy h:mm a, zzz'; - return formatInTimeZone(date, venueTimezone, pattern); + const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + // `formatInTimeZone`'s `zzz` token relies on `Intl.DateTimeFormat`, which rejects raw offset strings like + // `+07:00`, so the timezone label is derived from the offset and appended manually. + return `${formatInTimeZone(date, venueTimezone, pattern)}, ${getCancellationDateTimezoneLabel(venueTimezone)}`; } /** From 73c17ff5c54e063b697db6088714da4753b5a20a Mon Sep 17 00:00:00 2001 From: "Eric Han (via MelvinBot)" Date: Thu, 25 Jun 2026 10:30:04 +0000 Subject: [PATCH 15/15] Compute cancellation deadline in the venue's UTC offset Co-authored-by: Eric Han --- src/libs/TripReservationUtils.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index cd555bbcb931..dfcd5f40e502 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,4 +1,5 @@ -import {parseISO, subSeconds} from 'date-fns'; +import {format, subSeconds} from 'date-fns'; +import {fromZonedTime, toZonedTime} from 'date-fns-tz'; import type {ArrayValues} from 'type-fest'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; @@ -235,11 +236,33 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem } function getCancellationDeadline(pnrData: HotelPnr): string | undefined { + const deadlineUtc = pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601; const duration = pnrData.room.cancellationPolicy?.durationBeforeArrivalDeadline?.iso8601; const checkIn = pnrData.checkInDateTime?.iso8601; - if (duration && checkIn) { - const durationSeconds = parseDurationToSeconds(duration); - return subSeconds(parseISO(checkIn), durationSeconds).toISOString(); + if (deadlineUtc && duration && checkIn) { + // 1. Extract the target UTC offset from deadlineUtc (e.g. "-04:00") + const match = deadlineUtc.match(/(Z|[+-]\d{2}:\d{2})$/); + if (!match) { + return pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601; + } + const utcOffset = match[1] === 'Z' ? '+00:00' : match[1]; + + // 2. Convert the check-in wall-clock time to an absolute UTC instant, + // treating it as being expressed in the target offset timezone. + const checkInUtc = fromZonedTime(checkIn, utcOffset); + + // 3. Subtract the duration in UTC-space (preserves wall-clock intent for + // day/hour units; DST transitions are irrelevant for fixed offsets). + const deadlineUtcInstant = subSeconds(checkInUtc, parseDurationToSeconds(duration)); + + // 4. Convert back to the target offset's wall-clock representation. + const deadlineZoned = toZonedTime(deadlineUtcInstant, utcOffset); + + // 5. Format as ISO-8601 with the explicit offset suffix. + // `format` with "yyyy-MM-dd'T'HH:mm:ss" gives the bare local datetime; + // we then append the offset so the result is a proper fixed-offset string. + const localPart = format(deadlineZoned, "yyyy-MM-dd'T'HH:mm:ss"); + return `${localPart}${utcOffset}`; } return pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601; }