@softations/flow-datetime
v1.0.5
Published
Date/time contract: instants vs calendar dates, timezone-safe by construction. Zero dependencies.
Maintainers
Readme
@softations/flow-datetime
A small date/time contract that makes the difference between an instant and a calendar date explicit, and keeps timezone handling correct by construction.
It is published publicly so it can be installed without registry authentication. It is maintained for internal use — the API is stable but driven by internal needs, so treat semver as a guide rather than a promise.
Why
Most timezone bugs are not caused by a missing date function. They are caused by having no agreed representation — nothing saying "a due date is a calendar day, an email timestamp is an absolute instant, and here is the one place a provider's exclusive end-date gets its ±1." Adding another date library does not fix that; agreeing on types does.
Install
npm install @softations/flow-datetimeRequires Node ≥ 20.19. The package is ESM-only (.mjs), with no build step.
The floor comes from CommonJS consumers, which load it via Node's require(esm)
— verified as failing on 20.18.0 with ERR_REQUIRE_ESM and working from
20.19.0, where it was backported. ESM and bundler consumers have no such
constraint: the code itself uses only Intl.DateTimeFormat with
formatToParts/hourCycle, available since Node 14.
// ESM
import { todayIn, google } from "@softations/flow-datetime";
// CommonJS
const { todayIn, google } = require("@softations/flow-datetime");The model
| Type | Representation | Meaning |
|---|---|---|
| Instant | "2026-08-04T13:00:00.000Z", or a Firestore Timestamp at rest | An absolute point on the timeline. A zone is applied only at render. |
| PlainDate | "2026-08-04" — a string | A calendar day. No zone, no time. Identical for every viewer. |
| PlainTime | "09:00" | Wall-clock time, no date. |
| ZonedWallTime | { date, time, timeZone } | "9am in the user's zone". Becomes an Instant only at the provider boundary. |
| DayRange | { start, end }, end inclusive | All-day events, multi-day tasks. |
Rules
timeZoneis always an explicit argument. Nothing here reads the host zone orprocess.env.TZ, so a result never depends on where the code runs. PinningTZ=UTCon servers is still recommended: it makes any stray host-zone dependency fail identically everywhere instead of only in production.A PlainDate never round-trips through
Date.new Date("2026-08-04")parses as UTC midnight; reading it back with local getters yields the previous day for every zone west of Greenwich.parsePlainDatethrows on aDateargument specifically to catch this.End-exclusivity is a provider concern. Google Calendar's
end.date, Microsoft Graph's all-dayendand react-big-calendar's all-dayendare all exclusive. Keep ranges inclusive in your domain and lettoEventTimesapply the+1exactly once.Never send an Instant and a
timeZonetogether for the same field. Google lets an explicit offset win; Graph interprets the value intimeZoneand ignores the offset — so an identical payload produces different absolute times per provider. Send either aZinstant with no zone sibling, or a naive datetime plus the zone. The adapters here send naive+zone, which both providers interpret identically.Nothing returns a bare
DateexceptinstantToDate, the deliberate escape hatch for calendar UI libraries andTimestamp.fromDate.
API
// zones
isValidTimeZone / requireTimeZone / zoneOffsetMs / wallTimeToInstantMs
// PlainDate
isPlainDate / parsePlainDate / plainDateFrom / plainDateParts / plainDateOf
plainDateWeekday / todayIn / startOfDayIn / addDays / daysBetween / comparePlainDates
// PlainTime
isPlainTime / parsePlainTime / plainTimeFrom / plainTimeParts
// ZonedWallTime
resolveWallTime / wallTimeOf
// Instant
toInstantMs / toInstantISO / instantToDate
// DayRange
dayRangeToExclusiveEnd / dayRangeFromExclusiveEnd
// natural language (returns PlainDate | null, never a Date)
parseNaturalDate / parseNaturalTime
// normalisation
normalizeMailTimestamp / messageReceivedAt / messageReceivedMs
normalizeLegacyTaskKey / normalizeTaskBuckets / readTaskBuckets
// display (timeZone is a REQUIRED argument)
formatDate / formatTime / formatDateTime / formatTimeRange
formatRelativeDay / formatDayAge / formatSyncAge / formatDateContext
isToday / isSameDayIn / isPastDay
// providers
google.toEventTimes / google.fromEventTimes
graph.toEventTimes / graph.fromEventTimes / graph.PREFER_UTC_HEADER
providerDateTimeToInstant / eventPlainDateTypeScript declarations ship with the package.
Zero dependencies
Only Intl, which carries the full IANA database and is the same primitive the
major date libraries call underneath.
The tradeoff is real: roughly 60 lines of core.mjs do offset resolution and
DST gap/ambiguity handling that Luxon or Temporal would provide. That is covered
by the zone matrix below. Swapping the engine later is an internal change — the
API surface does not move.
Tests
npm test # one pass, host timezone
npm run test:tz # the full matrix — what CI runsEvery test must pass in all six zones:
| Zone | Why |
|---|---|
| UTC | baseline |
| America/New_York | negative offset + northern DST |
| Australia/Sydney | positive offset + southern DST (transitions the other way) |
| Asia/Kolkata | half-hour offset — catches whole-hour assumptions |
| Asia/Karachi | whole-hour positive offset |
| Pacific/Kiritimati | +14, where "today" is already "tomorrow" in UTC |
DST edge cases are asserted explicitly rather than assumed: 2026-03-08 02:30
America/New_York does not exist, 2026-11-01 01:30 happens twice, and a
recurring 09:00 meeting across a transition must hold its wall clock while its
UTC instant shifts.
Releasing
prepublishOnly runs the full zone matrix, so a publish cannot succeed with a
failing timezone.
npm version patch|minor|major
npm publishLicense
MIT
