diff --git a/.changeset/social-poems-start.md b/.changeset/social-poems-start.md new file mode 100644 index 0000000..dcfa94d --- /dev/null +++ b/.changeset/social-poems-start.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +feat(formatDate): Support second argument as explicit `format` string accepting both `Unicode` and `strftime` formats (converting `Unicode` to `strftime`) while still supporting period type string/enum. diff --git a/.changeset/tiny-dogs-shave.md b/.changeset/tiny-dogs-shave.md new file mode 100644 index 0000000..1f90de2 --- /dev/null +++ b/.changeset/tiny-dogs-shave.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +feat(parseDate): Support optional `format` argument accepting both `Unicode` and `strftime` formats (converting `Unicode` to `strftime`) diff --git a/packages/utils/package.json b/packages/utils/package.json index 1f7635e..f40f366 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,6 +22,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/d3-array": "^3.2.1", "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", "@types/lodash-es": "^4.17.12", "@vitest/coverage-v8": "^3.1.2", "prettier": "^3.5.3", @@ -38,6 +39,7 @@ "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" }, "main": "./dist/index.js", diff --git a/packages/utils/src/lib/date.test.ts b/packages/utils/src/lib/date.test.ts index acd8a74..ba783b5 100644 --- a/packages/utils/src/lib/date.test.ts +++ b/packages/utils/src/lib/date.test.ts @@ -86,6 +86,38 @@ describe('formatDate()', () => { it('should allow formatting with PeriodTypeCode', () => { expect(formatDate(testDate, 'day')).equal('11/21/2023'); }); + + describe('strftime format', () => { + test.each([ + [new Date('2023-03-07T04:00:00.000Z'), '%Y-%m-%d', '2023-03-07'], + // [new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%Y', '3/7/2023'], // Not suported + [new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%Y', '03/07/2023'], + [new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%y', '03/07/23'], + [new Date('2023-03-07T04:00:00.000Z'), '%A, %B %d, %Y', 'Tuesday, March 07, 2023'], + [new Date('1900-01-01T15:25:59.000Z'), '%H:%M:%S', '11:25:59'], + [new Date('1900-01-01T18:30:00.000Z'), '%I:%M %p', '02:30 PM'], + [new Date('2023-03-07T18:30:45.000Z'), '%Y-%m-%d %H:%M:%S', '2023-03-07 14:30:45'], + [new Date('2023-03-07T21:30:45.000Z'), '%Y-%m-%d %H:%M:%S %Z', '2023-03-07 17:30:45 -0400'], + ])('formatDate(%s, %s) => %s', (date, format, expected) => { + expect(formatDate(date, format)).toEqual(expected); + }); + }); + + describe('Unicode format', () => { + test.each([ + [new Date('2023-03-07T04:00:00.000Z'), 'yyyy-MM-dd', '2023-03-07'], + // [new Date('2023-03-07T04:00:00.000Z'), 'M/d/yyyy', '3/7/2023'], // Not suported + [new Date('2023-03-07T04:00:00.000Z'), 'MM/dd/yyyy', '03/07/2023'], + [new Date('2023-03-07T04:00:00.000Z'), 'M/d/yy', '03/07/23'], + [new Date('2023-03-07T04:00:00.000Z'), 'EEEE, MMMM dd, yyyy', 'Tuesday, March 07, 2023'], + [new Date('1900-01-01T15:25:59.000Z'), 'HH:mm:ss', '11:25:59'], + [new Date('1900-01-01T18:30:00.000Z'), 'hh:mm a', '02:30 PM'], + [new Date('2023-03-07T18:30:45.000Z'), 'yyyy-MM-dd HH:mm:ss', '2023-03-07 14:30:45'], + [new Date('2023-03-07T21:30:45.000Z'), 'yyyy-MM-dd HH:mm:ss z', '2023-03-07 17:30:45 -0400'], + ])('formatDate(%s, %s) => %s', (date, format, expected) => { + expect(formatDate(date, format)).toEqual(expected); + }); + }); }); describe('formatDateWithLocale()', () => { @@ -1296,6 +1328,39 @@ describe('parseDate()', () => { it('invalid date string', () => { expect(parseDate('some_string')).toEqual(new Date('Invalid Date')); }); + + describe('strftime format', () => { + test.each([ + ['2023-03-07', '%Y-%m-%d', new Date('2023-03-07T04:00:00.000Z')], + ['3/7/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')], + ['03/07/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')], + ['03/07/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')], + ['3/7/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')], + ['Tuesday, March 7, 2023', '%A, %B %d, %Y', new Date('2023-03-07T04:00:00.000Z')], + ['11:25:59', '%H:%M:%S', new Date('1900-01-01T15:25:59.000Z')], + ['2:30 PM', '%I:%M %p', new Date('1900-01-01T18:30:00.000Z')], + ['2023-03-07 14:30:45', '%Y-%m-%d %H:%M:%S', new Date('2023-03-07T18:30:45.000Z')], + ['2023-03-07 14:30:45 -07:00', '%Y-%m-%d %H:%M:%S %Z', new Date('2023-03-07T21:30:45.000Z')], + ])('parseDate(%s, %s) => %s', (date, format, expected) => { + expect(parseDate(date, format)).toEqual(expected); + }); + }); + + describe('Unicode format', () => { + test.each([ + ['2023-03-07', 'yyyy-MM-dd', new Date('2023-03-07T04:00:00.000Z')], + ['3/7/2023', 'M/d/yyyy', new Date('2023-03-07T04:00:00.000Z')], + ['03/07/2023', 'MM/dd/yyyy', new Date('2023-03-07T04:00:00.000Z')], + ['3/7/23', 'M/d/yy', new Date('2023-03-07T04:00:00.000Z')], + ['Tuesday, December 25, 2023', 'EEEE, MMMM dd, yyyy', new Date('2023-12-25T04:00:00.000Z')], + ['11:25:59', 'HH:mm:ss', new Date('1900-01-01T15:25:59.000Z')], + ['2:30 PM', 'hh:mm a', new Date('1900-01-01T18:30:00.000Z')], + ['2023-03-07 14:30:45', 'yyyy-MM-dd HH:mm:ss', new Date('2023-03-07T18:30:45.000Z')], + ['2023-03-07 14:30:45 -07:00', 'yyyy-MM-dd HH:mm:ss z', new Date('2023-03-07T21:30:45.000Z')], + ])('parseDate(%s, %s) => %s', (date, format, expected) => { + expect(parseDate(date, format)).toEqual(expected); + }); + }); }); describe('timeInterval()', () => { diff --git a/packages/utils/src/lib/date.ts b/packages/utils/src/lib/date.ts index 62fd28d..e701584 100644 --- a/packages/utils/src/lib/date.ts +++ b/packages/utils/src/lib/date.ts @@ -17,6 +17,7 @@ import { timeFriday, timeSaturday, } from 'd3-time'; +import { timeFormat, timeParse } from 'd3-time-format'; import { min, max } from 'd3-array'; import { hasKeyOf } from './typeGuards.js'; @@ -35,6 +36,7 @@ import { type TimeIntervalType, } from './date_types.js'; import { defaultLocale, type LocaleSettings } from './locale.js'; +import { convertUnicodeToStrftime } from './dateInternal.js'; export * from './date_types.js'; @@ -619,10 +621,33 @@ function range( export function formatDate( date: Date | string | null | undefined, - periodType: PeriodType | PeriodTypeCode, + periodOrFormat: PeriodType | PeriodTypeCode | string, options: FormatDateOptions = {} ): string { - return formatDateWithLocale(defaultLocale, date, periodType, options); + if (typeof periodOrFormat === 'string' && !getPeriodTypeByCode(periodOrFormat as any)) { + if (!date) { + return ''; + } else if (typeof date === 'string') { + // If periodOrFormat is string, treat as unicode/strftime format + date = parseDate(date); + } + + let strftimeFormat = periodOrFormat; + if (!periodOrFormat.includes('%')) { + // Unicode format, convert to strftime format + strftimeFormat = convertUnicodeToStrftime(periodOrFormat); + // console.log({ periodOrFormat, strftimeFormat }); + } + + return timeFormat(strftimeFormat)(date); + } + + return formatDateWithLocale( + defaultLocale, + date, + periodOrFormat as PeriodType | PeriodTypeCode, + options + ); } export function updatePeriodTypeWithWeekStartsOn( @@ -875,16 +900,34 @@ export function isStringDateWithTimezone(value: string) { return isStringDateWithTime(value) && /Z$|[+-]\d{2}:\d{2}$/.test(value); } -/** Parse a date string as a local Date if no timezone is specified */ -export function parseDate(datestr: string) { - if (!isStringDate(datestr)) return new Date('Invalid Date'); +/** Parse a date string as a local Date if no timezone is specified + * @param dateStr - The date string to parse + * @param format - The format of the date string. If not provided, expects ISO 8601 format. + * - If provided, will use the format to parse the date string. + * - Supports Unicode or strftime date format strings, but will be converted to applicable strftime format before parsing. + * @returns A Date object + */ +export function parseDate(dateStr: string, format?: string) { + // If format is provided, use it to parse the date string + if (format) { + let strftimeFormat = format; + if (!format.includes('%')) { + // Unicode format, convert to strftime format + strftimeFormat = convertUnicodeToStrftime(format); + // console.log({ format, strftimeFormat }); + } + + return timeParse(strftimeFormat)(dateStr) ?? new Date('Invalid Date'); + } + + if (!isStringDate(dateStr)) return new Date('Invalid Date'); - if (isStringDateWithTime(datestr)) { + if (isStringDateWithTime(dateStr)) { // Respect timezone. Also parses unqualified strings like '1982-03-30T04:00' as local date - return new Date(datestr); + return new Date(dateStr); } - const [date, time] = datestr.split('T'); + const [date, time] = dateStr.split('T'); const [year, month, day] = date.split('-').map(Number); if (time) { diff --git a/packages/utils/src/lib/dateInternal.ts b/packages/utils/src/lib/dateInternal.ts index cbeaead..ec66912 100644 --- a/packages/utils/src/lib/dateInternal.ts +++ b/packages/utils/src/lib/dateInternal.ts @@ -10,3 +10,178 @@ export function getWeekStartsOnFromIntl(locales?: string): DayOfWeek { const weekInfo = locale.weekInfo ?? locale.getWeekInfo?.(); return (weekInfo?.firstDay ?? 0) % 7; // (in Intl, sunday is 7 not 0, so we need to mod 7) } + +/** + * Unicode to strftime format mapping + * Based on Unicode TR35 Date Field Symbol Table and POSIX strftime + * @see https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table + * @see https://pubs.opengroup.org/onlinepubs/9699919799/functions/strftime.html + */ +const unicodeToStrftime = { + // ===== YEAR ===== + y: '%y', // 2-digit year (00-99) + yy: '%y', // 2-digit year with leading zero + yyyy: '%Y', // 4-digit year + Y: '%Y', // 4-digit year (short form) + + // ===== MONTH ===== + M: '%m', // Month as number (1-12, but strftime uses 01-12) + MM: '%m', // Month as 2-digit number (01-12) + MMM: '%b', // Abbreviated month name (Jan, Feb, etc.) + MMMM: '%B', // Full month name (January, February, etc.) + L: '%m', // Standalone month number (same as M in most cases) + LL: '%m', // Standalone month number, 2-digit + LLL: '%b', // Standalone abbreviated month name + LLLL: '%B', // Standalone full month name + + // ===== WEEK ===== + w: null, // ❌ Week of year (1-53) - no direct strftime equivalent + ww: null, // ❌ Week of year, 2-digit - no direct strftime equivalent + W: '%W', // Week of year (Monday as first day) - close match + + // ===== DAY ===== + d: '%d', // Day of month (1-31, but strftime uses 01-31) + dd: '%d', // Day of month, 2-digit (01-31) + D: '%j', // Day of year (1-366, but strftime uses 001-366) + DD: '%j', // Day of year, 2-digit - strftime always uses 3 digits + DDD: '%j', // Day of year, 3-digit (001-366) + + // ===== WEEKDAY ===== + E: '%a', // Abbreviated weekday name (Mon, Tue, etc.) + EE: '%a', // Abbreviated weekday name + EEE: '%a', // Abbreviated weekday name + EEEE: '%A', // Full weekday name (Monday, Tuesday, etc.) + EEEEE: null, // ❌ Narrow weekday name (M, T, W) - no strftime equivalent + EEEEEE: null, // ❌ Short weekday name - no strftime equivalent + e: '%u', // Local weekday number (1-7, Monday=1) - close match + ee: '%u', // Local weekday number, 2-digit + eee: '%a', // Local abbreviated weekday name + eeee: '%A', // Local full weekday name + c: '%u', // Standalone weekday number + cc: '%u', // Standalone weekday number, 2-digit + ccc: '%a', // Standalone abbreviated weekday name + cccc: '%A', // Standalone full weekday name + + // ===== PERIOD (AM/PM) ===== + a: '%p', // AM/PM + aa: '%p', // AM/PM + aaa: '%p', // AM/PM + aaaa: '%p', // AM/PM (long form, but strftime only has short) + aaaaa: null, // ❌ Narrow AM/PM (A/P) - no strftime equivalent + + // ===== HOUR ===== + h: '%I', // Hour in 12-hour format (1-12) + hh: '%I', // Hour in 12-hour format, 2-digit (01-12) + H: '%H', // Hour in 24-hour format (0-23) + HH: '%H', // Hour in 24-hour format, 2-digit (00-23) + K: null, // ❌ Hour in 12-hour format (0-11) - no direct strftime equivalent + KK: null, // ❌ Hour in 12-hour format, 2-digit (00-11) - no strftime equivalent + k: null, // ❌ Hour in 24-hour format (1-24) - no direct strftime equivalent + kk: null, // ❌ Hour in 24-hour format, 2-digit (01-24) - no strftime equivalent + + // ===== MINUTE ===== + m: '%M', // Minutes (0-59) + mm: '%M', // Minutes, 2-digit (00-59) + + // ===== SECOND ===== + s: '%S', // Seconds (0-59) + ss: '%S', // Seconds, 2-digit (00-59) + S: null, // ❌ Fractional seconds (1 digit) - no direct strftime equivalent + SS: null, // ❌ Fractional seconds (2 digits) - no direct strftime equivalent + SSS: null, // ❌ Fractional seconds (3 digits) - no direct strftime equivalent + A: null, // ❌ Milliseconds in day - no strftime equivalent + + // ===== TIMEZONE ===== + z: '%Z', // Timezone name (EST, PST, etc.) + zz: '%Z', // Timezone name + zzz: '%Z', // Timezone name + zzzz: '%Z', // Full timezone name + Z: '%z', // Timezone offset (+0000, -0500, etc.) + ZZ: '%z', // Timezone offset + ZZZ: '%z', // Timezone offset + ZZZZ: null, // ❌ GMT-relative timezone - partial strftime support + ZZZZZ: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + O: null, // ❌ Localized GMT offset - no strftime equivalent + OOOO: null, // ❌ Full localized GMT offset - no strftime equivalent + v: null, // ❌ Generic timezone - no strftime equivalent + vvvv: null, // ❌ Generic timezone full - no strftime equivalent + V: null, // ❌ Timezone ID - no strftime equivalent + VV: null, // ❌ Timezone ID - no strftime equivalent + VVV: null, // ❌ Timezone exemplar city - no strftime equivalent + VVVV: null, // ❌ Generic location format - no strftime equivalent + X: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + XX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + XXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + XXXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + XXXXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + x: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + xx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + xxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + xxxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + xxxxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent + + // ===== QUARTER ===== + Q: null, // ❌ Quarter (1-4) - no strftime equivalent + QQ: null, // ❌ Quarter, 2-digit (01-04) - no strftime equivalent + QQQ: null, // ❌ Abbreviated quarter (Q1, Q2, etc.) - no strftime equivalent + QQQQ: null, // ❌ Full quarter (1st quarter, etc.) - no strftime equivalent + QQQQQ: null, // ❌ Narrow quarter - no strftime equivalent + q: null, // ❌ Standalone quarter - no strftime equivalent + qq: null, // ❌ Standalone quarter, 2-digit - no strftime equivalent + qqq: null, // ❌ Standalone abbreviated quarter - no strftime equivalent + qqqq: null, // ❌ Standalone full quarter - no strftime equivalent + qqqqq: null, // ❌ Standalone narrow quarter - no strftime equivalent + + // ===== ERA ===== + G: null, // ❌ Era designator (AD, BC) - no strftime equivalent + GG: null, // ❌ Era designator - no strftime equivalent + GGG: null, // ❌ Era designator - no strftime equivalent + GGGG: null, // ❌ Era designator full - no strftime equivalent + GGGGG: null, // ❌ Era designator narrow - no strftime equivalent +}; + +/** + * Convert a Unicode format string to strftime format + * @param unicodeFormat - The Unicode format string to convert + * @returns The strftime format string + */ +export function convertUnicodeToStrftime(unicodeFormat: string) { + let result = ''; + let i = 0; + let unsupportedPatterns = []; + + while (i < unicodeFormat.length) { + let matched = false; + + // Try to match the longest possible pattern starting at current position + for (let len = Math.min(5, unicodeFormat.length - i); len >= 1; len--) { + const pattern = unicodeFormat.substring(i, i + len); + if (pattern in unicodeToStrftime) { + const strftimeEquivalent = unicodeToStrftime[pattern as keyof typeof unicodeToStrftime]; + + if (strftimeEquivalent === null) { + unsupportedPatterns.push(pattern); + result += pattern; // Keep original if unsupported + } else { + result += strftimeEquivalent; + } + + i += len; + matched = true; + break; + } + } + + // If no pattern matched, copy the character as-is + if (!matched) { + result += unicodeFormat[i]; + i++; + } + } + + if (unsupportedPatterns.length > 0) { + console.warn('Unsupported patterns:', [...new Set(unsupportedPatterns)]); + } + + return result; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736273e..a6a2e84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: d3-time: specifier: ^3.1.0 version: 3.1.0 + d3-time-format: + specifier: ^4.1.0 + version: 4.1.0 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -353,6 +356,9 @@ importers: '@types/d3-time': specifier: ^3.0.4 version: 3.0.4 + '@types/d3-time-format': + specifier: ^4.0.3 + version: 4.0.3 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -1285,6 +1291,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + '@types/d3-time@3.0.4': resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} @@ -3509,6 +3518,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-time-format@4.0.3': {} + '@types/d3-time@3.0.4': {} '@types/estree@0.0.39': {}