Skip to main content

Date and Time Handling

Overview​

The Competition Factory handles dates and times across several domains: tournament scheduling, match timing, embargo enforcement, and timezone conversion. All temporal data uses ISO 8601 format, and the factory is zero-dependency — every date, time, and timezone operation is built on platform APIs (Date, Intl.DateTimeFormat) with no external libraries.

Date Formats Used​

ContextFormatExample
Tournament dates, scheduled datesYYYY-MM-DD'2024-06-15'
Timestamps (embargo, createdAt, start/end times)ISO 8601 with timezone'2024-06-15T10:00:00Z'
Time-of-day (scheduling)HH:MM'14:30'
IANA timezone identifiersRegion/City'America/New_York'

Four Questions, Not One​

"A date" is four different questions, and answering the wrong one is the most common source of off-by-an-hour and off-by-a-day bugs in competition data. The factory names each one:

QuestionType of answerModuleDepends on a zone?
Which calendar day?2026-09-09tools.plainDateno
What time on the clock?14:00tools.plainTimeno
Which moment, at this venue?day + clock + zonetools.zonedDateTimeyes
Which absolute instant?2026-09-09T18:00:00.000ZISO string with Zalready resolved

The distinction is not academic. A tournament's startDate is a calendar day — it does not begin at an instant, and asking "what time is 2026-09-09" has no answer. A scheduledTime of 14:00 is a wall clock — it is 14:00 at the venue, and which instant that is depends on where the venue is and whether daylight saving is in force that week. Mixing the two without an explicit conversion produces a figure wrong by the venue's UTC offset, which in a report measured in minutes is worse than showing nothing at all.

tools.dateTime predates this split and spans the first two questions. It remains fully supported and its behaviour is unchanged — it re-exports from the intent modules, so there is exactly one implementation of each helper — but new code should reach for the module that names the intent.

Where Dates Appear​

Tournament Record:

  • startDate / endDate — tournament date range (YYYY-MM-DD)
  • localTimeZone — IANA timezone identifier for the tournament venue
  • activeDates — specific dates within the range when play occurs

Time Items:

  • itemDate — effective date (YYYY-MM-DD)
  • createdAt — creation timestamp (ISO 8601 with timezone)
  • itemValue — often contains timestamps for scheduling events

Publishing / Embargo:

  • embargo — ISO 8601 timestamp with timezone (Z or ±HH:MM offset required)

Scheduling:

  • scheduledDate — match date (YYYY-MM-DD)
  • scheduledTime — match time (HH:MM)

Date & Time Utilities​

The dateTime object on tools provides functions for working with dates and times:

import { tools } from 'tods-competition-factory';

Core Functions​

FunctionDescription
dateTime.formatDate(date, separator?, order?)Format a Date object to string
dateTime.extractDate(dateTimeString)Extract YYYY-MM-DD from a datetime string
dateTime.extractTime(dateTimeString)Extract HH:MM from a datetime string
dateTime.addDays(date, days)Add days to a date
dateTime.addWeek(date)Add 7 days to a date
dateTime.addMinutesToTimeString(time, minutes)Add minutes to an HH:MM string
dateTime.sameDay(date1, date2)Check if two dates are the same calendar day
dateTime.isISODateString(value)Validate ISO 8601 date/datetime string
dateTime.isValidEmbargoDate(value)Validate ISO 8601 datetime with timezone
dateTime.isDate(value)Check if value is a Date object
dateTime.isTimeString(value)Validate HH:MM format
dateTime.timeStringMinutes(time)Convert HH:MM to total minutes
dateTime.offsetDate(dateString)Create a Date from an ISO date string
dateTime.offsetTime(isoString?)Get timezone offset in milliseconds
dateTime.getUTCdateString(dateOrString)Get YYYY-MM-DD in UTC
dateTime.timeUTC(dateOrString?)Get UTC timestamp in milliseconds

Date Range Generation​

import { tools } from 'tods-competition-factory';

const dates = tools.generateDateRange('2024-06-10', '2024-06-15');
// ['2024-06-10', '2024-06-11', '2024-06-12', '2024-06-13', '2024-06-14', '2024-06-15']

Embargo Validation​

isValidEmbargoDate ensures a value is a valid ISO 8601 datetime string that includes timezone context. This is enforced automatically by all publishing methods, but is also available for pre-validation:

tools.dateTime.isValidEmbargoDate('2024-06-15T10:00:00Z'); // true — UTC
tools.dateTime.isValidEmbargoDate('2024-06-15T10:00:00+05:30'); // true — offset
tools.dateTime.isValidEmbargoDate('2024-06-15T10:00:00'); // false — no timezone
tools.dateTime.isValidEmbargoDate('2024-06-15'); // false — date only
tools.dateTime.isValidEmbargoDate(42); // false — not a string

