Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/early-ads-tap.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 24 additions & 10 deletions packages/core/src/prompts/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -278,7 +286,10 @@ export default class DatePrompt extends Prompt<Date> {
: 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();
}

Expand Down Expand Up @@ -331,7 +342,10 @@ export default class DatePrompt extends Prompt<Date> {
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;
Expand Down
64 changes: 50 additions & 14 deletions packages/core/test/prompts/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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___)', () => {
Expand All @@ -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', () => {
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/prompts/test/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading