Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/social-poems-start.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tiny-dogs-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@layerstack/utils': patch
---

feat(parseDate): Support optional `format` argument accepting both `Unicode` and `strftime` formats (converting `Unicode` to `strftime`)
2 changes: 2 additions & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions packages/utils/src/lib/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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()', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/utils/src/lib/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
175 changes: 175 additions & 0 deletions packages/utils/src/lib/dateInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading