diff --git a/packages/common/src/i18n/format_date.ts b/packages/common/src/i18n/format_date.ts index ebeefaedb5..9e2b9c9a38 100644 --- a/packages/common/src/i18n/format_date.ts +++ b/packages/common/src/i18n/format_date.ts @@ -101,6 +101,38 @@ export function formatDate( return text; } +/** + * Create a new Date object with the given date value, and the time set to midnight. + * + * We cannot use `new Date(year, month, date)` because it maps years between 0 and 99 to 1900-1999. + * See: https://github.com/angular/angular/issues/40377 + * + * Note that this function returns a Date object whose time is midnight in the current locale's + * timezone. In the future we might want to change this to be midnight in UTC, but this would be a + * considerable breaking change. + */ +function createDate(year: number, month: number, date: number): Date { + // The `newDate` is set to midnight (UTC) on January 1st 1970. + // - In PST this will be December 31st 1969 at 4pm. + // - In GMT this will be January 1st 1970 at 1am. + // Note that they even have different years, dates and months! + const newDate = new Date(0); + + // `setFullYear()` allows years like 0001 to be set correctly. This function does not + // change the internal time of the date. + // Consider calling `setFullYear(2019, 8, 20)` (September 20, 2019). + // - In PST this will now be September 20, 2019 at 4pm + // - In GMT this will now be September 20, 2019 at 1am + + newDate.setFullYear(year, month, date); + // We want the final date to be at local midnight, so we reset the time. + // - In PST this will now be September 20, 2019 at 12am + // - In GMT this will now be September 20, 2019 at 12am + newDate.setHours(0, 0, 0); + + return newDate; +} + function getNamedFormat(locale: string, format: string): string { const localeId = getLocaleId(locale); NAMED_FORMATS[localeId] = NAMED_FORMATS[localeId] || {}; @@ -362,13 +394,13 @@ function timeZoneGetter(width: ZoneWidth): DateFormatter { const JANUARY = 0; const THURSDAY = 4; function getFirstThursdayOfYear(year: number) { - const firstDayOfYear = (new Date(year, JANUARY, 1)).getDay(); - return new Date( + const firstDayOfYear = createDate(year, JANUARY, 1).getDay(); + return createDate( year, 0, 1 + ((firstDayOfYear <= THURSDAY) ? THURSDAY : THURSDAY + 7) - firstDayOfYear); } function getThursdayThisWeek(datetime: Date) { - return new Date( + return createDate( datetime.getFullYear(), datetime.getMonth(), datetime.getDate() + (THURSDAY - datetime.getDay())); } @@ -720,7 +752,7 @@ export function toDate(value: string|number|Date): Date { is applied. Note: ISO months are 0 for January, 1 for February, ... */ const [y, m = 1, d = 1] = value.split('-').map((val: string) => +val); - return new Date(y, m - 1, d); + return createDate(y, m - 1, d); } const parsedNb = parseFloat(value); diff --git a/packages/common/src/pipes/date_pipe.ts b/packages/common/src/pipes/date_pipe.ts index 4f03332a7f..3109d9f77b 100644 --- a/packages/common/src/pipes/date_pipe.ts +++ b/packages/common/src/pipes/date_pipe.ts @@ -114,7 +114,6 @@ import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; * | | O, OO & OOO | Short localized GMT format | GMT-8 | * | | OOOO | Long localized GMT format | GMT-08:00 | * - * Note that timezone correction is not applied to an ISO string that has no time component, such as "2016-09-19" * * ### Format examples * diff --git a/packages/common/test/i18n/format_date_spec.ts b/packages/common/test/i18n/format_date_spec.ts index a08ce87ddc..56d16197f9 100644 --- a/packages/common/test/i18n/format_date_spec.ts +++ b/packages/common/test/i18n/format_date_spec.ts @@ -347,6 +347,32 @@ describe('Format date', () => { .toEqual('10:14 AM'); }); + // The following test is disabled because backwards compatibility requires that date-only ISO + // strings are parsed with the local timezone. + + // it('should create UTC date objects when an ISO string is passed with no time components', + // () => { + // expect(formatDate('2019-09-20', `MMM d, y, h:mm:ss a ZZZZZ`, ɵDEFAULT_LOCALE_ID)) + // .toEqual('Sep 20, 2019, 12:00:00 AM Z'); + // }); + + // This test is to ensure backward compatibility for parsing date-only ISO strings. + it('should create local timezone date objects when an ISO string is passed with no time components', + () => { + // Dates created with individual components are evaluated against the local timezone. See + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#Individual_date_and_time_component_values + const localDate = new Date(2019, 8, 20, 0, 0, 0, 0); + expect(formatDate('2019-09-20', `MMM d, y, h:mm:ss a ZZZZZ`, ɵDEFAULT_LOCALE_ID)) + .toEqual(formatDate(localDate, `MMM d, y, h:mm:ss a ZZZZZ`, ɵDEFAULT_LOCALE_ID)); + }); + + it('should create local timezone date objects when an ISO string is passed with time components', + () => { + const localDate = new Date(2019, 8, 20, 0, 0, 0, 0); + expect(formatDate('2019-09-20T00:00:00', `MMM d, y, h:mm:ss a ZZZZZ`, ɵDEFAULT_LOCALE_ID)) + .toEqual(formatDate(localDate, `MMM d, y, h:mm:ss a ZZZZZ`, ɵDEFAULT_LOCALE_ID)); + }); + it('should remove bidi control characters', () => expect(formatDate(date, 'MM/dd/yyyy', ɵDEFAULT_LOCALE_ID)!.length).toEqual(10)); @@ -389,6 +415,17 @@ describe('Format date', () => { expect(formatDate('2013-12-29', 'YYYY', 'en')).toEqual('2014'); expect(formatDate('2010-01-02', 'YYYY', 'en')).toEqual('2009'); expect(formatDate('2010-01-04', 'YYYY', 'en')).toEqual('2010'); + expect(formatDate('0049-01-01', 'YYYY', 'en')).toEqual('0048'); + expect(formatDate('0049-01-04', 'YYYY', 'en')).toEqual('0049'); }); + + // https://github.com/angular/angular/issues/40377 + it('should format date with year between 0 and 99 correctly', () => { + expect(formatDate('0098-01-11', 'YYYY', ɵDEFAULT_LOCALE_ID)).toEqual('0098'); + expect(formatDate('0099-01-11', 'YYYY', ɵDEFAULT_LOCALE_ID)).toEqual('0099'); + expect(formatDate('0100-01-11', 'YYYY', ɵDEFAULT_LOCALE_ID)).toEqual('0100'); + expect(formatDate('0001-01-11', 'YYYY', ɵDEFAULT_LOCALE_ID)).toEqual('0001'); + expect(formatDate('0000-01-11', 'YYYY', ɵDEFAULT_LOCALE_ID)).toEqual('0000'); + }); }); });