Skip to main content

Schedule Governor

The Schedule Governor provides methods for assigning dates, times, venues, and courts to tournament matchUps. It supports manual assignment, automated scheduling, and hybrid approaches for tournaments ranging from simple club events to complex multi-day professional circuits.

import { scheduleGovernor } from 'tods-competition-factory';
tip

See Scheduling Overview, Automated Scheduling, Pro Scheduling, and Scheduling Policy for comprehensive scheduling concepts.

Manual Scheduling Methods​

addCourtGridBooking​

Adds a booking to the court grid for pro scheduling.

engine.addCourtGridBooking({
courtId, // required
startTime, // required - time string (HH:mm)
endTime, // required - time string (HH:mm)
bookingType, // required - type of booking
date, // required - date string (YYYY-MM-DD)
});

addMatchUpScheduledDate​

Assigns a scheduled date to a matchUp.

engine.addMatchUpScheduledDate({
matchUpId, // required
drawId, // required
scheduledDate, // required - ISO date string (YYYY-MM-DD)
disableNotice, // optional boolean - suppress notifications
});

Example:

engine.addMatchUpScheduledDate({
matchUpId: 'match-123',
drawId: 'draw-456',
scheduledDate: '2024-06-15',
});

addMatchUpScheduledTime​

Assigns a scheduled time to a matchUp.

engine.addMatchUpScheduledTime({
matchUpId, // required
drawId, // required
scheduledTime, // required - time string (HH:mm)
disableNotice, // optional boolean - suppress notifications
});

Example:

engine.addMatchUpScheduledTime({
matchUpId: 'match-123',
drawId: 'draw-456',
scheduledTime: '14:30',
});

addSchedulingProfileRound​

Adds a round to an existing scheduling profile.

engine.addSchedulingProfileRound({
scheduleDate, // required - date string
venueId, // required
drawId, // required
structureId, // required
roundNumber, // required
});

assignMatchUpVenue​

Assigns a venue to a matchUp.

engine.assignMatchUpVenue({
matchUpId, // required
drawId, // required
venueId, // required
disableNotice, // optional boolean - suppress notifications
});

Example:

engine.assignMatchUpVenue({
matchUpId: 'match-123',
drawId: 'draw-456',
venueId: 'venue-789',
});

assignMatchUpCourt​

Assigns a specific court to a matchUp.

engine.assignMatchUpCourt({
matchUpId, // required
drawId, // required
courtId, // required
courtDayDate, // optional - date string for multi-day scheduling
disableNotice, // optional boolean - suppress notifications
});

Example:

engine.assignMatchUpCourt({
matchUpId: 'match-123',
drawId: 'draw-456',
courtId: 'court-1',
courtDayDate: '2024-06-15',
});

assignMatchUpScorekeeper​

Nominates a tournament participant as the official scorekeeper of a matchUp. The nominee must be an INDIVIDUAL participant in the tournament; unlike an official there is no role restriction — a competitor may also be nominated to keep score. Stored the same way as court/official assignments (a SCHEDULE.ASSIGNMENT.SCOREKEEPER first-class value surfaced as matchUp.schedule.scorekeeper). Not cleared by rescheduling.

engine.assignMatchUpScorekeeper({
matchUpId, // required
drawId, // required
participantId, // required - an INDIVIDUAL participant in the tournament
disableNotice, // optional boolean - suppress notifications
});

Clear a nomination with removeMatchUpScorekeeper:

engine.removeMatchUpScorekeeper({ matchUpId, drawId });

A participant may instead (or additionally) be approved to keep score for any matchUp by carrying the SCOREKEEPER role in participantRoleResponsibilities (set via modifyParticipant), which is filterable via getParticipants:

engine.modifyParticipant({
participant: { participantId, participantRoleResponsibilities: ['SCOREKEEPER'] },
});

assignMatchUpTimekeeper​

Assigns a tournament participant as the timekeeper of a matchUp — relevant for timed matchUpFormats (e.g. INTENNSE bolt/serve clocks). Same participant rule and storage as the scorekeeper (a SCHEDULE.ASSIGNMENT.TIMEKEEPER first-class value surfaced as matchUp.schedule.timekeeper). Not cleared by rescheduling. The TIMEKEEPER role is also available as a participantRoleResponsibility.

engine.assignMatchUpTimekeeper({
matchUpId, // required
drawId, // required
participantId, // required - an INDIVIDUAL participant in the tournament
disableNotice, // optional boolean - suppress notifications
});

engine.removeMatchUpTimekeeper({ matchUpId, drawId });

setMatchUpScheduleLock​

Pins a matchUp's placement so bulk and automated scheduling cannot move it — the marquee match that must keep centre court at 19:00 while the rest of the day is rebuilt around it. Stored first-class as matchUp.schedule.lock. Presence of the object is the lock; attributes narrows it to specific placement fields.

Guards placement only: startTime / stopTime / resumeTime / endTime stay writable so a pinned matchUp can still be played. Every placement mutation accepts overrideScheduleLock: true for callers that have confirmed the move with the operator; an override moves the placement but never removes the lock. The lock goes inert (it is not deleted) once the matchUp completes, or while it has no placement to guard.

See Schedule Locks for the full model.

engine.setMatchUpScheduleLock({
matchUpId, // required
drawId, // required
lock, // required - a ScheduleLock object, or null to unlock
disableNotice, // optional boolean - suppress notifications
});

// pin only the clock time; the court stays free to move
engine.setMatchUpScheduleLock({ matchUpId, drawId, lock: { attributes: ['scheduledTime'] } });

// unlock
engine.setMatchUpScheduleLock({ matchUpId, drawId, lock: null });

Automated Scheduling Methods​

scheduleMatchUps​

Auto-schedules matchUps on a given date using the Garman formula for optimal court utilization. Intelligently assigns courts and times based on court availability, match duration estimates, recovery times, and participant daily limits.

engine.scheduleMatchUps({
scheduleDate, // required - ISO date string (YYYY-MM-DD)
matchUpIds, // required - array of matchUpIds to schedule

// Venue selection
venueIds, // optional - defaults to all venues

// Scheduling parameters
periodLength, // optional - scheduling block size in minutes (default: 30)
averageMatchUpMinutes, // optional - average match duration (default: 90)
recoveryMinutes, // optional - recovery time between matches (default: 0)

// Time constraints
startTime, // optional - schedule start time (HH:mm)
endTime, // optional - schedule end time (HH:mm)

// Limits and policies
matchUpDailyLimits, // optional - SINGLES, DOUBLES, total limits
checkPotentialRequestConflicts, // optional boolean (default: true)

// Execution control
dryRun, // optional boolean - preview without changes
});

Period Length: Scheduling Block Size​

The periodLength parameter controls the granularity of scheduling blocks:

  • 15 minutes: Fine-grained scheduling for short-format matches
  • 30 minutes (default): Standard scheduling for most tournaments
  • 60 minutes: Coarse scheduling for long-format matches

Smaller period lengths provide more precise start times but may reduce court utilization. Larger periods improve grouping but reduce precision.

See: Automated Scheduling - Period Length for detailed explanation.

scheduleMatchUps Examples​

Basic Scheduling:

engine.scheduleMatchUps({
scheduleDate: '2024-06-15',
matchUpIds: ['match-1', 'match-2', 'match-3'],
});

Custom Parameters:

engine.scheduleMatchUps({
scheduleDate: '2024-06-15',
matchUpIds,
venueIds: ['venue-1'],
periodLength: 30,
averageMatchUpMinutes: 90,
recoveryMinutes: 60,
startTime: '08:00',
endTime: '18:00',
matchUpDailyLimits: {
SINGLES: 2,
DOUBLES: 1,
total: 2,
},
});

Dry Run Preview:

const result = engine.scheduleMatchUps({
scheduleDate: '2024-06-15',
matchUpIds,
dryRun: true, // Preview without making changes
});

console.log('Would schedule:', result.scheduledMatchUpIds);
console.log('Would not fit:', result.noTimeMatchUpIds);

See: Automated Scheduling Concepts for algorithm details.


scheduleProfileRounds​

Auto-schedules all rounds specified in a scheduling profile across multiple days and venues. Uses a pre-defined profile that maps rounds to specific dates and venues.

engine.scheduleProfileRounds({
// Scheduling parameters
periodLength, // optional - scheduling block size (default: 30)

// Date selection
scheduleDates, // optional - specific dates to schedule
clearScheduleDates, // optional - boolean or array of dates to clear first

// Court selection
courtIds, // optional array - restrict scheduling to only these courts

// Execution control
dryRun, // optional boolean - preview without changes
pro, // optional boolean - use grid scheduling instead of Garman
checkPotentialRequestConflicts, // optional boolean (default: true)

// Daily-limit accounting (apply to already-scheduled matchUps that contribute
// to per-participant per-day counters)
excludeNoDateCompleted, // optional boolean (default: true) - skip COMPLETED/BYE
// matchUps that carry no scheduledDate
excludePriorDates, // optional boolean (default: true) - skip matchUps whose
// scheduledDate is strictly before the date being scheduled
});

Daily-limit accounting with excludeNoDateCompleted / excludePriorDates:

The Day Plan defines what is being scheduled today. When the scheduler reconciles already-scheduled matchUps against the per-participant daily limits in the scheduling policy, historical or orphan matchUps should not consume today's budget:

  • excludeNoDateCompleted (default true) drops COMPLETED and BYE matchUps that carry no scheduledDate. These represent finished play that was never anchored to a specific day; counting them would block legitimate scheduling for the day in question.
  • excludePriorDates (default true) drops matchUps whose scheduledDate is strictly before the date being scheduled. This is a defensive filter: in normal flow same-day filtering already excludes them, but the flag guarantees historical records never leak into a future day's counters.

Set either flag to false to restore the pre-defaults legacy semantics where every matchUp in the already-scheduled set consumes its participants' daily-limit budget regardless of status or date.

Court filtering with courtIds:

When courtIds is provided, the scheduler only considers those courts as available capacity. Useful when an operator wants auto-scheduling to operate against a subset of courts (e.g. when some courts are reserved for other purposes, or when scheduling is being applied per-court). Passing an empty array means "no courts are available" — the scheduler will run but place no matchUps. Omitting the parameter (the default) considers all enabled courts at the profile's venues.

In pro: true mode, matchUps will receive courtId assignments only from the filtered set. In default (Garman) mode, the filter constrains capacity but final court assignment may still be deferred to play time.

Returns:

{
(scheduledDates, // array - dates where matchUps were scheduled
scheduledMatchUpIds, // array - matchUpIds that were scheduled
noTimeMatchUpIds, // array - matchUps that couldn't fit
overLimitMatchUpIds, // array - matchUps exceeding participant limits
requestConflicts); // array - participant request conflicts
}

scheduleProfileRounds Examples​

Schedule All Profile Dates:

const result = engine.scheduleProfileRounds({
periodLength: 30,
});

console.log('Scheduled dates:', result.scheduledDates);
console.log('No time for:', result.noTimeMatchUpIds.length, 'matchUps');

Schedule Specific Dates:

engine.scheduleProfileRounds({
scheduleDates: ['2024-06-15', '2024-06-16'],
periodLength: 30,
});

Clear and Reschedule:

engine.scheduleProfileRounds({
clearScheduleDates: true, // Clear all dates
periodLength: 30,
});

// Or clear specific dates
engine.scheduleProfileRounds({
clearScheduleDates: ['2024-06-15', '2024-06-16'],
scheduleDates: ['2024-06-15', '2024-06-16'],
periodLength: 30,
});

Dry Run Preview:

const result = engine.scheduleProfileRounds({
dryRun: true,
});

console.log('Would schedule:', result.scheduledMatchUpIds.length, 'matchUps');
console.log('Request conflicts:', result.requestConflicts);

Professional Grid Scheduling:

engine.scheduleProfileRounds({
pro: true, // Use grid scheduling
periodLength: 30,
});
note

SINGLES and DOUBLES matchUps are scheduled automatically. TEAM matchUps require manual court allocation using allocateTeamMatchUpCourts().

See: Scheduling Profile and Pro Scheduling for details.


scheduleProfileGrid​

Profile-driven grid scheduling. Uses the scheduling profile to determine which rounds go on which dates at which venues, then places matchUps onto court grid positions (courtOrder) without assigning times. Used when an operator wants to manually assign times after the grid placement step.

engine.scheduleProfileGrid({
scheduleDates, // optional - specific dates to schedule
clearScheduleDates, // optional - boolean or array of dates to clear first
scheduleCompletedMatchUps, // optional boolean (default: false) - include
// already-COMPLETED matchUps in the placement pool
matchUpDailyLimits, // optional - { SINGLES, DOUBLES, total } per-participant
// daily limits; when omitted, no enforcement
minCourtGridRows, // optional number - rows per court (default: 10)
courtIds, // optional array - restrict placement to only these courts
});

