Skip to content

Commit 5475c85

Browse files
authored
2021e update, etc. (#22)
* Update to 2021e. * Prevent exceptions from being thrown by null/undefined locale settings. Treat null/undefined/empty string locale as default locale. * Add caching to speed up format parsing. * Add Timezone.getAliasesForZone() method. * Gracefully handle timezone names which may be new and known to @tubular/time, but which are not yet known to JavaScript's Intl package. * Fix z/zz formatting with `Intl`-unrecognized timezone names. * Add `Timezone.stdRule` and `Timezone.dstRule` accessors to get textual descriptions of a `Timezone`'s latest rules for starting and ending DST (`undefined` if timezone currently does not observe DST). * Improve format access to short-form timezone names.
1 parent 069d890 commit 5475c85

14 files changed

Lines changed: 834 additions & 644 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,12 @@ Return a timezone matching `name`, if available. If no such timezone exists, a c
13591359
static from(name: string): Timezone;
13601360
```
13611361

1362+
Get all timezone names which can be treated as aliases for the give zone name. All equivalent timezones are treated as aliases for each other by this method, with no particular regard given to which zone name is the actual root name as opposed to being a link.
1363+
1364+
```typescript
1365+
static getAliasesForZone(zone: string): string[]
1366+
```
1367+
13621368
This method returns a full list of available IANA timezone names. Does **not** include names for the above static constants:
13631369

13641370
```typescript
@@ -1467,8 +1473,10 @@ static hasShortName(name: string): boolean;
14671473
aliasFor: string | undefined; // undefined for a primary timezone name
14681474
countries: Set<string>; // ISO Alpha-2 country codes, empty set if no associated countries
14691475
dstOffset: number; // in seconds
1476+
dstRule: string | undefined; // undefined, or textual representation of last start-of-DST rule in effect.
14701477
error: string | undefined; // undefined if no error
14711478
population: number; // 0 if inapplicable or unknown
1479+
stdRule: string | undefined; // undefined, or textual representation of last end-of-DST rule in effect.
14721480
usesDst: boolean;
14731481
utcOffset: number; // in seconds
14741482
zoneName: string;

package-lock.json

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

package.json

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tubular/time",
3-
"version": "3.5.1",
3+
"version": "3.7.3",
44
"description": "Date/time, IANA timezones, leap seconds, TAI/UTC conversions, calendar with settable Julian/Gregorian switchover",
55
"main": "dist/cjs/index.js",
66
"module": "dist/fesm2015/index.js",
@@ -47,42 +47,42 @@
4747
"license": "MIT",
4848
"dependencies": {
4949
"@tubular/math": "^3.1.0",
50-
"@tubular/util": "^4.3.1"
50+
"@tubular/util": "^4.4.0"
5151
},
5252
"optionalDependencies": {
5353
"by-request": "^1.2.7",
5454
"json-z": "^3.3.2"
5555
},
5656
"devDependencies": {
57-
"@babel/core": "^7.14.6",
58-
"@babel/preset-env": "^7.14.7",
59-
"@babel/register": "^7.14.5",
57+
"@babel/core": "^7.15.5",
58+
"@babel/preset-env": "^7.15.4",
59+
"@babel/register": "^7.15.3",
6060
"@rollup/plugin-typescript": "^8.2.5",
61-
"@types/chai": "^4.2.19",
62-
"@types/mocha": "^8.2.2",
63-
"@types/node": "^15.14.1",
64-
"@typescript-eslint/eslint-plugin": "^4.28.2",
65-
"@typescript-eslint/parser": "^4.28.2",
61+
"@types/chai": "^4.2.21",
62+
"@types/mocha": "^8.2.3",
63+
"@types/node": "^15.14.9",
64+
"@typescript-eslint/eslint-plugin": "^4.31.0",
65+
"@typescript-eslint/parser": "^4.31.0",
6666
"babel-loader": "^8.2.2",
6767
"chai": "^4.3.4",
6868
"coveralls": "^3.1.1",
69-
"eslint": "^7.30.0",
69+
"eslint": "^7.32.0",
7070
"eslint-config-standard": "^16.0.3",
71-
"eslint-plugin-chai-friendly": "^0.7.1",
72-
"eslint-plugin-import": "^2.23.4",
71+
"eslint-plugin-chai-friendly": "^0.7.2",
72+
"eslint-plugin-import": "^2.24.2",
7373
"eslint-plugin-node": "^11.1.0",
7474
"eslint-plugin-promise": "^5.1.0",
75-
"mocha": "^9.0.2",
75+
"mocha": "^9.1.1",
7676
"nyc": "^15.1.0",
7777
"rimraf": "^3.0.2",
78-
"rollup": "^2.52.7",
78+
"rollup": "^2.56.3",
7979
"rollup-plugin-sourcemaps": "^0.6.3",
8080
"rollup-plugin-terser": "^7.0.2",
81-
"terser-webpack-plugin": "^5.1.4",
82-
"ts-node": "^10.0.0",
81+
"terser-webpack-plugin": "^5.2.4",
82+
"ts-node": "^10.2.1",
8383
"typescript": "~4.3.5",
84-
"webpack": "^5.42.1",
85-
"webpack-cli": "^4.7.2",
84+
"webpack": "^5.52.0",
85+
"webpack-cli": "^4.8.0",
8686
"webpack-node-externals": "^3.0.0"
8787
},
8888
"repository": "github:kshetline/tubular_time.git"

src/common.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ export const DELTA_TDT_MSEC = 32184;
1717
export const DELTA_TDT_DAYS = DELTA_TDT_SEC / DAY_SEC;
1818
export const DELTA_MJD = 2400000.5;
1919

20+
export const enEras = ['BC', 'AD', 'Before Christ', 'Anno Domini'];
21+
export const enMonths = ['January', 'February', 'March', 'April', 'May', 'June',
22+
'July', 'August', 'September', 'October', 'November', 'December'];
23+
export const enMonthsShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
24+
export const enWeekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
25+
export const enWeekdaysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
26+
export const enWeekdaysMin = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
27+
2028
// Hacks to eliminate circular dependencies.
2129
type Formatter = (dt: any, fmt: string, localeOverride?: string | string[]) => string;
2230
export let formatter: Formatter;

src/date-time.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ describe('DateTime', () => {
6161

6262
dt.locale = 'fr';
6363
expect(dt.locale).to.equal('fr');
64+
65+
dt.locale = undefined;
66+
expect(() => dt.format('D')).not.to.throw;
6467
});
6568

6669
it('should properly determine locale-specific starting days of the week', () => {

src/date-time.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,8 @@ export class DateTime extends Calendar {
669669
if (this.locked)
670670
throw lockError;
671671

672+
newLocale = newLocale || DateTime.getDefaultLocale();
673+
672674
if (this._locale !== newLocale)
673675
this._locale = newLocale;
674676
}

src/format-parse.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe('FormatParse', () => {
195195
expect(new DateTime('2021-08').format('MMMM~YYYY~', 'zh-tw')).to.equal('8月2021年');
196196
expect(new DateTime('2021-08').format('MMMM~YYYY~', 'zh-cn')).to.equal('八月2021年');
197197
expect(new DateTime('2021-08').format('MMM~Y~', 'zh-cn')).to.equal('8月2021年');
198-
expect(new DateTime('2021-08').format('MMM~Y~', 'es')).to.equal('ago 2021');
198+
expect(new DateTime('2021-08').format('MMM~Y~', 'es')).to.match(/^ago\.? 2021$/);
199199
expect(new DateTime('2021-08').format('M~YYYY~', 'zh-cn')).to.equal('8月2021年');
200200
expect(new DateTime('2021-08').format('MM~YYYY~', 'zh-cn')).to.equal('08月2021年');
201201
expect(new DateTime('-2021-08').format('MMMM~y~n', 'ko')).to.equal('8월 2022년 BC');

src/format-parse.ts

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { DateAndTime, getDatePart, getDateValue, parseTimeOffset, setFormatter } from './common';
1+
import {
2+
DateAndTime, enEras, enMonths, enMonthsShort, enWeekdays, enWeekdaysMin, enWeekdaysShort, getDatePart,
3+
getDateValue, parseTimeOffset, setFormatter
4+
} from './common';
25
import { DateTime } from './date-time';
36
import { abs, floor, mod } from '@tubular/math';
47
import { ILocale } from './i-locale';
@@ -17,6 +20,8 @@ const shortOptValues = { f: 'full', m: 'medium', n: 'narrow', s: 'short', l: 'lo
1720
const styleOptValues = { F: 'full', L: 'long', M: 'medium', S: 'short' };
1821
const patternTokens = /({[A-Za-z0-9/_]+?!?}|V|v|R|r|I[FLMSx][FLMS]?|MMMM~?|MMM~?|MM~?|Mo|M~?|Qo|Q|DDDD|DDD|Do|DD~?|D~?|dddd|ddd|do|dd|d|E|e|ww|wo|w|WW|Wo|W|YYYYYY|yyyyyy|YYYY~?|yyyy|YY|yy|Y~?|y~?|N{1,5}|n|gggg|gg|GGGG|GG|A|a|HH|H|hh|h|kk|k|mm|m|ss|s|LTS|LT|LLLL|llll|LLL|lll|LL|ll|L|l|S+|ZZZ|zzz|ZZ|zz|Z|z|XT|xt|XX|xx|X|x)/g;
1922
const cachedLocales: Record<string, ILocale> = {};
23+
const invalidZones = new Set<string>();
24+
const warnedZones = new Set<string>();
2025

2126
let allNumeric: RegExp;
2227
let dateMarkCheck: RegExp;
@@ -99,8 +104,19 @@ function formatEscape(s: string): string {
99104
return result;
100105
}
101106

107+
const CACHE_LIMIT = 500;
108+
const cachedParts = new Map<string, string[]>();
109+
const cachedPartsStripped = new Map<string, string[]>();
110+
102111
export function decomposeFormatString(format: string, stripDateMarks = false): string[] {
103-
const parts: (string | string[])[] = [];
112+
const cache = (stripDateMarks ? cachedPartsStripped : cachedParts);
113+
let parts: (string | string[])[] = cache.get(format);
114+
115+
if (parts)
116+
return parts as string[];
117+
else
118+
parts = [];
119+
104120
let inLiteral = true;
105121
let inBraces = false;
106122
let literal = '';
@@ -168,7 +184,14 @@ export function decomposeFormatString(format: string, stripDateMarks = false): s
168184
}
169185
});
170186

171-
return flatten(parts) as string[];
187+
parts = flatten(parts);
188+
189+
if (cache.size >= CACHE_LIMIT)
190+
cache.clear();
191+
192+
cache.set(format, parts as string[]);
193+
194+
return parts as string[];
172195
}
173196

174197
function parseDateTimeFormatMods(s: string): DateTimeFormatOptions {
@@ -252,6 +275,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
252275
const min = wt.min;
253276
const sec = wt.sec;
254277
const dayOfWeek = dt.getDayOfWeek();
278+
const zoneName = dt.timezone.zoneName;
255279

256280
for (let i = 0; i < parts.length; i += 2) {
257281
result.push(parts[i]);
@@ -267,8 +291,17 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
267291
usesDateMarks = true;
268292
}
269293

270-
if (/^[LlZzI]/.test(field) && locale.cachedTimezone !== dt.timezone.zoneName || (hasIntlDateTime && isEqual(locale.dateTimeFormats, {})))
271-
generatePredefinedFormats(locale, dt.timezone.zoneName);
294+
if (!invalidZones.has(zoneName) &&
295+
((/^[LlZzI]/.test(field) && locale.cachedTimezone !== zoneName) ||
296+
(hasIntlDateTime && isEqual(locale.dateTimeFormats, {})))) {
297+
try {
298+
generatePredefinedFormats(locale, zoneName);
299+
}
300+
catch (e) {
301+
if (/invalid time zone/i.test(e.message))
302+
invalidZones.add(zoneName);
303+
}
304+
}
272305

273306
switch (field) {
274307
case 'YYYYYY': // long year, always signed
@@ -500,8 +533,8 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
500533
break;
501534

502535
case 'ZZZ': // As IANA zone name, if possible
503-
if (dt.timezone.zoneName !== 'OS') {
504-
result.push(dt.timezone.zoneName);
536+
if (zoneName !== 'OS') {
537+
result.push(zoneName);
505538
break;
506539
}
507540
else if (hasIntlDateTime) {
@@ -511,7 +544,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
511544

512545
// eslint-disable-next-line no-fallthrough
513546
case 'zzz': // As long zone name (e.g. "Pacific Daylight Time"), if possible
514-
if (dt.timezone.zoneName === 'TAI') {
547+
if (zoneName === 'TAI') {
515548
result.push('Temps Atomique International');
516549
break;
517550
}
@@ -523,19 +556,25 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
523556
// eslint-disable-next-line no-fallthrough
524557
case 'zz': // As zone acronym (e.g. EST, PDT, AEST), if possible
525558
case 'z':
526-
if (dt.timezone.zoneName !== 'TAI' && hasIntlDateTime && locale.dateTimeFormats.z instanceof DateTimeFormat) {
559+
if (zoneName !== 'TAI' && hasIntlDateTime && locale.dateTimeFormats.z instanceof DateTimeFormat) {
527560
result.push(getDatePart(locale.dateTimeFormats.z, dt.epochMillis, 'timeZoneName'));
528561
break;
529562
}
530-
else if (dt.timezone.zoneName !== 'OS') {
531-
result.push(dt.timezone.zoneName);
563+
else if (invalidZones.has(zoneName)) {
564+
result.push(dt.timezone.getDisplayName(dt.epochMillis));
532565
break;
533566
}
567+
else if (zoneName !== 'OS') {
568+
result.push(zoneName);
569+
break;
570+
}
571+
else
572+
field = 'Z';
534573

535574
// eslint-disable-next-line no-fallthrough
536575
case 'ZZ': // Zone as UTC offset
537576
case 'Z':
538-
if (dt.timezone.zoneName === 'TAI')
577+
if (zoneName === 'TAI')
539578
result.push(Timezone.formatUtcOffset(dt.wallTime.deltaTai, field === 'ZZ'));
540579
else
541580
result.push(dt.timezone.getFormattedOffset(dt.epochMillis, field === 'ZZ'));
@@ -575,7 +614,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
575614

576615
options.calendar = 'gregory';
577616

578-
const zone = convertDigitsToAscii(dt.timezone.zoneName);
617+
const zone = convertDigitsToAscii(zoneName);
579618
let $: RegExpExecArray;
580619

581620
if (zone === 'TAI')
@@ -599,7 +638,11 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
599638
locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
600639
}
601640
catch {
602-
console.warn('Timezone "%s" not recognized', options.timeZone);
641+
if (!warnedZones.has(options.timeZone)) {
642+
console.warn('Timezone "%s" not recognized', options.timeZone);
643+
warnedZones.add(options.timeZone);
644+
}
645+
603646
delete options.timeZone;
604647
locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
605648
}
@@ -688,7 +731,24 @@ function quickFormat(localeNames: string | string[], timezone: string, opts: any
688731
options[key] = value;
689732
});
690733

691-
return newDateTimeFormat(localeNames, options);
734+
try {
735+
return newDateTimeFormat(localeNames, options);
736+
}
737+
catch (e) {
738+
if (/invalid time zone/i.test(e.message)) {
739+
const aliases = Timezone.getAliasesForZone(options.timeZone);
740+
741+
aliases.forEach(zone => {
742+
try {
743+
options.timeZone = zone;
744+
return newDateTimeFormat(localeNames, options);
745+
}
746+
catch {}
747+
});
748+
}
749+
750+
throw e;
751+
}
692752
}
693753

694754
// Find the shortest case-insensitive version of each string in the array that doesn't match
@@ -808,15 +868,14 @@ function getLocaleInfo(localeNames: string | string[]): ILocale {
808868
locale.zeroDigit = fmt({ m: 'd' }).format(0);
809869
}
810870
else {
811-
locale.eras = ['BC', 'AD', 'Before Christ', 'Anno Domini'];
812-
locale.months = ['January', 'February', 'March', 'April', 'May', 'June',
813-
'July', 'August', 'September', 'October', 'November', 'December'];
871+
locale.eras = enEras;
872+
locale.months = enMonths;
814873
locale.monthsMin = shortenItems(locale.months);
815-
locale.monthsShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
874+
locale.monthsShort = enMonthsShort;
816875
locale.monthsShortMin = shortenItems(locale.monthsShort);
817-
locale.weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
818-
locale.weekdaysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
819-
locale.weekdaysMin = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
876+
locale.weekdays = enWeekdays;
877+
locale.weekdaysShort = enWeekdaysShort;
878+
locale.weekdaysMin = enWeekdaysMin;
820879
locale.zeroDigit = '0';
821880
}
822881

src/locale-data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const localeList = [
8888
Object.freeze(localeList);
8989

9090
export function normalizeLocale(locale: string | string[]): string | string[] {
91-
if (!hasIntlDateTime)
91+
if (!hasIntlDateTime || !locale)
9292
return 'en-us';
9393

9494
if (isString(locale) && locale.includes(','))

0 commit comments

Comments
 (0)