Skip to content

Commit f4267bf

Browse files
authored
feat: update time handling to ensure UTC consistency and local timezone conversion (#277)
* feat: update time handling to ensure UTC consistency and local timezone conversion - Updated the localization strings in en.json to clarify that "Start Time" and "Expiry" are in UTC. - Modified DateTimePickerField to use forceToLocalZone for displaying values in the local timezone. - Added utility functions forceToUTC and forceToLocalZone to handle timezone conversions correctly. - Enhanced tests for time utility functions to cover various scenarios including timezone offsets and formatting. - Updated test cases across multiple components to reflect the changes in time handling and ensure consistency in displaying UTC times. * feat: replace iso8601ToTimestamp with iso8601ToTimestampIgnoreTimezone for consistent UTC handling * feat: allow past start dates for periodic and stream permissions * feat: allow past dates for erc20 and native token periodic and stream permissions
1 parent 34c6eb6 commit f4267bf

File tree

14 files changed

+278
-128
lines changed

14 files changed

+278
-128
lines changed

packages/gator-permissions-snap/locales/en.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"message": "The maximum amount of tokens that can be streamed."
5151
},
5252
"startTimeLabel": {
53-
"message": "Start Time"
53+
"message": "Start Time (UTC)"
5454
},
5555
"startTimeTooltip": {
5656
"message": "The time at which the first period begins"
@@ -59,7 +59,7 @@
5959
"message": "The start time of the stream"
6060
},
6161
"expiryLabel": {
62-
"message": "Expiry"
62+
"message": "Expiry (UTC)"
6363
},
6464
"expiryTooltip": {
6565
"message": "The expiry date of the permission"

packages/gator-permissions-snap/src/permissions/erc20TokenPeriodic/rules.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getClosestTimePeriod,
88
TIME_PERIOD_TO_SECONDS,
99
timestampToISO8601,
10-
iso8601ToTimestamp,
10+
iso8601ToTimestampIgnoreTimezone,
1111
} from '../../utils/time';
1212
import { getIconData } from '../iconUtil';
1313
import type {
@@ -102,14 +102,14 @@ export const startTimeRule: RuleDefinition<
102102
isVisible: true,
103103
tooltip: t('startTimeTooltip'),
104104
error: metadata.validationErrors.startTimeError,
105-
allowPastDate: false,
105+
allowPastDate: true, // start time can be in the past for periodic permissions
106106
isEditable: context.isAdjustmentAllowed,
107107
}),
108108
updateContext: (context: Erc20TokenPeriodicContext, value: string) => ({
109109
...context,
110110
permissionDetails: {
111111
...context.permissionDetails,
112-
startTime: iso8601ToTimestamp(value),
112+
startTime: iso8601ToTimestampIgnoreTimezone(value),
113113
},
114114
}),
115115
};

packages/gator-permissions-snap/src/permissions/erc20TokenStream/rules.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { RuleDefinition } from '../../core/types';
22
import { TimePeriod } from '../../core/types';
3-
import { timestampToISO8601, iso8601ToTimestamp } from '../../utils/time';
3+
import {
4+
timestampToISO8601,
5+
iso8601ToTimestampIgnoreTimezone,
6+
} from '../../utils/time';
47
import { getIconData } from '../iconUtil';
58
import { createExpiryRule } from '../rules';
69
import type {
@@ -74,14 +77,14 @@ export const startTimeRule: Erc20TokenStreamRuleDefinition = {
7477
isVisible: true,
7578
tooltip: t('streamStartTimeTooltip'),
7679
error: metadata.validationErrors.startTimeError,
77-
allowPastDate: false,
80+
allowPastDate: true, // start time can be in the past
7881
isEditable: context.isAdjustmentAllowed,
7982
}),
8083
updateContext: (context: Erc20TokenStreamContext, value: string) => ({
8184
...context,
8285
permissionDetails: {
8386
...context.permissionDetails,
84-
startTime: iso8601ToTimestamp(value),
87+
startTime: iso8601ToTimestampIgnoreTimezone(value),
8588
},
8689
}),
8790
};