When courtIds is provided, only courts in the set are considered as placement targets at each venue. Passing an empty array places nothing. Omitting the parameter places onto all courts at the profile's venues (the default).

Completed matchUps: By default the pro scheduler excludes matchUps that already carry a terminal status (COMPLETED, RETIRED, WALKOVER, DEFAULTED, etc.) from the placement pool — these don't need a court slot and would otherwise occupy grid rows, pushing newly-placed matchUps down. Set scheduleCompletedMatchUps: true only for callers (such as mocksEngine seeding) that intentionally want completed matchUps to receive grid coordinates.

Daily-limit enforcement (opt-in via matchUpDailyLimits): When provided, the pro scheduler refuses to place a matchUp whose entered participants or winner-advancement potentials would push any single participant past the per-day cap for the matchUp type or for the daily total. Counters are seeded from matchUps already on the grid for the date so subsequent runs see total daily load. Refused matchUps are reported in overLimitMatchUpIds, NOT in notScheduledMatchUpIds. Existing direct callers that omit matchUpDailyLimits see no behavior change.

Returns:

{
scheduledDates, // array - dates where matchUps were placed
scheduledMatchUpIds, // record<date, matchUpIds[]> - matchUps placed per date
notScheduledMatchUpIds, // record<date, matchUpIds[]> - matchUps that didn't fit per date
overLimitMatchUpIds, // record<date, matchUpIds[]> - matchUps rejected by daily limits
// (populated only when matchUpDailyLimits was provided)
}

Bulk Operations​

bulkScheduleMatchUps​

Schedules multiple matchUps at once with provided scheduling details.

engine.bulkScheduleMatchUps({
schedule, // required - array of schedule objects
});

bulkScheduleTournamentMatchUps​

Efficiently schedules multiple matchUps with identical or varying schedule details.

engine.bulkScheduleTournamentMatchUps({
// When all matchUps have same schedule
matchUpIds, // array of matchUpIds
schedule, // schedule object { scheduledDate, scheduledTime, venueId, courtId }

// When matchUps have different schedules
matchUpDetails, // array of { matchUpId, schedule }

// Validation and control
checkChronology, // optional boolean - warn on scheduling errors
errorOnAnachronism, // optional boolean - throw error on chronological errors
removePriorValues, // optional boolean - clear existing scheduling timeItems
});

bulkScheduleTournamentMatchUps Examples​

Same Schedule for All:

const schedule = {
scheduledDate: '2024-06-15',
scheduledTime: '08:00',
venueId: 'venue-1',
};

engine.bulkScheduleTournamentMatchUps({
matchUpIds: ['match-1', 'match-2', 'match-3'],
schedule,
});

Different Schedules:

const matchUpDetails = [
{
matchUpId: 'match-1',
schedule: {
scheduledDate: '2024-06-15',
scheduledTime: '08:00',
venueId: 'venue-1',
courtId: 'court-1',
},
},
{
matchUpId: 'match-2',
schedule: {
scheduledDate: '2024-06-15',
scheduledTime: '09:30',
venueId: 'venue-1',
courtId: 'court-2',
},
},
];

engine.bulkScheduleTournamentMatchUps({
matchUpDetails,
checkChronology: true,
errorOnAnachronism: true,
});

Replace All Schedules:

engine.bulkScheduleTournamentMatchUps({
matchUpIds,
schedule,
removePriorValues: true, // Clear previous scheduling
});

bulkRescheduleMatchUps​

Shifts scheduled matchUps by a specified number of days and/or minutes. Useful for weather delays or venue changes.

const {
rescheduled, // array of inContext matchUps that were rescheduled
notRescheduled, // array of inContext matchUps that were NOT rescheduled
allRescheduled, // boolean - true if all matchUps rescheduled
dryRun, // boolean - indicates if this was a dry run
} = engine.bulkRescheduleMatchUps({
matchUpIds, // required - array of matchUpIds to reschedule
scheduleChange: {
daysChange: number, // number of days +/- to shift
minutesChange: number, // number of minutes +/- to shift
},
dryRun, // optional boolean - preview without changes
});

bulkRescheduleMatchUps Examples​

Delay by One Day:

const result = engine.bulkRescheduleMatchUps({
matchUpIds: ['match-1', 'match-2'],
scheduleChange: {
daysChange: 1, // Move forward one day
},
});

console.log('Rescheduled:', result.rescheduled.length);
console.log('Failed:', result.notRescheduled.length);

Shift Start Times:

engine.bulkRescheduleMatchUps({
matchUpIds,
scheduleChange: {
minutesChange: 30, // Start 30 minutes later
},
});

Weather Delay:

// Rain delay - move all to next day, 2 hours earlier start
engine.bulkRescheduleMatchUps({
matchUpIds,
scheduleChange: {
daysChange: 1,
minutesChange: -120, // 2 hours earlier
},
});

Dry Run Preview:

const result = engine.bulkRescheduleMatchUps({
matchUpIds,
scheduleChange: { daysChange: 1 },
dryRun: true,
});

console.log('Would reschedule:', result.rescheduled.length);
console.log('Would fail:', result.notRescheduled.length);

bulkUpdatePublishedEventIds​

Returns filtered array of publishedEventIds from all eventIds included in a bulk matchUp status update. Useful for determining which events need re-publishing after bulk scoring.

const { publishedEventIds } = engine.bulkUpdatePublishedEventIds({
outcomes, // array of matchUp outcomes
});

// Re-publish affected events
publishedEventIds.forEach((eventId) => {
engine.publishEvent({ eventId });
});

Use Case: After bulk scoring at end of day, identify and republish only the affected published events rather than all events.


bulkUpdateCourtAssignments​

Updates court assignments for multiple matchUps at once.

engine.bulkUpdateCourtAssignments({
courtAssignments, // required - array of {matchUpId, courtId}
});

Clearing and Removing Schedules​

clearMatchUpSchedule​

Clears schedule information from a specific matchUp.

engine.clearMatchUpSchedule({
matchUpId, // required
drawId, // optional - optimizes lookup, triggers draw modification notice
scheduleAttributes, // optional - array of specific attributes to clear
});

clearMatchUpSchedule Examples​

Clear All Schedule Attributes:

engine.clearMatchUpSchedule({
matchUpId: 'match-123',
drawId: 'draw-456',
});

Clear Specific Attributes:

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

engine.clearMatchUpSchedule({
matchUpId: 'match-123',
scheduleAttributes: [scheduleConstants.SCHEDULED_TIME, scheduleConstants.SCHEDULED_DATE],
});

clearScheduledMatchUps​

Clears schedules from multiple matchUps based on criteria.

engine.clearScheduledMatchUps({
scheduledDates, // optional - array of dates to clear
venueIds, // optional - array of venueIds to clear
scheduleAttributes, // optional - which attributes to clear
ignoreMatchUpStatuses, // optional - matchUp statuses to skip
});

clearScheduledMatchUps Examples​

Clear Specific Date:

engine.clearScheduledMatchUps({
scheduledDates: ['2024-06-15'],
});

Clear Specific Venue:

engine.clearScheduledMatchUps({
venueIds: ['venue-1'],
});

Clear Only Times (Keep Dates):

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

engine.clearScheduledMatchUps({
scheduledDates: ['2024-06-15'],
scheduleAttributes: [scheduleConstants.SCHEDULED_TIME],
});

Skip Completed MatchUps:

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

engine.clearScheduledMatchUps({
scheduledDates: ['2024-06-15'],
ignoreMatchUpStatuses: [
matchUpStatusConstants.COMPLETED,
matchUpStatusConstants.RETIRED,
matchUpStatusConstants.DEFAULTED,
],
});

calculateScheduleTimes​

Calculates scheduling times based on match duration and court availability.

const { times } = engine.calculateScheduleTimes({
scheduleDate, // required
matchUpIds, // required
venueIds, // optional
});

isScheduleLocked​

Whether a matchUp's placement is pinned by a director. Accepts the matchUp either way round — { matchUpId, drawId } when only ids are in hand, or { matchUp } when it isn't (the cheap form: a table rendering hundreds of rows should not resolve each one by id).

Returns the lock alongside the verdict so a caller can show why a matchUp is pinned without a second lookup. scheduleLocked is false — with lock still returned — when a lock exists but is inert: the matchUp completed, or there is no placement left to guard. See Schedule Locks.

engine.isScheduleLocked({ matchUpId, drawId });
// → { success: true, scheduleLocked: true, lock: { reason: 'featured' } }

engine.isScheduleLocked({ matchUp });
engine.isScheduleLocked({ matchUp, attributes: ['courtId'] }); // narrow to one placement field

scheduleGovernor.matchUpScheduleLocked({ matchUp }) is the bare predicate behind it — a plain boolean with no result envelope, used by the factory's own enforcement. Consumers should prefer isScheduleLocked.


courtGridRows​

Returns court grid data for pro scheduling visualization.

const { rows } = engine.courtGridRows({
scheduleDate, // required
});

findMatchUpFormatTiming​

Finds format timing for a specific matchUp format.

const { timing } = engine.findMatchUpFormatTiming({
matchUpFormat, // required
});

findVenue​

Finds a venue by ID with full details.

const { venue } = engine.findVenue({
venueId, // required
});

generateBookings​

Generates booking objects for scheduled matchUps.

const { bookings } = engine.generateBookings({
scheduleDate, // required
});

generateVirtualCourts​

Creates virtual courts for scheduling simulation.

const { courts } = engine.generateVirtualCourts({
venueId, // required
courtsCount, // required
});

getMatchUpsToSchedule​

Returns matchUps that are ready to be scheduled.

const { matchUps } = engine.getMatchUpsToSchedule({
eventId, // optional
drawId, // optional
});

getPersonRequests​

Returns scheduling requests for persons (officials, participants).

const { requests } = engine.getPersonRequests({
personId, // optional - filter by person
});

getProfileRounds​

Returns rounds from a scheduling profile.

const { rounds } = engine.getProfileRounds({
scheduleDate, // optional - filter by date
});

getScheduledRoundsDetails​

Returns detailed information about scheduled rounds.

const { details } = engine.getScheduledRoundsDetails();

Since 7.7.0 a round that cannot be detailed is listed in an additive roundErrors array and its position in the rounds holds undefined, so the other rounds still schedule.

getSchedulingProfile​

Returns the current scheduling profile.

const { schedulingProfile } = engine.getSchedulingProfile();

getSchedulingProfileIssues​

Validates scheduling profile and returns any issues.

const { issues } = engine.getSchedulingProfileIssues({
schedulingProfile, // optional - validate specific profile
});

Schedule Modifications​

matchUpScheduleChange​

Swaps the schedule details of two scheduled matchUps. Useful for manual adjustments in schedule grid interfaces.

engine.matchUpScheduleChange({
courtDayDate, // required - date string
sourceMatchUpContextIds, // required - source matchUp context
targetMatchUpContextIds, // required - target matchUp context
sourceCourtId, // optional - source court
targetCourtId, // optional - target court
});

Example:

engine.matchUpScheduleChange({
courtDayDate: '2024-06-15',
sourceMatchUpContextIds: {
tournamentId: 'tournament-1',
drawId: 'draw-1',
matchUpId: 'match-1',
},
targetMatchUpContextIds: {
tournamentId: 'tournament-1',
drawId: 'draw-1',
matchUpId: 'match-2',
},
sourceCourtId: 'court-1',
targetCourtId: 'court-2',
});

Use Case: Drag-and-drop schedule interfaces where matchUps are swapped between time slots or courts.


modifyMatchUpFormatTiming​

Updates format timing parameters for scheduling.

engine.modifyMatchUpFormatTiming({
matchUpFormat, // required
averageMinutes, // optional - average duration
recoveryMinutes, // optional - recovery time
});

proAutoSchedule​

Professional auto-scheduling with grid-based court assignment.

engine.proAutoSchedule({
scheduleDate, // required
venueIds, // optional
});

proConflicts​

Detects scheduling conflicts in pro grid scheduling.

const { conflicts } = engine.proConflicts({
scheduleDate, // required
});

proColumnResolve​

Court-preserving, scheduledTime-preserving conflict resolver. Where proAutoSchedule may reassign courts, proColumnResolve re-lays the grid vertically only: a matchUp never changes its courtId or its scheduledTime — only its courtOrder (row) changes, and blank rows are inserted to space colliding matchUps onto different rows.

It removes cross-column participant collisions — the same participant, or the same potential participant (winner advancement), appearing in two cells of the same row — by folding both CONFLICT_PARTICIPANTS and CONFLICT_POTENTIAL_PARTICIPANTS into each matchUp's deep dependency participant set.

Per-column ordering: completed matchUps are anchored at the top (in play order), in-progress beneath them, and to-be-played below in scheduledTime order (which also repairs a director's time-inversions). Every dependency source is placed in a strictly earlier row than the match it feeds.

const { resolved, unresolvable } = engine.proColumnResolve({
scheduledDate, // required
matchUps, // required — { inContext: true, nextMatchUps: true }
courtIds, // optional — restrict to specific courts
});

Returns:

  • resolved — matchUps whose courtOrder changed: { matchUpId, courtId, from, to }
  • unresolvable — matchUps that cannot be deconflicted by spacing, each with a reason:
    • chronology — a director scheduled a feeder at a later clock time than the match it feeds (physically impossible; times are never changed)
    • orderingDeadlock — a same-column ordering cycle that had to be broken to make progress

Confirm the result by re-running proConflicts — every normally-placed row is conflict-free by construction.


publicFindCourt​

Finds a court with privacy policies applied for public APIs.

const { court } = engine.publicFindCourt({
courtId, // required
policyDefinitions, // optional
});

removeCourtGridBooking​

Removes a booking from the court grid.

engine.removeCourtGridBooking({
bookingId, // required
});

removeEventMatchUpFormatTiming​

Removes custom format timing for an event.

engine.removeEventMatchUpFormatTiming({
eventId, // required
});

reorderUpcomingMatchUps​

Reorders upcoming matchUps on a court, affecting their order of play.

engine.reorderUpcomingMatchUps({
matchUpContextIds, // required - array of matchUp context objects
firstToLast, // optional boolean - direction of reorder
});

Example:

const matchUpContextIds = [
{ tournamentId: 't1', drawId: 'd1', matchUpId: 'm1' },
{ tournamentId: 't1', drawId: 'd1', matchUpId: 'm2' },
{ tournamentId: 't1', drawId: 'd1', matchUpId: 'm3' },
];

engine.reorderUpcomingMatchUps({
matchUpContextIds,
firstToLast: true, // Move first to last
});

removeMatchUpCourtAssignment​

Removes court assignment from a matchUp while preserving other schedule details.

engine.removeMatchUpCourtAssignment({
tournamentId, // optional - for multi-tournament scenarios
courtDayDate, // required - date string
matchUpId, // required
drawId, // required
});

Example:

engine.removeMatchUpCourtAssignment({
courtDayDate: '2024-06-15',
matchUpId: 'match-123',
drawId: 'draw-456',
});

Use Case: Remove court assignment while keeping date/time (e.g., court becomes unavailable, need to reassign).


Team Match Scheduling​

allocateTeamMatchUpCourts​

Allocates courts to individual matchUps within a TEAM matchUp (tie). Used for team competitions where multiple singles/doubles matches occur simultaneously.

engine.allocateTeamMatchUpCourts({
matchUpId, // required - team matchUp ID
drawId, // required
courtIds, // required - array of courtIds to allocate
removePriorValues, // optional boolean - clear previous allocations
});

Example:

// Team match with 4 singles and 2 doubles
engine.allocateTeamMatchUpCourts({
matchUpId: 'team-match-1',
drawId: 'draw-456',
courtIds: ['court-1', 'court-2', 'court-3', 'court-4'],
});

Use Case: Davis Cup or Fed Cup style ties where multiple matches play simultaneously on different courts.


Scheduling Profile Management​

setSchedulingProfile​

Stores a scheduling profile that defines which rounds are scheduled on which dates and venues. Used by scheduleProfileRounds().

engine.setSchedulingProfile({
schedulingProfile, // required - profile object
});

Profile Structure:

const schedulingProfile = [
{
scheduleDate: '2024-06-15',
venues: [
{
venueId: 'venue-1',
rounds: [
{
drawId: 'draw-1',
structureId: 'structure-1',
roundNumber: 1,
},
{
drawId: 'draw-2',
structureId: 'structure-2',
roundNumber: 1,
},
],
},
],
},
{
scheduleDate: '2024-06-16',
venues: [
{
venueId: 'venue-1',
rounds: [
{
drawId: 'draw-1',
structureId: 'structure-1',
roundNumber: 2,
},
],
},
],
},
];

engine.setSchedulingProfile({ schedulingProfile });

See: Scheduling Profile Concepts for detailed profile structure and creation.


Schedule Scenario Methods​

Named alternate ("contingency") scheduling plans stored first-class on tournamentRecord.scheduling.scenarios. See Schedule Scenarios for the full concept.

addScheduleScenario​

Creates an alternate scheduling plan. scenarioName is required; scenarioId is generated when omitted. The scenario is validated before it is stored.

const { scenarioId } = engine.addScheduleScenario({
scenario: {
scenarioName, // required
scheduledDates, // optional - ISO 'YYYY-MM-DD' dates the plan re-plans
placements, // ScenarioPlacement[] — a bulkScheduleMatchUps matchUpDetails payload
},
});

getScheduleScenarios / getScheduleScenario​

Reads stored scenarios.

engine.getScheduleScenarios(); // → { scenarios: ScheduleScenario[] }
engine.getScheduleScenario({ scenarioId }); // → { scenario } | { error }

updateScheduleScenario​

Merges updates over an existing scenario (preserving scenarioId) and re-validates.

engine.updateScheduleScenario({
scenarioId, // required
updates, // Partial<ScheduleScenario>
});

removeScheduleScenario​

engine.removeScheduleScenario({ scenarioId });

applyScheduleScenario​

Commits a scenario's placements as the official schedule via bulkScheduleMatchUps. Skips completed matchUps by default; removePriorValues defaults to true. The scenario is left in place after commit.

const result = engine.applyScheduleScenario({
scenarioId, // required
removePriorValues, // optional - default true
scheduleCompletedMatchUps, // optional - default false
});
// → { success: true, applied: <matchUps scheduled>, ... }

validateScheduleScenario​

Shape + light referential validation for a scenario object (used internally by add/update).

engine.validateScheduleScenario({ scenario }); // → { valid: boolean, error?, info? }

getScenarioScheduleProjection​

The unofficial "Plan mode" overlay — the official schedule with a scenario's placements laid on top, without writing to any matchUp. Cells are tagged official / planned; conflicts come from mergeFacilitySchedule.

const { scheduleCells, grid, conflicts, plannedMatchUpIds, skippedCompletedMatchUpIds } =
engine.getScenarioScheduleProjection({
scenarioId, // required
venueIds, // optional - restrict to these venues
});

getScheduleScenarioStatus​

Reconciles a scenario against current state so a client can alert when a plan is out of date and preview what a commit would do.

engine.getScheduleScenarioStatus({ scenarioId });
// → { outOfDate, currentHash, basedOnHash, completedMatchUpIds, missingMatchUpIds, applicableMatchUpIds }

getScenarioScheduleView​

Grid-ready projection for a client "Plan mode" — returns the same shape as competitionScheduleMatchUps (dateMatchUps / rows / courtsData / courtPrefix) with the scenario's placements laid on top, so the client renders the plan through its existing grid path. The overlay is applied to a throwaway deep copy; the real records / engine state are never mutated.

const view = engine.getScenarioScheduleView({
scenarioId, // required
matchUpFilters, // e.g. { scheduledDate }
withCourtGridRows, // default true
minCourtGridRows,
});
// → { dateMatchUps, rows, courtsData, courtPrefix, plannedMatchUpIds, skippedCompletedMatchUpIds, ... }

rebaseScheduleScenario​

Re-anchors a scenario's drift baseline (basedOnHash) to the official schedule as it stands now — the explicit "I've reconciled, this plan is current" action.

engine.rebaseScheduleScenario({ scenarioId });

setMatchUpDailyLimits​

Sets daily limits for participant matchUp participation.

engine.setMatchUpDailyLimits({
dailyLimits, // required - limits object
});

Example:

engine.setMatchUpDailyLimits({
dailyLimits: {
SINGLES: 2,
DOUBLES: 1,
total: 2,
},
});

setMatchUpHomeParticipantId​

Designates a home participant for a matchUp (for home/away displays).

engine.setMatchUpHomeParticipantId({
matchUpId, // required
drawId, // required
participantId, // required
});

toggleParticipantCheckInState​

Toggles participant check-in status for scheduling.

engine.toggleParticipantCheckInState({
participantId, // required
});

validateSchedulingProfile​

Validates a scheduling profile for errors or conflicts.

const { valid, errors } = engine.validateSchedulingProfile({
schedulingProfile, // required
});

addMatchUpScheduleItems​

Adds multiple schedule attributes to a matchUp in a single call. This is the method used internally by setMatchUpStatus when a schedule object is provided, and is the most efficient way to set date, time, court, and venue together.

engine.addMatchUpScheduleItems({
matchUpId, // required — target matchUp
drawId, // required — resolved to drawDefinition by engine
schedule: {
// required — schedule attributes to set
scheduledDate, // optional — 'YYYY-MM-DD'
scheduledTime, // optional — 'HH:mm' or ISO datetime
startTime, // optional — actual start time
endTime, // optional — actual end time
resumeTime, // optional — resume after suspension
stopTime, // optional — suspension time
courtId, // optional — assigned court
venueId, // optional — assigned venue
courtOrder, // optional — order on court
homeParticipantId, // optional — home team participant
calledAt, // optional — ISO instant; call to court. `null` clears
courtIds, // optional — for TEAM matchUps, allocate courts
allocatedCourts, // optional — alias for courtIds; accepts the read-side shape
},
removePriorValues, // optional boolean — clear existing schedule timeItems first
checkChronology, // optional boolean — defaults to true; validate time ordering
errorOnAnachronism, // optional boolean — return error on chronological violations
errorOnUnknownAttributes, // optional boolean — error instead of warning on attributes that will not be written
proConflictDetection, // optional boolean — detect pro scheduling conflicts
disableNotice, // optional boolean — suppress modification notices
});

calledAt​

calledAt is an actual-play attribute and sits with startTime / stopTime / resumeTime / endTime — none of which a schedule lock guards, so a pinned matchUp can still be called to court, started, suspended and completed.

Its undefined handling differs from setMatchUpCalledAt deliberately. Called directly, that method reads undefined as clear. Here an omitted key destructures to undefined too, so honouring that reading would make every partial schedule write silently wipe a call-to-court:

engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { calledAt: isoInstant } });

