Fix cancellation policy date timezone conversion for hotel and car trips - #87755
Conversation
…imezone 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 <eh2077@users.noreply.github.com>
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
| // 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); |
There was a problem hiding this comment.
@MelvinBot no, you should use method parseISO, according to #87076 (comment). Please update and add necessary unit tests.
There was a problem hiding this comment.
Updated to use parseISO from date-fns instead of new Date(), and added unit tests covering:
- Positive timezone offset stripping (
+07:00) - Negative timezone offset stripping (
-05:00) - UTC indicator stripping (
Z) - Non-current-year formatting (includes year)
- ISO string without timezone offset
…e 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 <eh2077@users.noreply.github.com>
| // 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); |
There was a problem hiding this comment.
@MelvinBot Parse isoDateString directly with method parseISO
| // 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); |
There was a problem hiding this comment.
Done — removed the timezone offset stripping and now parsing isoDateString directly with parseISO. Updated unit tests accordingly.
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 <eh2077@users.noreply.github.com>
|
The only failing check is PR Reviewer Checklist / checklist, which is unrelated to the code changes — it tracks whether reviewers have completed their checklist. All code-related checks (ESLint, Prettier, builds, typecheck, etc.) are passing or still pending. No fix needed. |
|
@MelvinBot Mark it ready for review |
|
🤖 I attempted to mark this PR as ready for review, but the command was not authorized in my current environment.
gh pr ready 87755 --repo Expensify/App |
|
@MelvinBot C+ doesn't have write access to mark PR ready for review. Can you try again? |
|
🤖 Done — PR is now marked as ready for review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00171f9115
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const date = parseISO(isoDateString); | ||
| if (isThisYear(date)) { | ||
| return format(date, 'EEEE, MMM d h:mm a'); |
There was a problem hiding this comment.
Preserve venue-local cancellation time when parsing deadlines
Parsing cancellationDeadline with parseISO() still applies the embedded offset and then format() renders in the device timezone, so users outside the venue timezone will continue to see shifted times (and potentially the wrong day) for cancellation cutoffs. For example, 2026-04-19T15:00:00+07:00 will display as early morning in US timezones instead of 3:00 PM, which defeats the intended Spotnana-aligned behavior and can mislead users about the true cancellation window.
Useful? React with 👍 / 👎.
|
@MelvinBot I think we should use a different approach.
/** Deadline in duration before the check-in date time. */
durationBeforeArrivalDeadline: {
/** ISO 8601 format. */
iso8601: string;
};
Can you try this? |
- 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 <eh2077@users.noreply.github.com>
|
@marcochavezf Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
Done — updated to use the duration-based approach:
|
trjExpensify
left a comment
There was a problem hiding this comment.
@stitesExpensify do you have eyes on this? A couple of thoughts:
- It sounds like the cancellation date and time in Spotnana is the Hotel's local timezone, but it doesn't actually show that. Spotnana would do better here to include the timezone for clarity. Maybe we can suggest it?
- I agree the date/time we add to the "Cancellation policy" row in the product here should be accurate:
- show it "converted" to the local time of the user
- or show it in the "venue" time but include the timezone in the string so it's clear
As Spotnana doesn't include the timezone on their side, our "conversion" to local does make it a bit confusing - appearing as though there's a mismatch. So maybe the second option is the one to go for, for now?
Restore src/libs/DateUtils.ts getFormattedCancellationDate to the formatInTimeZone approach without the GMT timezone label, matching commit d2694e2. 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 <eh2077@users.noreply.github.com>
|
Restored What changed
Why this is safe even though the prior commit moved away from
|
|
@MelvinBot apply this commit 0d8c72d and fix unit tests |
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 <eh2077@users.noreply.github.com>
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 <eh2077@users.noreply.github.com>
|
Applied
Tests: Other checks
|
|
⏱️ Ran out of time before I could finish. This usually means the task was too complex or I got stuck exploring. Please try again with a more specific request or post in #melvinbot-ai. |
|
@MelvinBot merge with main |
…nDateTimezone Co-authored-by: Eric Han <eh2077@users.noreply.github.com>
|
🤖 Merged the latest
|
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
@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;
}There was a problem hiding this comment.
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.
Co-authored-by: Eric Han <eh2077@users.noreply.github.com>
Reviewer Checklist
Screenshots/VideosiOS: mWeb SafariScreen.Recording.2026-06-25.at.8.48.05.PM.movMacOS: Chrome / SafariScreen.Recording.2026-06-25.at.8.44.14.PM.mov |
This comment was marked as resolved.
This comment was marked as resolved.
|
@MelvinBot apply #87755 (comment) as PR checklist |
|
🤖 Done — I've applied the referenced comment as the PR description/checklist. |
|
@MelvinBot resolve conflicts |
…nDateTimezone Co-authored-by: Eric Han <eh2077@users.noreply.github.com> # Conflicts: # tests/unit/DateUtilsTest.ts
|
🤖 Resolved the merge conflict with Conflict:
|
|
@marcochavezf it's your call, thx! |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 marcochavezf has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/marcochavezf in version: 9.4.25-0 🚀
|
|
🚀 Deployed to production by https://github.com/cristipaval in version: 9.4.25-2 🚀
Bundle Size Analysis (Sentry): |






Explanation of Change
For Spotnana hotel reservation data
{ "ancillaries": [], "checkInDateTime": { "iso8601": "2026-04-24T15:00:00" }, "room": { "cancellationPolicy": { "deadlineUtc": { "iso8601": "2026-04-21T23:59:00-04:00" }, "durationBeforeArrivalDeadline": { "iso8601": "P2D" } } } }calculate cancellation date by subtracting durationBeforeArrivalDeadline from checkInDateTime and apply timezone from deadlineUtc. So we can have cancellation message like
Fixed Issues
$ #87076
PROPOSAL: #87076 (comment)
Tests
Offline tests
Same as Tests
QA Steps
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)Avatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari