Skip to content

Commit 52fce8a

Browse files
harshaktgdreyfus92
andauthored
fix(date): resolve timezone issues in DatePrompt (#486)
Co-authored-by: paul valladares <85648028+dreyfus92@users.noreply.github.com>
1 parent 090902c commit 52fce8a

File tree

4 files changed

+81
-25
lines changed

4 files changed

+81
-25
lines changed

.changeset/early-ads-tap.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": patch
3+
"@clack/core": patch
4+
---
5+
6+
Fix timezone issues in DatePrompt causing dates to be off by one day in non-UTC timezones

packages/core/src/prompts/date.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ function dateToSegmentValues(date: Date | undefined): DateParts {
3131
return { year: '____', month: '__', day: '__' };
3232
}
3333
return {
34-
year: String(date.getFullYear()).padStart(4, '0'),
35-
month: String(date.getMonth() + 1).padStart(2, '0'),
36-
day: String(date.getDate()).padStart(2, '0'),
34+
year: String(date.getUTCFullYear()).padStart(4, '0'),
35+
month: String(date.getUTCMonth() + 1).padStart(2, '0'),
36+
day: String(date.getUTCDate()).padStart(2, '0'),
3737
};
3838
}
3939

40-
function segmentValuesToParsed(parts: DateParts): { year: number; month: number; day: number } {
40+
function segmentValuesToParsed(parts: DateParts): {
41+
year: number;
42+
month: number;
43+
day: number;
44+
} {
4145
const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0;
4246
return {
4347
year: val(parts.year),
@@ -119,18 +123,22 @@ function segmentValuesToParts(
119123
if (!year || year < 1000 || year > 9999) return undefined;
120124
if (!month || month < 1 || month > 12) return undefined;
121125
if (!day || day < 1) return undefined;
122-
const date = new Date(year, month - 1, day);
123-
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
126+
const date = new Date(Date.UTC(year, month - 1, day));
127+
if (
128+
date.getUTCFullYear() !== year ||
129+
date.getUTCMonth() !== month - 1 ||
130+
date.getUTCDate() !== day
131+
) {
124132
return undefined;
125133
}
126134
return { year, month, day };
127135
}
128136

129-
/** Build a Date from segment values using local midnight so getFullYear/getMonth/getDate are timezone-stable. */
137+
/** Build a Date from segment values using UTC midnight so getFullYear/getMonth/getDate are timezone-stable. */
130138
function segmentValuesToDate(parts: DateParts): Date | undefined {
131139
const parsed = segmentValuesToParts(parts);
132140
if (!parsed) return undefined;
133-
return new Date(parsed.year, parsed.month - 1, parsed.day);
141+
return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day));
134142
}
135143

136144
function segmentValuesToISOString(parts: DateParts): string | undefined {
@@ -278,7 +286,10 @@ export default class DatePrompt extends Prompt<Date> {
278286
: clamp(bounds.min, num + direction, bounds.max);
279287

280288
const newSegmentValue = String(newNum).padStart(segment.len, '0');
281-
this.#segmentValues = { ...this.#segmentValues, [segment.type]: newSegmentValue };
289+
this.#segmentValues = {
290+
...this.#segmentValues,
291+
[segment.type]: newSegmentValue,
292+
};
282293
this.#refreshFromSegmentValues();
283294
}
284295

@@ -331,7 +342,10 @@ export default class DatePrompt extends Prompt<Date> {
331342
const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1);
332343

333344
if (!newSegmentVal.includes('_')) {
334-
const newParts = { ...this.#segmentValues, [segment.type]: newSegmentVal };
345+
const newParts = {
346+
...this.#segmentValues,
347+
[segment.type]: newSegmentVal,
348+
};
335349
const validationMsg = getSegmentValidationMessage(newParts, segment);
336350
if (validationMsg) {
337351
this.inlineError = validationMsg;

packages/core/test/prompts/date.test.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const DD_MM_YYYY = buildFormatConfig(
3232

3333
const d = (iso: string) => {
3434
const [y, m, day] = iso.slice(0, 10).split('-').map(Number);
35-
return new Date(y, m - 1, day);
35+
return new Date(Date.UTC(y, m - 1, day));
3636
};
3737

3838
describe('DatePrompt', () => {
@@ -70,7 +70,7 @@ describe('DatePrompt', () => {
7070
instance.prompt();
7171
expect(instance.userInput).to.equal('2025/01/15');
7272
expect(instance.value).toBeInstanceOf(Date);
73-
expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15');
73+
expect(instance.value?.toISOString().slice(0, 10)).to.equal('2025-01-15');
7474
});
7575

7676
test('left/right navigates between segments', () => {
@@ -82,18 +82,30 @@ describe('DatePrompt', () => {
8282
initialValue: d('2025-01-15'),
8383
});
8484
instance.prompt();
85-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
85+
expect(instance.segmentCursor).to.deep.equal({
86+
segmentIndex: 0,
87+
positionInSegment: 0,
88+
});
8689
// Move within year (0->1->2->3), then right from end goes to month
8790
for (let i = 0; i < 4; i++) {
8891
input.emit('keypress', undefined, { name: 'right' });
8992
}
90-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
93+
expect(instance.segmentCursor).to.deep.equal({
94+
segmentIndex: 1,
95+
positionInSegment: 0,
96+
});
9197
for (let i = 0; i < 2; i++) {
9298
input.emit('keypress', undefined, { name: 'right' });
9399
}
94-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 });
100+
expect(instance.segmentCursor).to.deep.equal({
101+
segmentIndex: 2,
102+
positionInSegment: 0,
103+
});
95104
input.emit('keypress', undefined, { name: 'left' });
96-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
105+
expect(instance.segmentCursor).to.deep.equal({
106+
segmentIndex: 1,
107+
positionInSegment: 0,
108+
});
97109
});
98110

99111
test('up/down increments and decrements segment', () => {
@@ -181,14 +193,20 @@ describe('DatePrompt', () => {
181193
initialValue: d('2025-01-15'),
182194
});
183195
instance.prompt();
184-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
196+
expect(instance.segmentCursor).to.deep.equal({
197+
segmentIndex: 0,
198+
positionInSegment: 0,
199+
});
185200
// Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit)
186201
input.emit('keypress', '2', { name: undefined, sequence: '2' });
187202
input.emit('keypress', '0', { name: undefined, sequence: '0' });
188203
input.emit('keypress', '2', { name: undefined, sequence: '2' });
189204
input.emit('keypress', '3', { name: undefined, sequence: '3' });
190205
expect(instance.userInput).to.equal('2023/01/15');
191-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 3 });
206+
expect(instance.segmentCursor).to.deep.equal({
207+
segmentIndex: 0,
208+
positionInSegment: 3,
209+
});
192210
});
193211

194212
test('backspace clears entire segment at any cursor position', () => {
@@ -201,11 +219,17 @@ describe('DatePrompt', () => {
201219
});
202220
instance.prompt();
203221
expect(instance.userInput).to.equal('2025/12/21');
204-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
222+
expect(instance.segmentCursor).to.deep.equal({
223+
segmentIndex: 0,
224+
positionInSegment: 0,
225+
});
205226
// Backspace at first position clears whole year segment
206227
input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' });
207228
expect(instance.userInput).to.equal('____/12/21');
208-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
229+
expect(instance.segmentCursor).to.deep.equal({
230+
segmentIndex: 0,
231+
positionInSegment: 0,
232+
});
209233
});
210234

211235
test('backspace clears segment when cursor at first char (2___)', () => {
@@ -219,14 +243,23 @@ describe('DatePrompt', () => {
219243
// Type "2" to get "2___"
220244
input.emit('keypress', '2', { name: undefined, sequence: '2' });
221245
expect(instance.userInput).to.equal('2___/__/__');
222-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 1 });
246+
expect(instance.segmentCursor).to.deep.equal({
247+
segmentIndex: 0,
248+
positionInSegment: 1,
249+
});
223250
// Move to first char (position 0)
224251
input.emit('keypress', undefined, { name: 'left' });
225-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
252+
expect(instance.segmentCursor).to.deep.equal({
253+
segmentIndex: 0,
254+
positionInSegment: 0,
255+
});
226256
// Backspace should clear whole segment - also test char-based detection
227257
input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' });
228258
expect(instance.userInput).to.equal('____/__/__');
229-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 });
259+
expect(instance.segmentCursor).to.deep.equal({
260+
segmentIndex: 0,
261+
positionInSegment: 0,
262+
});
230263
});
231264

232265
test('digit input updates segment and jumps to next when complete', () => {
@@ -242,7 +275,10 @@ describe('DatePrompt', () => {
242275
input.emit('keypress', c, { name: undefined, sequence: c });
243276
}
244277
expect(instance.userInput).to.equal('2025/__/__');
245-
expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 });
278+
expect(instance.segmentCursor).to.deep.equal({
279+
segmentIndex: 1,
280+
positionInSegment: 0,
281+
});
246282
});
247283

248284
test('submit returns ISO string for valid date', async () => {

packages/prompts/test/date.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { MockReadable, MockWritable } from './test-utils.js';
55

66
const d = (iso: string) => {
77
const [y, m, day] = iso.slice(0, 10).split('-').map(Number);
8-
return new Date(y, m - 1, day);
8+
return new Date(Date.UTC(y, m - 1, day));
99
};
1010

1111
describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => {

0 commit comments

Comments
 (0)