Skip to content

Commit 67e100c

Browse files
authored
Merge pull request #74 from techniq/parse-format
Explicit parseDate() / formatDate() formats
2 parents bcf961c + ad4c34f commit 67e100c

File tree

7 files changed

+314
-8
lines changed

7 files changed

+314
-8
lines changed

.changeset/social-poems-start.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@layerstack/utils': patch
3+
---
4+
5+
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.

.changeset/tiny-dogs-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@layerstack/utils': patch
3+
---
4+
5+
feat(parseDate): Support optional `format` argument accepting both `Unicode` and `strftime` formats (converting `Unicode` to `strftime`)

packages/utils/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@sveltejs/vite-plugin-svelte": "^5.0.3",
2323
"@types/d3-array": "^3.2.1",
2424
"@types/d3-time": "^3.0.4",
25+
"@types/d3-time-format": "^4.0.3",
2526
"@types/lodash-es": "^4.17.12",
2627
"@vitest/coverage-v8": "^3.1.2",
2728
"prettier": "^3.5.3",
@@ -38,6 +39,7 @@
3839
"dependencies": {
3940
"d3-array": "^3.2.4",
4041
"d3-time": "^3.1.0",
42+
"d3-time-format": "^4.1.0",
4143
"lodash-es": "^4.17.21"
4244
},
4345
"main": "./dist/index.js",

