Skip to content

Commit 9a3b7e0

Browse files
committed
feat: add "local time" default option to TimezonepickerInput (#1969)
1 parent 4616290 commit 9a3b7e0

File tree

6 files changed

+118
-41
lines changed

6 files changed

+118
-41
lines changed

cypress/component/date/DateEditor.spec.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,6 @@ const getTodayKebabString = () => {
6363
return `${getToday().year}-${month}-${date}`;
6464
};
6565

66-
const getTimezoneOffsetString = () => {
67-
const currentDate = new Date();
68-
const offsetInMinutes = currentDate.getTimezoneOffset();
69-
const offsetHours = Math.floor(Math.abs(offsetInMinutes) / 60);
70-
const offsetMinutes = Math.abs(offsetInMinutes) % 60;
71-
const offsetSign = offsetInMinutes <= 0 ? '+' : '-';
72-
73-
return `${offsetSign}${offsetHours.toString().padStart(2, '0')}:${offsetMinutes
74-
.toString()
75-
.padStart(2, '0')}`;
76-
};
77-
7866
type Parameters = ParametersAPI & {
7967
instance: {
8068
format: DateTimeFormat;
@@ -93,7 +81,7 @@ const setupDateEditor = ({
9381
}) => {
9482
const [fieldSdk] = createFakeFieldAPI(undefined, initialValue);
9583
mount(
96-
<DateEditor field={fieldSdk} isInitiallyDisabled={initiallyDisabled} parameters={parameters} />
84+
<DateEditor field={fieldSdk} isInitiallyDisabled={initiallyDisabled} parameters={parameters} />,
9785
);
9886
return fieldSdk;
9987
};
@@ -130,10 +118,7 @@ describe('Date Editor', () => {
130118
.should('have.attr', 'placeholder', '00:00')
131119
.should('have.value', '00:00');
132120

133-
selectors
134-
.getTimezoneInput()
135-
.should('be.visible')
136-
.should('have.value', getTimezoneOffsetString());
121+
selectors.getTimezoneInput().should('be.visible').should('have.value', 'Local time');
137122
});
138123

139124
it('calendar should show current year, month and date', () => {
@@ -173,7 +158,7 @@ describe('Date Editor', () => {
173158
selectors.getDateInput().should('have.value', '');
174159
selectors.getTimeInput().should('have.value', '00:00');
175160

176-
selectors.getTimezoneInput().should('have.value', getTimezoneOffsetString());
161+
selectors.getTimezoneInput().should('have.value', 'Local time');
177162
selectors.getClearBtn().should('not.exist');
178163
});
179164

packages/date/src/DateEditor.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,8 @@ import { DatepickerInput } from './DatepickerInput';
99
import { TimepickerInput } from './TimepickerInput';
1010
import { TimezonepickerInput } from './TimezonePickerInput';
1111
import { TimeFormat, DateTimeFormat, TimeResult } from './types';
12-
import {
13-
userInputFromDatetime,
14-
buildFieldValue,
15-
getDefaultAMPM,
16-
getDefaultUtcOffset,
17-
} from './utils/date';
12+
import { userInputFromDatetime, buildFieldValue, getDefaultAMPM } from './utils/date';
13+
import { defaultZoneOffset } from './utils/zoneOffsets';
1814

1915
export interface DateEditorProps {
2016
/**
@@ -147,7 +143,7 @@ function DateEditorContainer({
147143
date: undefined,
148144
time: undefined,
149145
ampm: getDefaultAMPM(),
150-
utcOffset: getDefaultUtcOffset(),
146+
utcOffset: defaultZoneOffset,
151147
});
152148
}}
153149
>

packages/date/src/TimezonePickerInput.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Select } from '@contentful/f36-components';
44

55
import { zoneOffsets, defaultZoneOffset } from './utils/zoneOffsets';
66

7-
87
export type TimezonepickerProps = {
98
disabled: boolean;
109
onChange: (value: string) => void;
@@ -23,10 +22,11 @@ export const TimezonepickerInput = ({
2322
isDisabled={disabled}
2423
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
2524
onChange(e.currentTarget.value);
26-
}}>
25+
}}
26+
>
2727
{zoneOffsets.map((offset) => (
2828
<Select.Option key={offset} value={offset}>
29-
UTC{offset}
29+
{offset === defaultZoneOffset ? offset : `UTC${offset}`}
3030
</Select.Option>
3131
))}
3232
</Select>

packages/date/src/utils/data.spec.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// eslint-disable-next-line -- TODO: move to date-fns
22
import moment from 'moment';
3-
import { buildFieldValue } from './date';
3+
import { buildFieldValue, computeLocalZoneOffset, userInputFromDatetime } from './date';
4+
import { defaultZoneOffset } from './zoneOffsets';
45

56
describe('date utils', () => {
67
describe('buildFieldValue', () => {
@@ -15,7 +16,7 @@ describe('date utils', () => {
1516
},
1617
usesTimezone: true,
1718
usesTime: true,
18-
})
19+
}),
1920
).toEqual({
2021
invalid: false,
2122
valid: '2018-02-02T17:00+03:00',
@@ -31,7 +32,7 @@ describe('date utils', () => {
3132
},
3233
usesTimezone: true,
3334
usesTime: true,
34-
})
35+
}),
3536
).toEqual({
3637
invalid: false,
3738
valid: '2015-01-14T05:00-05:00',
@@ -47,11 +48,73 @@ describe('date utils', () => {
4748
},
4849
usesTimezone: true,
4950
usesTime: true,
50-
})
51+
}),
5152
).toEqual({
5253
invalid: false,
5354
valid: '2015-01-14T17:00-05:00',
5455
});
5556
});
57+
58+
it('uses the DST-aware local offset when utcOffset is "Local time"', () => {
59+
const date = moment('2025-07-04');
60+
const expectedOffset = computeLocalZoneOffset(date);
61+
const result = buildFieldValue({
62+
data: {
63+
date,
64+
time: '20:00',
65+
ampm: 'PM',
66+
utcOffset: defaultZoneOffset,
67+
},
68+
usesTimezone: true,
69+
usesTime: true,
70+
});
71+
expect(result).toEqual({
72+
invalid: false,
73+
valid: `2025-07-04T20:00${expectedOffset}`,
74+
});
75+
});
76+
});
77+
78+
describe('computeLocalZoneOffset', () => {
79+
it('returns a valid UTC offset string', () => {
80+
const offset = computeLocalZoneOffset(moment('2025-07-04'));
81+
expect(offset).toMatch(/^[+-]\d{2}:\d{2}$/);
82+
});
83+
84+
it('is not affected by a fixed UTC offset on the input moment', () => {
85+
// Same calendar date/time stored with two different explicit offsets
86+
const withPositiveOffset = moment.parseZone('2025-07-04T12:00+05:30');
87+
const withNegativeOffset = moment.parseZone('2025-07-04T12:00-08:00');
88+
// Both should produce the same local offset for 2025-07-04 at 12:00
89+
expect(computeLocalZoneOffset(withPositiveOffset)).toBe(
90+
computeLocalZoneOffset(withNegativeOffset),
91+
);
92+
});
93+
});
94+
95+
describe('userInputFromDatetime', () => {
96+
it('detects a value stored in the local timezone and returns "Local time"', () => {
97+
const localOffset = computeLocalZoneOffset(moment('2025-07-04'));
98+
const result = userInputFromDatetime({
99+
value: `2025-07-04T12:00${localOffset}`,
100+
uses12hClock: false,
101+
});
102+
expect(result.utcOffset).toBe(defaultZoneOffset);
103+
});
104+
105+
it('preserves an explicit offset that differs from the local timezone', () => {
106+
const localOffset = computeLocalZoneOffset(moment('2025-07-04'));
107+
const explicitOffset = localOffset === '+05:30' ? '+03:00' : '+05:30';
108+
const result = userInputFromDatetime({
109+
value: `2025-07-04T12:00${explicitOffset}`,
110+
uses12hClock: false,
111+
});
112+
expect(result.utcOffset).toBe(explicitOffset);
113+
});
114+
115+
it('returns "Local time" as the default when no value is provided', () => {
116+
const result = userInputFromDatetime({ value: null, uses12hClock: false });
117+
expect(result.utcOffset).toBe(defaultZoneOffset);
118+
});
56119
});
57120
});

packages/date/src/utils/date.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
// eslint-disable-next-line -- TODO: move to date-fns
22
import moment from 'moment';
33
import { TimeResult } from '../types';
4+
import { defaultZoneOffset } from './zoneOffsets';
45

56
const ZONE_RX = /(Z|[+-]\d{2}[:+]?\d{2})$/;
67

7-
function startOfToday(format: string) {
8-
return moment().set({ hours: 0, minutes: 0 }).format(format);
9-
}
10-
118
function fieldValueToMoment(datetimeString: string | null | undefined): moment.Moment | null {
129
if (!datetimeString) {
1310
return null;
@@ -42,9 +39,15 @@ function datetimeFromUserInput(input: TimeResult): {
4239
}
4340

4441
const time = timeFromUserInput(input);
42+
let utcOffset = input.utcOffset;
43+
44+
// If we have the default local offset, compute the local offset for the specified date
45+
if (utcOffset === defaultZoneOffset) {
46+
utcOffset = computeLocalZoneOffset(input.date, time);
47+
}
4548

4649
const date = moment
47-
.parseZone(input.utcOffset, 'Z')
50+
.parseZone(utcOffset, 'Z')
4851
.set(input.date.toObject())
4952
.set({ hours: time.hours(), minutes: time.minutes() });
5053

@@ -93,7 +96,31 @@ export function getDefaultAMPM() {
9396
}
9497

9598
export function getDefaultUtcOffset() {
96-
return startOfToday('Z');
99+
return moment().format('Z');
100+
}
101+
102+
/**
103+
* Compute the local zone offset for the specified date and time.
104+
*
105+
* Creates a new local-mode moment from the calendar date/time components so
106+
* that format('Z') returns the machine's local timezone offset (DST-aware),
107+
* regardless of whether `date` carries a fixed UTC offset.
108+
*
109+
* @param date - The moment object for the date
110+
* @param time - Optional moment with time
111+
* @returns The offset as a string (e.g., "-08:00", "+05:30")
112+
*/
113+
export function computeLocalZoneOffset(date: moment.Moment, time?: moment.Moment): string {
114+
const localMoment = moment({
115+
year: date.year(),
116+
month: date.month(),
117+
date: date.date(),
118+
hour: time ? time.hour() : 0,
119+
minute: time ? time.minute() : 0,
120+
second: 0,
121+
millisecond: 0,
122+
});
123+
return localMoment.format('Z');
97124
}
98125

99126
/**
@@ -109,17 +136,22 @@ export function userInputFromDatetime({
109136
const datetime = fieldValueToMoment(value);
110137

111138
if (datetime) {
139+
let utcOffset = datetime.format('Z');
140+
const localOffset = computeLocalZoneOffset(datetime);
141+
if (utcOffset === localOffset) {
142+
utcOffset = defaultZoneOffset;
143+
}
112144
const timeFormat = uses12hClock ? 'hh:mm' : 'HH:mm';
113145
return {
114146
date: datetime,
115147
time: datetime.format(timeFormat),
116148
ampm: datetime.format('A'),
117-
utcOffset: datetime.format('Z'),
149+
utcOffset: utcOffset,
118150
};
119151
} else {
120152
return {
121153
ampm: getDefaultAMPM(),
122-
utcOffset: getDefaultUtcOffset(),
154+
utcOffset: defaultZoneOffset,
123155
};
124156
}
125157
}

packages/date/src/utils/zoneOffsets.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
export const defaultZoneOffset = '+00:00';
1+
export const defaultZoneOffset = 'Local time';
22

33
export const zoneOffsets = [
4+
'Local time',
45
'-12:00',
56
'-11:00',
67
'-10:00',

0 commit comments

Comments
 (0)