From f7c69b18f91163131a3688d24b5cdf2e226be352 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Tue, 14 Jan 2025 20:57:51 +0100 Subject: [PATCH] #178 - explicit support for telling if the Www date should be same, earlier than the first day of the week or later than the last day of the week - syntax W1 W1- W1+ --- src/custom-sort/matchers.ts | 42 +++++++++++++++----- src/test/unit/matchers.spec.ts | 16 ++++++++ src/test/unit/sorting-spec-processor.spec.ts | 4 +- src/test/unit/week-of-year.spec.ts | 6 ++- src/utils/week-of-year.ts | 17 +++++--- 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/custom-sort/matchers.ts b/src/custom-sort/matchers.ts index 2cf55cb..e10ec56 100644 --- a/src/custom-sort/matchers.ts +++ b/src/custom-sort/matchers.ts @@ -17,15 +17,19 @@ export const Date_dd_Mmm_yyyy_RegexStr: string = ' *([0-3]*[0-9]-(?:Jan|Feb|Mar| export const Date_Mmm_dd_yyyy_RegexStr: string = ' *((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-[0-3]*[0-9]-\\d{4})'; // Date like Jan-01-2020 export const Date_yyyy_Www_mm_dd_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9] \\([0-3]*[0-9]-[0-3]*[0-9]\\))' -export const Date_yyyy_WwwISO_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9])' +export const Date_yyyy_WwwISO_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9][-+]?)' export const Date_yyyy_Www_RegexStr: string = Date_yyyy_WwwISO_RegexStr -export const DOT_SEPARATOR = '.' +export const DOT_SEPARATOR = '.' // ASCII 46 export const DASH_SEPARATOR = '-' -const SLASH_SEPARATOR = '/' // ASCII 47 +const SLASH_SEPARATOR = '/' // ASCII 47, right before ASCII 48 = '0' +const COLON_SEPARATOR = ':' // ASCII 58, first non-digit character const PIPE_SEPARATOR = '|' // ASCII 124 +const EARLIER_THAN_SLASH_SEPARATOR = DOT_SEPARATOR +const LATER_THAN_SLASH_SEPARATOR = COLON_SEPARATOR + export const DEFAULT_NORMALIZATION_PLACES = 8; // Fixed width of a normalized number (with leading zeros) // Property escapes: @@ -62,9 +66,9 @@ export function getNormalizedNumber(s: string = '', separator?: string, places?: // guarantees correct order (/ = ASCII 47, | = ASCII 124) if (separator) { const components: Array = s.split(separator).filter(s => s) - return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//` + return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } else { - return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}//` + return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } @@ -108,9 +112,9 @@ export function getNormalizedRomanNumber(s: string, separator?: string, places?: // guarantees correct order (/ = ASCII 47, | = ASCII 124) if (separator) { const components: Array = s.split(separator).filter(s => s) - return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//` + return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } else { - return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}//` + return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } @@ -128,7 +132,7 @@ export function getNormalizedDate_NormalizerFn_for(separator: string, dayIdx: nu const monthValue = months ? `${1 + MONTHS.indexOf(components[monthIdx])}` : components[monthIdx] const month = prependWithZeros(monthValue, MONTH_POSITIONS) const year = prependWithZeros(components[yearIdx], YEAR_POSITIONS) - return `${year}-${month}-${day}//` + return `${year}-${month}-${day}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` } } @@ -137,14 +141,18 @@ export const getNormalizedDate_yyyy_dd_mm_NormalizerFn = getNormalizedDate_Norma export const getNormalizedDate_dd_Mmm_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 0, 1, 2, MONTHS) export const getNormalizedDate_Mmm_dd_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 0, 2, MONTHS) +const DateExtractor_orderModifier_earlier_than = '-' +const DateExtractor_orderModifier_later_than = '+' + const DateExtractor_yyyy_Www_mm_dd_Regex = /(\d{4})-W(\d{1,2}) \((\d{2})-(\d{2})\)/ -const DateExtractor_yyyy_Www_Regex = /(\d{4})-W(\d{1,2})/ +const DateExtractor_yyyy_Www_Regex = /(\d{4})-W(\d{1,2})([-+]?)/ // Matching groups const YEAR_IDX = 1 const WEEK_IDX = 2 const MONTH_IDX = 3 const DAY_IDX = 4 +const RELATIVE_ORDER_IDX = 3 // For the yyyy-Www only: yyyy-Www> or yyyy-Www< const DECEMBER = 12 const JANUARY = 1 @@ -157,10 +165,19 @@ export function getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(consumeWeek: boole let yearNumber = Number.parseInt(yearStr,10) let monthNumber: number let dayNumber: number + let separator = SLASH_SEPARATOR // different values enforce relative > < order of same dates + let useLastDayOfWeek: boolean = false if (consumeWeek) { const weekNumberStr = matches![WEEK_IDX] const weekNumber = Number.parseInt(weekNumberStr, 10) - const dateForWeek = getDateForWeekOfYear(yearNumber, weekNumber, weeksISO) + const orderModifier: string|undefined = matches![RELATIVE_ORDER_IDX] + if (orderModifier === DateExtractor_orderModifier_earlier_than) { + separator = EARLIER_THAN_SLASH_SEPARATOR + } else if (orderModifier === DateExtractor_orderModifier_later_than) { + separator = LATER_THAN_SLASH_SEPARATOR // Will also need to adjust the date to the last day of the week + useLastDayOfWeek = true + } + const dateForWeek = getDateForWeekOfYear(yearNumber, weekNumber, weeksISO, useLastDayOfWeek) monthNumber = dateForWeek.getMonth()+1 // 1 - 12 dayNumber = dateForWeek.getDate() // 1 - 31 // Be careful with edge dates, which can belong to previous or next year @@ -178,7 +195,10 @@ export function getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(consumeWeek: boole monthNumber = Number.parseInt(matches![MONTH_IDX],10) dayNumber = Number.parseInt(matches![DAY_IDX], 10) } - return `${prependWithZeros(`${yearNumber}`, YEAR_POSITIONS)}-${prependWithZeros(`${monthNumber}`, MONTH_POSITIONS)}-${prependWithZeros(`${dayNumber}`, DAY_POSITIONS)}//` + return `${prependWithZeros(`${yearNumber}`, YEAR_POSITIONS)}` + + `-${prependWithZeros(`${monthNumber}`, MONTH_POSITIONS)}` + + `-${prependWithZeros(`${dayNumber}`, DAY_POSITIONS)}` + + `${separator}${SLASH_SEPARATOR}` } } diff --git a/src/test/unit/matchers.spec.ts b/src/test/unit/matchers.spec.ts index 0a4b986..339745f 100644 --- a/src/test/unit/matchers.spec.ts +++ b/src/test/unit/matchers.spec.ts @@ -458,3 +458,19 @@ describe('getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn', () => { expect(getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn(s)).toBe(out) }) }) + +describe('getNormalizedDate_yyyy_Www_NormalizerFn', () => { + /* ORDER for week numbers vs. dates of 1st day / last day of the week: + W1 - exactly on the first day of 1st week - the actual title then decides about relative order + W1- - before the first day of 1st week, yet after the last day of prev week) + W1+ - after the last day of 1st week, yet before the first day of next week) + */ + const params = [ + ['2012-W1', '2011-12-26//'], + ['2012-W1+', '2012-01-01:/'], + ['2012-W1-', '2011-12-26./'], + ]; + it.each(params)('>%s< should become %s', (s: string, out: string) => { + expect(getNormalizedDate_yyyy_Www_NormalizerFn(s)).toBe(out) + }) +}) diff --git a/src/test/unit/sorting-spec-processor.spec.ts b/src/test/unit/sorting-spec-processor.spec.ts index a83a462..891013c 100644 --- a/src/test/unit/sorting-spec-processor.spec.ts +++ b/src/test/unit/sorting-spec-processor.spec.ts @@ -458,13 +458,13 @@ const expectedSortSpecsExampleSortingSymbols: { [key: string]: CustomSortSpec } }, { type: CustomSortGroupType.ExactName, regexPrefix: { - regex: /^Week number interpreted in ISO standard *(\d{4}-W[0-5]*[0-9])$/i, + regex: /^Week number interpreted in ISO standard *(\d{4}-W[0-5]*[0-9][-+]?)$/i, normalizerFn: Date_yyyy_WwwISO_NormalizerFn } }, { type: CustomSortGroupType.ExactName, regexPrefix: { - regex: /^Week number interpreted in U\.S\. standard *(\d{4}-W[0-5]*[0-9])$/i, + regex: /^Week number interpreted in U\.S\. standard *(\d{4}-W[0-5]*[0-9][-+]?)$/i, normalizerFn: Date_yyyy_Www_NormalizerFn } }, { diff --git a/src/test/unit/week-of-year.spec.ts b/src/test/unit/week-of-year.spec.ts index 7a5aaf6..16eba99 100644 --- a/src/test/unit/week-of-year.spec.ts +++ b/src/test/unit/week-of-year.spec.ts @@ -43,9 +43,13 @@ describe('getDateForWeekOfYear', () => { expect(getDateForWeekOfYear(year, 10)).toStrictEqual(dateUS) expect(getDateForWeekOfYear(year, 10, true)).toStrictEqual(dateISO) }) - it('should correctly handle edge case - a year spanning 54 weeks (leap year staring on Sun)', () => { + it('should correctly handle edge case - a year spanning 54 weeks (leap year starting on Sun)', () => { + const USstandard = false + const SUNDAY = true // This works in U.S. standard only, where 1st week can start on Sunday expect(getDateForWeekOfYear(2012,1)).toStrictEqual(new Date('2011-12-26T00:00:00.000Z')) + expect(getDateForWeekOfYear(2012,1, USstandard, SUNDAY)).toStrictEqual(new Date('2012-01-01T00:00:00.000Z')) expect(getDateForWeekOfYear(2012,54)).toStrictEqual(new Date('2012-12-31T00:00:00.000Z')) + expect(getDateForWeekOfYear(2012,54, USstandard, SUNDAY)).toStrictEqual(new Date('2013-01-06T00:00:00.000Z')) }) }) diff --git a/src/utils/week-of-year.ts b/src/utils/week-of-year.ts index 41eab02..e93b8df 100644 --- a/src/utils/week-of-year.ts +++ b/src/utils/week-of-year.ts @@ -2,8 +2,10 @@ // Cache of start of years and number of days in the 1st week interface MondayCache { year: number // full year, e.g. 2015 - mondayDateOf1stWeekUS: number // U.S. standard, can be in Dec of previous year + mondayDateOf1stWeekUS: number // U.S. standard, the 1st of Jan determines the first week, monday can be in Dec of previous year + sundayDateOf1stWeekUS: number mondayDateOf1stWeekISO: number // ISO standard, when the first Thursday of the year determines week numbering + sundayDateOf1stWeekISO: number } type YEAR = number @@ -35,20 +37,25 @@ const calculateMondayDateIn1stWeekOfYear = (year: number): MondayCache => { return { year: year, mondayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday), + sundayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + DAYS_IN_WEEK - 1), mondayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset), + sundayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset + DAYS_IN_WEEK - 1), } } // Week number = 1 to 54, U.S. standard by default, can also work in ISO (parameter driven) -export const getDateForWeekOfYear = (year: number, weekNumber: number, useISO?: boolean): Date => { +export const getDateForWeekOfYear = (year: number, weekNumber: number, useISO?: boolean, sunday?: boolean): Date => { const WEEK_OF_MILIS = DAYS_IN_WEEK * DAY_OF_MILIS const dataOfMondayIn1stWeekOfYear = (MondaysCache[year] ??= calculateMondayDateIn1stWeekOfYear(year)) - const mondayOfTheRequestedWeek = new Date( + const mondayOfTheRequestedWeek = (useISO ? dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekUS) + (weekNumber-1)*WEEK_OF_MILIS - ) - return mondayOfTheRequestedWeek + const sundayOfTheRequestedWeek = + (useISO ? dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekUS) + + (weekNumber-1)*WEEK_OF_MILIS + + return new Date(sunday ? sundayOfTheRequestedWeek : mondayOfTheRequestedWeek) } export const _unitTests = {