packages/utils/src/lib/date.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,38 @@ describe('formatDate()', () => {
8686
it('should allow formatting with PeriodTypeCode', () => {
8787
expect(formatDate(testDate, 'day')).equal('11/21/2023');
8888
});
89+
90+
describe('strftime format', () => {
91+
test.each([
92+
[new Date('2023-03-07T04:00:00.000Z'), '%Y-%m-%d', '2023-03-07'],
93+
// [new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%Y', '3/7/2023'], // Not suported
94+
[new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%Y', '03/07/2023'],
95+
[new Date('2023-03-07T04:00:00.000Z'), '%m/%d/%y', '03/07/23'],
96+
[new Date('2023-03-07T04:00:00.000Z'), '%A, %B %d, %Y', 'Tuesday, March 07, 2023'],
97+
[new Date('1900-01-01T15:25:59.000Z'), '%H:%M:%S', '11:25:59'],
98+
[new Date('1900-01-01T18:30:00.000Z'), '%I:%M %p', '02:30 PM'],
99+
[new Date('2023-03-07T18:30:45.000Z'), '%Y-%m-%d %H:%M:%S', '2023-03-07 14:30:45'],
100+
[new Date('2023-03-07T21:30:45.000Z'), '%Y-%m-%d %H:%M:%S %Z', '2023-03-07 17:30:45 -0400'],
101+
])('formatDate(%s, %s) => %s', (date, format, expected) => {
102+
expect(formatDate(date, format)).toEqual(expected);
103+
});
104+
});
105+
106+
describe('Unicode format', () => {
107+
test.each([
108+
[new Date('2023-03-07T04:00:00.000Z'), 'yyyy-MM-dd', '2023-03-07'],
109+
// [new Date('2023-03-07T04:00:00.000Z'), 'M/d/yyyy', '3/7/2023'], // Not suported
110+
[new Date('2023-03-07T04:00:00.000Z'), 'MM/dd/yyyy', '03/07/2023'],
111+
[new Date('2023-03-07T04:00:00.000Z'), 'M/d/yy', '03/07/23'],
112+
[new Date('2023-03-07T04:00:00.000Z'), 'EEEE, MMMM dd, yyyy', 'Tuesday, March 07, 2023'],
113+
[new Date('1900-01-01T15:25:59.000Z'), 'HH:mm:ss', '11:25:59'],
114+
[new Date('1900-01-01T18:30:00.000Z'), 'hh:mm a', '02:30 PM'],
115+
[new Date('2023-03-07T18:30:45.000Z'), 'yyyy-MM-dd HH:mm:ss', '2023-03-07 14:30:45'],
116+
[new Date('2023-03-07T21:30:45.000Z'), 'yyyy-MM-dd HH:mm:ss z', '2023-03-07 17:30:45 -0400'],
117+
])('formatDate(%s, %s) => %s', (date, format, expected) => {
118+
expect(formatDate(date, format)).toEqual(expected);
119+
});
120+
});
89121
});
90122

91123
describe('formatDateWithLocale()', () => {
@@ -1296,6 +1328,39 @@ describe('parseDate()', () => {
12961328
it('invalid date string', () => {
12971329
expect(parseDate('some_string')).toEqual(new Date('Invalid Date'));
12981330
});
1331+
1332+
describe('strftime format', () => {
1333+
test.each([
1334+
['2023-03-07', '%Y-%m-%d', new Date('2023-03-07T04:00:00.000Z')],
1335+
['3/7/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')],
1336+
['03/07/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')],
1337+
['03/07/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')],
1338+
['3/7/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')],
1339+
['Tuesday, March 7, 2023', '%A, %B %d, %Y', new Date('2023-03-07T04:00:00.000Z')],
1340+
['11:25:59', '%H:%M:%S', new Date('1900-01-01T15:25:59.000Z')],
1341+
['2:30 PM', '%I:%M %p', new Date('1900-01-01T18:30:00.000Z')],
1342+
['2023-03-07 14:30:45', '%Y-%m-%d %H:%M:%S', new Date('2023-03-07T18:30:45.000Z')],
1343+
['2023-03-07 14:30:45 -07:00', '%Y-%m-%d %H:%M:%S %Z', new Date('2023-03-07T21:30:45.000Z')],
1344+
])('parseDate(%s, %s) => %s', (date, format, expected) => {
1345+
expect(parseDate(date, format)).toEqual(expected);
1346+
});
1347+
});
1348+
1349+
describe('Unicode format', () => {
1350+
test.each([
1351+
['2023-03-07', 'yyyy-MM-dd', new Date('2023-03-07T04:00:00.000Z')],
1352+
['3/7/2023', 'M/d/yyyy', new Date('2023-03-07T04:00:00.000Z')],
1353+
['03/07/2023', 'MM/dd/yyyy', new Date('2023-03-07T04:00:00.000Z')],
1354+
['3/7/23', 'M/d/yy', new Date('2023-03-07T04:00:00.000Z')],
1355+
['Tuesday, December 25, 2023', 'EEEE, MMMM dd, yyyy', new Date('2023-12-25T04:00:00.000Z')],
1356+
['11:25:59', 'HH:mm:ss', new Date('1900-01-01T15:25:59.000Z')],
1357+
['2:30 PM', 'hh:mm a', new Date('1900-01-01T18:30:00.000Z')],
1358+
['2023-03-07 14:30:45', 'yyyy-MM-dd HH:mm:ss', new Date('2023-03-07T18:30:45.000Z')],
1359+
['2023-03-07 14:30:45 -07:00', 'yyyy-MM-dd HH:mm:ss z', new Date('2023-03-07T21:30:45.000Z')],
1360+
])('parseDate(%s, %s) => %s', (date, format, expected) => {
1361+
expect(parseDate(date, format)).toEqual(expected);
1362+
});
1363+
});
12991364
});
13001365

