Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6427,6 +6427,7 @@ const CONST = {
ROOM_NAME_WITHOUT_LIMIT: /^#[\p{Ll}0-9-]+$/u,
DOMAIN_BASE: '^(?:https?:\\/\\/)?(?:www\\.)?([^\\/]+)',
ALPHANUMERIC_WITH_SPACE_AND_HYPHEN: /^[A-Za-z0-9 -]+$/,
ISO_8601_UTC_OFFSET_SUFFIX: /([+-]\d{2}:\d{2})$/,

// eslint-disable-next-line no-misleading-character-class
EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5011,6 +5011,7 @@ ${amount} für ${merchant} – ${date}`,
roomType: 'Zimmertyp',
cancellation: 'Stornierungsrichtlinie',
cancellationUntil: 'Kostenlose Stornierung bis',
hotelLocalTime: 'Ortszeit des Hotels',
confirmation: 'Bestätigungsnummer',
cancellationPolicies: {
unknown: 'Unbekannt',
Expand Down
1 change: 1 addition & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5065,6 +5065,7 @@ ${amount} για ${merchant} - ${date}`,
roomType: 'Τύπος δωματίου',
cancellation: 'Πολιτική ακύρωσης',
cancellationUntil: 'Δωρεάν ακύρωση έως',
hotelLocalTime: 'τοπική ώρα του ξενοδοχείου',
confirmation: 'Αριθμός επιβεβαίωσης',
cancellationPolicies: {
unknown: 'Άγνωστο',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5195,6 +5195,7 @@ const translations = {
roomType: 'Room type',
cancellation: 'Cancellation policy',
cancellationUntil: 'Free cancellation until',
hotelLocalTime: "hotel's local time",
confirmation: 'Confirmation number',
cancellationPolicies: {
unknown: 'Unknown',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4916,6 +4916,7 @@ ${amount} para ${merchant} - ${date}`,
roomType: 'Tipo de habitación',
cancellation: 'Política de cancelación',
cancellationUntil: 'Cancelación gratuita hasta el',
hotelLocalTime: 'hora local del hotel',
confirmation: 'Número de confirmación',
cancellationPolicies: {
unknown: 'Desconocido',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5019,6 +5019,7 @@ ${amount} pour ${merchant} - ${date}`,
roomType: 'Type de chambre',
cancellation: 'Politique d’annulation',
cancellationUntil: 'Annulation gratuite jusqu’au',
hotelLocalTime: 'heure locale de l’hôtel',
confirmation: 'Numéro de confirmation',
cancellationPolicies: {
unknown: 'Inconnu',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4987,6 +4987,7 @@ ${amount} per ${merchant} - ${date}`,
roomType: 'Tipo di camera',
cancellation: 'Politica di cancellazione',
cancellationUntil: 'Cancellazione gratuita fino al',
hotelLocalTime: 'ora locale dell’hotel',
confirmation: 'Numero di conferma',
cancellationPolicies: {
unknown: 'Sconosciuto',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4944,6 +4944,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの'
roomType: '部屋タイプ',
cancellation: 'キャンセルポリシー',
cancellationUntil: '無料キャンセル期限:',
hotelLocalTime: 'ホテルの現地時間',
confirmation: '確認番号',
cancellationPolicies: {
unknown: '不明',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4987,6 +4987,7 @@ ${amount} voor ${merchant} - ${date}`,
roomType: 'Kamertype',
cancellation: 'Annuleringsbeleid',
cancellationUntil: 'Gratis annuleren tot',
hotelLocalTime: 'lokale tijd van het hotel',
confirmation: 'Bevestigingsnummer',
cancellationPolicies: {
unknown: 'Onbekend',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5021,6 +5021,7 @@ ${amount} dla ${merchant} - ${date}`,
roomType: 'Rodzaj pokoju',
cancellation: 'Zasady anulowania',
cancellationUntil: 'Bezpłatne anulowanie do',
hotelLocalTime: 'czas lokalny hotelu',
confirmation: 'Numer potwierdzenia',
cancellationPolicies: {
unknown: 'Nieznane',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4982,6 +4982,7 @@ ${amount} para ${merchant} - ${date}`,
roomType: 'Tipo de quarto',
cancellation: 'Política de cancelamento',
cancellationUntil: 'Cancelamento grátis até',
hotelLocalTime: 'horário local do hotel',
confirmation: 'Número de confirmação',
cancellationPolicies: {
unknown: 'Desconhecido',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4837,6 +4837,7 @@ ${amount},商户:${merchant} - 日期:${date}`,
roomType: '房型',
cancellation: '取消政策',
cancellationUntil: '免费取消截止至',
hotelLocalTime: '酒店当地时间',
confirmation: '确认编号',
cancellationPolicies: {
unknown: '未知',
Expand Down
16 changes: 12 additions & 4 deletions src/libs/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,18 +965,26 @@ function getCancellationDateTimezoneLabel(venueTimezone: string): string {
* 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
* When the string has no offset, `localTimeLabel` (e.g. "hotel's local time") is appended instead of a timezone label, if passed.
*/
function getFormattedCancellationDate(isoDateString: string, dateFnsLocale: DateFnsLocale | undefined): string {
function getFormattedCancellationDate(isoDateString: string, dateFnsLocale: DateFnsLocale | undefined, localTimeLabel?: string): string {
if (!isoDateString) {
return '';
}
const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/);
const offsetMatch = isoDateString.match(CONST.REGEX.ISO_8601_UTC_OFFSET_SUFFIX);
const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC';
const date = new Date(isoDateString);
// A string with no offset and no `Z` is the venue's wall-clock time in an unknown timezone. `new Date` would read it
// in the device's timezone, so read it as-is and leave out the timezone label instead of guessing one.
const isWallClockTime = !offsetMatch && !isoDateString.endsWith('Z');
const date = isWallClockTime ? fromZonedTime(isoDateString, venueTimezone) : new Date(isoDateString);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Decide year visibility in the formatted timezone

When a device timezone crosses a year boundary relative to UTC, this conversion makes the subsequent isThisYear(date) check use a different calendar year from formatInTimeZone. For example, on Dec 31, 2026 in Los Angeles, the wall-clock deadline 2027-01-01T00:30:00 becomes a Date that is still Dec 31 locally, so the formatter outputs Jan 1 without 2027. Compare the year in venueTimezone (or directly from the wall-clock value) before choosing the pattern.

Useful? React with 👍 / 👎.

@ikevin127 ikevin127 Oct 29, 2026 •

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.

🟢 Codex's comment is valid but low impact; I'd treat it as optional (NAB).

Why it happens: isThisYear(date) checks the year in the device's timezone, while formatInTimeZone prints the date in the hotel's timezone. Near New Year the two can fall in different years, so the year gets dropped.

Three caveats:

  • Not new: main already has this bug for every deadline with an offset, which is the London path. The PR only extends it to deadlines without an offset.
  • Narrow: it only happens within a few hours of New Year, and only when the hotel's timezone and yours are on different sides of it.
  • Harmless when it happens: the weekday, date and time are still right; only the year is missing. On Dec 31, "Friday, Jan 1" still reads as tomorrow.

The fix is one line, so it's fine for Melvin to take it. Check the year in the hotel's timezone instead of the device's:

const isSameYear = formatInTimeZone(date, venueTimezone, 'yyyy') === formatInTimeZone(new Date(), venueTimezone, 'yyyy');
const pattern = isSameYear ? `EEEE, MMM d ${CONST.DATE.LOCAL_TIME_FORMAT}` : `EEEE, MMM d, yyyy ${CONST.DATE.LOCAL_TIME_FORMAT}`;

@trjExpensify quick product check: when Spotnana sends a deadline with no timezone (car bookings, or the hotel fallback), we now show the hotel's local time with no label, e.g. Monday, Oct 19 11:59 PM. Before, it showed a shifted time labeled UTC, which was wrong. Hotel deadlines that do have an offset still show it, e.g. GMT+1. Are you ok with no label in that case?

Note

Asked on Slack as well and got a response.

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.

Ended up going with:

  • Hotel, no offset: Free cancellation until Monday, Oct 19 11:59 PM, hotel's local time
  • Hotel, with an offset (unchanged): Free cancellation until Monday, Oct 19 11:59 PM, GMT+1
  • Car: unchanged. No label is passed, so the output stays the same.

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.

If it's 0.5% of bookings, I think it's fine.

I seem to recall this topic before somewhere, and there's some Spotnana limitation of sending us the actual timezone abbreviation for the hotel - which would make this a lot cleaner overall. I.e OCT 19 11:59 PM, BST.

const pattern = isThisYear(date) ? `EEEE, MMM d ${CONST.DATE.LOCAL_TIME_FORMAT}` : `EEEE, MMM d, yyyy ${CONST.DATE.LOCAL_TIME_FORMAT}`;
const formattedDate = formatInTimeZone(date, venueTimezone, pattern, {locale: dateFnsLocale});
if (isWallClockTime) {
return localTimeLabel ? `${formattedDate}, ${localTimeLabel}` : formattedDate;
}
// `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, {locale: dateFnsLocale})}, ${getCancellationDateTimezoneLabel(venueTimezone)}`;
return `${formattedDate}, ${getCancellationDateTimezoneLabel(venueTimezone)}`;
}

/**
Expand Down
42 changes: 13 additions & 29 deletions src/libs/TripReservationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import type IconAsset from '@src/types/utils/IconAsset';

import type {ArrayValues} from 'type-fest';

import {format, subSeconds} from 'date-fns';
import {fromZonedTime, toZonedTime} from 'date-fns-tz';
import {SafeString} from 'expensify-common';

import {getMoneyRequestSpendBreakdown} from './ReportUtils';
Expand Down Expand Up @@ -241,35 +239,21 @@ function getAirReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem
}

function getCancellationDeadline(pnrData: HotelPnr): string | undefined {
// A deadline would show "Free cancellation until …", which is wrong for a non-refundable room.
if (pnrData.room.cancellationPolicy?.policy === CONST.CANCELLATION_POLICY.NON_REFUNDABLE) {
return 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}`;
// `deadlineUtc` with an offset is the exact deadline Spotnana shows, in the hotel's timezone.
if (deadlineUtc && CONST.REGEX.ISO_8601_UTC_OFFSET_SUFFIX.test(deadlineUtc)) {
return deadlineUtc;
}
// `deadline` is usually the hotel's wall-clock time with no offset.
const deadline = pnrData.room.cancellationPolicy?.deadline?.iso8601;
if (deadline) {

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.

If Spotnana sends a deadline on a non-refundable room, like the hotelPnr fixture in TripReservationUtilsTest.ts does, this fallback now returns it, and the hotel page shows "Free cancellation until ..." instead of "Non-refundable". That fixture's expected cancellationDeadline changed from undefined to 2023-10-04T15:00:00Z in this PR. Can we only use the deadline when the policy is FREE_CANCELLATION_UNTIL?

This comment was marked as resolved.

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 Can you verify my response to the comment based on this earlier Slack feedback comment ?

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.

The linked comment doesn't support your reply. It covers only the hotel's local time label and says nothing about non-refundable rooms. I also can't open the Slack thread, so I can't confirm that Spotnana never sends a deadline for a non-refundable room.

lakchote's description of the code is correct:

If Spotnana ever sends a deadline on a NON_REFUNDABLE room, the page would show "Free cancellation until …" instead of "Non-refundable".

My suggestion: Even if the Slack answer holds, add a one-line guard. Skip the deadline when policy === CONST.CANCELLATION_POLICY.NON_REFUNDABLE. That answers lakchote's concern without relying on Spotnana's behavior. I'd skip only NON_REFUNDABLE rather than require FREE_CANCELLATION_UNTIL. A strict match could hide a real deadline if the policy is UNKNOWN or PARTIALLY_REFUNDABLE. I haven't checked whether Spotnana sends deadlines with those policies.

Details
  • The risk isn't new, but the PR widens it. main also returns deadlineUtc without checking the policy. This PR adds deadline as a second source, so there are more ways to reach "Free cancellation until".
  • About the fixture: The hotelPnr fixture is hand-written test data with policy: 'Non-refundable' and a deadline (TripReservationUtilsTest.ts:2096-2102). It doesn't show what Spotnana really sends. It does show the bad path, because its expected value changed to 2023-10-04T15:00:00Z (line 2790). With the guard, that expected value would go back to undefined.
  • Policy values: reservation.cancellationPolicy is Spotnana's raw policy string (TripReservationUtils.ts:296). The hotel page maps it with CONST.CANCELLATION_POLICY, so a guard against CONST.CANCELLATION_POLICY.NON_REFUNDABLE matches the values the page already uses.

Reply @MelvinBot add the guard if you want me to add it, along with a test for a non-refundable room that has a deadline.

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 add the guard

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.

I added the guard in 68c3394. A room whose policy is NON_REFUNDABLE now gets no deadline, so the page shows "Non-refundable" instead of "Free cancellation until …".

Correction to my last comment: The hotelPnr fixture's expected value doesn't go back to undefined. The fixture uses policy: 'Non-refundable', which isn't the NON_REFUNDABLE value the page maps, so the guard doesn't match it. I left the fixture as it is. The new test covers the guard.

Checks: DateUtilsTest and TripReservationUtilsTest pass (189/189). Typecheck and ESLint on both changed files pass.

return deadline;
}
return pnrData.room.cancellationPolicy?.deadlineUtc?.iso8601;
return deadlineUtc;
}

function getHotelReservations(pnr: Pnr, travelers: PnrTraveler[]): ReservationItem[] {
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 @@ -38,7 +38,7 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps)
const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale);
const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale);
const cancellationText = reservation.cancellationDeadline
? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}`
? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale, translate('travel.hotelDetails.hotelLocalTime'))}`
: cancellationMapping[reservation.cancellationPolicy ?? CONST.CANCELLATION_POLICY.UNKNOWN];

const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name;
Expand Down
43 changes: 42 additions & 1 deletion tests/unit/DateUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,11 +979,52 @@ describe('DateUtils', () => {
expect(DateUtils.getFormattedCancellationDate('', undefined)).toBe('');
});

it('should fall back to UTC when no timezone offset is present in the ISO string', () => {
it('should show the wall-clock time without a timezone label when no timezone offset is present in the ISO string', () => {
// Given a wall-clock time with no offset, so its timezone is unknown.
// 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'));

// When it is formatted
const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00', undefined);

// Then the time is shown as-is with no guessed timezone label
expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM');
});

it('should append the local time label when no timezone offset is present in the ISO string', () => {
// Given a hotel-local deadline with no offset, so the hotel page passes a label saying it is the hotel's local time
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-10-01T00:00:00Z'));

// When it is formatted with the label
const result = DateUtils.getFormattedCancellationDate('2026-10-19T23:59:00', undefined, "hotel's local time");

// Then the label is shown at the end in place of a timezone label
expect(result).toBe("Monday, Oct 19 11:59 PM, hotel's local time");
});

it('should keep the GMT offset label instead of the local time label when an offset is present', () => {
// Given a hotel deadline with a known offset
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-10-01T00:00:00Z'));

// When it is formatted with the local time label
const result = DateUtils.getFormattedCancellationDate('2026-10-19T23:59:00+01:00', undefined, "hotel's local time");

// Then the known offset is shown, since it is more precise than the label
expect(result).toBe('Monday, Oct 19 11:59 PM, GMT+1');
});

it('should label a UTC time ending in Z as UTC', () => {
// Given a deadline that ends in Z, so its timezone is known
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T00:00:00Z'));

// When it is formatted
const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00Z', undefined);

// Then it keeps the UTC label
expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC');
});

Expand Down
Loading
Loading