packages/gator-permissions-snap/src/permissions/nativeTokenPeriodic/rules.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getClosestTimePeriod,
88
TIME_PERIOD_TO_SECONDS,
99
timestampToISO8601,
10-
iso8601ToTimestamp,
10+
iso8601ToTimestampIgnoreTimezone,
1111
} from '../../utils/time';
1212
import { getIconData } from '../iconUtil';
1313
import type {
@@ -102,14 +102,14 @@ export const startTimeRule: RuleDefinition<
102102
isVisible: true,
103103
tooltip: t('startTimeTooltip'),
104104
error: metadata.validationErrors.startTimeError,
105-
allowPastDate: false,
105+
allowPastDate: true, // start time can be in the past
106106
isEditable: context.isAdjustmentAllowed,
107107
}),
108108
updateContext: (context: NativeTokenPeriodicContext, value: string) => ({
109109
...context,
110110
permissionDetails: {
111111
...context.permissionDetails,
112-
startTime: iso8601ToTimestamp(value),
112+
startTime: iso8601ToTimestampIgnoreTimezone(value),
113113
},
114114
}),
115115
};

packages/gator-permissions-snap/src/permissions/nativeTokenStream/rules.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { RuleDefinition } from '../../core/types';
22
import { TimePeriod } from '../../core/types';
3-
import { timestampToISO8601, iso8601ToTimestamp } from '../../utils/time';
3+
import {
4+
timestampToISO8601,
5+
iso8601ToTimestampIgnoreTimezone,
6+
} from '../../utils/time';
47
import { getIconData } from '../iconUtil';
58
import { createExpiryRule } from '../rules';
69
import type {
@@ -75,14 +78,14 @@ export const startTimeRule: NativeTokenStreamRuleDefinition = {
7578
isVisible: true,
7679
tooltip: t('streamStartTimeTooltip'),
7780
error: metadata.validationErrors.startTimeError,
78-
allowPastDate: false,
81+
allowPastDate: true, // start time can be in the past
7982
isEditable: context.isAdjustmentAllowed,
8083
}),
8184
updateContext: (context: NativeTokenStreamContext, value: string) => ({
8285
...context,
8386
permissionDetails: {
8487
...context.permissionDetails,
85-
startTime: iso8601ToTimestamp(value),
88+
startTime: iso8601ToTimestampIgnoreTimezone(value),
8689
},
8790
}),
8891
};

packages/gator-permissions-snap/src/permissions/rules.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
} from '../core/types';
1616
import type { TranslateFunction } from '../utils/i18n';
1717
import {
18-
iso8601ToTimestamp,
18+
iso8601ToTimestampIgnoreTimezone,
1919
TIME_PERIOD_TO_SECONDS,
2020
timestampToISO8601,
2121
} from '../utils/time';
@@ -72,7 +72,7 @@ export const createExpiryRule = <
7272
value === ''
7373
? Math.floor(Date.now() / 1000) +
7474
Number(TIME_PERIOD_TO_SECONDS[TimePeriod.MONTHLY])
75-
: iso8601ToTimestamp(value);
75+
: iso8601ToTimestampIgnoreTimezone(value);
7676

7777
expiry = {
7878
timestamp,

packages/gator-permissions-snap/src/ui/components/DateTimePickerField.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DateTimePicker } from '@metamask/snaps-sdk/jsx';
33
import type { BaseFieldProps } from './Field';
44
import { Field } from './Field';
55
import { TextField } from './TextField';
6+
import { forceToLocalZone } from '../../utils/time';
67