13011366
describe('timeInterval()', () => {

packages/utils/src/lib/date.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
timeFriday,
1818
timeSaturday,
1919
} from 'd3-time';
20+
import { timeFormat, timeParse } from 'd3-time-format';
2021
import { min, max } from 'd3-array';
2122

2223
import { hasKeyOf } from './typeGuards.js';
@@ -35,6 +36,7 @@ import {
3536
type TimeIntervalType,
3637
} from './date_types.js';
3738
import { defaultLocale, type LocaleSettings } from './locale.js';
39+
import { convertUnicodeToStrftime } from './dateInternal.js';
3840

3941
export * from './date_types.js';
4042

@@ -619,10 +621,33 @@ function range(
619621

620622
export function formatDate(
621623
date: Date | string | null | undefined,
622-
periodType: PeriodType | PeriodTypeCode,
624+
periodOrFormat: PeriodType | PeriodTypeCode | string,
623625
options: FormatDateOptions = {}
624626
): string {
625-
return formatDateWithLocale(defaultLocale, date, periodType, options);
627+
if (typeof periodOrFormat === 'string' && !getPeriodTypeByCode(periodOrFormat as any)) {
628+
if (!date) {
629+
return '';
630+
} else if (typeof date === 'string') {
631+
// If periodOrFormat is string, treat as unicode/strftime format
632+
date = parseDate(date);
633+
}
634+
635+
let strftimeFormat = periodOrFormat;
636+
if (!periodOrFormat.includes('%')) {
637+
// Unicode format, convert to strftime format
638+
strftimeFormat = convertUnicodeToStrftime(periodOrFormat);
639+
// console.log({ periodOrFormat, strftimeFormat });
640+
}
641+
642+
return timeFormat(strftimeFormat)(date);
643+
}
644+
645+
return formatDateWithLocale(
646+
defaultLocale,
647+
date,
648+
periodOrFormat as PeriodType | PeriodTypeCode,
649+
options
650+
);
626651
}
627652

628653
export function updatePeriodTypeWithWeekStartsOn(
@@ -875,16 +900,34 @@ export function isStringDateWithTimezone(value: string) {
875900
return isStringDateWithTime(value) && /Z$|[+-]\d{2}:\d{2}$/.test(value);
876901
}
877902

878-
/** Parse a date string as a local Date if no timezone is specified */
879-
export function parseDate(datestr: string) {
880-
if (!isStringDate(datestr)) return new Date('Invalid Date');
903+
/** Parse a date string as a local Date if no timezone is specified
904+
* @param dateStr - The date string to parse
905+
* @param format - The format of the date string. If not provided, expects ISO 8601 format.
906+
* - If provided, will use the format to parse the date string.
907+
* - Supports Unicode or strftime date format strings, but will be converted to applicable strftime format before parsing.
908+
* @returns A Date object
909+
*/
910+
export function parseDate(dateStr: string, format?: string) {
911+
// If format is provided, use it to parse the date string
912+
if (format) {
913+
let strftimeFormat = format;
914+
if (!format.includes('%')) {
915+
// Unicode format, convert to strftime format
916+
strftimeFormat = convertUnicodeToStrftime(format);
917+
// console.log({ format, strftimeFormat });
918+
}
919+
920+
return timeParse(strftimeFormat)(dateStr) ?? new Date('Invalid Date');
921+
}
922+
923+
if (!isStringDate(dateStr)) return new Date('Invalid Date');
881924

882-
if (isStringDateWithTime(datestr)) {
925+
if (isStringDateWithTime(dateStr)) {
883926
// Respect timezone. Also parses unqualified strings like '1982-03-30T04:00' as local date
884-
return new Date(datestr);
927+
return new Date(dateStr);
885928
}
886929

887-
const [date, time] = datestr.split('T');
930+
const [date, time] = dateStr.split('T');
888931
const [year, month, day] = date.split('-').map(Number);
889932

890933
if (time) {

packages/utils/src/lib/dateInternal.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,178 @@ export function getWeekStartsOnFromIntl(locales?: string): DayOfWeek {
1010
const weekInfo = locale.weekInfo ?? locale.getWeekInfo?.();
1111
return (weekInfo?.firstDay ?? 0) % 7; // (in Intl, sunday is 7 not 0, so we need to mod 7)
1212
}
13+
14+
/**
15+
* Unicode to strftime format mapping
16+
* Based on Unicode TR35 Date Field Symbol Table and POSIX strftime
17+
* @see https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table
18+
* @see https://pubs.opengroup.org/onlinepubs/9699919799/functions/strftime.html
19+
*/
20+
const unicodeToStrftime = {
21+
// ===== YEAR =====
22+
y: '%y', // 2-digit year (00-99)
23+
yy: '%y', // 2-digit year with leading zero
24+
yyyy: '%Y', // 4-digit year
25+
Y: '%Y', // 4-digit year (short form)
26+
27+
// ===== MONTH =====
28+
M: '%m', // Month as number (1-12, but strftime uses 01-12)
29+
MM: '%m', // Month as 2-digit number (01-12)
30+
MMM: '%b', // Abbreviated month name (Jan, Feb, etc.)
31+
MMMM: '%B', // Full month name (January, February, etc.)
32+
L: '%m', // Standalone month number (same as M in most cases)
33+
LL: '%m', // Standalone month number, 2-digit
34+
LLL: '%b', // Standalone abbreviated month name
35+
LLLL: '%B', // Standalone full month name
36+
37+
// ===== WEEK =====
38+
w: null, // ❌ Week of year (1-53) - no direct strftime equivalent
39+
ww: null, // ❌ Week of year, 2-digit - no direct strftime equivalent
40+
W: '%W', // Week of year (Monday as first day) - close match
41+
42+
// ===== DAY =====
43+
d: '%d', // Day of month (1-31, but strftime uses 01-31)
44+
dd: '%d', // Day of month, 2-digit (01-31)
45+
D: '%j', // Day of year (1-366, but strftime uses 001-366)
46+
DD: '%j', // Day of year, 2-digit - strftime always uses 3 digits
47+
DDD: '%j', // Day of year, 3-digit (001-366)
48+
49+
// ===== WEEKDAY =====
50+
E: '%a', // Abbreviated weekday name (Mon, Tue, etc.)
51+
EE: '%a', // Abbreviated weekday name
52+
EEE: '%a', // Abbreviated weekday name
53+
EEEE: '%A', // Full weekday name (Monday, Tuesday, etc.)
54+
EEEEE: null, // ❌ Narrow weekday name (M, T, W) - no strftime equivalent
55+
EEEEEE: null, // ❌ Short weekday name - no strftime equivalent
56+
e: '%u', // Local weekday number (1-7, Monday=1) - close match
57+
ee: '%u', // Local weekday number, 2-digit
58+
eee: '%a', // Local abbreviated weekday name
59+
eeee: '%A', // Local full weekday name
60+
c: '%u', // Standalone weekday number
61+
cc: '%u', // Standalone weekday number, 2-digit
62+
ccc: '%a', // Standalone abbreviated weekday name
63+
cccc: '%A', // Standalone full weekday name
64+
65+
// ===== PERIOD (AM/PM) =====
66+
a: '%p', // AM/PM
67+
aa: '%p', // AM/PM
68+
aaa: '%p', // AM/PM
69+
aaaa: '%p', // AM/PM (long form, but strftime only has short)
70+
aaaaa: null, // ❌ Narrow AM/PM (A/P) - no strftime equivalent
71+
72+
// ===== HOUR =====
73+
h: '%I', // Hour in 12-hour format (1-12)
74+
hh: '%I', // Hour in 12-hour format, 2-digit (01-12)
75+
H: '%H', // Hour in 24-hour format (0-23)
76+
HH: '%H', // Hour in 24-hour format, 2-digit (00-23)
77+
K: null, // ❌ Hour in 12-hour format (0-11) - no direct strftime equivalent
78+
KK: null, // ❌ Hour in 12-hour format, 2-digit (00-11) - no strftime equivalent
79+
k: null, // ❌ Hour in 24-hour format (1-24) - no direct strftime equivalent
80+
kk: null, // ❌ Hour in 24-hour format, 2-digit (01-24) - no strftime equivalent
81+
82+
// ===== MINUTE =====
83+
m: '%M', // Minutes (0-59)
84+
mm: '%M', // Minutes, 2-digit (00-59)
85+
86+
// ===== SECOND =====
87+
s: '%S', // Seconds (0-59)
88+
ss: '%S', // Seconds, 2-digit (00-59)
89+
S: null, // ❌ Fractional seconds (1 digit) - no direct strftime equivalent
90+
SS: null, // ❌ Fractional seconds (2 digits) - no direct strftime equivalent
91+
SSS: null, // ❌ Fractional seconds (3 digits) - no direct strftime equivalent
92+
A: null, // ❌ Milliseconds in day - no strftime equivalent
93+
94+
// ===== TIMEZONE =====
95+
z: '%Z', // Timezone name (EST, PST, etc.)
96+
zz: '%Z', // Timezone name
97+
zzz: '%Z', // Timezone name
98+
zzzz: '%Z', // Full timezone name
99+
Z: '%z', // Timezone offset (+0000, -0500, etc.)
100+
ZZ: '%z', // Timezone offset
101+
ZZZ: '%z', // Timezone offset
102+
ZZZZ: null, // ❌ GMT-relative timezone - partial strftime support
103+
ZZZZZ: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
104+
O: null, // ❌ Localized GMT offset - no strftime equivalent
105+
OOOO: null, // ❌ Full localized GMT offset - no strftime equivalent
106+
v: null, // ❌ Generic timezone - no strftime equivalent
107+
vvvv: null, // ❌ Generic timezone full - no strftime equivalent
108+
V: null, // ❌ Timezone ID - no strftime equivalent
109+
VV: null, // ❌ Timezone ID - no strftime equivalent
110+
VVV: null, // ❌ Timezone exemplar city - no strftime equivalent
111+
VVVV: null, // ❌ Generic location format - no strftime equivalent
112+
X: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
113+
XX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
114+
XXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
115+
XXXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
116+
XXXXX: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
117+
x: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
118+
xx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
119+
xxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
120+
xxxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
121+
xxxxx: null, // ❌ ISO 8601 timezone - no direct strftime equivalent
122+
123+
// ===== QUARTER =====
124+
Q: null, // ❌ Quarter (1-4) - no strftime equivalent
125+
QQ: null, // ❌ Quarter, 2-digit (01-04) - no strftime equivalent
126+
QQQ: null, // ❌ Abbreviated quarter (Q1, Q2, etc.) - no strftime equivalent
127+
QQQQ: null, // ❌ Full quarter (1st quarter, etc.) - no strftime equivalent
128+
QQQQQ: null, // ❌ Narrow quarter - no strftime equivalent
129+
q: null, // ❌ Standalone quarter - no strftime equivalent
130+
qq: null, // ❌ Standalone quarter, 2-digit - no strftime equivalent
131+
qqq: null, // ❌ Standalone abbreviated quarter - no strftime equivalent
132+
qqqq: null, // ❌ Standalone full quarter - no strftime equivalent
133+
qqqqq: null, // ❌ Standalone narrow quarter - no strftime equivalent
134+
135+
// ===== ERA =====
136+
G: null, // ❌ Era designator (AD, BC) - no strftime equivalent
137+
GG: null, // ❌ Era designator - no strftime equivalent
138+
GGG: null, // ❌ Era designator - no strftime equivalent
139+
GGGG: null, // ❌ Era designator full - no strftime equivalent
140+
GGGGG: null, // ❌ Era designator narrow - no strftime equivalent
141+
};
142+
143+
/**
144+
* Convert a Unicode format string to strftime format
145+
* @param unicodeFormat - The Unicode format string to convert
146+
* @returns The strftime format string
147+
*/
148+
export function convertUnicodeToStrftime(unicodeFormat: string) {
149+
let result = '';
150+
let i = 0;
151+
let unsupportedPatterns = [];
152+
153+
while (i < unicodeFormat.length) {
154+
let matched = false;
155+
156+
// Try to match the longest possible pattern starting at current position
157+
for (let len = Math.min(5, unicodeFormat.length - i); len >= 1; len--) {
158+
const pattern = unicodeFormat.substring(i, i + len);
159+
if (pattern in unicodeToStrftime) {
160+
const strftimeEquivalent = unicodeToStrftime[pattern as keyof typeof unicodeToStrftime];
161+
162+
if (strftimeEquivalent === null) {
163+
unsupportedPatterns.push(pattern);
164+
result += pattern; // Keep original if unsupported
165+
} else {
166+
result += strftimeEquivalent;
167+
}
168+
169+
i += len;
170+
matched = true;
171+
break;
172+
}
173+
}
174+
175+
// If no pattern matched, copy the character as-is
176+
if (!matched) {
177+
result += unicodeFormat[i];
178+
i++;
179+
}
180+
}
181+
182+
if (unsupportedPatterns.length > 0) {
183+
console.warn('Unsupported patterns:', [...new Set(unsupportedPatterns)]);
184+
}
185+
186+
return result;
187+
}

0 commit comments

Comments
 (0)