-
Notifications
You must be signed in to change notification settings - Fork 71
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add client timezone to transaction history (#1911)
Resolve an issue where transaction history grouping does not account for daylight saving time if the user is in a timezone that observes it. - Add a new query parameter named transactionId to transaction history - Group transactions in the history list based on transactionId, If not provided falls back to the old timezone_offset
- Loading branch information
Showing
8 changed files
with
279 additions
and
8 deletions.
There are no files selected for viewing
104 changes: 104 additions & 0 deletions
104
src/routes/transactions/helpers/timezone.helper.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { | ||
calculateTimezoneOffset, | ||
convertToTimezone, | ||
} from '@/routes/transactions/helpers/timezone.helper'; | ||
|
||
describe('Intl', () => { | ||
it('Should ensure Intl timezone is enabled on server', () => { | ||
expect(Intl).toBeDefined(); | ||
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBeDefined(); | ||
}); | ||
}); | ||
|
||
describe('convertToTimezone()', () => { | ||
it('Should correctly convert a date to the specified timezone', () => { | ||
const inputTimeZone = 'Europe/Berlin'; | ||
const inputDate = new Date('2024-09-09T23:00:00Z'); // UTC time | ||
|
||
const expectedDate = new Date('2024-09-10T02:00:00+02:00'); // Berlin Summer time (UTC+2) to UTC | ||
|
||
const result = convertToTimezone(inputDate, inputTimeZone); | ||
|
||
expect(result?.toISOString()).toBe(expectedDate.toISOString()); | ||
}); | ||
|
||
it('Should correctly handle a different timezone', () => { | ||
const inputDate = new Date('2024-09-09T12:00:00Z'); // UTC time | ||
const inputTimeZone = 'America/New_York'; | ||
const expectedDate = new Date('2024-09-08T20:00:00-04:00'); // New York time (UTC-4) | ||
|
||
const result = convertToTimezone(inputDate, inputTimeZone); | ||
|
||
expect(result?.toISOString()).toBe(expectedDate.toISOString()); | ||
}); | ||
|
||
it('Should throw if an invalid timezone provided', () => { | ||
const date = new Date('2024-09-09T12:00:00Z'); // UTC time | ||
const timeZone = 'Invalid/Timezone'; | ||
|
||
const result = (): Date => convertToTimezone(date, timeZone); | ||
|
||
expect(result).toThrow(RangeError); | ||
}); | ||
|
||
it('Should handle a date at midnight UTC correctly', () => { | ||
const date = new Date('2024-09-09T00:00:00Z'); // Midnight UTC | ||
const timeZone = 'Asia/Tokyo'; | ||
const expectedDate = new Date('2024-09-09T09:00:00+09:00'); // Tokyo time (UTC+9) | ||
|
||
const result = convertToTimezone(date, timeZone); | ||
|
||
expect(result?.toISOString()).toBe(expectedDate.toISOString()); | ||
}); | ||
|
||
it('Should return the same date for the same timezone', () => { | ||
const date = new Date('2024-09-09T00:00:00Z'); // UTC time | ||
const timeZone = 'UTC'; | ||
|
||
const result = convertToTimezone(date, timeZone); | ||
|
||
expect(result?.toISOString()).toBe(date.toISOString()); | ||
}); | ||
}); | ||
|
||
describe('calculateTimezoneOffset', () => { | ||
it('Should return the correct UTC date at midnight when given a positive timezone offset', () => { | ||
const timestamp = new Date('2024-09-09T12:00:00Z'); // 12:00 PM UTC | ||
const timezoneOffset = 3 * 60 * 60 * 1000; // 3 hours in milliseconds | ||
|
||
const result = calculateTimezoneOffset(timestamp, timezoneOffset); | ||
const expected = new Date(Date.UTC(2024, 8, 9)); // September is month 8 (0-indexed), should be 2024-09-09T00:00:00Z | ||
|
||
expect(result).toEqual(expected); | ||
}); | ||
|
||
it('Should return the correct UTC date at midnight when given a negative timezone offset', () => { | ||
const timestamp = new Date('2024-09-09T12:00:00Z'); // 12:00 PM UTC | ||
const timezoneOffset = -3 * 60 * 60 * 1000; // -3 hours in milliseconds | ||
|
||
const result = calculateTimezoneOffset(timestamp, timezoneOffset); | ||
const expected = new Date(Date.UTC(2024, 8, 9)); // September is month 8 (0-indexed), should be 2024-09-09T00:00:00Z | ||
|
||
expect(result).toEqual(expected); | ||
}); | ||
|
||
it('Should handle timezone offset resulting in previous or next day correctly', () => { | ||
const inputTimestamp = new Date('2024-09-09T23:00:00Z'); | ||
const inputTimezoneOffset = 2 * 60 * 60 * 1000; // 2 hours in milliseconds | ||
|
||
const result = calculateTimezoneOffset(inputTimestamp, inputTimezoneOffset); | ||
const expected = new Date(Date.UTC(2024, 8, 10)); | ||
|
||
expect(result).toEqual(expected); | ||
}); | ||
|
||
it('Should return the correct UTC date at midnight with zero offset', () => { | ||
const timestamp = new Date('2024-09-09T12:00:00Z'); // 12:00 PM UTC | ||
const timezoneOffset = 0; // No offset | ||
|
||
const result = calculateTimezoneOffset(timestamp, timezoneOffset); | ||
const expected = new Date(Date.UTC(2024, 8, 9)); // September is month 8 (0-indexed), should be 2024-09-09T00:00:00Z | ||
|
||
expect(result).toEqual(expected); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
/** | ||
* Converts a given date to a specified timezone. | ||
* | ||
* @param {Date} date The date object to be converted. | ||
* @param {string} timeZone The target timezone (e.g., "Europe/Berlin"). | ||
* | ||
* @returns {Date} A new Date object representing the date converted to the specified timezone | ||
* @throws {RangeError} Throws if an invalid timezone is sent | ||
*/ | ||
export const convertToTimezone = (date: Date, timeZone: string): Date => { | ||
const convertedDateParts = new Intl.DateTimeFormat(undefined, { | ||
timeZone, | ||
year: 'numeric', | ||
month: '2-digit', | ||
day: '2-digit', | ||
}).formatToParts(date); | ||
|
||
const year = ~~convertedDateParts.find((part) => part.type === 'year')!.value; | ||
const month = ~~convertedDateParts.find((part) => part.type === 'month')! | ||
.value; | ||
const day = ~~convertedDateParts.find((part) => part.type === 'day')!.value; | ||
|
||
const zeroBasedMonth = month - 1; // JavaScript months are zero-indexed (0 for January, 11 for December), so we subtract 1 | ||
|
||
return new Date(Date.UTC(year, zeroBasedMonth, day)); | ||
}; | ||
|
||
/** | ||
* Calculates the local time in UTC based on a provided timestamp and timezone offset. | ||
* | ||
* This function adjusts the provided timestamp by a given timezone offset (in milliseconds) and returns a new `Date` object | ||
* that represents the equivalent UTC date at midnight (00:00:00) for that adjusted timestamp. | ||
* | ||
* @param {Date} timestamp - The initial date and time to adjust. This will be cloned and modified. | ||
* @param {number} timezoneOffset - The offset to apply to the timestamp, in milliseconds. Positive values will move the time forward, and negative values will move it backward. | ||
* | ||
* @returns {Date} - A new `Date` object representing the calculated UTC date at midnight (00:00:00) for the adjusted timestamp. | ||
*/ | ||
export const calculateTimezoneOffset = ( | ||
timestamp: Date, | ||
timezoneOffset: number, | ||
): Date => { | ||
const date = new Date(timestamp.getTime() + timezoneOffset); | ||
|
||
return new Date( | ||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
src/validation/entities/schemas/__tests__/timezone.schema.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { TimezoneSchema } from '@/validation/entities/schemas/timezone.schema'; | ||
|
||
describe('TimezoneSchema()', () => { | ||
it('Should return true if the timezone is valid', () => { | ||
const input = 'Europe/Berlin'; | ||
|
||
const result = TimezoneSchema.safeParse(input); | ||
|
||
expect(result.success).toBe(true); | ||
}); | ||
|
||
it('Should return false if the timezone is invalid', () => { | ||
const input = 'Invalid/Timezone'; | ||
|
||
const result = TimezoneSchema.safeParse(input); | ||
|
||
expect(result.success).toBe(false); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { z } from 'zod'; | ||
|
||
/** | ||
* Validates a timezone schema | ||
* e.g. whether or not the requested timezone is a valid timezone string | ||
* | ||
* @param {string} timezone The timezone string to check for validity | ||
* | ||
* @returns {boolean} Returns 'true' if the timezone is valid, otherwise 'false' | ||
*/ | ||
export const TimezoneSchema = z.string().refine( | ||
(timezone: string): boolean => { | ||
try { | ||
Intl.DateTimeFormat(undefined, { timeZone: timezone }); | ||
|
||
return true; | ||
} catch { | ||
return false; | ||
} | ||
}, | ||
{ message: 'Invalid Timezone' }, | ||
); |