See Embargo and Scheduled Rounds for the full embargo documentation.

Timezone Utilities​

The timeZone object on tools provides timezone-aware operations using Intl.DateTimeFormat. These functions handle DST transitions correctly and work in all modern JavaScript runtimes.

import { tools } from 'tods-competition-factory';

Validating Timezones​

Tournament records can specify a localTimeZone (IANA timezone identifier). The factory validates this value on creation using isValidIANATimeZone:

tools.timeZone.isValidIANATimeZone('America/New_York'); // true
tools.timeZone.isValidIANATimeZone('Europe/London'); // true
tools.timeZone.isValidIANATimeZone('UTC'); // true
tools.timeZone.isValidIANATimeZone('EST'); // runtime-dependent
tools.timeZone.isValidIANATimeZone('Not/A/Zone'); // false

When creating a tournament record with an invalid localTimeZone, the factory returns an INVALID_TIME_ZONE error:

const result = engine.createTournamentRecord({
tournamentName: 'Example Open',
localTimeZone: 'Invalid/Zone',
});
// result.error === INVALID_TIME_ZONE

Getting UTC Offsets​

getTimeZoneOffsetMinutes returns the UTC offset in minutes for a timezone at a specific instant, correctly accounting for DST:

// Winter: EST is UTC-5 (-300 minutes)
tools.timeZone.getTimeZoneOffsetMinutes('America/New_York', new Date('2024-01-15T12:00:00Z'));
// -300

// Summer: EDT is UTC-4 (-240 minutes)
tools.timeZone.getTimeZoneOffsetMinutes('America/New_York', new Date('2024-06-15T12:00:00Z'));
// -240

// Fixed offset (no DST)
tools.timeZone.getTimeZoneOffsetMinutes('Asia/Kolkata'); // 330 (UTC+5:30, always)

// An unrecognised or absent zone is `undefined`, never a substituted 0
tools.timeZone.getTimeZoneOffsetMinutes('Not/A/Zone'); // undefined

Converting Between Wall-Clock Time and UTC​

These functions convert between "local time at a venue" and absolute UTC timestamps.

Wall-clock to UTC:

// "3:00 AM in New York on June 20" → UTC
tools.timeZone.wallClockToUTC('2024-06-20', '03:00', 'America/New_York');
// '2024-06-20T07:00:00.000Z' (EDT = UTC-4)

// Same time in winter → different UTC
tools.timeZone.wallClockToUTC('2024-01-15', '03:00', 'America/New_York');
// '2024-01-15T08:00:00.000Z' (EST = UTC-5)

UTC to wall-clock:

// UTC → "what time is it in New York?"
tools.timeZone.utcToWallClock('2024-06-20T07:00:00.000Z', 'America/New_York');
// { date: '2024-06-20', time: '03:00' }

// Day boundary: UTC June 21 03:00 → still June 20 in New York
tools.timeZone.utcToWallClock('2024-06-21T03:00:00.000Z', 'America/New_York');
// { date: '2024-06-20', time: '23:00' }

Creating Embargo Strings from Local Time​

toEmbargoUTC is a convenience function that converts wall-clock time to a validated UTC embargo string, ready to pass to any publishing method:

const embargo = tools.timeZone.toEmbargoUTC('2024-06-15', '08:00', 'America/New_York');
// '2024-06-15T12:00:00.000Z'

// The result always passes isValidEmbargoDate
tools.dateTime.isValidEmbargoDate(embargo); // true

// Use directly
engine.publishOrderOfPlay({
scheduledDates: ['2024-06-15'],
embargo,
});

Practical Pattern: Tournament-Aware Embargo​

Combine localTimeZone from the tournament record with toEmbargoUTC for a complete workflow:

// Get the tournament's timezone
const { tournamentInfo } = engine.getTournamentInfo();
const { localTimeZone } = tournamentInfo;
// e.g. 'America/New_York'

// Admin says "make the draw visible at 9 AM local time on June 15"
const embargo = tools.timeZone.toEmbargoUTC('2024-06-15', '09:00', localTimeZone);

engine.publishEvent({
eventId,
drawDetails: {
[drawId]: {
publishingDetail: { published: true, embargo },
},
},
});

// Later, display the embargo in local time for the admin
const { date, time } = tools.timeZone.utcToWallClock(embargo, localTimeZone);
console.log(`Embargo lifts at ${time} on ${date} (${localTimeZone})`);
// "Embargo lifts at 09:00 on 2024-06-15 (America/New_York)"

Date Handling Across the Factory​

Tournament Dates​

Tournament records track dates as YYYY-MM-DD strings. Validation is applied on creation and when modifying dates:

