Skip to content

Commit 6becffb

Browse files
committed
Compensate for Intl.DateTimeFormat implementations (like in Safari) that don't handle dateStyle and timeStyle options. Fix parsing of UTC offset using \u2212 minus sign. Add roundToMinutes option to parseTimeOffset(). Make sure Etc/GMTxx timezone names parse correctly.
1 parent f0375a1 commit 6becffb

10 files changed

Lines changed: 141 additions & 7367 deletions

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
/webpack.config.cjs
1414
/make-remote-zone-data.ts
1515
*.tgz
16+
/test.html

package-lock.json

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

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tubular/time",
3-
"version": "2.4.1",
3+
"version": "2.4.2",
44
"description": "Date/time, IANA timezones, calendar with settable Julian/Gregorian switchover",
55
"browser": "dist/web/index.js",
66
"browser-es5": "dist/web5/index.js",
@@ -58,8 +58,6 @@
5858
"eslint-plugin-standard": "^5.0.0",
5959
"esm": "^3.2.25",
6060
"mocha": "^8.2.1",
61-
"moment": "^2.29.1",
62-
"moment-timezone": "^0.5.32",
6361
"nyc": "^15.1.0",
6462
"rimraf": "^3.0.2",
6563
"ts-node": "^9.1.1",

src/common.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'chai';
2-
import { parseISODateTime } from './common';
2+
import { parseISODateTime, parseTimeOffset } from './common';
33

44
describe('Common', () => {
55
it('should parse ISO date/time strings.', () => {
@@ -22,5 +22,12 @@ describe('Common', () => {
2222
expect(parseISODateTime('2020-W01-1')).to.include({ yw: 2020, w: 1, dw: 1, hrs: 0, min: 0, sec: 0 });
2323
expect(parseISODateTime('2020001')).to.include({ y: 2020, dy: 1, hrs: 0, min: 0, sec: 0 });
2424
expect(parseISODateTime('2020-001')).to.include({ y: 2020, dy: 1, hrs: 0, min: 0, sec: 0 });
25+
26+
expect(parseTimeOffset('+0100')).to.equal(3600);
27+
expect(parseTimeOffset('+010023')).to.equal(3623);
28+
expect(parseTimeOffset('-02')).to.equal(-7200);
29+
expect(parseTimeOffset('-02:00')).to.equal(-7200);
30+
expect(parseTimeOffset('-02:00:25', true)).to.equal(-7200);
31+
expect(parseTimeOffset('-02:00:35', true)).to.equal(-7260);
2532
});
2633
});

src/common.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export function parseISODateTime(date: string, allowLeapSecond = false): DateAnd
252252
return syncDateAndTime(time);
253253
}
254254

255-
export function parseTimeOffset(offset: string): number {
255+
export function parseTimeOffset(offset: string, roundToMinutes = false): number {
256256
let sign = 1;
257257

258258
if (offset.startsWith('-')) {
@@ -267,8 +267,14 @@ export function parseTimeOffset(offset: string): number {
267267
offset.match(/../g);
268268
let offsetSeconds = 60 * (60 * Number(parts[0]) + Number(parts[1] ?? 0));
269269

270-
if (parts[2])
271-
offsetSeconds += Number(parts[2]);
270+
if (parts[2]) {
271+
const seconds = Number(parts[2]);
272+
273+
if (roundToMinutes)
274+
offsetSeconds += (seconds < 30 ? 0 : 60);
275+
else
276+
offsetSeconds += seconds;
277+
}
272278

273279
return sign * offsetSeconds;
274280
}

src/date-time.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export class DateTime extends Calendar {
196196
if (initialTime!.includes('₂'))
197197
occurrence = 2;
198198

199-
initialTime = initialTime!.replace(/[­]/g, '-').replace(/\s+/g, ' ').replace(//g, '').trim();
199+
initialTime = initialTime!.replace(/[\u00AD\u2010-\u2014\u2212]/g, '-').replace(/\s+/g, ' ').replace(//g, '').trim();
200200
let $ = /^\/Date\((\d+)([-+]\d\d\d\d)?\)\/$/i.exec(initialTime);
201201

202202
if ($) {
@@ -208,14 +208,14 @@ export class DateTime extends Calendar {
208208
const saveTime = initialTime;
209209
let zone: string;
210210

211-
$ = /(Z|\b[_/a-z]+)$/i.exec(initialTime);
211+
$ = /(Z|\bEtc\/GMT(?:0|[-+]\d{1,2})|\b[_/a-z]+)$/i.exec(initialTime);
212212

213213
if ($) {
214214
zone = $[1];
215215

216216
initialTime = initialTime.slice(0, -zone.length).trim() || null;
217217

218-
if (/^(Z|UT|UTC|GMT)$/i.test(zone))
218+
if (/^(Z|UTC?|GMT)$/i.test(zone))
219219
zone = 'UT';
220220

221221
parseZone = Timezone.has(zone) ? Timezone.from(zone) : null;

src/format-parse.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ describe('FormatParse', () => {
174174
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) - 3 * 3_600_000);
175175
expect(parse('Jul 7, 2022 04:05 PM UT+0300', 'MMM D, y n hh:mm A z').utcTimeMillis)
176176
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) - 3 * 3_600_000);
177-
expect(parse('Jul 7, 2022 04:05 PM GMT+0300', 'MMM D, y n hh:mm A z').utcTimeMillis)
177+
expect(parse('Jul 7, 2022 04:05 PM GMT-0300', 'MMM D, y n hh:mm A z').utcTimeMillis)
178+
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) + 3 * 3_600_000);
179+
expect(parse('Jul 7, 2022 04:05 PM Etc/GMT+3', 'MMM D, y n hh:mm A z').utcTimeMillis)
178180
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) + 3 * 3_600_000);
179181
});
180182
});

