diff --git a/.changeset/early-ads-tap.md b/.changeset/early-ads-tap.md new file mode 100644 index 00000000..4744ce64 --- /dev/null +++ b/.changeset/early-ads-tap.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Fix timezone issues in DatePrompt causing dates to be off by one day in non-UTC timezones diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index 91947b41..702e00ce 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -31,13 +31,17 @@ function dateToSegmentValues(date: Date | undefined): DateParts { return { year: '____', month: '__', day: '__' }; } return { - year: String(date.getFullYear()).padStart(4, '0'), - month: String(date.getMonth() + 1).padStart(2, '0'), - day: String(date.getDate()).padStart(2, '0'), + year: String(date.getUTCFullYear()).padStart(4, '0'), + month: String(date.getUTCMonth() + 1).padStart(2, '0'), + day: String(date.getUTCDate()).padStart(2, '0'), }; } -function segmentValuesToParsed(parts: DateParts): { year: number; month: number; day: number } { +function segmentValuesToParsed(parts: DateParts): { + year: number; + month: number; + day: number; +} { const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; return { year: val(parts.year), @@ -119,18 +123,22 @@ function segmentValuesToParts( if (!year || year < 1000 || year > 9999) return undefined; if (!month || month < 1 || month > 12) return undefined; if (!day || day < 1) return undefined; - const date = new Date(year, month - 1, day); - if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { return undefined; } return { year, month, day }; } -/** Build a Date from segment values using local midnight so getFullYear/getMonth/getDate are timezone-stable. */ +/** Build a Date from segment values using UTC midnight so getFullYear/getMonth/getDate are timezone-stable. */ function segmentValuesToDate(parts: DateParts): Date | undefined { const parsed = segmentValuesToParts(parts); if (!parsed) return undefined; - return new Date(parsed.year, parsed.month - 1, parsed.day); + return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)); } function segmentValuesToISOString(parts: DateParts): string | undefined { @@ -278,7 +286,10 @@ export default class DatePrompt extends Prompt { : clamp(bounds.min, num + direction, bounds.max); const newSegmentValue = String(newNum).padStart(segment.len, '0'); - this.#segmentValues = { ...this.#segmentValues, [segment.type]: newSegmentValue }; + this.#segmentValues = { + ...this.#segmentValues, + [segment.type]: newSegmentValue, + }; this.#refreshFromSegmentValues(); } @@ -331,7 +342,10 @@ export default class DatePrompt extends Prompt { const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); if (!newSegmentVal.includes('_')) { - const newParts = { ...this.#segmentValues, [segment.type]: newSegmentVal }; + const newParts = { + ...this.#segmentValues, + [segment.type]: newSegmentVal, + }; const validationMsg = getSegmentValidationMessage(newParts, segment); if (validationMsg) { this.inlineError = validationMsg; diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index 22bc2201..0db5a42e 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -32,7 +32,7 @@ const DD_MM_YYYY = buildFormatConfig( const d = (iso: string) => { const [y, m, day] = iso.slice(0, 10).split('-').map(Number); - return new Date(y, m - 1, day); + return new Date(Date.UTC(y, m - 1, day)); }; describe('DatePrompt', () => { @@ -70,7 +70,7 @@ describe('DatePrompt', () => { instance.prompt(); expect(instance.userInput).to.equal('2025/01/15'); expect(instance.value).toBeInstanceOf(Date); - expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15'); + expect(instance.value?.toISOString().slice(0, 10)).to.equal('2025-01-15'); }); test('left/right navigates between segments', () => { @@ -82,18 +82,30 @@ describe('DatePrompt', () => { initialValue: d('2025-01-15'), }); instance.prompt(); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); // Move within year (0->1->2->3), then right from end goes to month for (let i = 0; i < 4; i++) { input.emit('keypress', undefined, { name: 'right' }); } - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 1, + positionInSegment: 0, + }); for (let i = 0; i < 2; i++) { input.emit('keypress', undefined, { name: 'right' }); } - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 2, + positionInSegment: 0, + }); input.emit('keypress', undefined, { name: 'left' }); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 1, + positionInSegment: 0, + }); }); test('up/down increments and decrements segment', () => { @@ -181,14 +193,20 @@ describe('DatePrompt', () => { initialValue: d('2025-01-15'), }); instance.prompt(); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) input.emit('keypress', '2', { name: undefined, sequence: '2' }); input.emit('keypress', '0', { name: undefined, sequence: '0' }); input.emit('keypress', '2', { name: undefined, sequence: '2' }); input.emit('keypress', '3', { name: undefined, sequence: '3' }); expect(instance.userInput).to.equal('2023/01/15'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 3 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 3, + }); }); test('backspace clears entire segment at any cursor position', () => { @@ -201,11 +219,17 @@ describe('DatePrompt', () => { }); instance.prompt(); expect(instance.userInput).to.equal('2025/12/21'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); // Backspace at first position clears whole year segment input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); expect(instance.userInput).to.equal('____/12/21'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); }); test('backspace clears segment when cursor at first char (2___)', () => { @@ -219,14 +243,23 @@ describe('DatePrompt', () => { // Type "2" to get "2___" input.emit('keypress', '2', { name: undefined, sequence: '2' }); expect(instance.userInput).to.equal('2___/__/__'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 1 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 1, + }); // Move to first char (position 0) input.emit('keypress', undefined, { name: 'left' }); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); // Backspace should clear whole segment - also test char-based detection input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); expect(instance.userInput).to.equal('____/__/__'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 0, + positionInSegment: 0, + }); }); test('digit input updates segment and jumps to next when complete', () => { @@ -242,7 +275,10 @@ describe('DatePrompt', () => { input.emit('keypress', c, { name: undefined, sequence: c }); } expect(instance.userInput).to.equal('2025/__/__'); - expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); + expect(instance.segmentCursor).to.deep.equal({ + segmentIndex: 1, + positionInSegment: 0, + }); }); test('submit returns ISO string for valid date', async () => { diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/test/date.test.ts index 77243162..339e77fa 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/test/date.test.ts @@ -5,7 +5,7 @@ import { MockReadable, MockWritable } from './test-utils.js'; const d = (iso: string) => { const [y, m, day] = iso.slice(0, 10).split('-').map(Number); - return new Date(y, m - 1, day); + return new Date(Date.UTC(y, m - 1, day)); }; describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => {