Skip to content

Commit 7ab2de6

Browse files
authored
Fix length-of-day Julian/Gregorian transition issue (#51)
* Fix odd Julian/Gregorian calendar transition length-of-day bug. * Increased test coverage for format-parse.ts. * Documentation tweaks.
1 parent 7c5c3f9 commit 7ab2de6

8 files changed

Lines changed: 62 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
_Updates limited to IANA tzdb updates omitted._
22

3+
### 3.10.2
4+
5+
* Fixed a very edge-case bug where `getSecondsInDay()` and `getMinutesInDay()` might return 0 for the very last Julian calendar date before a transition to the Gregorian calendar.
6+
* Increased test coverage.
7+
38
### 3.10.1
49

510
* Minor documentation update.
611

712
### 3.10.0
813

914
* Fixed bug with creating `DateTime` instances using `Date` objects.
10-
* Other minor edge-case bug fixes.
15+
* Other minor bug fixes.
1116
* Code coverage increased to 95%.
1217
* Updated build, linting, and documentation.
1318
* Improved sourcemaps.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Not all days are 24 hours. Some are 23 hours, or 25, or even 23.5 or 24.5 or 47
1212
* Supports and recognizes negative Daylight Saving Time.
1313
* Extensive date/time manipulation and calculation capabilities.
1414
* Many features available using a familiar Moment.js-style API.
15-
* Astronomical time conversions among TDT (Terrestrial Dynamic Time), UT1, UTC and TAI.
15+
* Astronomical time conversions among TDT (Terrestrial Dynamic Time), UT1, UTC, and TAI.
1616
* Local mean time, by geographic longitude, to one minute (of time) resolution.
1717
* Astronomical time conversions among TDT (Terrestrial Dynamic Time), UT1, UTC and TAI, as well as local mean time, by geographic longitude, to one minute (of time) resolution.
1818
* Internationalization via JavaScript’s `Intl` Internationalization API, with additional built-in i18n support for issues not covered by `Intl`, and US-English fallback for environments without `Intl` support.
@@ -979,7 +979,7 @@ The `epochMillis` getter/setter returns, or allows you to modify, the fundamenta
979979

980980
For a TAI instance, `epochMillis` is the same as `taiMillis`, with `utcMillis` providing a conversion to or from UTC (or UT1 outside the well-defined UTC range). For a non-TAI instance `epochMillis` is the same as `utcMillis`, with `taiMillis` performing conversions.
981981

982-
During a leap second the `epochMillis`/`utcMillis` value is pinned 59 seconds, 999 milliseconds into the minute in which the leap seconds occurs. The `taiMillis` value, however, still varies over the course of that second.
982+
During a leap second the `epochMillis`/`utcMillis` value is pinned 59 seconds, 999 milliseconds into the minute in which the leap second occurs. The `taiMillis` value, however, still varies over the course of that second.
983983

984984
In the unlikely event a negative leap second is ever declared, the `epochMillis`/`utcMillis` value for a non-TAI `DateTime` instance will simply skip over the leap second, while `taiMillis` advances contiguously.
985985

@@ -1031,7 +1031,7 @@ All arguments to the constructor are optional. When passed no arguments, `new Da
10311031

10321032
* `initialTime`: This can be a single number (for milliseconds since 1970-01-01T00:00 UTC), an ISO-8601 date as a string, and ECMA-262 date as string, an ASP.​NET JSON date string, a JavaScript `Date` object, [a `DateAndTime` object](#the-ymddate-and-dateandtime-objects), an array of numbers (in the order year, month, day, hour, etc.), or a `null`, which causes the current time to be used.
10331033
* `timezone`: This can be a `Timezone` instance, a string specifying an IANA timezone (e.g. 'Pacific/Honolulu'), a UTC offset (e.g. 'UTC+04:00'), or `null` to use the default timezone.
1034-
* `locale`: a locale string (e.g. 'fr-FR'), an array of locales strings in order of preference (e.g. ['fr-FR', 'fr-CA', 'en-US']), or `null` to use the default locale.
1034+
* `locale`: a locale string (e.g. 'fr-FR'), an array of locale strings in order of preference (e.g. ['fr-FR', 'fr-CA', 'en-US']), or `null` to use the default locale.
10351035
* `gregorianChange`: The first date when the Gregorian calendar is active, the string `'J'` for a pure Julian calendar, the string 'G' for a pure Gregorian calendar, the constant `ttime.PURE_JULIAN`, the constant `ttime.PURE_GREGORIAN`, or `null` for the default of 1582-10-15. A date can take the form of a year-month-day ISO-8601 date string (e.g. '1752-09-14'), a year-month-day numeric array (e.g. [1918, 2, 14]), or a date as a `YMDDate` object.
10361036

10371037
As a string, `initialTime` can also include a trailing timezone or UTC offset, using the letter `Z` to indicate UTC (e.g. '1969‑07‑12T20:17Z'), or a specific timezone (e.g. '1969‑07‑20T16:17 EDT', '1969‑07‑20T16:17 America/New_York', or '1969‑07‑20T16:17-0400').

package-lock.json

Lines changed: 2 additions & 2 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tubular/time",
3-
"version": "3.10.1",
3+
"version": "3.10.2",
44
"description": "Date/time, IANA timezones, leap seconds, TAI/UTC conversions, calendar with settable Julian/Gregorian switchover",
55
"module": "dist/index.min.mjs",
66
"main": "dist/index.min.cjs",

src/calendar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ export class Calendar {
709709
if (j < 0) {
710710
if (year === this.lastJulianYear && month === this.lastJulianMonth) {
711711
if (day > this.lastJulianDate)
712-
day = this.lastJulianDate;
712+
day = this.lastJulianDate + 1;
713713
}
714714
else if (year === this.gcYear && month === this.gcMonth && (day > this.lastJulianDate ||
715715
(this.lastJulianMonth !== this.gcMonth && this.lastJulianMonth > 0)) && day < this.gcDate) {

src/date-time.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,16 @@ describe('DateTime', () => {
7676
expect(new DateTime('2021-11-07T01:23').compare(new DateTime('2021-11-07T01:23', Timezone.guess()))).to.equal(0);
7777
DateTime.setDefaultTimezone(saveZone);
7878

79-
const dt = new DateTime();
79+
let dt = new DateTime();
8080

8181
dt.locale = 'fr';
8282
expect(dt.locale).to.equal('fr');
8383

8484
dt.locale = undefined;
8585
expect(() => dt.format('D')).not.to.throw;
86+
87+
dt = new DateTime('2025-06-20T12:00', 'America/Chicago', [2025, 7, 4]);
88+
expect(dt.getSecondsInDay()).to.equal(86400);
8689
});
8790

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

src/format-parse.spec.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai';
22
import { DateTime } from './date-time';
3-
import ttime, { initTimezoneLarge } from './index';
3+
import ttime, { initTimezoneLarge, Timezone } from './index';
44
import { localeList } from './locale-data';
55
import { analyzeFormat, parse } from './format-parse';
66

@@ -17,6 +17,9 @@ describe('FormatParse', () => {
1717
expect(new DateTime('2022-07-07 8:08 PST').format('IMM{hourCycle:h23} zzz ZZZ z')).to.equal('Jul 7, 2022, 09:08:00 Pacific Daylight Time America/Los_Angeles PDT');
1818
expect(new DateTime('2022-07-07 8:08 PDT').format('IMM zzz ZZZ z')).to.equal('Jul 7, 2022, 8:08:00 AM Pacific Daylight Time America/Los_Angeles PDT');
1919
expect(new DateTime('1995-05-06 EDT').format('IMM zzz ZZZ z')).to.equal('May 6, 1995, 12:00:00 AM Eastern Daylight Time America/New_York EDT');
20+
expect(new DateTime('1995-05-06 TAI').format('IS', 'en-us')).to.equal('5/6/95');
21+
expect(new DateTime('1995-05-06T21:00', 'UTC+0100').format('ISS', 'en-us')).to.equal('5/6/95, 9:00 PM');
22+
expect(new DateTime('1995-05-06T21:00', 'UTC+0115').format('ISS', 'en-us')).to.equal('5/6/95, 9:00\u202FPM');
2023
expect(new DateTime('foo').valid).is.false;
2124
expect(new DateTime('foo').wallTime.error).to.equal('Invalid ISO date/time');
2225
expect(new DateTime('2021-01-02').format('GGGG-[W]WW-E')).to.equal('2020-W53-6');
@@ -93,6 +96,8 @@ describe('FormatParse', () => {
9396
expect(parse('11/7/2021 1:25₂ AM', 'MM/DD/YYYY h:m a', 'America/Denver').toString()).to.equal('DateTime<2021-11-07T01:25:00.000₂-07:00>');
9497
expect(parse('2016-12-31T23:59:60', ttime.DATETIME_LOCAL_SECONDS, 'UTC',
9598
undefined, true).toString()).to.equal('DateTime<2016-12-31T23:59:60.000 +00:00>');
99+
expect(() => parse('1/17/2022 1:22:33', 'MM/Do/YYYY H:m:s', 'UTC')).to.throw();
100+
expect(() => parse('1/17/2022 1:22:33', 'MM/d/YYYY H:m:s', 'UTC')).to.throw();
96101
});
97102

98103
it('should correctly handle two-digit years', () => {
@@ -168,6 +173,8 @@ describe('FormatParse', () => {
168173
it('should be able to parse times with timezones', () => {
169174
expect(parse('Jul 7, 2022 04:05 PM America/Chicago', 'MMM D, y n hh:mm A z').epochMillis)
170175
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) + 5 * 3_600_000);
176+
expect(parse('Jul 7, 2022 04:05 PM America/Chicago', 'MMM D, y n hh:mm A Z').epochMillis)
177+
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) + 5 * 3_600_000);
171178
expect(parse('Jul 7, 2022 04:05 PM EDT', 'MMM D, y n hh:mm A z').epochMillis)
172179
.to.equal(Date.UTC(2022, 6, 7, 16, 5, 0) + 4 * 3_600_000);
173180
expect(parse('Jul 7, 2022 04:05 PM PST', 'MMM D, y n hh:mm A z').epochMillis)
@@ -210,4 +217,24 @@ describe('FormatParse', () => {
210217
expect(new DateTime('2021-08-20T20:36').format('MMM D, y n hh:mm~ A z', 'en')).to.equal('Aug 20, 2021 08:36~ PM EDT');
211218
expect(parse('1/17/2022 1:22:33', 'MM~/DD~/YYYY~ H:m:s', 'UTC').toIsoString(19)).to.equal('2022-01-17T01:22:33');
212219
});
220+
221+
it('should handling special formatting', () => {
222+
expect(new DateTime('2021-08').format('YYYYYY')).to.equal('+002021');
223+
expect(new DateTime('-2021-08').format('YYYYYY')).to.equal('-002021');
224+
expect(new DateTime('2021-08-08').format('GGGG gggg GG gg')).to.equal('2021 2021 21 21');
225+
expect(new DateTime('-2021-08-08').format('GGGG gggg GG gg')).to.equal('-2021 -2021 -21 -21');
226+
expect(new DateTime('22021-08-08').format('GGGG gggg GG gg')).to.equal('+22021 +22021 21 21');
227+
expect(new DateTime('-22021-08-08').format('GGGG gggg GG gg')).to.equal('-22021 -22021 -21 -21');
228+
expect(new DateTime('2021-08-08').format('W w D~')).to.equal('31 33 8');
229+
expect(new DateTime('2021-08-08T12:03:04', Timezone.OS_ZONE).format('ZZZ')).to.match(/\w/);
230+
expect(new DateTime('2021-08-08T12:03:04 TAI').format('zzz')).to.equal('Temps Atomique International');
231+
expect(new DateTime('2025-07-03').format('(d) dd MMM Do, YYYY')).to.equal('(4) Th Jul 3rd, 2025');
232+
expect(new DateTime('1970-01-01T12:03:04Z').format('XX xx XT xt X x KK K kk k m s zz ZZ V'))
233+
.to.equal('43384 43384000 43392 43392 43384 43384000 00 0 12 12 3 4 UTC +0000 ');
234+
expect(new DateTime('1970-01-01T12:03:04.77Z').format('mm:ss.SSS')).to.equal('03:04.770');
235+
expect(new DateTime('1970-01-01T12:03:04 EST').format('LLL zz', 'en-us'))
236+
.to.equal('January 1, 1970 at 12:03 PM EST');
237+
expect(parse('11/7/2021 1:25 AM', 'MM/DD/YYYY h:m a', 'America/Denver').format('r')).to.equal('');
238+
expect(new DateTime('foo').format('YYYYYY')).to.equal('##Invalid_Date##');
239+
});
213240
});

src/format-parse.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const shortOpts = { Y: 'year', M: 'month', D: 'day', w: 'weekday', h: 'hour', m:
1818
ds: 'dateStyle', ts: 'timeStyle', e: 'era' };
1919
const shortOptValues = { f: 'full', m: 'medium', n: 'narrow', s: 'short', l: 'long', dd: '2-digit', d: 'numeric' };
2020
const styleOptValues = { F: 'full', L: 'long', M: 'medium', S: 'short' };
21-
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;
21+
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|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;
2222
const cachedLocales: Record<string, ILocale> = {};
2323
const invalidZones = new Set<string>();
2424
const warnedZones = new Set<string>();
@@ -234,14 +234,15 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
234234
return '##Invalid_Date##';
235235

236236
const currentLocale = normalizeLocale(localeOverride ?? dt.locale);
237-
const localeNames = !hasIntlDateTime ? 'en' : currentLocale;
237+
const localeNames = !hasIntlDateTime ? /* istanbul ignore next: unreached sanity check */ 'en' : currentLocale;
238238
const locale = getLocaleInfo(localeNames);
239239
const cjk = /^(ja|ko|zh)/.test(locale.name);
240240
const ko = /^ko/.test(locale.name);
241241
const dateMarks = cjk ? ko ? ['년', '월', '일'] : ['年', '月', '日'] : ['\x80', '\x80', '\x80'];
242242
let usesDateMarks = false;
243243
const zeroAdj = locale.zeroDigit.charCodeAt(0) - 48;
244244
const toNum = (n: number | string, pad = 1): string => {
245+
/* istanbul ignore next: unreached sanity check */
245246
if (n == null || (isNumber(n) && isNaN(n)))
246247
return '?'.repeat(pad);
247248
else
@@ -473,7 +474,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
473474
case 'A': // AM/PM indicator (may have more than just two forms)
474475
case 'a':
475476
{
476-
const values = locale.meridiemAlt ?? locale.meridiem;
477+
const values = locale.meridiemAlt ?? /* istanbul ignore next: unreached sanity check */ locale.meridiem;
477478
const dayPartsForHour = values[values.length === 2 ? floor(hour / 12) : hour];
478479

479480
// If there is no case distinction between the first two forms, use the first form
@@ -482,7 +483,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
482483
(!isCased(dayPartsForHour[0]) && !isCased(dayPartsForHour[0])))
483484
result.push(dayPartsForHour[0]);
484485
else
485-
result.push(dayPartsForHour[field === 'A' && dayPartsForHour.length > 1 ? 1 : 0]);
486+
result.push(dayPartsForHour[field === 'A' && dayPartsForHour.length > 1 ? 1 : /* istanbul ignore next: unreached sanity check */ 0]);
486487
}
487488
break;
488489

@@ -523,8 +524,10 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
523524
{
524525
const localeFormat = locale.dateTimeFormats[field];
525526

527+
/* istanbul ignore next: unreached sanity check */
526528
if (localeFormat == null)
527529
result.push(`[${field}?]`);
530+
/* istanbul ignore next: unreached sanity check */
528531
else if (isString(localeFormat))
529532
result.push(format(dt, localeFormat, localeOverride));
530533
else
@@ -558,14 +561,15 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
558561
result.push(getDatePart(locale.dateTimeFormats.z, dt.epochMillis, 'timeZoneName'));
559562
break;
560563
}
561-
else if (invalidZones.has(zoneName)) {
564+
else /* istanbul ignore next: unreached sanity check */ if (invalidZones.has(zoneName)) {
562565
result.push(dt.timezone.getDisplayName(dt.epochMillis));
563566
break;
564567
}
565568
else if (zoneName !== 'OS') {
566569
result.push(zoneName);
567570
break;
568571
}
572+
/* istanbul ignore else: unreached sanity check */
569573
else
570574
field = 'Z';
571575

@@ -584,7 +588,7 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
584588

585589
case 'R':
586590
case 'r':
587-
result.push(wt.occurrence === 2 ? '\u2082' : field === 'R' ? ' ' : ''); // Subscript 2
591+
result.push(wt.occurrence === 2 ? '\u2082' : field === 'R' ? ' ' : ''); // Subscript 2 (₂)
588592
break;
589593

590594
case 'n':
@@ -595,11 +599,13 @@ export function format(dt: DateTime, fmt: string, localeOverride?: string | stri
595599
break;
596600

597601
default:
602+
/* istanbul ignore else: unreached sanity check */
598603
if (field.startsWith('N'))
599604
result.push(locale.eras[(year < 1 ? 0 : 1) + (field.length === 4 ? 2 : 0)]);
600605
else if (field.startsWith('I')) {
606+
/* istanbul ignore else: unreached backup for lack of Intl library */
601607
if (hasIntlDateTime) {
602-
const formatKey = field + (dtfMods ? JSON.stringify(dtfMods) : '');
608+
const formatKey = field + (dtfMods ? JSON.stringify(dtfMods) : /* istanbul ignore next: unreached sanity check */ '');
603609
let intlFormat = locale.dateTimeFormats[formatKey] as DateTimeFormat;
604610

605611
if (!intlFormat) {
@@ -782,6 +788,7 @@ function getLocaleInfo(localeNames: string | string[]): ILocale {
782788

783789
locale.name = isArray(localeNames) ? localeNames.join(',') : localeNames;
784790

791+
/* istanbul ignore else: unreached backup for lack of Intl library */
785792
if (hasIntlDateTime) {
786793
locale.months = [];
787794
locale.monthsShort = [];
@@ -858,6 +865,7 @@ function getLocaleInfo(localeNames: string | string[]): ILocale {
858865
if (fullValue && fullValue !== value) {
859866
newHourForm += ',' + fullValue;
860867

868+
/* istanbul ignore else: unreached sanity check */
861869
if (fullValue === lcFullValue)
862870
newMeridiems.push(fullValue);
863871
else
@@ -917,6 +925,7 @@ function generatePredefinedFormats(locale: ILocale, timezone: string): void {
917925
locale.cachedTimezone = timezone;
918926
locale.dateTimeFormats = {};
919927

928+
/* istanbul ignore else: unreached backup for lack of Intl library */
920929
if (hasIntlDateTime) {
921930
locale.dateTimeFormats.LLLL = fmt({ Y: 'd', M: 'l', D: 'd', w: 'l', h: 'd', m: 'dd' }); // Thursday, September 4, 1986 8:30 PM
922931
locale.dateTimeFormats.llll = fmt({ Y: 'd', M: 's', D: 'd', w: 's', h: 'd', m: 'dd' }); // Thu, Sep 4, 1986 8:30 PM
@@ -953,6 +962,7 @@ function generatePredefinedFormats(locale: ILocale, timezone: string): void {
953962
}
954963

955964
function isLocale(locale: string | string[], matcher: string): boolean {
965+
/* istanbul ignore else: unreached sanity check */
956966
if (isString(locale))
957967
return locale.startsWith(matcher);
958968
else if (locale.length > 0)

0 commit comments

Comments
 (0)