Skip to content

Commit 7c986f8

Browse files
committed
feat(parseDate): Support optional format argument accepting both Unicode and strftime formats (converting Unicode to strftime)
1 parent bcf961c commit 7c986f8

6 files changed

Lines changed: 252 additions & 6 deletions

File tree

.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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,39 @@ describe('parseDate()', () => {
12961296
it('invalid date string', () => {
12971297
expect(parseDate('some_string')).toEqual(new Date('Invalid Date'));
12981298
});
1299+
1300+
describe('strftime format', () => {
1301+
test.each([
1302+
['2023-03-07', '%Y-%m-%d', new Date('2023-03-07T04:00:00.000Z')],
1303+
['3/7/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')],
1304+
['03/07/2023', '%m/%d/%Y', new Date('2023-03-07T04:00:00.000Z')],
1305+
['03/07/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')],
1306+
['3/7/23', '%m/%d/%y', new Date('2023-03-07T04:00:00.000Z')],
1307+
['Monday, March 7, 2023', '%A, %B %d, %Y', new Date('2023-03-07T04:00:00.000Z')],
1308+
['11:25:59', '%H:%M:%S', new Date('1900-01-01T15:25:59.000Z')],
1309+
['2:30 PM', '%I:%M %p', new Date('1900-01-01T18:30:00.000Z')],
1310+
['2023-03-07 14:30:45', '%Y-%m-%d %H:%M:%S', new Date('2023-03-07T18:30:45.000Z')],
1311+
['2023-03-07 14:30:45 -07:00', '%Y-%m-%d %H:%M:%S %Z', new Date('2023-03-07T21:30:45.000Z')],
1312+
])('parseDate(%s, %s) => %s', (date, format, expected) => {
1313+
expect(parseDate(date, format)).toEqual(expected);
1314+
});
1315+
});
1316+
1317+
describe('Unicode format', () => {
1318+
test.each([
1319+
['2023-03-07', 'yyyy-MM-dd', new Date('2023-03-07T04:00:00.000Z')],
1320+
['3/7/2023', 'M/d/yyyy', new Date('2023-03-07T04:00:00.000Z')],
1321+
['03/07/2023', 'MM/dd/yyyy', new Date('2023-03-07T04:00:00.000Z')],
1322+
['3/7/23', 'M/d/yy', new Date('2023-03-07T04:00:00.000Z')],
1323+
['Monday, December 25, 2023', 'EEEE, MMMM dd, yyyy', new Date('2023-12-25T04:00:00.000Z')],
1324+
['11:25:59', 'HH:mm:ss', new Date('1900-01-01T15:25:59.000Z')],
1325+
['2:30 PM', 'hh:mm a', new Date('1900-01-01T18:30:00.000Z')],
1326+
['2023-03-07 14:30:45', 'yyyy-MM-dd HH:mm:ss', new Date('2023-03-07T18:30:45.000Z')],
1327+
['2023-03-07 14:30:45 -07:00', 'yyyy-MM-dd HH:mm:ss z', new Date('2023-03-07T21:30:45.000Z')],
1328+
])('parseDate(%s, %s) => %s', (date, format, expected) => {
1329+
expect(parseDate(date, format)).toEqual(expected);
1330+
});
1331+
});
12991332
});
13001333

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

packages/utils/src/lib/date.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
timeFriday,
1818
timeSaturday,
1919
} from 'd3-time';
20+
import { 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

@@ -875,16 +877,34 @@ export function isStringDateWithTimezone(value: string) {
875877
return isStringDateWithTime(value) && /Z$|[+-]\d{2}:\d{2}$/.test(value);
876878
}
877879

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');
880+
/** Parse a date string as a local Date if no timezone is specified
881+
* @param dateStr - The date string to parse
882+
* @param format - The format of the date string. If not provided, expects ISO 8601 format.
883+
* - If provided, will use the format to parse the date string.
884+
* - Supports Unicode or strftime date format strings, but will be converted to applicable strftime format before parsing.
885+
* @returns A Date object
886+
*/
887+
export function parseDate(dateStr: string, format?: string) {
888+
if (format) {
889+
if (format.includes('%')) {
890+
// strftime format
891+
return timeParse(format)(dateStr) ?? new Date('Invalid Date');
892+
} else {
893+
// Unicode format, convert to strftime format
894+
const strftimeFormat = convertUnicodeToStrftime(format);
895+
console.log({ format, strftimeFormat });
896+
return timeParse(strftimeFormat)(dateStr) ?? new Date('Invalid Date');
897+
}
898+
}
899+
900+
if (!isStringDate(dateStr)) return new Date('Invalid Date');
881901

882-
if (isStringDateWithTime(datestr)) {
902+
if (isStringDateWithTime(dateStr)) {
883903
// Respect timezone. Also parses unqualified strings like '1982-03-30T04:00' as local date
884-
return new Date(datestr);
904+
return new Date(dateStr);
885905
}
886906

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

890910
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+
}

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)