// later, a re-time that says nothing about calledAt — the call survives
engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { scheduledTime: '15:00' } });

// explicit clear
engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { calledAt: null } });

Attributes that will not be written are reported​

The method writes the attributes it recognises and, before 6.32.0, silently ignored everything else while still returning { success: true }. Two real attributes were lost that way — allocatedCourts (see below) and calledAt.

Incoming keys are now sorted three ways:

bucketattributesbehaviour
writtenscheduledDate, scheduledTime, startTime, stopTime, resumeTime, endTime, calledAt, courtId, courtIds, allocatedCourts, venueId, courtOrder, courtAnnotation, timeModifiers, homeParticipantIdapplied
derivedisoDateString, milliseconds, time, venueName, venueAbbreviation, courtName, averageMinutes, recoveryMinutes, timeAfterRecovery, typeChangeRecoveryMinutes, typeChangeTimeAfterRecovery, endDateignored silently
anything elselock, official, scorekeeper, timekeeper, scoredTime, misspellingsreturned in warnings

The derived bucket exists so that read-modify-write keeps working: a hydrated schedule carries a dozen keys the caller cannot omit and cannot influence, and warning about those would make every round-trip noisy.

const { warnings } = engine.addMatchUpScheduleItems({
matchUpId,
drawId,
schedule: { scheduledDate, official: personId },
});
// warnings → [{ code: 'UNWRITABLE_SCHEDULE_ATTRIBUTES', attributes: ['official'] }]

lock is in the reported bucket on purpose — silently discarding a director's pin is the worst of these to discover later. Assign officials through the officiating governor and pin with setMatchUpScheduleLock.

Warnings rather than errors, because callers round-trip locked and scored matchUps today. Pass errorOnUnknownAttributes: true to make it a hard error instead — the same escalation errorOnAnachronism provides for chronology:

const result = engine.addMatchUpScheduleItems({
matchUpId,
drawId,
errorOnUnknownAttributes: true,
schedule: { scheduledDate, nonsense: true },
});
// result.error → UNWRITABLE_SCHEDULE_ATTRIBUTES; nothing is written

Court allocation: courtIds in, allocatedCourts out​

A TEAM matchUp's court allocation is written as bare courtIds and read back as schedule.allocatedCourts — court objects ({ courtId, venueId }, hydrated with courtName / venueName). Because the write path originally destructured only courtIds, a schedule object read off one matchUp and applied to another silently carried no allocation: the unrecognised key was ignored, with no error and no warning.

allocatedCourts is now accepted as an alias, in either shape:

engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { allocatedCourts: [courtIdA, courtIdB] } });
engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { ...anotherMatchUp.schedule } }); // round-trips

An explicit courtIds wins when a caller supplies both. A non-array allocatedCourts is ignored rather than treated as an error, since it cannot have come from a read.

Grid position is cleared on a date change​

courtOrder, courtId, and venueId describe a matchUp's position on one specific day's schedule grid. When a call changes scheduledDate to a different day and does not supply an explicit courtOrder/courtId/venueId in the same schedule object, the engine clears those stale grid-position attributes so the matchUp does not inherit the prior day's row on its new day.

  • A date-only re-date (e.g. schedule: { scheduledDate, scheduledTime }) returns the matchUp to the unplaced pool for the new day — it keeps its date and time but is no longer pinned to a court or row.
  • Re-applying the same date leaves the existing grid position untouched.
  • Supplying an explicit courtOrder (and/or courtId/venueId) alongside the new date is honored verbatim — a deliberate "move to a new day and row" is respected.

This also governs bulkScheduleMatchUps, which delegates to addMatchUpScheduleItems per matchUp. The lower-level addMatchUpScheduledDate primitive does not clear grid position; use addMatchUpScheduleItems (the path TMX and integrations use) for date changes.

Assigning a BYE preserves scheduling​

A tournament director may schedule an entire event and then swap participants around, placing byes temporarily or permanently. The engine therefore never discards scheduling on its own: a matchUp that becomes a BYE keeps its court, courtOrder and times.

assignDrawPositionBye (and setMatchUpStatus with matchUpStatus: 'BYE') accepts preserveScheduling:

valuebehaviour
truekeep the placement
falserelease allocatedCourts, courtId, venueId, courtAnnotation, courtOrder, scheduledDate, scheduledTime, timeModifiers — on both matchUp.schedule and legacy timeItems. Actual-play timestamps (startTime / stopTime / resumeTime / endTime) are never touched
undefinedpreserve — except on an operator position-action against a matchUp that already holds scheduling, which returns MATCHUP_HAS_SCHEDULING
engine.assignDrawPositionBye({ drawId, structureId, drawPosition, isPositionAction: true });
// → { error: MATCHUP_HAS_SCHEDULING } when that drawPosition's matchUp holds a court or a time

engine.assignDrawPositionBye({ drawId, structureId, drawPosition, preserveScheduling: false });
// → { success: true }, court released

The refusal is deliberately scoped to isPositionAction — the flag the engine's own positionActions payload carries, i.e. an action an operator chose. Engine-internal callers (directLoser while a score is being entered, doubleExitAdvancement, positionSwap, draw generation, and this function's own BYE cascade) never receive it and take the preserving default, so entering a score can never hard-fail because the draw happened to be scheduled. An explicit preserveScheduling: false propagates through the cascade: one operator decision covers every matchUp that BYE reaches.

A BYE that holds a court is shown, not hidden​

BYE matchUps are excluded from competitionScheduleMatchUps by default. Pass courtByeMatchUps: true to include the ones holding a courtId, so they occupy a cell on the schedule grid. proConflicts annotates them CONFLICT_BYE_SCHEDULED at SCHEDULE_WARNING severity — a real double-booking or participant conflict on the same matchUp still outranks it.

This is what keeps a preserved placement honest. Before it, a byed matchUp kept its court and disappeared from every schedule surface at once: the slot read as free, the next matchUp was dropped onto it, and proConflicts reported a courtDoubleBooking naming a partner that had no cell to click through to (production, 2026-08-22). Making the occupant visible — rather than deleting the director's placement — is the fix.

Byes holding only a date/time occupy no cell and are not included; the WARNING tracks court occupancy specifically.

A produced exit holding a court is treated the same way​

An exit the cascade produces — a WALKOVER or DEFAULTED stamped onto a side by an upstream double exit, with nobody arriving to contest it — will never be played either, and its placement is preserved for the same reason. Since 7.5.0 it is shown the same way: courtByeMatchUps: true includes a court-holding produced exit beside the byes, and proConflicts annotates it CONFLICT_EXIT_SCHEDULED at SCHEDULE_WARNING — a code of its own, so a client can offer "release" against a walkover and "re-seat" against a BYE. A walkover a director recorded between two participants is not in this set: that is the director's own act.

The mutation that preserved a placement says so​

setMatchUpStatus returns, on success, a warnings array when the call left a BYE or a produced exit holding a court or a time:

{
success: true,
warnings: [{ code: 'SCHEDULE_PRESERVED_ON_EXIT', matchUpIds: ['...', '...'] }],
}

The draw is right — the placement was preserved — but the client that just made the mutation is the one that can offer "release these slots?", which maps to the same preserveScheduling: false the operator's BYE action takes. The read-side flags above tell a scheduler view; the payload tells the scorer. Only matchUps that became unplayable in that call are named, so a BYE reported when it was placed is not repeated on every later score. executionQueue passes the field through unchanged.


setMatchUpCalledAt​

Sets or clears matchUp.schedule.calledAt — the ISO instant captured when a tournament director deliberately places a matchUp on the TMX active strip, signalling that it is imminent.

engine.setMatchUpCalledAt({
matchUpId, // required
drawId, // required
calledAt, // ISO timestamp; `null` or `undefined` CLEARS the previous value
disableNotice, // optional boolean
});

Called directly, undefined reads as clear. That differs deliberately from addMatchUpScheduleItems, where an omitted key must not wipe a call to court.

A calledAt before the tournament starts is refused​

Calling a match to court is a physical act at the venue, so it cannot happen before the venue opens. addMatchUpScheduledDate already refuses a scheduledDate outside the tournament's range; a calledAt predating startDate is the same class of impossibility and was previously accepted without complaint.

The check needs a time zone to be correct. startDate is a bare venue-local calendar day while calledAt is a UTC instant, and comparing them without a zone compares two different things. In Sydney (UTC+10) a 09:00 call on opening day is 23:00 UTC on the previous day, so a naive UTC-day comparison would reject a perfectly legitimate call — the opposite of the failure being fixed, and worse, because it blocks the running desk.

So the venue's zone resolves the instant to its real local day, via the same localTimeZone the rest of the venue time frame already depends on.

