Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1b40531
Display cancellation deadline in venue-local time instead of device t…
MelvinBot Apr 13, 2026
2ed88df
Use parseISO from date-fns instead of new Date() for cancellation dat…
MelvinBot Apr 13, 2026
00171f9
Use parseISO directly without stripping timezone offset
MelvinBot Apr 13, 2026
64853e9
Compute cancellation deadline from checkInDateTime minus duration
MelvinBot Apr 14, 2026
4f1e972
Fix: use undefined instead of null for optional cancellation fields
MelvinBot Apr 14, 2026
d2694e2
Use formatInTimeZone to preserve venue timezone in cancellation dates
MelvinBot Apr 23, 2026
45e2afc
Revert changes to TripReservationUtils.ts and TripData.ts
MelvinBot Apr 23, 2026
0d8c72d
Include timezone abbreviation in cancellation date format
MelvinBot Apr 23, 2026
c8f0de9
Use deadlineUtc for hotel cancellation deadline
MelvinBot Apr 23, 2026
086ab82
Compute cancellation deadline from checkInDateTime minus duration
MelvinBot Apr 14, 2026
01e48b3
Fix getFormattedCancellationDate to handle offset timezones and stabi…
MelvinBot Jun 1, 2026
f8ba298
Restore formatInTimeZone-based cancellation date formatting
MelvinBot Jun 4, 2026
db09e96
Include timezone abbreviation in cancellation date format
MelvinBot Apr 23, 2026
8c93c2a
Derive cancellation timezone label from offset instead of zzz token
MelvinBot Jun 15, 2026
c675c95
Merge remote-tracking branch 'origin/main' into claude-fixCancellatio…
MelvinBot Jun 24, 2026
73c17ff
Compute cancellation deadline in the venue's UTC offset
MelvinBot Jun 25, 2026
909a8a3
Merge remote-tracking branch 'origin/main' into claude-fixCancellatio…
MelvinBot Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions src/libs/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,16 +825,39 @@ function getFormattedTransportDateAndHour(date: Date): {date: string; hour: stri
}

/**
* Returns a formatted cancellation date.
* 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:
* 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(date: Date): string {
if (isThisYear(date)) {
return format(date, 'EEEE, MMM d h:mm a');
function getFormattedCancellationDate(isoDateString: string): string {
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';
// `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)}`;
}

/**
Expand Down
47 changes: 41 additions & 6 deletions src/libs/TripReservationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
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';
Expand Down Expand Up @@ -71,15 +73,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<AirPnr['travelerInfos']>, legIdx: number, flightIdx: number): string | undefined {
Expand Down Expand Up @@ -232,6 +235,38 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem
return reservationList;
}

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 (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;
}

Comment on lines +238 to +269

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot replace this method with

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 (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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced getCancellationDeadline with your version in src/libs/TripReservationUtils.ts:237 and added the required imports (format from date-fns, fromZonedTime/toZonedTime from date-fns-tz; dropped the now-unused parseISO).

Checks run locally on the changed file:

  • lint
  • typecheck (tsgo) ✅
  • prettier
  • npm test tests/unit/TripReservationUtilsTest.ts ✅ (37/37)
  • npm test tests/ui/ReservationAddressTest.tsx ✅ (2/2)

Pushed as 73c17ff.

function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] {
const reservationList: ReservationItem[] = [];

Expand Down Expand Up @@ -274,7 +309,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),
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Travel/CarTripDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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) {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Travel/HotelTripDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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;
Expand Down
6 changes: 6 additions & 0 deletions src/types/onyx/TripData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/DateUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,38 @@ 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
expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7');
});

it('should format without year when date is in the current year', () => {
// 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');
});

it('should return empty string for falsy input', () => {
expect(DateUtils.getFormattedCancellationDate('')).toBe('');
});

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

describe('getRemainingSecondsInWindow', () => {
const windowMs = 30 * 1000;

Expand Down
Loading