Add DateTime value object with comprehensive unit tests
- Implemented DateTime class for handling ISO date-time parsing, formatting, and validation. - Added InvalidDateTimeError for error handling in date-time operations. - Created extensive unit tests covering various scenarios for date-time creation, parsing, and formatting, ensuring robust functionality and error handling. - Included tests for timezone handling, invalid inputs, and edge cases to validate the DateTime implementation.
This commit is contained in:
@@ -0,0 +1,302 @@
|
|||||||
|
import { DateTime } from './date-time';
|
||||||
|
import { InvalidDateTimeError } from './invalid-date-time.error';
|
||||||
|
|
||||||
|
describe('DateTime', () => {
|
||||||
|
const originalTimezone = process.env.DEFAULT_TIMEZONE;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalTimezone === undefined) {
|
||||||
|
delete process.env.DEFAULT_TIMEZONE;
|
||||||
|
} else {
|
||||||
|
process.env.DEFAULT_TIMEZONE = originalTimezone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('parses ISO with +07:00 offset into UTC unix milliseconds', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00+07:00');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses ISO with Z into the same instant as an equivalent offset', () => {
|
||||||
|
const withOffset = DateTime.create('2026-08-20T17:00:00+07:00');
|
||||||
|
const withZ = DateTime.create('2026-08-20T10:00:00.000Z');
|
||||||
|
|
||||||
|
expect(withZ.value).toBe(withOffset.value);
|
||||||
|
expect(withZ.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses compact offset without colon', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00.123+0700');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 123));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses hour-only ISO offset', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00+07');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts lowercase z designator', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T10:00:00z');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates fractional seconds beyond milliseconds', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T10:00:00.123456Z');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 123));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
const dt = DateTime.create(' 2026-08-20T10:00:00Z ');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interprets naive ISO as wall clock in DEFAULT_TIMEZONE (GMT+7)', () => {
|
||||||
|
delete process.env.DEFAULT_TIMEZONE;
|
||||||
|
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interprets naive ISO with fractional seconds using env timezone', () => {
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'GMT+7';
|
||||||
|
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00.500');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 500));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a custom DEFAULT_TIMEZONE for naive ISO parse', () => {
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'UTC+0';
|
||||||
|
|
||||||
|
const dt = DateTime.create('2026-08-20T17:00:00');
|
||||||
|
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 17, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not change value of offset-aware instant when DEFAULT_TIMEZONE changes', () => {
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'GMT+7';
|
||||||
|
const dt = DateTime.create('2026-08-20T10:00:00Z');
|
||||||
|
const valueBefore = dt.value;
|
||||||
|
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'UTC-5';
|
||||||
|
|
||||||
|
expect(dt.value).toBe(valueBefore);
|
||||||
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects date-only strings', () => {
|
||||||
|
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(() => DateTime.create('')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only input', () => {
|
||||||
|
expect(() => DateTime.create(' ')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string input', () => {
|
||||||
|
expect(() => DateTime.create(null as unknown as string)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
expect(() => DateTime.create(123 as unknown as string)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects junk', () => {
|
||||||
|
expect(() => DateTime.create('not-a-date')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects overflow calendar dates', () => {
|
||||||
|
expect(() => DateTime.create('2026-02-30T10:00:00Z')).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects out-of-range ISO offsets', () => {
|
||||||
|
expect(() => DateTime.create('2026-08-20T10:00:00+15:00')).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
expect(() => DateTime.create('2026-08-20T10:00:00+14:30')).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
expect(() => DateTime.create('2026-08-20T10:00:00-12:30')).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid DEFAULT_TIMEZONE when parsing naive ISO', () => {
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'Asia/Jakarta';
|
||||||
|
|
||||||
|
expect(() => DateTime.create('2026-08-20T17:00:00')).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a generic message that does not echo the input', () => {
|
||||||
|
expect(() => DateTime.create('not-a-date')).toThrow('Invalid date time');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromUnixMs', () => {
|
||||||
|
it('reconstructs from a unix millisecond timestamp', () => {
|
||||||
|
const ms = Date.UTC(2026, 7, 20, 10, 0, 0, 0);
|
||||||
|
const dt = DateTime.fromUnixMs(ms);
|
||||||
|
|
||||||
|
expect(dt.value).toBe(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips with create', () => {
|
||||||
|
const created = DateTime.create('2026-08-20T17:00:00+07:00');
|
||||||
|
const restored = DateTime.fromUnixMs(created.value);
|
||||||
|
|
||||||
|
expect(restored.equals(created)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-finite numbers', () => {
|
||||||
|
expect(() => DateTime.fromUnixMs(Number.NaN)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
expect(() => DateTime.fromUnixMs(Number.POSITIVE_INFINITY)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-safe integers', () => {
|
||||||
|
expect(() => DateTime.fromUnixMs(1.5)).toThrow(InvalidDateTimeError);
|
||||||
|
expect(() => DateTime.fromUnixMs(Number.MAX_SAFE_INTEGER + 2)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects values outside the Date time-value range', () => {
|
||||||
|
expect(() => DateTime.fromUnixMs(8_640_000_000_000_001)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
expect(() => DateTime.fromUnixMs(-8_640_000_000_000_001)).toThrow(
|
||||||
|
InvalidDateTimeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts pre-1970 negative timestamps', () => {
|
||||||
|
const ms = Date.UTC(1969, 11, 31, 23, 59, 59, 0);
|
||||||
|
const dt = DateTime.fromUnixMs(ms);
|
||||||
|
|
||||||
|
expect(dt.value).toBe(ms);
|
||||||
|
expect(dt.format('UTC')).toBe('1969-12-31T23:59:59.000+00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-number input', () => {
|
||||||
|
expect(() =>
|
||||||
|
DateTime.fromUnixMs('1724150000000' as unknown as number),
|
||||||
|
).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format / toString', () => {
|
||||||
|
it('formats in DEFAULT_TIMEZONE (GMT+7) by default', () => {
|
||||||
|
delete process.env.DEFAULT_TIMEZONE;
|
||||||
|
const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
|
||||||
|
expect(dt.format()).toBe('2026-08-20T17:00:00.000+07:00');
|
||||||
|
expect(dt.toString()).toBe('2026-08-20T17:00:00.000+07:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats in an explicit timezone override', () => {
|
||||||
|
const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
|
||||||
|
expect(dt.format('UTC')).toBe('2026-08-20T10:00:00.000+00:00');
|
||||||
|
expect(dt.format('Z')).toBe('2026-08-20T10:00:00.000+00:00');
|
||||||
|
expect(dt.format('+00:00')).toBe('2026-08-20T10:00:00.000+00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes render output when DEFAULT_TIMEZONE changes', () => {
|
||||||
|
const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'GMT+7';
|
||||||
|
expect(dt.format()).toBe('2026-08-20T17:00:00.000+07:00');
|
||||||
|
|
||||||
|
process.env.DEFAULT_TIMEZONE = 'UTC-5';
|
||||||
|
expect(dt.format()).toBe('2026-08-20T05:00:00.000-05:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid timezone override', () => {
|
||||||
|
const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
|
||||||
|
expect(() => dt.format('Asia/Jakarta')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects out-of-range timezone override', () => {
|
||||||
|
const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
|
|
||||||
|
expect(() => dt.format('+15:00')).toThrow(InvalidDateTimeError);
|
||||||
|
expect(() => dt.format('GMT-13')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects formatting when offset shift overflows Date range', () => {
|
||||||
|
const dt = DateTime.fromUnixMs(8_640_000_000_000_000);
|
||||||
|
|
||||||
|
expect(() => dt.format('GMT+7')).toThrow(InvalidDateTimeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('returns true for the same instant from different inputs', () => {
|
||||||
|
const a = DateTime.create('2026-08-20T17:00:00+07:00');
|
||||||
|
const b = DateTime.create('2026-08-20T10:00:00Z');
|
||||||
|
|
||||||
|
expect(a.equals(b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for different instants', () => {
|
||||||
|
const a = DateTime.create('2026-08-20T10:00:00Z');
|
||||||
|
const b = DateTime.create('2026-08-20T11:00:00Z');
|
||||||
|
|
||||||
|
expect(a.equals(b)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null or undefined', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T10:00:00Z');
|
||||||
|
|
||||||
|
expect(dt.equals(null as unknown as DateTime)).toBe(false);
|
||||||
|
expect(dt.equals(undefined as unknown as DateTime)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serialization', () => {
|
||||||
|
it('toJSON returns unix milliseconds', () => {
|
||||||
|
const ms = Date.UTC(2026, 7, 20, 10, 0, 0, 0);
|
||||||
|
const dt = DateTime.fromUnixMs(ms);
|
||||||
|
|
||||||
|
expect(dt.toJSON()).toBe(ms);
|
||||||
|
expect(JSON.stringify({ at: dt })).toBe(`{"at":${ms}}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('immutability', () => {
|
||||||
|
it('is frozen after creation', () => {
|
||||||
|
const dt = DateTime.create('2026-08-20T10:00:00Z');
|
||||||
|
|
||||||
|
expect(Object.isFrozen(dt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be constructed with new from outside', () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new (DateTime as unknown as new (value: number) => DateTime)(
|
||||||
|
Date.UTC(2026, 7, 20, 10, 0, 0, 0),
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { InvalidDateTimeError } from './invalid-date-time.error';
|
||||||
|
|
||||||
|
const DEFAULT_TZ_FALLBACK = 'GMT+7';
|
||||||
|
|
||||||
|
/** ECMAScript Date time value limits (±100_000_000 days from epoch). */
|
||||||
|
const MAX_UNIX_MS = 8_640_000_000_000_000;
|
||||||
|
|
||||||
|
/** ISO datetime with optional fractional seconds and optional Z / offset. */
|
||||||
|
const ISO_DATETIME =
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
|
||||||
|
|
||||||
|
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
|
||||||
|
const TZ_OFFSET =
|
||||||
|
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad3(n: number): string {
|
||||||
|
return n.toString().padStart(3, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFractionalMs(raw: string | undefined): number {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Truncate beyond millisecond precision (do not round — avoids second carry).
|
||||||
|
return Number.parseInt(raw.slice(0, 3).padEnd(3, '0'), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidOffset(hours: number, minutes: number, sign: number): void {
|
||||||
|
if (minutes > 59) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
// Real fixed-offset range is -12:00 to +14:00.
|
||||||
|
const total = sign * (hours * 60 + minutes);
|
||||||
|
if (total < -12 * 60 || total > 14 * 60) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a fixed-offset timezone string into minutes east of UTC.
|
||||||
|
* Accepts: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700, Z, UTC, GMT (and negatives).
|
||||||
|
*/
|
||||||
|
export function parseTimezoneOffsetMinutes(raw: string): number {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const match = TZ_OFFSET.exec(trimmed);
|
||||||
|
if (!match) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Z / UTC / GMT alone leave the sign capture unset.
|
||||||
|
if (match[1] === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = match[1] === '-' ? -1 : 1;
|
||||||
|
const hours = Number.parseInt(match[2], 10);
|
||||||
|
const minutes = match[3] !== undefined ? Number.parseInt(match[3], 10) : 0;
|
||||||
|
assertValidOffset(hours, minutes, sign);
|
||||||
|
|
||||||
|
return sign * (hours * 60 + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultTimezone(): string {
|
||||||
|
const fromEnv = process.env.DEFAULT_TIMEZONE;
|
||||||
|
if (fromEnv === undefined || fromEnv.trim() === '') {
|
||||||
|
return DEFAULT_TZ_FALLBACK;
|
||||||
|
}
|
||||||
|
return fromEnv.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultOffsetMinutes(): number {
|
||||||
|
return parseTimezoneOffsetMinutes(resolveDefaultTimezone());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOffset(offsetMinutes: number): string {
|
||||||
|
const sign = offsetMinutes < 0 ? '-' : '+';
|
||||||
|
const abs = Math.abs(offsetMinutes);
|
||||||
|
const hours = Math.floor(abs / 60);
|
||||||
|
const minutes = abs % 60;
|
||||||
|
return `${sign}${pad2(hours)}:${pad2(minutes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcMsFromParts(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
hour: number,
|
||||||
|
minute: number,
|
||||||
|
second: number,
|
||||||
|
ms: number,
|
||||||
|
offsetMinutes: number,
|
||||||
|
): number {
|
||||||
|
const asUtc = Date.UTC(year, month - 1, day, hour, minute, second, ms);
|
||||||
|
const d = new Date(asUtc);
|
||||||
|
|
||||||
|
// Reject overflow (e.g. Feb 30) via round-trip on calendar components.
|
||||||
|
if (
|
||||||
|
d.getUTCFullYear() !== year ||
|
||||||
|
d.getUTCMonth() !== month - 1 ||
|
||||||
|
d.getUTCDate() !== day ||
|
||||||
|
d.getUTCHours() !== hour ||
|
||||||
|
d.getUTCMinutes() !== minute ||
|
||||||
|
d.getUTCSeconds() !== second ||
|
||||||
|
d.getUTCMilliseconds() !== ms
|
||||||
|
) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return asUtc - offsetMinutes * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInOffset(utcMs: number, offsetMinutes: number): string {
|
||||||
|
const shifted = new Date(utcMs + offsetMinutes * 60_000);
|
||||||
|
if (Number.isNaN(shifted.getTime())) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
const y = shifted.getUTCFullYear();
|
||||||
|
const m = pad2(shifted.getUTCMonth() + 1);
|
||||||
|
const d = pad2(shifted.getUTCDate());
|
||||||
|
const h = pad2(shifted.getUTCHours());
|
||||||
|
const min = pad2(shifted.getUTCMinutes());
|
||||||
|
const s = pad2(shifted.getUTCSeconds());
|
||||||
|
const ms = pad3(shifted.getUTCMilliseconds());
|
||||||
|
return `${y}-${m}-${d}T${h}:${min}:${s}.${ms}${formatOffset(offsetMinutes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DateTime {
|
||||||
|
private static readonly createToken = Symbol('DateTime.create');
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly unixMs: number,
|
||||||
|
token: symbol,
|
||||||
|
) {
|
||||||
|
if (token !== DateTime.createToken) {
|
||||||
|
throw new TypeError(
|
||||||
|
'DateTime can only be created via DateTime.create() or DateTime.fromUnixMs()',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(raw: string): DateTime {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const match = ISO_DATETIME.exec(trimmed);
|
||||||
|
if (!match) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number.parseInt(match[1], 10);
|
||||||
|
const month = Number.parseInt(match[2], 10);
|
||||||
|
const day = Number.parseInt(match[3], 10);
|
||||||
|
const hour = Number.parseInt(match[4], 10);
|
||||||
|
const minute = Number.parseInt(match[5], 10);
|
||||||
|
const second = Number.parseInt(match[6], 10);
|
||||||
|
const ms = parseFractionalMs(match[7]);
|
||||||
|
|
||||||
|
let offsetMinutes: number;
|
||||||
|
if (match[8] !== undefined && match[8].toUpperCase() === 'Z') {
|
||||||
|
offsetMinutes = 0;
|
||||||
|
} else if (match[9] !== undefined) {
|
||||||
|
const sign = match[9] === '-' ? -1 : 1;
|
||||||
|
const oh = Number.parseInt(match[10], 10);
|
||||||
|
const om = match[11] !== undefined ? Number.parseInt(match[11], 10) : 0;
|
||||||
|
assertValidOffset(oh, om, sign);
|
||||||
|
offsetMinutes = sign * (oh * 60 + om);
|
||||||
|
} else {
|
||||||
|
offsetMinutes = resolveDefaultOffsetMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcMs = utcMsFromParts(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
second,
|
||||||
|
ms,
|
||||||
|
offsetMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new DateTime(utcMs, DateTime.createToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromUnixMs(ms: number): DateTime {
|
||||||
|
if (
|
||||||
|
typeof ms !== 'number' ||
|
||||||
|
!Number.isSafeInteger(ms) ||
|
||||||
|
Math.abs(ms) > MAX_UNIX_MS
|
||||||
|
) {
|
||||||
|
throw new InvalidDateTimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DateTime(ms, DateTime.createToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this.unixMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: DateTime): boolean {
|
||||||
|
return other instanceof DateTime && this.unixMs === other.unixMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
format(tz?: string): string {
|
||||||
|
const offsetMinutes =
|
||||||
|
tz === undefined
|
||||||
|
? resolveDefaultOffsetMinutes()
|
||||||
|
: parseTimezoneOffsetMinutes(tz);
|
||||||
|
return formatInOffset(this.unixMs, offsetMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): number {
|
||||||
|
return this.unixMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class InvalidDateTimeError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid date time');
|
||||||
|
this.name = 'InvalidDateTimeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user