Skip to content

Commit

Permalink
Add client timezone to transaction history (#1911)
Browse files Browse the repository at this point in the history
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
PooyaRaki authored Sep 11, 2024
1 parent dd4018b commit 6e84c77
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 8 deletions.
104 changes: 104 additions & 0 deletions src/routes/transactions/helpers/timezone.helper.spec.ts
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);
});
});
48 changes: 48 additions & 0 deletions src/routes/transactions/helpers/timezone.helper.ts
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()),
);
};
24 changes: 17 additions & 7 deletions src/routes/transactions/mappers/transactions-history.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { MultisigTransactionMapper } from '@/routes/transactions/mappers/multisi
import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper';
import { IConfigurationService } from '@/config/configuration.service.interface';
import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers/transfer-imitation.mapper';
import {
calculateTimezoneOffset,
convertToTimezone,
} from '@/routes/transactions/helpers/timezone.helper';

@Injectable()
export class TransactionsHistoryMapper {
Expand Down Expand Up @@ -48,6 +52,7 @@ export class TransactionsHistoryMapper {
timezoneOffset: number,
onlyTrusted: boolean,
showImitations: boolean,
timezone?: string,
): Promise<Array<TransactionItem | DateLabel>> {
if (transactionsDomain.length == 0) {
return [];
Expand All @@ -74,6 +79,7 @@ export class TransactionsHistoryMapper {
const transactionsByDay = this.groupByDay(
mappedTransactions,
timezoneOffset,
timezone,
);
return transactionsByDay.reduce<Array<TransactionItem | DateLabel>>(
(transactionList, transactionsOnDay) => {
Expand Down Expand Up @@ -158,11 +164,12 @@ export class TransactionsHistoryMapper {
private groupByDay(
transactions: TransactionItem[],
timezoneOffset: number,
timezone?: string,
): TransactionItem[][] {
const grouped = groupBy(transactions, ({ transaction }) => {
// timestamp will always be defined for historical transactions
const date = new Date(transaction.timestamp ?? 0);
return this.getDayStartForDate(date, timezoneOffset).getTime();
return this.getDayStartForDate(date, timezoneOffset, timezone).getTime();
});
return Object.values(grouped);
}
Expand All @@ -172,13 +179,16 @@ export class TransactionsHistoryMapper {
*
* @param timestamp - date to convert
* @param timezoneOffset - Offset of time zone in milliseconds
* @param {string} timezone - If timezone id is passed, timezoneOffset will be ignored
*/
private getDayStartForDate(timestamp: Date, timezoneOffset: number): Date {
const date = structuredClone(timestamp);
date.setTime(date.getTime() + timezoneOffset);
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
private getDayStartForDate(
timestamp: Date,
timezoneOffset: number,
timezone?: string,
): Date {
return timezone
? convertToTimezone(timestamp, timezone)
: calculateTimezoneOffset(timestamp, timezoneOffset);
}

private async mapTransfers(
Expand Down
56 changes: 56 additions & 0 deletions src/routes/transactions/transactions-history.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,62 @@ describe('Transactions History Controller (Unit)', () => {
});
});

it('Should group transactions according to timezone', async () => {
const safeAddress = faker.finance.ethereumAddress();
const timezone = 'Europe/Berlin';
const chainResponse = chainBuilder().build();
const chainId = chainResponse.chainId;
const moduleTransaction1 = moduleTransactionBuilder()
.with('dataDecoded', null)
.with('executionDate', new Date('2022-12-31T21:09:36Z'))
.build();
const moduleTransaction2 = moduleTransactionBuilder()
.with('dataDecoded', null)
.with('executionDate', new Date('2022-12-31T23:09:36Z'))
.build();
const safe = safeBuilder().build();
const transactionHistoryBuilder = {
count: 40,
next: `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/?executed=false&limit=10&offset=10&queued=true&trusted=true`,
previous: null,
results: [
moduleTransactionToJson(moduleTransaction2),
moduleTransactionToJson(moduleTransaction1),
],
};
networkService.get.mockImplementation(({ url }) => {
const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
// Param ValidationPipe checksums address
const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`;
const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`;
if (url === getChainUrl) {
return Promise.resolve({ data: chainResponse, status: 200 });
}
if (url === getAllTransactions) {
return Promise.resolve({
data: transactionHistoryBuilder,
status: 200,
});
}
if (url === getSafeUrl) {
return Promise.resolve({ data: safe, status: 200 });
}
return Promise.reject(new Error(`Could not match ${url}`));
});

await request(app.getHttpServer())
.get(
`/v1/chains/${chainId}/safes/${safeAddress}/transactions/history/?timezone=${timezone}`,
)
.expect(200)
.then(({ body }) => {
expect(body.results).toHaveLength(4);

// The first and second transactions should be assigned to different groups, and for that reason, the element at index 2 of the array should be a DATE_LABEL.
expect(body.results[2].type).toBe('DATE_LABEL');
});
});

it('Should return correctly each transaction', async () => {
const chain = chainBuilder().build();
const safe = safeBuilder().build();
Expand Down
12 changes: 11 additions & 1 deletion src/routes/transactions/transactions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { ValidationPipe } from '@/validation/pipes/validation.pipe';
import { DeleteTransactionDtoSchema } from '@/routes/transactions/entities/schemas/delete-transaction.dto.schema';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
import { CreationTransaction } from '@/routes/transactions/entities/creation-transaction.entity';
import { TimezoneSchema } from '@/validation/entities/schemas/timezone.schema';

@ApiTags('transactions')
@Controller({
Expand Down Expand Up @@ -231,8 +232,14 @@ export class TransactionsController {

@ApiOkResponse({ type: TransactionItemPage })
@Get('chains/:chainId/safes/:safeAddress/transactions/history')
@ApiQuery({ name: 'timezone_offset', required: false, type: String })
@ApiQuery({
name: 'timezone_offset',
required: false,
type: String,
deprecated: true,
})
@ApiQuery({ name: 'cursor', required: false, type: String })
@ApiQuery({ name: 'timezone', required: false, type: String })
async getTransactionsHistory(
@Param('chainId') chainId: string,
@RouteUrlDecorator() routeUrl: URL,
Expand All @@ -245,6 +252,8 @@ export class TransactionsController {
trusted: boolean,
@Query('imitation', new DefaultValuePipe(true), ParseBoolPipe)
imitation: boolean,
@Query('timezone', new ValidationPipe(TimezoneSchema.optional()))
timezone?: string,
): Promise<Partial<TransactionItemPage>> {
return this.transactionsService.getTransactionHistory({
chainId,
Expand All @@ -254,6 +263,7 @@ export class TransactionsController {
timezoneOffsetMs,
onlyTrusted: trusted,
showImitations: imitation,
timezone,
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/routes/transactions/transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export class TransactionsService {
timezoneOffsetMs: number;
onlyTrusted: boolean;
showImitations: boolean;
timezone?: string;
}): Promise<TransactionItemPage> {
const paginationDataAdjusted = this.getAdjustedPaginationForHistory(
args.paginationData,
Expand Down Expand Up @@ -410,6 +411,7 @@ export class TransactionsService {
args.timezoneOffsetMs,
args.onlyTrusted,
args.showImitations,
args.timezone,
);

return {
Expand Down
19 changes: 19 additions & 0 deletions src/validation/entities/schemas/__tests__/timezone.schema.spec.ts
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);
});
});
22 changes: 22 additions & 0 deletions src/validation/entities/schemas/timezone.schema.ts
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' },
);

0 comments on commit 6e84c77

Please sign in to comment.