Time
Timezone-aware datetime helpers built on top of native Intl.DateTimeFormat. No TZ-database vendor; operators get the IANA names Node's ICU build supports (full set on every mainstream platform).
The module covers four concerns: parsing ISO 8601 strings into Date, decomposing an instant into calendar parts in a named timezone, formatting an instant for human display, and DST-safe calendar arithmetic (addDays / addMonths / startOfDay / endOfDay / diffDays).
Every operation accepts a Date, a millisecond-epoch number, or an ISO 8601 string interchangeably. The timezone opt defaults to "UTC" and the locale opt defaults to "en-US".
Calendar arithmetic anchors on parts in the requested timezone, not on UTC milliseconds — so addDays(d, 1, { timezone: "America/New_York" }) always lands on the same wall-clock time the next civil day, even across the spring-forward / fall-back transitions.
b.time.toParts(input, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Decompose an instant into calendar parts as observed in a named timezone. Returns { year, month, day, hour, minute, second, millisecond, weekday: 1..7, weekdayName: "Mon".."Sun", dayOfYear }. Weekday numbering follows ISO 8601 (Monday = 1, Sunday = 7).
Accepts a Date, ms-epoch number, or ISO 8601 string. timezone defaults to "UTC".
var parts = b.time.toParts("2026-05-09T14:30:00Z", {
timezone: "America/New_York",
});
parts.year; // → 2026
parts.month; // → 5
parts.day; // → 9
parts.hour; // → 10
parts.weekdayName; // → "Sat"
parts.weekday; // → 6
parts.dayOfYear; // → 129
b.time.format(input, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
locale: string, // BCP 47; defaults to "en-US"
dateStyle: string, // "full" | "long" | "medium" | "short"
timeStyle: string, // "full" | "long" | "medium" | "short"
year: string, // "numeric" | "2-digit"
month: string, // "numeric" | "2-digit" | "long" | "short" | "narrow"
day: string,
hour: string,
minute: string,
second: string,
weekday: string,
era: string,
hour12: boolean,
fractionalSecondDigits: number,
timeZoneName: string, // "long" | "short" | "shortOffset" | etc.
}
Render an instant as an operator-readable string in a named timezone and locale. Accepts the same Date | number | string input as the rest of the module. When neither dateStyle / timeStyle nor any per-field opt is supplied, defaults to dateStyle: "medium" + timeStyle: "short".
Per-field opts (year / month / day / hour / minute / second / weekday / era / hour12 / fractionalSecondDigits / timeZoneName) pass through to Intl.DateTimeFormat unchanged.
var when = "2026-05-09T14:30:00Z";
b.time.format(when, { timezone: "America/New_York" });
// → "May 9, 2026, 10:30 AM"
b.time.format(when, {
timezone: "Asia/Tokyo",
dateStyle: "full",
timeStyle: "long",
});
// → operator-readable Japanese-locale-style string
b.time.tzOffsetMs(input, timezone) #
Compute the offset in milliseconds between the named timezone's local wall-clock and UTC at the given instant. Positive east of UTC, negative west. The value depends on the instant — DST transitions are honored automatically.
Throws TimeError when timezone is missing, non-string, or not an IANA name supported by Node's ICU build.
var offset = b.time.tzOffsetMs("2026-05-09T12:00:00Z", "America/New_York");
// → -14400000 (UTC-4 during DST; 4h * 60m * 60s * 1000ms)
var winter = b.time.tzOffsetMs("2026-01-15T12:00:00Z", "America/New_York");
// → -18000000 (UTC-5 in standard time)
b.time.startOfDay(input, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Return a Date pointing at midnight (00:00:00.000) of the input's civil day in the named timezone. DST-safe — the spring-forward day still resolves to the first valid wall-clock instant. Useful for day-bucketed audit queries and "is this still today?" comparisons.
var dayStart = b.time.startOfDay("2026-05-09T14:30:00Z", {
timezone: "America/New_York",
});
dayStart.toISOString();
// → "2026-05-09T04:00:00.000Z" (midnight NY = 04:00 UTC during DST)
b.time.endOfDay(input, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Return a Date pointing at the last representable millisecond (23:59:59.999) of the input's civil day in the named timezone. DST-safe. Pair with startOfDay to bracket "all events on day X in timezone Y" range queries.
var dayEnd = b.time.endOfDay("2026-05-09T14:30:00Z", {
timezone: "America/New_York",
});
dayEnd.toISOString();
// → "2026-05-10T03:59:59.999Z" (23:59:59.999 NY = 03:59 next-day UTC)
b.time.addDays(input, n, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Add n calendar days to the input, anchored on the named timezone's wall clock. Negative n subtracts. Calendar-day arithmetic — the wall-clock hour / minute / second / millisecond stay the same across DST transitions, even though the resulting UTC offset between the two instants will differ by an hour around the transition.
Throws TimeError when n is not a finite number.
var due = b.time.addDays("2026-05-09T14:30:00Z", 7, {
timezone: "America/New_York",
});
due.toISOString();
// → "2026-05-16T14:30:00.000Z"
// Subtract: "yesterday at this time"
var yesterday = b.time.addDays(Date.now(), -1, { timezone: "UTC" });
b.time.addMonths(input, n, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Add n calendar months to the input, anchored on the named timezone's wall clock. Negative n subtracts. End-of-month days clamp to the target month's last day — Jan 31 + 1 month is Feb 28/29, not "March 3". Wall-clock hour / minute / second / millisecond are preserved.
Throws TimeError when n is not a finite number.
var renewal = b.time.addMonths("2026-01-31T09:00:00Z", 1, {
timezone: "UTC",
});
renewal.toISOString();
// → "2026-02-28T09:00:00.000Z" (clamped: Feb has no day 31)
var nextQuarter = b.time.addMonths(Date.now(), 3, { timezone: "UTC" });
b.time.diffDays(a, b, opts) #
{
timezone: string, // IANA name; defaults to "UTC"
}
Calendar days between two instants in the named timezone, computed as startOfDay(b) - startOfDay(a) rounded to whole days. Positive when b is after a; negative otherwise. Foundation for "X days ago" / "Y days until" relative formatting.
var posted = "2026-05-02T08:00:00Z";
var now = "2026-05-09T14:30:00Z";
var ago = b.time.diffDays(posted, now, { timezone: "UTC" });
// → 7
// "X days ago" relative formatting:
var label = ago === 0 ? "today"
: ago === 1 ? "yesterday"
: ago + " days ago";
// → "7 days ago"
b.time.readDate(text, at?) #
Read YYYY-MM-DD at index at (default 0), returning { year, month, day, end } as TEXT plus the index just past it, or null. Shape only — a month of 13 reads fine here and is rejected by whoever cares about the calendar.
b.time.readDate("2026-08-16T00:00:00Z").day; // → "16"
b.time.readTime(text, at?, opts?) #
{
offsetColon: "require"|"optional", // default: "require" (RFC 3339 §5.6)
offsetCase: "either"|"upper", // default: "either" (RFC 3339 §5.6)
requireSeconds: boolean, // default: true
}
Read HH:MM:SS with an optional fraction and an optional offset at index at (default 0), returning { hour, minute, second, fraction, offset, end } or null. fraction carries its leading dot and offset its sign, both empty when absent, so a caller can tell "no offset" from +00:00 — which matters, because one says the time is UTC and the other says nobody recorded a zone.
A fraction with no digits after the dot is not a time.
requireSeconds: false accepts HH:MM — the shape ISO 8601 permits and RFC 3339 §5.6 does not. A time with no seconds is one two systems will round differently, so it is off by default; the option exists because narrowing what an EXISTING parser accepts is a change to every caller below it. Without seconds there is no fraction either, which is where they hang.
offsetCase: "upper" refuses a lower-case z. RFC 3339 §5.6 permits it, so the default takes it — but a caller that will branch on the returned offset has to say which spellings it handles, and several in this framework only ever handled Z.
b.time.readTime("12:34:56.5+01:00").offset; // → "+01:00"
b.time.readTime("12:34:56Z", 0).fraction; // → ""
b.time.readTime("12:34Z", 0, { requireSeconds: false }).second; // → ""
b.time.readTime("12:34:56z", 0, { offsetCase: "upper" }); // → null
b.time.readDateTime(text, opts?) #
{
separators: string, // default: "Tt " — what may sit between the halves
requireTime: boolean, // default: true — is a bare date a date-time?
requireOffset: boolean, // default: false — must the zone be stated?
offsetColon: "require"|"optional", // default: "require"
}
Read a whole ISO 8601 / RFC 3339 date-time — the ENTIRE string, so trailing text is a refusal rather than an ignored tail. Returns the date fields and, when a time is present, the time fields too; null otherwise.
b.time.readDateTime("2026-08-16T12:34:56Z").hour; // → "12"
b.time.readDateTime("2026-08-16T12:34:56"); // → the fields, offset ""
b.time.readDateTime("2026-08-16T12:34:56", { requireOffset: true }); // → null
b.time.parseISO(s) #
Parse an ISO 8601 / RFC 3339 datetime string into a Date. Accepts YYYY-MM-DD, YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, optional .sss fractional seconds, and an optional trailing Z / +HH:MM / -HH:MM zone designator. A space separator between date and time is also accepted. Strings without a zone designator are interpreted as UTC.
Throws TimeError for non-strings, malformed input, or out-of-range component values (month > 12, day > 31, hour > 23, etc.).
var d = b.time.parseISO("2026-05-09T14:30:00Z");
d.toISOString(); // → "2026-05-09T14:30:00.000Z"
// Offset zone:
var withOffset = b.time.parseISO("2026-05-09T10:30:00-04:00");
withOffset.toISOString(); // → "2026-05-09T14:30:00.000Z"
// Date-only (interpreted as UTC midnight):
var date = b.time.parseISO("2026-05-09");
date.toISOString(); // → "2026-05-09T00:00:00.000Z"
b.time.toIso8601NoMs(input) #
Emit an ISO 8601 string with the trailing .sssZ milliseconds dropped — produces 2026-05-09T14:30:00Z instead of 2026-05-09T14:30:00.000Z. Used by SAS / SigV4 / log-filename builders that need a one-second-resolution timestamp string. The strip lives in one place so every caller agrees on the shape.
b.time.toIso8601NoMs("2026-05-09T14:30:00.789Z");
// → "2026-05-09T14:30:00Z"
b.time.toIso8601NoMs(new Date(Date.UTC(2026, 4, 9, 14, 30, 0)));
// → "2026-05-09T14:30:00Z"
b.time.stripIsoMilliseconds(text) #
Drop a trailing .sssZ down to Z, leaving anything else alone. The half of toIso8601NoMs that works on a STRING, so a caller comparing two timestamps at one-second resolution can normalize both without going through a Date — and without running a pattern over a value that arrived on the wire.
b.time.stripIsoMilliseconds("2026-05-09T14:30:00.789Z");
// → "2026-05-09T14:30:00Z"
b.time.stripIsoMilliseconds("2026-05-09T14:30:00Z");
// → "2026-05-09T14:30:00Z"
b.time.monotonicClock(opts?) #
{
source: function, // default: Date.now — must return a SAFE-INTEGER number of ms
maxDriftMs: number, // default: 1000 — lead over the source before it reports
onDrift: function, // ({ driftMs, maxDriftMs, value, sourceMs, label }) — replaces the observability event
strict: boolean, // default: false — true throws instead of reporting
label: string, // default: "default" — names this clock in drift reports
}
An isolated clock whose now() never returns a value less than or equal to the one before it.
Date.now() gives neither guarantee. It repeats when two writes land in the same millisecond, and it moves BACKWARDS when NTP steps the clock — the correction b.ntpCheck exists to detect. Anything that orders records by a timestamp inherits both problems: an append-only chain gets a row that appears to precede its predecessor, and keyset pagination over a timestamp column drops or repeats rows that share a millisecond.
Three things the four-line hand-rolled version leaves out, and this settles:
- **An injectable source.** opts.source defaults to Date.now but a test can hand over a scripted one, which a closure over Date.now cannot. It must return a SAFE-INTEGER count of milliseconds. A fractional clock such as performance.timeOrigin + performance.now() is refused rather than rounded: this primitive's whole promise is that the next value exceeds the last, and it keeps that promise by handing out last + 1 when the source has not moved — arithmetic that needs an integer to stay exact. - **A floor that survives a restart.** Process memory resets to zero on restart or failover — precisely when a fresh node is syncing NTP and a backwards step is most likely. observeFloor(ms) seeds the guarantee from a value read back out of storage, and ignores anything below where the clock already is. It takes a NUMBER and refuses anything else, including the decimal string a Postgres BIGINT arrives as through most drivers: coercing a floor silently would accept "abc" as NaN and every later comparison against it as false, so the conversion is the caller's to make and to get wrong loudly. - **A ceiling on how far ahead of the source it may run.** A burst inside one millisecond walks the returned value into the future. Unbounded, that is a silently wrong timestamp; maxDriftMs is what makes it not silent.
Passing the cap REPORTS — through onDrift when given, otherwise as the time.monotonic.drift_exceeded observability event — and keeps returning monotonic values. For the framework's own consumer, an append-only audit chain, a timestamp a few milliseconds optimistic is a smaller harm than a dropped row, and a dropped row is what an attacker who can step the clock would be aiming for. A caller whose property is timestamp ACCURACY rather than completeness asks for strict: true and gets a time/monotonic-drift-cap throw instead.
The report never goes through b.audit: a clock that stamps audit rows and audits its own drift would call itself.
Each option is checked when the clock is built, under its own code: time/bad-source, time/bad-max-drift, time/bad-on-drift, time/bad-strict and time/bad-label. A key that is not one of the five throws time/bad-opts.
var clock = b.time.monotonicClock({ label: "device_event_log" });
// Number(...) is not decoration: a Postgres BIGINT arrives as a decimal
// STRING through most drivers, and observeFloor refuses one rather than
// coercing it — a floor is too important to guess at.
clock.observeFloor(Number(tipRow.recordedAt)); // durable, read from storage
var recordedAt = clock.now();
// → strictly greater than tipRow.recordedAt, whatever the wall clock did
b.time.monotonicNow() #
The process-wide monotonic clock: epoch milliseconds that never repeat and never move backwards, whatever Date.now() does. The shape most callers want — one shared sequence, so two unrelated call sites in the same process cannot mint the same value.
Takes no arguments. A caller who needs an injectable source, a durable floor, a different drift cap, or a sequence isolated from everyone else's builds their own with b.time.monotonicClock; configuring a shared clock from one call site would silently change every other site's behavior.
**Not for measuring how long something took.** The guarantee here is ORDERING — values never repeat and never go backwards — and it is built over Date.now(), so a forward clock correction carries straight into the number this returns. Subtracting two of them measures the wall clock, corrections included. b.time.stopwatch and b.time.monotonicMs read a source no operator can adjust, and are what an interval, a lease, or a rate window should be measured against.
var a = b.time.monotonicNow();
var b2 = b.time.monotonicNow();
// → b2 > a, even inside the same millisecond
b.time.monotonicMs() #
Milliseconds from a source no clock correction can move. Read it twice and the difference is elapsed time; the value itself means nothing on its own — it counts from an arbitrary point, not an epoch, so it is not a timestamp and cannot be stored, compared across processes, or shown to anyone.
Use it for a rate window, a refill interval, a lease, a timeout — anything whose correctness depends on how much time has passed rather than on what time it is. Date.now() and b.time.monotonicNow() both answer the second question, and an operator correcting a host's clock forward hands out a whole window's budget without any time passing, or reaps a lease a session is still holding.
There is deliberately no injectable source. A test that needs to control the passage of time drives the reading it takes, not the clock underneath — a source parameter here would let a caller point the one steppable-proof primitive back at the wall clock.
var started = b.time.monotonicMs();
await doWork();
var tookMs = b.time.monotonicMs() - started;
b.time.stopwatch() #
Start measuring an interval. Returns a reader: ms() gives the milliseconds since the stopwatch was created, and reset() starts it again from now.
Built on b.time.monotonicMs, so the answer is unaffected by an operator correcting the host clock — which is the entire reason to reach for this rather than subtracting two Date.now() calls.
var sw = b.time.stopwatch();
await handler(req, res);
log.info("handled in " + sw.ms().toFixed(1) + "ms");