Without a resolvable zone the guard weakens rather than guesses. Many tournaments carry neither a localTimeZone nor a venue address, and there is then no exact answer to "which local day was that?" — only a bound. The furthest-ahead zone in use is UTC+14 (Kiritimati), so local midnight opening the tournament can be no earlier than startDate 00:00 UTC − 14h:

  • an instant before that bound is before the start in every zone that exists, and is refused;
  • an instant after it is opening day somewhere, and is admitted.

That is the most the check can soundly say without a zone. It is deliberately weaker than the zoned form — an evening-before call in New York is caught only when the tournament names its zone — because refusing a real call stops play, which is by far the worse of the two failures.

endDate is not guarded. A match called near midnight on the final day legitimately runs past it, and the last day's play routinely spills over; there is no equivalent impossibility on that side.


Auto-captured scoredTime​

matchUp.schedule.scoredTime is a first-class ISO-8601 timestamp that the engine captures automatically the first time a matchUp becomes scored — that is, when a score with value, a winningSide, or a completed matchUpStatus is applied via setMatchUpStatus. No caller action is required.

Behavior:

  • Captured once. The first scored mutation stamps the timestamp; later score corrections preserve the original value (it records when the result first entered the system, not the latest edit).
  • Cleared on removal. If the score is removed (the matchUp is reset to TO_BE_PLAYED), scoredTime is deleted, so a subsequent re-score gets a fresh stamp.
  • Proxy for completion time. It is a lightweight stand-in for "when did this match actually finish" when no explicit END_TIME time-item was recorded — useful for analytics on tournament-director behaviour (how promptly results are entered). An actual endTime, when present, supersedes it.
  • No legacy mirror. Like calledAt, scoredTime is a CODES-native schedule attribute with no legacy time-item equivalent — it is always read from and written to matchUp.schedule.scoredTime.

This is engine-generated state, not an input: it is treated as read-only by consumers and reflects the engine's own capture, not a value supplied by the caller.


timeModifiers are suppressed once a matchUp's start is settled​

A FOLLOWED_BY or AFTER_REST / NOT_BEFORE annotation is a promise about when a matchUp may begin. Once that question is settled the annotation is not merely redundant — on a published order of play it is misinformation, telling a player and a referee that a match is waiting on something that has already happened.

From 6.32.0, hydration omits schedule.timeModifiers once either of two local signals says the start is settled:

  • Called to court — schedule.calledAt is present. A match that has been called is on court; "followed by" is moot for it whatever else on that court did or did not finish.
  • Any score at all — a single game is enough, and the partial case is the one that matters: that is the state a live match sits in for an hour while the annotation goes on claiming it has not begun. A completed status settles it too, walkovers included.

Suppressed, never cleared​

The stored value is untouched. That is what makes the behaviour reversible without a rule of its own:

engine.addMatchUpScheduleItems({ matchUpId, drawId, schedule: { timeModifiers: ['FOLLOWED_BY'] } });

engine.setMatchUpStatus({ matchUpId, drawId, outcome }); // any score
engine.findMatchUp({ matchUpId, inContext: true }).matchUp.schedule.timeModifiers; // undefined

engine.setMatchUpStatus({ matchUpId, drawId, outcome: { score: undefined, winningSide: undefined } });
engine.findMatchUp({ matchUpId, inContext: true }).matchUp.schedule.timeModifiers; // ['FOLLOWED_BY']

Consequences worth knowing:

  • Read-side only. The write path reads storage directly, so adding and removing modifiers still operates on the real value — a UI that renders annotation controls from a hydrated matchUp will show none on a settled matchUp, which is intended.
  • Subscribers get it for free. Score entry and setMatchUpCalledAt already emit notices, and subscribers receive matchUps through hydration, so no new notice type and no new mutation are involved.
  • Publishing inherits it. Embargo and scheduleVisibilityFilters are applied to the hydrated schedule, downstream of this.
  • The record still carries the value. A TODS export will contain a timeModifiers entry that no longer displays anywhere. That is the price of not destroying an operator's stated intent.

Readiness and rest queries​

Two read-only queries answer, for one matchUp, what conflict reporting answers for a whole day. Both were built in TMX behind a relocation seam and moved here in 7.x, so the payload shapes are unchanged from the client analyses they replace.

getMatchUpReadiness​

Can this placement happen at the time it is scheduled for? Anchored on the matchUp's own scheduledTime; declines to evaluate when there is not one.

const { readiness } = tournamentEngine.getMatchUpReadiness({ matchUpId });
parametertypenotes
matchUpIdstringrequired
matchUpsHydratedMatchUp[]optional; supply hydrated matchUps to avoid a second resolution

Returns { readiness }, either { evaluated: false, reason } — unknownMatchUp, bye, completed, notScheduled, noTime — or { evaluated: true, findings }. Each finding carries a kind of overlap, dependency, recovery or undetermined, a severity, the participants and matchUps involved, and a notBefore clock when the blocker can be projected. Findings are ordered strongest-first.

Since 7.7.0 a dependency finding also carries readyAt: the court-free notBefore plus the winner's recovery, omitted when recovery is zero ("finishes ~15:30 → ready ~16:30"). An overlap is dated by when the neighbour actually went on (startTime, then scheduledTime), and a neighbour nobody played (a WALKOVER or DOUBLE_WALKOVER) projects no recovery window. The payload types are exported by name — ReadinessResult, ReadinessFinding — so a consumer can type against them.

getParticipantRest​

How long has each individual in this matchUp actually had off, and is it enough?

const { rest } = tournamentEngine.getParticipantRest({ matchUpId, asOf: new Date().toISOString() });
parametertypenotes
matchUpIdstringrequired
asOfISO stringrequired — the factory holds no clock; the caller supplies the instant
scheduledDateYYYY-MM-DDthe day to measure; defaults to the matchUp's own
timeZoneIANA zonedefaults to the tournament's localTimeZone; DST-correct
utcOffsetMinutesnumberfixed-offset fallback when no zone is available
matchUpsHydratedMatchUp[]optional; as above

Returns { rest }, either { evaluated: false, reason } — adding noAsOf and noDay to the list above — or { evaluated: true, asOf, scheduledDate, rows }, one row per individual, ordered worst-first. Every row names the ladder rung its anchor came from, so an inferred figure never reads as a measured one.

Since 7.7.0 the result adds one pendingUpstream row per undecided side, built from the feeder that decides it: onCourt, a projected readyAt, a pending:<feederMatchUpId> source and an all-zero load, so a UI can show who is still to come. Both sides undecided gives two rows. nameFor returns a doubles entrant's own name rather than the pair's. RestResult and RestRow are exported by name.