@hexdspace/time
v0.2.0
Published
Validated instants, elapsed durations, local time, and recurrence
Downloads
298
Readme
@hexdspace/time
Validated, millisecond-precision instants and elapsed durations, plus local recurrence rules.
The package has two entry points: @hexdspace/time for time operations and @hexdspace/time/recurrence for recurring starts. Temporal handles parsing and time-zone rules internally. Public instants and durations are branded numbers measured in milliseconds; ISO strings and local values are accepted at boundaries.
Instants, local time, and durations
import {
addDuration,
durationBetween,
parseDuration,
parseInstant,
instantToIso,
instantToLocal,
localToInstant,
now,
parseLocalDateTime,
parseTimeZone,
} from '@hexdspace/time';
const zone = parseTimeZone('Europe/Warsaw');
const startLocal = parseLocalDateTime({ date: '2026-09-22', time: '09:00' });
const start = localToInstant(startLocal, zone);
const end = addDuration(start, parseDuration('PT90M'));
const elapsed = durationBetween(start, end);
const local = instantToLocal(end, zone, { truncateTo: 'minute' });
const storedAt = instantToIso(now());
const readBack = parseInstant(storedAt);elapsed is 5_400_000 milliseconds, and storedAt is a UTC ISO string with millisecond precision.
instantFromEpochMilliseconds validates numeric timestamps. now({ nowEpochMilliseconds: () => fixedEpochMilliseconds }) injects a clock for tests. compareInstants compares instants, and isWithinInterval accepts bounds '[)' (default), '[]', '()', or '(]'. durationBetween(start, end) returns signed elapsed milliseconds (end - start). Duration ISO parsing accepts elapsed time components such as PT1H30M0.5S; calendar components such as P1D and P1M are rejected. durationToIso converts elapsed milliseconds back to ISO.
Local input accepts HH:mm, HH:mm:ss, and fractional seconds to three digits. parseLocalTime returns one canonical spelling for each time: HH:mm for whole minutes, HH:mm:ss for whole seconds, and HH:mm:ss.SSS otherwise. instantToLocal preserves milliseconds by default and can request minute or second precision. parseLocalDate, parseLocalTime, and parseTimeZone validate input received from forms or storage.
parseTimeZone also canonicalizes spelling: europe/warsaw becomes Europe/Warsaw and +0200 becomes +02:00. Aliases are kept as written, so compare zones with sameTimeZone, which treats Asia/Calcutta and Asia/Kolkata as equal.
Local dates
import { addDays, daysBetween, parseLocalDate, parseTimeZone, startOfDay, today, weekdayOf } from '@hexdspace/time';
const zone = parseTimeZone('Europe/Warsaw');
const date = today(zone);
const tomorrow = addDays(date, 1);
const dayStart = startOfDay(date, zone);
const dayEnd = startOfDay(tomorrow, zone);[dayStart, dayEnd) is the local day as instants; it is 23 or 25 hours long on a transition day. startOfDay returns the first instant of the date, which is later than midnight in zones where a transition skips it. today accepts the same optional clock as now. addDays takes a signed integer and throws if the result leaves years 0000 to 9999. daysBetween(start, end) returns signed calendar days (end - start), compareLocalDates orders dates, and weekdayOf returns a lowercase name such as 'tuesday'; weekdays lists them Monday first.
For minute-based scheduling controls, localTimeToMinutes(parseLocalTime('09:30')) returns 570 and minutesToLocalTime(570) returns '09:30'. These functions reject out-of-range or finer-precision values instead of silently wrapping or truncating them.
Daylight saving transitions
localToInstant chooses the earlier occurrence when a local time repeats and shifts forward by the size of the gap when a local time does not exist. This does not throw. Pass { repeatedTime: 'later' } or { nonexistentTime: 'shiftBackward' } to override either choice. resolveLocal returns the chosen instant, resulting local date and time, and whether the requested time was unique, nonexistent, or repeated. Recurrence queries accept the same choices in localTimeResolution.
import { parseLocalDateTime, parseTimeZone, resolveLocal } from '@hexdspace/time';
const local = parseLocalDateTime({ date: '2026-03-29', time: '02:30' });
const zone = parseTimeZone('Europe/Warsaw');
const resolution = resolveLocal(local, zone);Here resolution.kind is 'nonexistent' and resolution.resolved.time is '03:30'.
Recurring starts
A recurrence schedule contains one or more rules and a shared occurrence policy. Each rule selects local dates with a DatePattern, then produces one start for each entry in localTimes on every selected date. The policy bounds and adjusts those dates. Supply a time zone when generating instants; the schedule itself contains no time zone or block duration.
import { parseInstant, parseTimeZone } from '@hexdspace/time';
import { instants, parseRecurrenceSchedule } from '@hexdspace/time/recurrence';
const schedule = parseRecurrenceSchedule({
rules: [{
pattern: {
kind: 'weekly',
weekdays: ['tuesday', 'friday'],
repeat: { everyWeeks: 2, countingFrom: '2026-09-21' },
},
localTimes: ['09:00', '15:00'],
}],
policy: { startsOn: '2026-09-22' },
});
const starts = instants(schedule, {
timeZone: parseTimeZone('Europe/Warsaw'),
from: parseInstant('2026-09-22T00:00:00Z'),
to: parseInstant('2026-10-22T00:00:00Z'),
});Omit repeat to select every matching day, week, or month. For spaced repetition, countingFrom sets the cycle without limiting the output. For a weekly pattern, counting starts with the ISO week containing that date. The policy's startsOn sets the first allowed date; endsOn is optional and inclusive. A bounded query is always required, even with endsOn. A monthly pattern selects either a day of the month or an ordinal weekday:
{ kind: 'monthly', on: { kind: 'dayOfMonth', day: 31, ifMissing: 'lastDay' } }
{ kind: 'monthly', on: { kind: 'weekday', weekday: 'friday', occurrence: 'last' } }parseRecurrenceSchedule validates unknown input, rejects unknown fields, and returns a JSON-serializable schedule with version: 1. Store the schedule as JSON and parse it after reading it back. parseRecurrenceRule also validates a standalone rule. Each rule needs at least one local time; equivalent times such as 09:00 and 09:00:00 cannot appear twice in one rule.
Use separate rules when selected days need different times. If two rules select the same local start, the schedule produces one occurrence for it.
Date adjustments
Monthly rules can apply latestDayOfMonth through the shared policy. previousSameWeekday selects an earlier matching weekday in the month, useLatestDay uses the specified date even when its weekday differs, and skip omits that month's occurrence. Every rule must use a monthly pattern; previousSameWeekday additionally requires a monthly weekday pattern.
policy: {
startsOn: '2026-01-01',
adjustments: {
latestDayOfMonth: { day: 25, ifLater: 'previousSameWeekday' },
disallowedDate: { ifDisallowed: 'previousAllowedDate' },
},
}disallowedDate defaults to allowing Monday through Friday. To store a different weekly rule in the schedule, set allowedDateSource: { kind: 'weekdays', allowedWeekdays: ['monday', 'wednesday', 'friday'] }. For holidays or other dynamic rules, store an external calendar key:
disallowedDate: {
allowedDateSource: { kind: 'externalCalendar', key: 'user-123-holidays' },
ifDisallowed: 'previousAllowedDate',
}The calendar provider receives an inclusive date range. It returns a snapshot that covers that range and answers whether each date is allowed. Compose mondayToFridayCalendar.isAllowedDate(date) with your holiday data if weekends should remain excluded. The planner checks the snapshot's key and coverage before expanding:
import { planExpansion } from '@hexdspace/time/recurrence';
const plan = planExpansion(schedule, { timeZone, from, to });
const starts = plan.kind === 'ready'
? plan.expand()
: plan.expand(await loadCalendar(plan.request));For callers that only need the results, build an async expander once. It calls the loader only when a schedule needs an external calendar:
import { createExpander } from '@hexdspace/time/recurrence';
const expand = createExpander(loadCalendar);
const starts = await expand(schedule, { timeZone, from, to });The synchronous instants() and occurrences() functions work for schedules with the default or stored weekday calendar. They throw if an external calendar is required for the query. planOccurrences() and createOccurrenceExpander() provide the corresponding detailed results.
previousAllowedDate and nextAllowedDate keep searching until they find an allowed date. latestDayOfMonth runs first. The final date must remain in its candidate month, on or after startsOn, on or before endsOn if present, and within the latest day. If the chosen policy cannot meet those limits, that occurrence is skipped.
Queries return starts whose instants fall in [from, to) by default; bounds changes endpoint inclusion. instants() preserves duplicate instants when multiple scheduled dates move onto one date. occurrences() exposes each original scheduled date and time, its stable ID, final local date and time, instant, and applied adjustments. Block ends are the app's responsibility: add its elapsed duration to each start.
Existing util helpers
@hexdspace/util currently contains older time helpers. This package is independent of them. In particular, its Unix timestamps are in seconds, while Instant here is always in milliseconds; conversions must account for that unit difference.
