From cdc5e3953237a192beafd6330f9d9e36ede34f2c Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 11 Jan 2024 15:36:44 +0100 Subject: [PATCH] fix(common): The date pipe should return ISO format for week and week-year as intended in the unit test. (#53879) ISO 8601 defines * Monday as the first day of the week. * week 01 is the week with the first Thursday Therefore: Sunday Dec 31st 2023 is the last day of the last week of the year : W52 2023. PR Close #53879 --- packages/common/src/i18n/format_date.ts | 17 ++- packages/common/src/pipes/date_pipe.ts | 128 +++++++++--------- packages/common/test/i18n/format_date_spec.ts | 40 +++++- packages/common/test/pipes/date_pipe_spec.ts | 12 +- 4 files changed, 125 insertions(+), 72 deletions(-) diff --git a/packages/common/src/i18n/format_date.ts b/packages/common/src/i18n/format_date.ts index 8ab9b8e41f219..49b66aa553988 100644 --- a/packages/common/src/i18n/format_date.ts +++ b/packages/common/src/i18n/format_date.ts @@ -462,11 +462,20 @@ function getFirstThursdayOfYear(year: number) { ); } -function getThursdayThisWeek(datetime: Date) { +/** + * ISO Week starts on day 1 (Monday) and ends with day 0 (Sunday) + */ +export function getThursdayThisIsoWeek(datetime: Date) { + // getDay returns 0-6 range with sunday as 0. + const currentDay = datetime.getDay(); + + // On a Sunday, read the previous Thursday since ISO weeks start on Monday. + const deltaToThursday = currentDay === 0 ? -3 : THURSDAY - currentDay; + return createDate( datetime.getFullYear(), datetime.getMonth(), - datetime.getDate() + (THURSDAY - datetime.getDay()), + datetime.getDate() + deltaToThursday, ); } @@ -479,7 +488,7 @@ function weekGetter(size: number, monthBased = false): DateFormatter { const today = date.getDate(); result = 1 + Math.floor((today + nbDaysBefore1stDayOfMonth) / 7); } else { - const thisThurs = getThursdayThisWeek(date); + const thisThurs = getThursdayThisIsoWeek(date); // Some days of a year are part of next year according to ISO 8601. // Compute the firstThurs from the year of this week's Thursday const firstThurs = getFirstThursdayOfYear(thisThurs.getFullYear()); @@ -496,7 +505,7 @@ function weekGetter(size: number, monthBased = false): DateFormatter { */ function weekNumberingYearGetter(size: number, trim = false): DateFormatter { return function (date: Date, locale: string) { - const thisThurs = getThursdayThisWeek(date); + const thisThurs = getThursdayThisIsoWeek(date); const weekNumberingYear = thisThurs.getFullYear(); return padNumber( weekNumberingYear, diff --git a/packages/common/src/pipes/date_pipe.ts b/packages/common/src/pipes/date_pipe.ts index 01179ebfef3b5..abf973850d1bd 100644 --- a/packages/common/src/pipes/date_pipe.ts +++ b/packages/common/src/pipes/date_pipe.ts @@ -115,70 +115,70 @@ export const DATE_PIPE_DEFAULT_OPTIONS = new InjectionToken( * Format details depend on the locale. * Fields marked with (*) are only available in the extra data set for the given locale. * - * | Field type | Format | Description | Example Value | - * |-------------------- |-------------|---------------------------------------------------------------|------------------------------------------------------------| - * | Era | G, GG & GGG | Abbreviated | AD | - * | | GGGG | Wide | Anno Domini | - * | | GGGGG | Narrow | A | - * | Year | y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | - * | | yy | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | - * | | yyy | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | - * | | yyyy | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | - * | Week-numbering year | Y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | - * | | YY | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | - * | | YYY | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | - * | | YYYY | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | - * | Month | M | Numeric: 1 digit | 9, 12 | - * | | MM | Numeric: 2 digits + zero padded | 09, 12 | - * | | MMM | Abbreviated | Sep | - * | | MMMM | Wide | September | - * | | MMMMM | Narrow | S | - * | Month standalone | L | Numeric: 1 digit | 9, 12 | - * | | LL | Numeric: 2 digits + zero padded | 09, 12 | - * | | LLL | Abbreviated | Sep | - * | | LLLL | Wide | September | - * | | LLLLL | Narrow | S | - * | Week of year | w | Numeric: minimum digits | 1... 53 | - * | | ww | Numeric: 2 digits + zero padded | 01... 53 | - * | Week of month | W | Numeric: 1 digit | 1... 5 | - * | Day of month | d | Numeric: minimum digits | 1 | - * | | dd | Numeric: 2 digits + zero padded | 01 | - * | Week day | E, EE & EEE | Abbreviated | Tue | - * | | EEEE | Wide | Tuesday | - * | | EEEEE | Narrow | T | - * | | EEEEEE | Short | Tu | - * | Week day standalone | c, cc | Numeric: 1 digit | 2 | - * | | ccc | Abbreviated | Tue | - * | | cccc | Wide | Tuesday | - * | | ccccc | Narrow | T | - * | | cccccc | Short | Tu | - * | Period | a, aa & aaa | Abbreviated | am/pm or AM/PM | - * | | aaaa | Wide (fallback to `a` when missing) | ante meridiem/post meridiem | - * | | aaaaa | Narrow | a/p | - * | Period* | B, BB & BBB | Abbreviated | mid. | - * | | BBBB | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | - * | | BBBBB | Narrow | md | - * | Period standalone* | b, bb & bbb | Abbreviated | mid. | - * | | bbbb | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | - * | | bbbbb | Narrow | md | - * | Hour 1-12 | h | Numeric: minimum digits | 1, 12 | - * | | hh | Numeric: 2 digits + zero padded | 01, 12 | - * | Hour 0-23 | H | Numeric: minimum digits | 0, 23 | - * | | HH | Numeric: 2 digits + zero padded | 00, 23 | - * | Minute | m | Numeric: minimum digits | 8, 59 | - * | | mm | Numeric: 2 digits + zero padded | 08, 59 | - * | Second | s | Numeric: minimum digits | 0... 59 | - * | | ss | Numeric: 2 digits + zero padded | 00... 59 | - * | Fractional seconds | S | Numeric: 1 digit | 0... 9 | - * | | SS | Numeric: 2 digits + zero padded | 00... 99 | - * | | SSS | Numeric: 3 digits + zero padded (= milliseconds) | 000... 999 | - * | Zone | z, zz & zzz | Short specific non location format (fallback to O) | GMT-8 | - * | | zzzz | Long specific non location format (fallback to OOOO) | GMT-08:00 | - * | | Z, ZZ & ZZZ | ISO8601 basic format | -0800 | - * | | ZZZZ | Long localized GMT format | GMT-8:00 | - * | | ZZZZZ | ISO8601 extended format + Z indicator for offset 0 (= XXXXX) | -08:00 | - * | | O, OO & OOO | Short localized GMT format | GMT-8 | - * | | OOOO | Long localized GMT format | GMT-08:00 | + * | Field type | Format | Description | Example Value | + * |-------------------------|-------------|---------------------------------------------------------------|------------------------------------------------------------| + * | Era | G, GG & GGG | Abbreviated | AD | + * | | GGGG | Wide | Anno Domini | + * | | GGGGG | Narrow | A | + * | Year | y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | + * | | yy | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | + * | | yyy | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | + * | | yyyy | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | + * | ISO Week-numbering year | Y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | + * | | YY | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | + * | | YYY | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | + * | | YYYY | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | + * | Month | M | Numeric: 1 digit | 9, 12 | + * | | MM | Numeric: 2 digits + zero padded | 09, 12 | + * | | MMM | Abbreviated | Sep | + * | | MMMM | Wide | September | + * | | MMMMM | Narrow | S | + * | Month standalone | L | Numeric: 1 digit | 9, 12 | + * | | LL | Numeric: 2 digits + zero padded | 09, 12 | + * | | LLL | Abbreviated | Sep | + * | | LLLL | Wide | September | + * | | LLLLL | Narrow | S | + * | ISO Week of year | w | Numeric: minimum digits | 1... 53 | + * | | ww | Numeric: 2 digits + zero padded | 01... 53 | + * | Week of month | W | Numeric: 1 digit | 1... 5 | + * | Day of month | d | Numeric: minimum digits | 1 | + * | | dd | Numeric: 2 digits + zero padded | 01 | + * | Week day | E, EE & EEE | Abbreviated | Tue | + * | | EEEE | Wide | Tuesday | + * | | EEEEE | Narrow | T | + * | | EEEEEE | Short | Tu | + * | Week day standalone | c, cc | Numeric: 1 digit | 2 | + * | | ccc | Abbreviated | Tue | + * | | cccc | Wide | Tuesday | + * | | ccccc | Narrow | T | + * | | cccccc | Short | Tu | + * | Period | a, aa & aaa | Abbreviated | am/pm or AM/PM | + * | | aaaa | Wide (fallback to `a` when missing) | ante meridiem/post meridiem | + * | | aaaaa | Narrow | a/p | + * | Period* | B, BB & BBB | Abbreviated | mid. | + * | | BBBB | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | BBBBB | Narrow | md | + * | Period standalone* | b, bb & bbb | Abbreviated | mid. | + * | | bbbb | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | bbbbb | Narrow | md | + * | Hour 1-12 | h | Numeric: minimum digits | 1, 12 | + * | | hh | Numeric: 2 digits + zero padded | 01, 12 | + * | Hour 0-23 | H | Numeric: minimum digits | 0, 23 | + * | | HH | Numeric: 2 digits + zero padded | 00, 23 | + * | Minute | m | Numeric: minimum digits | 8, 59 | + * | | mm | Numeric: 2 digits + zero padded | 08, 59 | + * | Second | s | Numeric: minimum digits | 0... 59 | + * | | ss | Numeric: 2 digits + zero padded | 00... 59 | + * | Fractional seconds | S | Numeric: 1 digit | 0... 9 | + * | | SS | Numeric: 2 digits + zero padded | 00... 99 | + * | | SSS | Numeric: 3 digits + zero padded (= milliseconds) | 000... 999 | + * | Zone | z, zz & zzz | Short specific non location format (fallback to O) | GMT-8 | + * | | zzzz | Long specific non location format (fallback to OOOO) | GMT-08:00 | + * | | Z, ZZ & ZZZ | ISO8601 basic format | -0800 | + * | | ZZZZ | Long localized GMT format | GMT-8:00 | + * | | ZZZZZ | ISO8601 extended format + Z indicator for offset 0 (= XXXXX) | -08:00 | + * | | O, OO & OOO | Short localized GMT format | GMT-8 | + * | | OOOO | Long localized GMT format | GMT-08:00 | * * * ### Format examples diff --git a/packages/common/test/i18n/format_date_spec.ts b/packages/common/test/i18n/format_date_spec.ts index 7f555a9d5d9b6..c8a3f81be6a74 100644 --- a/packages/common/test/i18n/format_date_spec.ts +++ b/packages/common/test/i18n/format_date_spec.ts @@ -13,7 +13,12 @@ import localeFi from '@angular/common/locales/fi'; import localeHu from '@angular/common/locales/hu'; import localeSr from '@angular/common/locales/sr'; import localeTh from '@angular/common/locales/th'; -import {formatDate, isDate, toDate} from '@angular/common/src/i18n/format_date'; +import { + formatDate, + getThursdayThisIsoWeek, + isDate, + toDate, +} from '@angular/common/src/i18n/format_date'; import {ɵDEFAULT_LOCALE_ID, ɵregisterLocaleData, ɵunregisterLocaleData} from '@angular/core'; describe('Format date', () => { @@ -444,13 +449,33 @@ describe('Format date', () => { // https://github.com/angular/angular/issues/38739 it('should return correct ISO 8601 week-numbering year for dates close to year end/beginning', () => { expect(formatDate('2013-12-27', 'YYYY', 'en')).toEqual('2013'); - expect(formatDate('2013-12-29', 'YYYY', 'en')).toEqual('2014'); + expect(formatDate('2013-12-29', 'YYYY', 'en')).toEqual('2013'); + expect(formatDate('2013-12-31', 'YYYY', 'en')).toEqual('2014'); + + // Dec. 31st is a Sunday, last day of the last week of 2023 + expect(formatDate('2023-12-31', 'YYYY', 'en')).toEqual('2023'); + 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/53813 + it('should return correct ISO 8601 week number close to year end/beginning', () => { + expect(formatDate('2013-12-27', 'w', 'en')).toEqual('52'); + expect(formatDate('2013-12-29', 'w', 'en')).toEqual('52'); + expect(formatDate('2013-12-31', 'w', 'en')).toEqual('1'); + + // Dec. 31st is a Sunday, last day of the last week of 2023 + expect(formatDate('2023-12-31', 'w', 'en')).toEqual('52'); + + expect(formatDate('2010-01-02', 'w', 'en')).toEqual('53'); + expect(formatDate('2010-01-04', 'w', 'en')).toEqual('1'); + expect(formatDate('0049-01-01', 'w', 'en')).toEqual('53'); + expect(formatDate('0049-01-04', 'w', 'en')).toEqual('1'); + }); + // 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'); @@ -464,5 +489,16 @@ describe('Format date', () => { it('should support fullDate in finnish, which uses standalone week day', () => { expect(formatDate(date, 'fullDate', 'fi')).toMatch('maanantai 15. kesäkuuta 2015'); }); + + it('should return thursday date of the same week', () => { + // Dec. 31st is a Sunday, last day of the last week of 2023 + expect(getThursdayThisIsoWeek(new Date('2023-12-31'))).toEqual(new Date('2023-12-28')); + + // Dec. 29th is a Thursday + expect(getThursdayThisIsoWeek(new Date('2022-12-29'))).toEqual(new Date('2022-12-29')); + + // Jan 01st is a Monday + expect(getThursdayThisIsoWeek(new Date('2024-01-01'))).toEqual(new Date('2024-01-04')); + }); }); }); diff --git a/packages/common/test/pipes/date_pipe_spec.ts b/packages/common/test/pipes/date_pipe_spec.ts index cfcf389d6a5ec..43dab5c8001fb 100644 --- a/packages/common/test/pipes/date_pipe_spec.ts +++ b/packages/common/test/pipes/date_pipe_spec.ts @@ -120,13 +120,21 @@ describe('DatePipe', () => { it('should return first week if some dates fall in previous year but belong to next year according to ISO 8601 format', () => { expect(pipe.transform('2019-12-28T00:00:00', 'w')).toEqual('52'); - expect(pipe.transform('2019-12-29T00:00:00', 'w')).toEqual('1'); + + // December 29th is a Sunday, week number is from previous thursday + expect(pipe.transform('2019-12-29T00:00:00', 'w')).toEqual('52'); + + // December 30th is a monday, week number is from next thursday expect(pipe.transform('2019-12-30T00:00:00', 'w')).toEqual('1'); }); it('should return first week if some dates fall in previous leap year but belong to next year according to ISO 8601 format', () => { expect(pipe.transform('2012-12-29T00:00:00', 'w')).toEqual('52'); - expect(pipe.transform('2012-12-30T00:00:00', 'w')).toEqual('1'); + + // December 30th is a Sunday, week number is from previous thursday + expect(pipe.transform('2012-12-30T00:00:00', 'w')).toEqual('52'); + + // December 31th is a monday, week number is from next thursday expect(pipe.transform('2012-12-31T00:00:00', 'w')).toEqual('1'); });