@siwenwu/tinydate-helpers
v1.0.0
Published
Tiny, dependency-free date helpers: relative time formatting, strict ISO 8601 validation, and timezone-safe date-only comparison.
Maintainers
Readme
@siwenwu/tinydate-helpers
Tiny, dependency-free date helpers for the three things that keep going wrong:
- Relative time —
"3 days ago","in 2 hours", localized viaIntl. - Strict ISO 8601 validation — rejects
2019-02-29and2024-13-01instead of quietly producing anInvalid Date. - Timezone-safe date-only comparison —
"is this the same day?"without the UTC-midnight off-by-one-day bug.
Zero dependencies, ESM, ships with a tinydate CLI. Requires Node.js 18+.
Install
npm install @siwenwu/tinydate-helpersQuick start
import {
formatRelativeTime,
isValidISODate,
isSameDay,
toDateOnly,
diffInCalendarDays,
} from '@siwenwu/tinydate-helpers';
formatRelativeTime('2024-03-01', { now: '2024-03-04' });
//=> '3 days ago'
isValidISODate('2019-02-29');
//=> false (2019 is not a leap year)
toDateOnly('2024-03-10T00:00:00Z', 'America/New_York');
//=> '2024-03-09' (midnight UTC is still the 9th in New York)
isSameDay('2024-07-01T03:00:00Z', '2024-07-01T22:00:00Z', 'Asia/Tokyo');
//=> false (Jul 1 12:00 and Jul 2 07:00 in Tokyo)
diffInCalendarDays('2024-02-28', '2024-03-01');
//=> 2 (2024 has a Feb 29; the same range in 2023 is 1)Why the timezone-safe part matters
// The bug this library exists to prevent:
new Date('2024-03-10').getDate(); //=> 9 in any US timezone
toDateOnly('2024-03-10', 'America/New_York'); //=> '2024-03-10'A bare YYYY-MM-DD string is treated as a floating calendar date and is never
re-interpreted through a timezone. Anything with a time component is an instant,
and you choose the timezone it is resolved in. Day arithmetic is done on
{ year, month, day } integers, so a 23-hour or 25-hour DST day still counts as
exactly one day.
API
All functions are named exports. Every function that accepts a date takes a Date,
epoch milliseconds, or a strict ISO 8601 string, and throws a TypeError on
anything else — nothing silently returns NaN or Invalid Date.
Relative time
formatRelativeTime(value, options?)
Formats the distance between value and a reference point as a phrase.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| now | date | new Date() | The reference point. |
| locale | string \| string[] | host locale | BCP 47 locale for Intl.RelativeTimeFormat. |
| numeric | 'auto' \| 'always' | 'auto' | 'auto' prefers "yesterday" over "1 day ago". |
| style | 'long' \| 'short' \| 'narrow' | 'long' | Phrase width. |
| timeZone | string | host zone | IANA zone used for calendar-day boundaries. |
formatRelativeTime('2024-03-03', { now: '2024-03-04' }); //=> 'yesterday'
formatRelativeTime('2024-03-03', { now: '2024-03-04', numeric: 'always' }); //=> '1 day ago'
formatRelativeTime('2024-03-07', { now: '2024-03-04' }); //=> 'in 3 days'
formatRelativeTime('2024-03-01', { now: '2024-03-04', locale: 'es' }); //=> 'hace 3 días'
formatRelativeTime('2024-03-01', { now: '2024-03-04', style: 'narrow' }); //=> '3d ago'Unit selection: seconds below 45s, minutes below 45min, hours below 22h, then
calendar days, weeks, months, and years. Because units at or above a day are
calendar-based, "yesterday" always means the previous calendar day in timeZone
rather than "roughly 24 hours ago".
timeAgo(value, options?)
formatRelativeTime with numeric: 'always', for when you always want a number.
timeAgo('2024-03-03', { now: '2024-03-04' }); //=> '1 day ago'ISO 8601 validation
isValidISODate(value, options?)
Returns true only for a strict ISO 8601 calendar date or date-time. Non-strings
return false rather than throwing.
Accepted: YYYY-MM-DD, YYYY-MM-DDTHH:mm, …:ss, …:ss.sss (1–9 fractional
digits), each optionally suffixed with Z or ±HH:mm.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| allowTime | boolean | true | Allow a time-of-day portion. |
| requireTime | boolean | false | Require a time-of-day portion. |
| requireOffset | boolean | false | Require Z or ±HH:mm. |
isValidISODate('2024-02-29'); //=> true
isValidISODate('2023-02-29'); //=> false
isValidISODate('1900-02-29'); //=> false (century non-leap)
isValidISODate('2024-03-04T12:30:45.123+05:30'); //=> true
isValidISODate('2024-03-04 12:30'); //=> false (space separator)
isValidISODate('2024-03-04T24:00:00Z'); //=> false
isValidISODate('March 4, 2024'); //=> false
isValidISODate('2024-03-04T12:00:00', { requireOffset: true }); //=> falseparseISODate(value, options?)
Returns a Date, or null if the input is not strictly valid. Date-only strings
are read as midnight UTC.
parseISODate('2024-03-04').toISOString(); //=> '2024-03-04T00:00:00.000Z'
parseISODate('2024-03-04T12:00:00+02:00').toISOString(); //=> '2024-03-04T10:00:00.000Z'
parseISODate('2019-02-29'); //=> nullisLeapYear(year) · daysInMonth(year, month)
isLeapYear(2024); //=> true
isLeapYear(1900); //=> false
isLeapYear(2000); //=> true
daysInMonth(2024, 2); //=> 29
daysInMonth(2023, 2); //=> 28Date-only (calendar day) operations
Each of these takes an optional trailing timeZone (IANA name). Omit it to use the
host timezone; pass an unknown zone and you get a RangeError.
toDateOnly(value, timeZone?)
The calendar date of value in timeZone, as YYYY-MM-DD.
toDateOnly(new Date('2024-03-10T00:00:00Z'), 'America/New_York'); //=> '2024-03-09'
toDateOnly(new Date('2024-03-10T00:00:00Z'), 'Asia/Tokyo'); //=> '2024-03-10'
toDateOnly('2024-03-10', 'America/New_York'); //=> '2024-03-10' (floating)compareDateOnly(a, b, timeZone?)
-1, 0, or 1 — usable directly as an Array#sort comparator.
compareDateOnly('2024-03-04T23:59:59Z', '2024-03-04T00:00:00Z', 'UTC'); //=> 0
dates.sort((a, b) => compareDateOnly(a, b, 'UTC'));isSameDay(a, b, timeZone?)
isSameDay('2024-03-10T23:00:00Z', '2024-03-11T02:00:00Z', 'Asia/Tokyo'); //=> true
isSameDay('2024-03-10T23:00:00Z', '2024-03-11T02:00:00Z', 'UTC'); //=> falsediffInCalendarDays(a, b, timeZone?) · diffInCalendarMonths(a, b, timeZone?)
Signed whole-unit differences from a to b. Partial units are not counted, and
DST transitions never add or drop a day.
diffInCalendarDays('2024-01-01', '2025-01-01'); //=> 366 (leap year)
diffInCalendarDays('2023-01-01', '2024-01-01'); //=> 365
// 47 elapsed hours across New York's spring-forward, still 2 calendar days:
diffInCalendarDays('2024-03-09T17:00:00Z', '2024-03-11T16:00:00Z', 'America/New_York'); //=> 2
diffInCalendarMonths('2024-01-15', '2024-03-14'); //=> 1 (one day short of 2)
diffInCalendarMonths('2024-01-31', '2024-02-29'); //=> 0 (not yet a full month)toDate(value, label?)
The coercion the other functions use, exported for reuse. Returns a defensive copy
and throws a TypeError on anything unusable.
toDate('2024-03-04'); //=> Date 2024-03-04T00:00:00.000Z
toDate('not a date'); // TypeErrorCLI
npx @siwenwu/tinydate-helpers --helptinydate relative <date> [--now <date>] [--locale <bcp47>] [--numeric auto|always]
[--style long|short|narrow] [--tz <IANA zone>]
tinydate validate <value> [--require-offset] [--no-time]
tinydate date-only <date> [--tz <IANA zone>]
tinydate diff <from> <to> [--tz <IANA zone>]
tinydate compare <a> <b> [--tz <IANA zone>]$ tinydate relative 2024-03-01 --now 2024-03-04
3 days ago
$ tinydate date-only 2024-03-10T00:00:00Z --tz America/New_York
2024-03-09
$ tinydate diff 2024-02-28 2024-03-01
2
$ tinydate validate 2019-02-29
invalidExit codes: 0 success, 1 usage or input error, 2 validate reported invalid —
so validate works in a shell conditional:
if tinydate validate "$INPUT" > /dev/null; then echo "ok"; fiDevelopment
npm testThe suite runs on Node's built-in test runner with no dev dependencies, and passes
under any host timezone (verified against UTC, America/New_York, Asia/Tokyo,
Pacific/Kiritimati, and the half-hour-offset Australia/Lord_Howe).
