@siwenwu/tiny-date-kit
v1.0.0
Published
Tiny, dependency-free date helpers: relative time formatting, strict ISO date validation, and timezone-safe date-only comparison.
Maintainers
Readme
@siwenwu/tiny-date-kit
Tiny, dependency-free date helpers for Node.js — relative time formatting, strict ISO 8601 validation, and timezone-safe date-only comparison.
No dependencies. CommonJS with TypeScript definitions. Node 18+.
Why
Three things go wrong often enough to be worth a small library:
new Date(string)guesses. For anything outside the ISO 8601 grammar the spec hands parsing to the engine, so the same string can mean different instants — orInvalid Date— depending on where it runs. These helpers accept only strict ISO 8601 and reject everything else.- "Same day" is a time zone question. Comparing
getDate()reads the host's offset, which is rarely the one you meant. Every date-only function here takes an explicit IANAtimeZone. - Day arithmetic drifts across DST. Dividing elapsed milliseconds by 86,400,000 is off by an hour twice a year.
diffInDayscounts day boundaries crossed instead.
Install
npm install @siwenwu/tiny-date-kitUsage
const {
formatRelativeTime,
isValidISODate,
toDateKey,
isSameDay,
diffInDays,
} = require('@siwenwu/tiny-date-kit');
formatRelativeTime('2024-06-12T12:00:00Z', { now: '2024-06-15T12:00:00Z' });
// => '3 days ago'
isValidISODate('2023-02-29');
// => false (2023 is not a leap year)
toDateKey('2024-06-15T02:00:00Z', { timeZone: 'America/New_York' });
// => '2024-06-14' (still the previous evening in New York)
isSameDay('2024-06-15T01:00:00Z', '2024-06-15T05:00:00Z', { timeZone: 'America/New_York' });
// => false (they straddle midnight there)
diffInDays('2024-02-28', '2024-03-01', { timeZone: 'UTC' });
// => 2 (2024 has a 29 February)Every function accepts a Date, epoch milliseconds, or a strict ISO 8601 string.
API
formatRelativeTime(value, options?)
Human-readable relative time, backed by Intl.RelativeTimeFormat.
formatRelativeTime(Date.now() - 7200000); // => '2 hours ago'
formatRelativeTime('2024-06-16', { now: '2024-06-15' }); // => 'tomorrow'
formatRelativeTime(then, { locale: 'es' }); // => 'hace 3 días'
formatRelativeTime(then, { style: 'narrow' }); // => '3d ago'
formatRelativeTime(then, { numeric: 'always' }); // => '1 day ago', not 'yesterday'| Option | Default | Meaning |
| --- | --- | --- |
| now | current time | Reference instant to measure against |
| timeZone | host zone | IANA zone used for day-and-above units |
| locale | host locale | BCP 47 locale, e.g. 'es', 'fr' |
| numeric | 'auto' | 'auto' prefers yesterday over 1 day ago |
| style | 'long' | 'long', 'short' or 'narrow' |
Gaps under 22 hours are reported in seconds, minutes or hours. At a day and above the count comes from the calendar in timeZone, so it tracks boundaries crossed rather than elapsed 24-hour periods:
// 2024-03-10 in New York is a 23-hour day, but it is still one day.
diffInDays('2024-03-09T17:00:00Z', '2024-03-10T16:00:00Z', { timeZone: 'America/New_York' });
// => 1getRelativeTimeParts(value, options?)
The { value, unit } pair behind formatRelativeTime, for rendering your own markup. Same options. value is negative in the past; unit is one of second, minute, hour, day, month, year.
getRelativeTimeParts('2024-06-12', { now: '2024-06-15' });
// => { value: -3, unit: 'day' }isValidISODate(value) / isValidISODateTime(value)
Strict format and calendar checks. Non-strings return false rather than being coerced.
isValidISODate('2024-02-29'); // => true
isValidISODate('2023-02-29'); // => false (not a leap year)
isValidISODate('1900-02-29'); // => false (century, not divisible by 400)
isValidISODate('2024-04-31'); // => false (April has 30 days)
isValidISODate('2024-1-01'); // => false (unpadded month)
isValidISODate('2024-01-01T00:00:00Z'); // => false (that is a date-time)
isValidISODateTime('2024-02-29T12:30:00Z'); // => true
isValidISODateTime('2024-02-29T12:30:00+05:30'); // => true
isValidISODateTime('2024-02-29T24:00:00Z'); // => false (hour 24)parseISODate(value)
Parses strict ISO 8601 and returns a Date, or null — never an Invalid Date you have to test for separately. A bare YYYY-MM-DD resolves to midnight UTC, per the ECMAScript spec.
parseISODate('2024-02-29'); // => Date 2024-02-29T00:00:00.000Z
parseISODate('2023-02-29'); // => null
parseISODate('next tuesday'); // => nulltoDateKey(value, options?)
The calendar date an instant falls on, as YYYY-MM-DD, in options.timeZone. The timezone-safe way to bucket timestamps by day.
const instant = '2024-06-15T02:00:00Z';
toDateKey(instant, { timeZone: 'UTC' }); // => '2024-06-15'
toDateKey(instant, { timeZone: 'America/New_York' }); // => '2024-06-14'
toDateKey(instant, { timeZone: 'Asia/Tokyo' }); // => '2024-06-15'compareDateOnly(a, b, options?)
Compares two instants by calendar date, ignoring clock time. Returns -1, 0 or 1, so it drops straight into Array.prototype.sort.
compareDateOnly('2024-06-15T00:00:00Z', '2024-06-15T23:59:59Z', { timeZone: 'UTC' }); // => 0
compareDateOnly('2024-06-14T23:59:59Z', '2024-06-15T00:00:00Z', { timeZone: 'UTC' }); // => -1isSameDay(a, b, options?)
compareDateOnly(a, b, options) === 0.
diffInDays(a, b, options?)
Whole calendar days from a to b, signed. Counts day boundaries crossed, so DST transitions never produce fractional or off-by-one results.
diffInDays('2024-06-14T23:30:00Z', '2024-06-15T00:30:00Z', { timeZone: 'UTC' }); // => 1
diffInDays('2024-06-15T00:01:00Z', '2024-06-15T23:59:00Z', { timeZone: 'UTC' }); // => 0
diffInDays('2024-01-01', '2025-01-01', { timeZone: 'UTC' }); // => 366isLeapYear(value) / daysInMonth(year, month)
isLeapYear(2024); // => true
isLeapYear(1900); // => false (century, not divisible by 400)
isLeapYear(2000); // => true
daysInMonth(2024, 2); // => 29
daysInMonth(2023, 2); // => 28CLI
npx @siwenwu/tiny-date-kit rel 2024-01-01 --now 2024-01-04
# 3 days ago
npx @siwenwu/tiny-date-kit key 2024-03-10T04:30:00Z --tz America/New_York
# 2024-03-09
npx @siwenwu/tiny-date-kit valid 2023-02-29 # prints false, exits 1
npx @siwenwu/tiny-date-kit diff 2024-02-28 2024-03-01
# 2Commands: rel, key, valid, diff, cmp. Pass --tz <zone> to set the time zone and now in place of a date for the current instant. tiny-date --help lists everything.
Errors
Invalid input throws rather than propagating NaN or Invalid Date:
formatRelativeTime('yesterday'); // TypeError: expected a strict ISO 8601 string
toDateKey(Date.now(), { timeZone: 'Mars/Olympus' }); // RangeError: not a recognized IANA time zoneThe predicates (isValidISODate, isValidISODateTime) and parseISODate never throw — they return false/null.
Time zone defaults
Every timeZone option defaults to the host zone (Intl.DateTimeFormat().resolvedOptions().timeZone). That is convenient for CLIs and user-facing formatting, but pass an explicit zone on a server, where the host zone is an accident of deployment.
Testing
npm test49 tests via the built-in node:test runner, covering leap years and century rules, DST transitions in both directions, fractional-hour zones, midnight boundaries, unit-selection thresholds, and invalid input.
License
MIT