src/format-parse.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import { DateTime } from './date-time';
33
import { abs, floor, mod } from '@tubular/math';
44
import { ILocale } from './i-locale';
55
import { flatten, isArray, isEqual, isNumber, isString, last, toNumber } from '@tubular/util';
6-
import { getMeridiems, getMinDaysInWeek, getOrdinals, getStartOfWeek, getWeekend, hasIntlDateTime, normalizeLocale } from './locale-data';
6+
import {
7+
checkDtfOptions, getMeridiems, getMinDaysInWeek, getOrdinals, getStartOfWeek, getWeekend,
8+
hasIntlDateTime, normalizeLocale
9+
} from './locale-data';
710
import { Timezone } from './timezone';
11+
import DateTimeFormat = Intl.DateTimeFormat;
812
import DateTimeFormatOptions = Intl.DateTimeFormatOptions;
913

1014
const shortOpts = { Y: 'year', M: 'month', D: 'day', w: 'weekday', h: 'hour', m: 'minute', s: 'second', z: 'timeZoneName',
@@ -118,7 +122,7 @@ function isCased(s: string): boolean {
118122
}
119123

120124
function timeMatch(dt: DateTime, locale: ILocale): boolean {
121-
const format = locale.dateTimeFormats.check as Intl.DateTimeFormat;
125+
const format = locale.dateTimeFormats.check as DateTimeFormat;
122126

123127
if (!format)
124128
return false;
@@ -384,21 +388,21 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
384388
break;
385389
}
386390
else if (hasIntlDateTime) {
387-
result.push(Intl.DateTimeFormat().resolvedOptions().timeZone);
391+
result.push(DateTimeFormat().resolvedOptions().timeZone);
388392
break;
389393
}
390394

391395
// eslint-disable-next-line no-fallthrough
392396
case 'zzz': // As long zone name (e.g. "Pacific Daylight Time"), if possible
393-
if (hasIntlDateTime && locale.dateTimeFormats.Z instanceof Intl.DateTimeFormat) {
397+
if (hasIntlDateTime && locale.dateTimeFormats.Z instanceof DateTimeFormat) {
394398
result.push(getDatePart(locale.dateTimeFormats.Z, dt.utcTimeMillis, 'timeZoneName'));
395399
break;
396400
}
397401

398402
// eslint-disable-next-line no-fallthrough
399403
case 'zz': // As zone acronym (e.g. EST, PDT, AEST), if possible
400404
case 'z':
401-
if (hasIntlDateTime && locale.dateTimeFormats.z instanceof Intl.DateTimeFormat) {
405+
if (hasIntlDateTime && locale.dateTimeFormats.z instanceof DateTimeFormat) {
402406
result.push(getDatePart(locale.dateTimeFormats.z, dt.utcTimeMillis, 'timeZoneName'));
403407
break;
404408
}
@@ -435,10 +439,10 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
435439
result.push(locale.eras[(year < 1 ? 0 : 1) + (field.length === 4 ? 2 : 0)]);
436440
else if (field.startsWith('I')) {
437441
if (hasIntlDateTime) {
438-
let intlFormat = locale.dateTimeFormats[field] as Intl.DateTimeFormat;
442+
let intlFormat = locale.dateTimeFormats[field] as DateTimeFormat;
439443

440444
if (!intlFormat) {
441-
const options: Intl.DateTimeFormatOptions = { calendar: 'gregory' };
445+
const options: DateTimeFormatOptions = { calendar: 'gregory' };
442446
const zone = convertDigits(dt.timezone.zoneName);
443447
let $: RegExpExecArray;
444448

@@ -458,12 +462,12 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
458462
options.timeStyle = styleOptValues[field.charAt(2)];
459463

460464
try {
461-
locale.dateTimeFormats[field] = intlFormat = new Intl.DateTimeFormat(localeNames, options);
465+
locale.dateTimeFormats[field] = intlFormat = new DateTimeFormat(localeNames, checkDtfOptions(options));
462466
}
463467
catch {
464468
console.warn('Timezone "%s" not recognized', options.timeZone);
465469
delete options.timeZone;
466-
locale.dateTimeFormats[field] = intlFormat = new Intl.DateTimeFormat(localeNames, options);
470+
locale.dateTimeFormats[field] = intlFormat = new DateTimeFormat(localeNames, options);
467471
}
468472
}
469473

@@ -512,8 +516,8 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
512516
return result.join('');
513517
}
514518

515-
function quickFormat(localeNames: string | string[], timezone: string, opts: any): Intl.DateTimeFormat {
516-
const options: Intl.DateTimeFormatOptions = { calendar: 'gregory' };
519+
function quickFormat(localeNames: string | string[], timezone: string, opts: any): DateTimeFormat {
520+
const options: DateTimeFormatOptions = { calendar: 'gregory' };
517521
let $: RegExpExecArray;
518522

519523
localeNames = normalizeLocale(localeNames);
@@ -535,7 +539,7 @@ function quickFormat(localeNames: string | string[], timezone: string, opts: any
535539
options[key] = value;
536540
});
537541

538-
return new Intl.DateTimeFormat(localeNames, options);
542+
return new DateTimeFormat(localeNames, checkDtfOptions(options));
539543
}
540544

541545
// Find the shortest case-insensitive version of each string in the array that doesn't match
@@ -568,15 +572,15 @@ function getLocaleInfo(localeNames: string | string[]): ILocale {
568572
if (locale && Object.keys(locale).length > 0)
569573
return locale;
570574

571-
const fmt = (opts: any): Intl.DateTimeFormat => quickFormat(localeNames, 'UTC', opts);
575+
const fmt = (opts: any): DateTimeFormat => quickFormat(localeNames, 'UTC', opts);
572576

573577
locale.name = isArray(localeNames) ? localeNames.join(',') : localeNames;
574578

575579
if (hasIntlDateTime) {
576580
locale.months = [];
577581
locale.monthsShort = [];
578582
const narrow: string[] = [];
579-
let format: Intl.DateTimeFormat;
583+
let format: DateTimeFormat;
580584

581585
for (let month = 1; month <= 12; ++month) {
582586
const date = Date.UTC(2021, month - 1, 1);
@@ -671,7 +675,7 @@ function getLocaleInfo(localeNames: string | string[]): ILocale {
671675
}
672676

673677
function generatePredefinedFormats(locale: ILocale, timezone: string): void {
674-
const fmt = (opts: any): Intl.DateTimeFormat => quickFormat(locale.name, timezone, opts);
678+
const fmt = (opts: any): DateTimeFormat => quickFormat(locale.name, timezone, opts);
675679

676680
locale.cachedTimezone = timezone;
677681
locale.dateTimeFormats = {};
@@ -694,7 +698,7 @@ function generatePredefinedFormats(locale: ILocale, timezone: string): void {
694698
Object.keys(locale.dateTimeFormats).forEach(key => {
695699
if (/^L/i.test(key))
696700
locale.dateTimeFormats['_' + key] = analyzeFormat(locale.name.split(','),
697-
locale.dateTimeFormats[key] as Intl.DateTimeFormat);
701+
locale.dateTimeFormats[key] as DateTimeFormat);
698702
});
699703
}
700704
else {
@@ -720,9 +724,9 @@ function isLocale(locale: string | string[], matcher: string): boolean {
720724
return false;
721725
}
722726

723-
export function analyzeFormat(locale: string | string[], formatter: Intl.DateTimeFormat): string;
727+
export function analyzeFormat(locale: string | string[], formatter: DateTimeFormat): string;
724728
export function analyzeFormat(locale: string | string[], dateStyle: string, timeStyle?: string): string;
725-
export function analyzeFormat(locale: string | string[], dateStyleOrFormatter: string | Intl.DateTimeFormat,
729+
export function analyzeFormat(locale: string | string[], dateStyleOrFormatter: string | DateTimeFormat,
726730
timeStyle?: string): string {
727731
const options: DateTimeFormatOptions = { timeZone: 'UTC', calendar: 'gregory' };
728732
let dateStyle: string;
@@ -745,7 +749,7 @@ export function analyzeFormat(locale: string | string[], dateStyleOrFormatter: s
745749
}
746750

747751
const sampleDate = Date.UTC(2233, 3 /* 4 */, 5, 6, 7, 8);
748-
const format = new Intl.DateTimeFormat(locale, options);
752+
const format = new DateTimeFormat(locale, checkDtfOptions(options));
749753
const parts = format.formatToParts(sampleDate);
750754
const dateLong = (dateStyle === 'full' || dateStyle === 'long');
751755
const monthLong = (dateLong || (dateStyle === 'medium' && isLocale(locale, 'ne')));
@@ -921,7 +925,8 @@ export function parse(input: string, format: string, zone?: Timezone | string, l
921925
if (input.includes('₂'))
922926
occurrence = 2;
923927

924-
input = convertDigits(input.replace(/[­]/g, '-').replace(/\s+/g, ' ').trim()).replace(/[\u200F]/g, '');
928+
input = convertDigits(input.replace(/[\u00AD\u2010-\u2014\u2212]/g, '-')
929+
.replace(/\s+/g, ' ').trim()).replace(/[\u200F]/g, '');
925930
format = format.trim().replace(/\u200F/g, '');
926931
locales = !hasIntlDateTime ? 'en' : normalizeLocale(locales ?? DateTime.getDefaultLocale());
927932

@@ -1167,10 +1172,10 @@ export function parse(input: string, format: string, zone?: Timezone | string, l
11671172
case 'z':
11681173
trimmed = false;
11691174

1170-
if (($ = /^(Z|[_/a-z]+)([^-+_/a-z]|$)/i.exec(input))) {
1175+
if (!/^UTC?[-+]/.test(input) && ($ = /^(Z|\bEtc\/GMT(?:0|[-+]\d{1,2})|[_/a-z]+)([^-+_/a-z]|$)/i.exec(input))) {
11711176
let embeddedZone: string | Timezone = $[1];
11721177

1173-
if (/^(Z|UT|UTC|GMT)$/i.test(embeddedZone))
1178+
if (/^(Z|UTC?|GMT)$/i.test(embeddedZone))
11741179
embeddedZone = 'UT';
11751180

11761181
embeddedZone = Timezone.from(embeddedZone);
@@ -1194,8 +1199,8 @@ export function parse(input: string, format: string, zone?: Timezone | string, l
11941199
trimmed = true;
11951200
}
11961201
}
1197-
else if (($ = /^(UTC|UT|GMT)?([-+]\d\d(?:\d{4}|:\d\d(:\d\d)?)?)/i.exec(input))) {
1198-
w.utcOffset = parseTimeOffset($[2]) * ($[1] === 'GMT' ? -1 : 1);
1202+
else if (($ = /^(UTC?|GMT)?([-+]\d\d(?:\d{4}|:\d\d(:\d\d)?)?)/i.exec(input))) {
1203+
w.utcOffset = parseTimeOffset($[2]);
11991204
input = input.substr($[0].length).trimStart();
12001205
trimmed = true;
12011206
}

src/locale-data.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,56 @@
11
import { isArray, isNumber, isString } from '@tubular/util';
2+
import DateTimeFormat = Intl.DateTimeFormat;
3+
import DateTimeFormatOptions = Intl.DateTimeFormatOptions;
24

35
let _hasIntl = false;
6+
let _hasDateTimeStyle = true;
47

58
try {
69
_hasIntl = typeof Intl !== 'undefined' && !!Intl?.DateTimeFormat;
10+
11+
if (_hasIntl) {
12+
const full = new DateTimeFormat('en-us', { dateStyle: 'full' }).format(0);
13+
const short = new DateTimeFormat('en-us', { dateStyle: 'short' }).format(0);
14+
15+
_hasDateTimeStyle = full !== short;
16+
17+
if (!_hasDateTimeStyle)
18+
console.warn('Intl.DateTimeFormatOptions dateStyle and timeStyle not available');
19+
}
20+
else
21+
console.warn('Intl.DateTimeFormat not available');
722
}
823
catch {}
924

1025
export const hasIntlDateTime = _hasIntl;
26+
export const hasDateTimeStyle = _hasDateTimeStyle;
27+
28+
const backupDateFormats: Record<string, DateTimeFormatOptions> = {
29+
full: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
30+
long: { year: 'numeric', month: 'long', day: 'numeric' },
31+
medium: { year: 'numeric', month: 'short', day: 'numeric' },
32+
short: { year: '2-digit', month: 'numeric', day: 'numeric' }
33+
};
34+
const backupTimeFormats: Record<string, DateTimeFormatOptions> = {
35+
full: { hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'long' },
36+
long: { hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short' },
37+
medium: { hour: 'numeric', minute: '2-digit', second: '2-digit' },
38+
short: { hour: 'numeric', minute: '2-digit' }
39+
};
40+
41+
export function checkDtfOptions(options: DateTimeFormatOptions): DateTimeFormatOptions {
42+
if (!hasDateTimeStyle && options.dateStyle) {
43+
Object.assign(options, backupDateFormats[options.dateStyle]);
44+
delete options.dateStyle;
45+
}
46+
47+
if (!hasDateTimeStyle && options.timeStyle) {
48+
Object.assign(options, backupTimeFormats[options.timeStyle]);
49+
delete options.timeStyle;
50+
}
51+
52+
return options;
53+
}
1154

1255
export const localeList = [
1356
'af', 'ar', 'ar-dz', 'ar-kw', 'ar-ly', 'ar-ma', 'ar-sa', 'ar-tn', 'az', 'be', 'bg', 'bm', 'bn', 'bn-bd',

test.html

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Browser Check</title>
6+
<script src="dist/web/index.js"></script>
7+
</head>
8+
<body>
9+
<script>
10+
const ttime = window.tbTime.ttime;
11+
const now = ttime();
12+
const styles = ['F', 'L', 'M', 'S', 'x'];
13+
14+
for (const locale of ['en', 'fr', 'de', 'es', 'it']) {
15+
const nowL = now.toLocale(locale);
16+
17+
for (let i = 0; i < 5; ++i) {
18+
for (let j = 0; j < 5 - +(i === 4); ++j) {
19+
const format = 'I' + styles[i] + (styles[j] !== 'x' ? styles[j] : '');
20+
const matchFormat = format.replace(/^Ix/, 'IS');
21+
const formatted = nowL.format(format);
22+
const parsed = ttime(i < 4 ? formatted : nowL.format(matchFormat), matchFormat, locale);
23+
const matched = now.isSame(parsed, j === 4 ? 'day' : j < 4 ? 'minute' : 'second');
24+
if (!matched)
25+
console.warn('mismatch');
26+
27+
document.write('<code>' + format.padEnd(3) + '</code>: ' + formatted +
28+
(matched ? ' <span style="color: green">✔</span>' : ' <span style="color: red">✘</span>') + '<br>\n');
29+
}
30+
}
31+
32+
document.write('<br><br>\n');
33+
}
34+
</script>
35+
</body>
36+
</html>

0 commit comments

Comments
 (0)