// startDate, endDate, and activeDates are validated as ISO date strings
engine.createTournamentRecord({
tournamentName: 'Summer Open',
startDate: '2024-06-10',
endDate: '2024-06-16',
activeDates: ['2024-06-10', '2024-06-12', '2024-06-14', '2024-06-16'], // optional
localTimeZone: 'America/New_York', // validated as IANA timezone
});

// Modifying dates validates and adjusts event dates accordingly
engine.setTournamentDates({
startDate: '2024-06-11',
endDate: '2024-06-15',
activeDates: ['2024-06-11', '2024-06-13', '2024-06-15'], // optional
});

Active Dates​

activeDates is an optional array of YYYY-MM-DD date strings representing the specific dates within a tournament's (or event's) date range when play actually occurs. This is useful for tournaments that span a date range but don't play every day — for example, a week-long tournament with rest days.

  • Omitted or undefined: all dates in the startDate–endDate range are implicitly active
  • Non-empty array: only the listed dates are active play dates
  • Validation: every date must be a valid date string and must fall within startDate/endDate; returns INVALID_DATE on failure
  • Derivation: when createTournamentRecord is called with activeDates but without startDate and/or endDate, the missing boundaries are derived from the earliest and latest active dates. This ensures the tournament record always has valid startDate/endDate for scheduling, court availability, and other subsystems that depend on them.
  • Falsy filtering: falsy values (empty strings, null) are automatically filtered out before validation

activeDates can be set at creation time via createTournamentRecord or later via setTournamentDates. Events also support activeDates via setEventDates.

// Set activeDates at creation — startDate/endDate provided explicitly
engine.createTournamentRecord({
startDate: '2024-06-10',
endDate: '2024-06-16',
activeDates: ['2024-06-10', '2024-06-12', '2024-06-14', '2024-06-16'],
});

// Set activeDates at creation — startDate/endDate derived automatically
// startDate becomes '2024-06-10', endDate becomes '2024-06-16'
engine.createTournamentRecord({
activeDates: ['2024-06-10', '2024-06-12', '2024-06-14', '2024-06-16'],
});

// Update activeDates later
engine.setTournamentDates({
activeDates: ['2024-06-10', '2024-06-11', '2024-06-14', '2024-06-16'],
});

// Set event-level activeDates
engine.setEventDates({
eventId,
activeDates: ['2024-06-10', '2024-06-14'],
});

Scheduling​

Match scheduling uses YYYY-MM-DD for dates and HH:MM for times. The scheduling system stores these as Time Items with full timestamps:

engine.addMatchUpScheduleItems({
matchUpId,
drawId,
schedule: {
scheduledDate: '2024-06-15',
scheduledTime: '14:30',
courtId: 'court-5',
},
});

Time Items​

All temporal records use ISO 8601 format. createdAt is auto-generated with timezone context:

{
itemType: 'SCHEDULE.TIME.START',
itemValue: '2024-06-15T14:05:00Z', // Always includes timezone
itemDate: '2024-06-15', // YYYY-MM-DD
createdAt: '2024-06-15T14:05:00.000Z', // Auto-generated
}

See Time Items for the complete temporal data reference.

Temporal API​

TC39 Temporal reached Stage 4 in March 2026 and is part of ES2026. It provides first-class timezone-aware dates, wall-clock times, and duration arithmetic in the language itself.

RuntimeNative support
Node.js 26+ (official builds)shipping, unflagged
Firefox 139+shipping
Chrome / Edge 144+shipping
Deno 2.7stable
Safari / iOS Safarinot yet shipping

Safari is the remaining gap, so Temporal is not yet "Baseline". A browser application that must run on iOS supplies a polyfill (temporal-polyfill or @js-temporal/polyfill), which populates globalThis.Temporal; the factory itself will never bundle one, in keeping with the zero-dependency policy.

Node version is not the whole story

Whether Temporal exists in a given Node build depends on how that build was compiled, not only on its version number. Check with:

node -p "[process.version, typeof Temporal, process.config.variables.v8_enable_temporal_support].join(' | ')"
# want: v26.x.y | object | 1

Some distributions compile Temporal out. In that case the flag --harmony-temporal is accepted but has no effect, because the code is absent rather than disabled.

What changes when the factory adopts it​

The calendar-intent modules were named after Temporal's types deliberately: plainDate, plainTime and zonedDateTime map onto Temporal.PlainDate, Temporal.PlainTime and Temporal.ZonedDateTime, so adoption becomes a substitution of implementations rather than a redesign of the API.

Signatures stay strings in and strings out, and the wire format does not change — records on disk and on the wire remain ISO strings. Two internal things improve:

  • The two-pass offset guess that zonedDateTime uses to resolve a wall clock is a Date workaround. Temporal.PlainDateTime.from(…).toZonedDateTime(tz, { disambiguation }) does it in one step.
  • The ambiguous hour that a fall-back DST transition repeats is currently settled by a consistent but arbitrary rule. Temporal makes it an explicit disambiguation choice.