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
| Context | Format | Example |
|---|---|---|
| Tournament dates, scheduled dates | YYYY-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 identifiers | Region/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:
| Question | Type of answer | Module | Depends on a zone? |
|---|---|---|---|
| Which calendar day? | 2026-09-09 | tools.plainDate | no |
| What time on the clock? | 14:00 | tools.plainTime | no |
| Which moment, at this venue? | day + clock + zone | tools.zonedDateTime | yes |
| Which absolute instant? | 2026-09-09T18:00:00.000Z | ISO string with Z | already 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 venueactiveDates— 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 (Zor±HH:MMoffset 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
| Function | Description |
|---|---|
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 thestartDate–endDaterange 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; returnsINVALID_DATEon failure - Derivation: when
createTournamentRecordis called withactiveDatesbut withoutstartDateand/orendDate, the missing boundaries are derived from the earliest and latest active dates. This ensures the tournament record always has validstartDate/endDatefor 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.
| Runtime | Native support |
|---|---|
| Node.js 26+ (official builds) | shipping, unflagged |
| Firefox 139+ | shipping |
| Chrome / Edge 144+ | shipping |
| Deno 2.7 | stable |
| Safari / iOS Safari | not 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.
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
zonedDateTimeuses to resolve a wall clock is aDateworkaround.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
disambiguationchoice.
Related Documentation
- Time Items — Temporal records on CODES document elements
- Embargo and Scheduled Rounds — Time-based visibility gates
- Scheduling Overview — Match scheduling concepts
- Tools API — Complete function reference for
plainDate,plainTime,zonedDateTime,dateTimeandtimeZone