Skip to content

Commit

Permalink
fix: use non timezone-aware date APIs in date time picker #8021 (CP:…
Browse files Browse the repository at this point in the history
… 24.4) (#8027)
  • Loading branch information
sissbruecker authored Oct 28, 2024
1 parent be0dfd3 commit 073c99a
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 58 deletions.
125 changes: 116 additions & 9 deletions packages/date-picker/src/vaadin-date-picker-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,41 @@ export function getISOWeekNumber(date) {
return Math.floor(daysSinceFirstOfJanuary / 7 + 1);
}

/**
* Creates a new object with the same date, but sets the hours, minutes, seconds and milliseconds to 0.
*
* @param {Date} date in system timezone
* @return {Date} The same date with time elements set to 0, in UTC timezone.
*/
export function normalizeDate(date) {
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
return normalizedDate;
}

/**
* Creates a new object with the same date, but sets the hours, minutes, seconds and milliseconds to 0.
*
* Uses UTC date components to allow handling date instances independently of
* the system time-zone.
*
* @param {Date} date in UTC timezone
* @return {Date} The same date with time elements set to 0, in UTC timezone.
*/
export function normalizeUTCDate(date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0));
}

/**
* Check if two dates are equal.
*
* @param {Date} date1
* @param {Date} date2
* @param {function(Date): Date} normalizer
* @return {boolean} True if the given date objects refer to the same date
*/
export function dateEquals(date1, date2) {
return (
date1 instanceof Date &&
date2 instanceof Date &&
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
export function dateEquals(date1, date2, normalizer = normalizeDate) {
return date1 instanceof Date && date2 instanceof Date && normalizer(date1).getTime() === normalizer(date2).getTime();
}

/**
Expand Down Expand Up @@ -156,7 +176,7 @@ export function getAdjustedYear(referenceDate, year, month = 0, day = 1) {
* - ISO 8601 `"YYYY-MM-DD"`
* - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
* @param {!string} str Date string to parse
* @return {Date} Parsed date
* @return {Date} Parsed date in system timezone
*/
export function parseDate(str) {
// Parsing with RegExp to ensure correct format
Expand All @@ -171,3 +191,90 @@ export function parseDate(str) {
date.setDate(parseInt(parts[3], 10));
return date;
}

/**
* Parse date string of one of the following date formats:
* - ISO 8601 `"YYYY-MM-DD"`
* - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
*
* Uses UTC date components to allow handling date instances independently of
* the system time-zone.
*
* @param {!string} str Date string to parse
* @return {Date} Parsed date in UTC timezone
*/
export function parseUTCDate(str) {
// Parsing with RegExp to ensure correct format
const parts = /^([-+]\d{1}|\d{2,4}|[-+]\d{6})-(\d{1,2})-(\d{1,2})$/u.exec(str);
if (!parts) {
return undefined;
}

const date = new Date(Date.UTC(0, 0)); // Wrong date (1900-01-01), but with midnight in UTC
date.setUTCFullYear(parseInt(parts[1], 10));
date.setUTCMonth(parseInt(parts[2], 10) - 1);
date.setUTCDate(parseInt(parts[3], 10));

return date;
}

function formatISODateBase(dateParts) {
const pad = (num, fmt = '00') => (fmt + num).substr((fmt + num).length - fmt.length);

let yearSign = '';
let yearFmt = '0000';
let yearAbs = dateParts.year;
if (yearAbs < 0) {
yearAbs = -yearAbs;
yearSign = '-';
yearFmt = '000000';
} else if (dateParts.year >= 10000) {
yearSign = '+';
yearFmt = '000000';
}

const year = yearSign + pad(yearAbs, yearFmt);
const month = pad(dateParts.month + 1);
const day = pad(dateParts.day);
return [year, month, day].join('-');
}

/**
* Format a date instance in ISO 8601 (`"YYYY-MM-DD"`) or 6-digit extended ISO
* 8601 (`"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`) format.
* @param {Date} date in system timezone
* @returns {string}
*/
export function formatISODate(date) {
if (!(date instanceof Date)) {
return '';
}

return formatISODateBase({
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
});
}

/**
* Format a date instance in ISO 8601 (`"YYYY-MM-DD"`) or 6-digit extended ISO
* 8601 (`"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`) format.
*
* Uses UTC date components to allow handling date instances independently of
* the system time-zone.
*
* @param {Date} date in UTC timezone
* @returns {string}
*/
export function formatUTCISODate(date) {
if (!(date instanceof Date)) {
return '';
}

return formatISODateBase({
year: date.getUTCFullYear(),
month: date.getUTCMonth(),
day: date.getUTCDate(),
});
}
24 changes: 2 additions & 22 deletions packages/date-picker/src/vaadin-date-picker-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
dateAllowed,
dateEquals,
extractDateParts,
formatISODate,
getAdjustedYear,
getClosestDate,
parseDate,
Expand Down Expand Up @@ -751,28 +752,7 @@ export const DatePickerMixin = (subclass) =>

/** @private */
_formatISO(date) {
if (!(date instanceof Date)) {
return '';
}

const pad = (num, fmt = '00') => (fmt + num).substr((fmt + num).length - fmt.length);

let yearSign = '';
let yearFmt = '0000';
let yearAbs = date.getFullYear();
if (yearAbs < 0) {
yearAbs = -yearAbs;
yearSign = '-';
yearFmt = '000000';
} else if (date.getFullYear() >= 10000) {
yearSign = '+';
yearFmt = '000000';
}

const year = yearSign + pad(yearAbs, yearFmt);
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
return [year, month, day].join('-');
return formatISODate(date);
}

/** @protected */
Expand Down
43 changes: 24 additions & 19 deletions packages/date-time-picker/src/vaadin-date-time-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js';
import { DatePicker } from '@vaadin/date-picker/src/vaadin-date-picker.js';
import { dateEquals, parseDate } from '@vaadin/date-picker/src/vaadin-date-picker-helper.js';
import {
dateEquals,
formatUTCISODate,
normalizeUTCDate,
parseUTCDate,
} from '@vaadin/date-picker/src/vaadin-date-picker-helper.js';
import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js';
import { inputFieldShared } from '@vaadin/field-base/src/styles/input-field-shared-styles.js';
import { TimePicker } from '@vaadin/time-picker/src/vaadin-time-picker.js';
Expand Down Expand Up @@ -615,16 +620,16 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
__updateTimePickerMinMax() {
if (this.__timePicker && this.__datePicker) {
const selectedDate = this.__parseDate(this.__datePicker.value);
const isMinMaxSameDay = dateEquals(this.__minDateTime, this.__maxDateTime);
const isMinMaxSameDay = dateEquals(this.__minDateTime, this.__maxDateTime, normalizeUTCDate);
const oldTimeValue = this.__timePicker.value;

if ((this.__minDateTime && dateEquals(selectedDate, this.__minDateTime)) || isMinMaxSameDay) {
if ((this.__minDateTime && dateEquals(selectedDate, this.__minDateTime, normalizeUTCDate)) || isMinMaxSameDay) {
this.__timePicker.min = this.__dateToIsoTimeString(this.__minDateTime);
} else {
this.__timePicker.min = this.__defaultTimeMinValue;
}

if ((this.__maxDateTime && dateEquals(selectedDate, this.__maxDateTime)) || isMinMaxSameDay) {
if ((this.__maxDateTime && dateEquals(selectedDate, this.__maxDateTime, normalizeUTCDate)) || isMinMaxSameDay) {
this.__timePicker.max = this.__dateToIsoTimeString(this.__maxDateTime);
} else {
this.__timePicker.max = this.__defaultTimeMaxValue;
Expand Down Expand Up @@ -750,7 +755,7 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
* @private
*/
__parseDate(str) {
return parseDate(str);
return parseUTCDate(str);
}

/**
Expand All @@ -764,7 +769,7 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
if (!date) {
return defaultValue;
}
return DatePicker.prototype._formatISO(date);
return formatUTCISODate(date);
}

/**
Expand Down Expand Up @@ -811,10 +816,10 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
return;
}

date.setHours(parseInt(time.hours));
date.setMinutes(parseInt(time.minutes || 0));
date.setSeconds(parseInt(time.seconds || 0));
date.setMilliseconds(parseInt(time.milliseconds || 0));
date.setUTCHours(parseInt(time.hours));
date.setUTCMinutes(parseInt(time.minutes || 0));
date.setUTCSeconds(parseInt(time.seconds || 0));
date.setUTCMilliseconds(parseInt(time.milliseconds || 0));

return date;
}
Expand Down Expand Up @@ -844,10 +849,10 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
__dateToIsoTimeString(date) {
return this.__formatTimeISO(
this.__validateTime({
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
milliseconds: date.getMilliseconds(),
hours: date.getUTCHours(),
minutes: date.getUTCMinutes(),
seconds: date.getUTCSeconds(),
milliseconds: date.getUTCMilliseconds(),
}),
);
}
Expand Down Expand Up @@ -908,14 +913,14 @@ class DateTimePicker extends FieldMixin(DisabledMixin(FocusMixin(ThemableMixin(E
* @private
*/
__dateTimeEquals(date1, date2) {
if (!dateEquals(date1, date2)) {
if (!dateEquals(date1, date2, normalizeUTCDate)) {
return false;
}
return (
date1.getHours() === date2.getHours() &&
date1.getMinutes() === date2.getMinutes() &&
date1.getSeconds() === date2.getSeconds() &&
date1.getMilliseconds() === date2.getMilliseconds()
date1.getUTCHours() === date2.getUTCHours() &&
date1.getUTCMinutes() === date2.getUTCMinutes() &&
date1.getUTCSeconds() === date2.getUTCSeconds() &&
date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
);
}

Expand Down
16 changes: 8 additions & 8 deletions packages/date-time-picker/test/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,17 @@ describe('Basic features', () => {

describe('value property formats', () => {
it('should accept ISO format', () => {
const date = new Date(0, 1, 3, 8, 30, 0);
const date = new Date(Date.UTC(0, 1, 3, 8, 30, 0));

date.setFullYear(0);
date.setUTCFullYear(0);
dateTimePicker.value = '0000-02-03T08:30:00';
expect(dateTimePicker.__selectedDateTime).to.eql(date);

date.setFullYear(10000);
date.setUTCFullYear(10000);
dateTimePicker.value = '+010000-02-03T08:30:00';
expect(dateTimePicker.__selectedDateTime).to.eql(date);

date.setFullYear(-10000);
date.setUTCFullYear(-10000);
dateTimePicker.value = '-010000-02-03T08:30:00';
expect(dateTimePicker.__selectedDateTime).to.eql(date);
});
Expand All @@ -221,9 +221,9 @@ describe('Basic features', () => {
});

it('should output ISO format', () => {
const date = new Date(0, 1, 3, 8, 30, 0);
const date = new Date(Date.UTC(0, 1, 3, 8, 30, 0));

date.setFullYear(0);
date.setUTCFullYear(0);
dateTimePicker.__selectedDateTime = date;
expect(dateTimePicker.value).to.equal('0000-02-03T08:30');

Expand All @@ -241,7 +241,7 @@ describe('Basic features', () => {
dateTimePicker.__selectedDateTime = date;
expect(dateTimePicker.value).to.equal('0000-02-03T08:30:00.000');

date.setFullYear(10000);
date.setUTCFullYear(10000);
dateTimePicker.step = undefined;
dateTimePicker.__selectedDateTime = new Date(date.getTime());
expect(dateTimePicker.value).to.equal('+010000-02-03T08:30');
Expand All @@ -250,7 +250,7 @@ describe('Basic features', () => {
dateTimePicker.step = 0.001;
expect(dateTimePicker.value).to.equal('+010000-02-03T08:30:00.000');

date.setFullYear(-10000);
date.setUTCFullYear(-10000);
dateTimePicker.step = undefined;
dateTimePicker.__selectedDateTime = new Date(date.getTime());
expect(dateTimePicker.value).to.equal('-010000-02-03T08:30');
Expand Down
37 changes: 37 additions & 0 deletions packages/date-time-picker/test/timezone.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect } from '@esm-bundle/chai';
import { fixtureSync } from '@vaadin/testing-helpers';
import '../src/vaadin-date-time-picker.js';

describe('timezone independent', () => {
let dateTimePicker;
let datePicker;
let timePicker;

beforeEach(() => {
dateTimePicker = fixtureSync('<vaadin-date-time-picker></vaadin-date-time-picker>');
datePicker = dateTimePicker.querySelector('[slot="date-picker"]');
timePicker = dateTimePicker.querySelector('[slot="time-picker"]');
});

it('should not skip missing hour from DST switch', () => {
// There's no good way to mock the system timezone, so use multiple test
// cases to cover different timezones, in one of which this test hopefully
// runs. The setup should verify that the component does not use the system
// timezone to manipulate date instances, which would lead to skipping an
// hour when entering the date and time of DST start, as this is an hour
// that does not exist in timezones that use DST.
const testCases = [
'2024-03-10T02:00', // US Eastern DST start
'2024-03-31T02:00', // Central european DST start
'2024-03-31T03:00', // Eastern european DST start
];

testCases.forEach((value) => {
const [date, time] = value.split('T');
datePicker.value = date;
timePicker.value = time;

expect(dateTimePicker.value).to.equal(value);
});
});
});

0 comments on commit 073c99a

Please sign in to comment.