78
export type DateTimePickerFieldParams = Pick<
89
BaseFieldProps,
@@ -36,11 +37,13 @@ export const DateTimePickerField = ({
3637
const isFieldEnabled = value !== null && value !== undefined;
3738

3839
if (!isEditable) {
39-
// Format the ISO date to a readable format for display
40+
// Format the ISO date to a readable format for display.
41+
// We convert to local timezone for display purposes so users see the time
42+
// they're familiar with, even though the internal timestamp representation is in UTC.
4043
let displayValue = value ?? '';
4144
if (value) {
4245
try {
43-
const date = new Date(value);
46+
const date = new Date(forceToLocalZone(value));
4447
if (!isNaN(date.getTime())) {
4548
displayValue = date.toLocaleString();
4649
}
@@ -67,9 +70,16 @@ export const DateTimePickerField = ({
6770
addFieldButtonName={addFieldButtonName}
6871
removeFieldButtonName={removeFieldButtonName}
6972
>
73+
{/*
74+
The DateTimePicker component from Snaps SDK shows times in the user's local timezone.
75+
We convert the UTC timestamp to local timezone representation for display.
76+
The user sees and edits times in their local timezone, but internally these are
77+
converted back to UTC (via iso8601ToTimestampIgnoreTimezone) for consistent blockchain
78+
execution regardless of the user's timezone.
79+
*/}
7080
<DateTimePicker
7181
name={name}
72-
value={value}
82+
value={forceToLocalZone(value)}
7383
type="datetime"
7484
disablePast={disablePast}
7585
/>

packages/gator-permissions-snap/src/utils/time.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,40 @@ export const zPeriodDuration = zTimestamp
112112
return Number(TIME_PERIOD_TO_SECONDS[periodType]);
113113
});
114114

115+
/**
116+
* Forces any ISO 8601 string to be treated as UTC, ignoring the original timezone.
117+
* e.g. "2026-04-17T18:14:00.000+12:00" → "2026-04-17T18:14:00.000Z"
118+
* @param iso - The input ISO 8601 string, which may include a timezone offset.
119+
* @returns The input ISO string with the timezone removed and replaced with 'Z' to indicate UTC.
120+
*/
121+
export const forceToUTC = (iso: string): string => {
122+
const normalized = iso.replace(/([+-]\d{2}:\d{2}|Z)$/u, '');
123+
return `${normalized}Z`;
124+
};
125+
126+
/**
127+
* Forces any ISO 8601 string to be treated as the local timezone, ignoring the original timezone.
128+
* e.g. "2026-04-17T06:14:00.000Z" → "2026-04-17T06:14:00.000+12:00" (in NZ)
129+
* e.g. "2026-04-17T06:14:00.000+05:00" → "2026-04-17T06:14:00.000+12:00" (in NZ)
130+
* @param iso - The input ISO 8601 string, which may include a timezone offset.
131+
* @returns The input ISO string with the timezone removed and replaced with the local timezone offset.
132+
*/
133+
export const forceToLocalZone = (iso: string | undefined): string => {
134+
if (!iso) {
135+
return '';
136+
}
137+
const normalized = iso.replace(/([+-]\d{2}:\d{2}|Z)$/u, '');
138+
const date = new Date(normalized);
139+
const offsetMinutes = -date.getTimezoneOffset();
140+
const sign = offsetMinutes >= 0 ? '+' : '-';
141+
const hours = String(Math.floor(Math.abs(offsetMinutes) / 60)).padStart(
142+
2,
143+
'0',
144+
);
145+
const minutes = String(Math.abs(offsetMinutes) % 60).padStart(2, '0');
146+
return `${normalized}${sign}${hours}:${minutes}`;
147+
};
148+
115149
/**
116150
* Converts a Unix timestamp (in seconds) to an ISO 8601 date string with timezone.
117151
* This format is required by the snaps-sdk DateTimePicker component.
@@ -133,17 +167,21 @@ export const timestampToISO8601 = (timestamp: number): string => {
133167

134168
/**
135169
* Converts an ISO 8601 date string to a Unix timestamp (in seconds).
170+
* This function ignores any timezone information in the input string and treats
171+
* the time as UTC. For example, "2024-01-01T10:00:00.000+02:00" is treated as
172+
* "2024-01-01T10:00:00.000Z", not as the UTC equivalent.
136173
* This is used to convert DateTimePicker values back to timestamps.
137174
*
138-
* @param iso - The ISO 8601 formatted date string.
175+
* @param iso - The ISO 8601 formatted date string (timezone info will be ignored).
139176
* @returns The Unix timestamp in seconds.
140177
*/
141-
export const iso8601ToTimestamp = (iso: string): number => {
142-
const date = new Date(iso);
178+
export const iso8601ToTimestampIgnoreTimezone = (iso: string): number => {
179+
const normalizedIso = forceToUTC(iso);
180+
const date = new Date(normalizedIso);
143181

144182
if (isNaN(date.getTime())) {
145183
throw new InvalidInputError(
146-
`iso8601ToTimestamp: Invalid ISO 8601 string: ${iso}`,
184+
`iso8601ToTimestampIgnoreTimezone: Invalid ISO 8601 string: ${iso}`,
147185
);
148186
}
149187

0 commit comments

Comments
 (0)