Skip to content

Run of Show Builder โ€‹

One typed metadata tree, one template. The planner builds the evening's program as an ordered list of items (speeches, dinner courses, a band set, open dancing, cake cutting, breaks), and never types a clock time. Each row carries only a duration, and the page computes every row's start and end live from the event's start plus the sum of every duration above it: durations in, clock times out.

The payoff is the amplified-music curfew. Whether a band set or the open dance floor ends before the curfew depends only on what sits above it, so moving it earlier can fix a violation and moving a long dinner course above it can create one. Order changes the clock, and the clock decides what is valid.

What you are looking at

Every section below is rendered from one metadata tree through one neutrally styled template. Clock times are computed, never entered.

Reordering is UI-level, not XSD

The display order and the per-row order value it writes are library behavior layered on the repeatable choice; they have no equivalent in <xs:choice> occurrence semantics. See Choices for the generic mechanics this page specializes. The Inbox Rules Builder example (shipping separately) shows the same repeatable-ordered-choice shape from a different angle: there, order decides which rule fires; here, order decides the clock and whether the program is valid. Neither page is the primary one, they are equals with distinct angles on the same underlying mechanism.

The run of show, live โ€‹

Three editable anchors (event start, amplified-music curfew, venue close) sit above the program. Add items with the buttons below the program card, then reorder them with the up/down arrows on each row: watch the clock chips and the curfew status move with them.

Schedule anchors

Three editable times. Change any one and every row's clock time and the curfew check recompute live, without touching the program's order or content.

Program

Add an item to the end of the list, then reorder with the arrows on each row.

7 of 20
๐ŸŽค
1Speech / toast Amplified 18:00โ€“18:10
๐Ÿฝ๏ธ
2Dinner course18:10โ€“18:55
๐Ÿชฉ
3Open dance floor Amplified 18:55โ€“20:25
๐Ÿง˜
4Break / room turn20:25โ€“20:55
๐Ÿฝ๏ธ
5Dinner course20:55โ€“21:55
Amplified-music curfew ยท 23:00
๐ŸŽธ
6Band / DJ set Amplified 21:55โ€“23:55
โš ๏ธEnds 23:55, 55 minutes after the 23:00 curfew. Move this item earlier, or shorten an item above it.
๐ŸŽ‚
7Cake cutting23:55โ€“00:10
IsDirty: false
Touched: false
Valid: true

// form values:
{
  "schedule": {
    "eventStart": "18:00",
    "curfew": "23:00",
    "venueClose": "01:00"
  },
  "program": {
    "speech": [
      {
        "order": 1,
        "speaker": "Father of the bride",
        "duration": "10",
        "onMic": true
      }
    ],
    "dinner": [
      {
        "order": 2,
        "course": "starter",
        "duration": "45"
      },
      {
        "order": 5,
        "course": "main",
        "duration": "60"
      }
    ],
    "dance": [
      {
        "order": 3,
        "duration": "90",
        "musicStyle": "Disco & pop"
      }
    ],
    "break": [
      {
        "order": 4,
        "duration": "30"
      }
    ],
    "band": [
      {
        "order": 6,
        "act": "The Night Owls",
        "duration": "120"
      }
    ],
    "cake": [
      {
        "order": 7,
        "duration": "15",
        "note": "Sparklers from the staff"
      }
    ]
  }
}
      

One number in the summary panel never moves no matter how you reorder: the program's total length against venue close. Venue-close overflow is a duration total, not an order effect, so reordering alone can never fix it and can never break it either; only adding, removing, or shortening an item changes it.

One metadata tree, one template โ€‹

Everything above is declarative. The engine walks the tree, classifies each node by its shape (a group, a repeatable choice, a leaf), and renders it through the neutral template below. The rest of this page walks through the metadata behind it, the same way the other advanced examples do.

Metadata walkthrough โ€‹

Since this page sets aside: false, there is no "On this page" outline. Use this short list instead:

The repeatable ordered choice โ€‹

The program is a choice with maxOccurs > 1: one occurrence per program item, six branches with genuinely different fields. explicitChoiceSelection means nothing renders until a branch is picked from the add toolbar. displayOrder: 'added' and preserveOrder: true are both required together: the first is what makes the engine's rendered occurrence order sort at all, the second is what gives the reorder buttons a persisted order child to write to. Cake cutting carries an optional note child purely so every branch is an object, which preserveOrder requires:

ts
{
  name: 'program',
  type: 'program',
  fieldOptions: { label: 'Program' },
  description: 'Add an item to the end of the list, then reorder with the arrows on each row.',
  minOccurs: PROGRAM_MIN_OCCURS,
  maxOccurs: PROGRAM_MAX_OCCURS,
  explicitChoiceSelection: true,
  // Both flags are required together: displayOrder: 'added' is what makes the engine's
  // renderedChoiceOccurrences sort at all, and preserveOrder: true is what gives the reorder
  // buttons a persisted `order` child to write to. Either alone leaves the up/down controls
  // writing data the render never reads.
  displayOrder: 'added',
  preserveOrder: true,
  choice: [
    {
      name: 'speech',
      type: 'program',
      fieldOptions: { label: 'Speech / toast' },
      children: [
        { name: 'speaker', fieldOptions: { label: 'Speaker' }, placeholder: 'e.g. Father of the bride' },
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(SPEECH_DURATIONS),
          restriction: { enumeration: SPEECH_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
        { name: 'onMic', type: 'checkbox', minOccurs: 0, fieldOptions: { label: 'On the mic' } },
      ],
    },
    {
      name: 'dinner',
      type: 'program',
      fieldOptions: { label: 'Dinner course' },
      children: [
        {
          name: 'course',
          type: 'select',
          fieldOptions: { label: 'Course' },
          options: COURSE_OPTIONS,
          restriction: { enumeration: COURSE_OPTIONS.map(option => option.key) },
        },
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(DINNER_DURATIONS),
          restriction: { enumeration: DINNER_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
      ],
    },
    {
      name: 'band',
      type: 'program',
      fieldOptions: { label: 'Band / DJ set' },
      description: 'Amplified by definition.',
      children: [
        { name: 'act', fieldOptions: { label: 'Act' }, placeholder: 'e.g. The Night Owls' },
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(BAND_DURATIONS),
          restriction: { enumeration: BAND_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
      ],
    },
    {
      name: 'dance',
      type: 'program',
      fieldOptions: { label: 'Open dance floor' },
      description: 'Amplified by definition.',
      children: [
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(DANCE_DURATIONS),
          restriction: { enumeration: DANCE_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
        { name: 'musicStyle', fieldOptions: { label: 'Music style' }, placeholder: 'e.g. Disco & pop' },
      ],
    },
    {
      name: 'cake',
      type: 'program',
      fieldOptions: { label: 'Cake cutting' },
      children: [
        // A single fixed option is intentional: cake cutting is a short, fixed-length ritual,
        // not a missing increment list.
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(CAKE_DURATIONS),
          restriction: { enumeration: CAKE_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
        // Optional, and the only reason this branch is an object rather than a bare marker:
        // the ordered-choice reorder control needs every branch to carry a real child field.
        { name: 'note', minOccurs: 0, fieldOptions: { label: 'Note' }, placeholder: 'e.g. Sparklers from the staff' },
      ],
    },
    {
      name: 'break',
      type: 'program',
      fieldOptions: { label: 'Break / room turn' },
      children: [
        {
          name: 'duration',
          type: 'select',
          fieldOptions: { label: 'Duration' },
          options: durationOptions(BREAK_DURATIONS),
          restriction: { enumeration: BREAK_DURATIONS.map(String) },
          computedProps: [seedOccurrencePresence],
        },
      ],
    },
  ],
},

Template-driven reordering: slot routing โ€‹

Every branch declares type: 'program', so all six route to the same pair of slots: program-choice-array for the container and program-choice-array-item for each row. The hidden curfew-check field, covered below, renders nothing of its own; its message reaches the reader entirely through the summary panel and the submit button:

ts
<template #program-choice-array="{ fieldMetadata, fieldContext: { errorMessage, label }, addChoiceOccurrence, canAddChoiceOccurrence, usedChoiceOccurrences }">
  <RunOfShowTimeline
    :hide="fieldMetadata.hide"
    :label
    :description="fieldMetadata.description"
    :error-message="errorMessage.value"
    :dataTestid="fieldMetadata.path"
    :options="fieldMetadata.choice.map((x: any) => ({ value: x.name, title: toValue(x.fieldOptions?.label), description: x.description }))"
    :addChoiceOccurrence
    :canAddChoiceOccurrence
    :usedChoiceOccurrences
    :maxOccurs="fieldMetadata.maxOccurs"
    :programPath="fieldMetadata.path ?? ''"
    :branches="fieldMetadata.choice.map((x: any) => ({ name: x.name ?? '', amplifiedByDefault: x.name === 'band' || x.name === 'dance' }))"
  >
    <slot />
  </RunOfShowTimeline>
</template>

<template #program-choice-array-item="{ fieldMetadata, fieldContext: { label }, branchKey, canRemoveItems, removeItem, index, globalIndex }">
  <RunOfShowRow
    :path="fieldMetadata.path ?? ''"
    :programPath="fieldMetadata.parent?.path ?? ''"
    :branches="(fieldMetadata.parent?.choice ?? []).map((x: any) => ({ name: x.name ?? '', amplifiedByDefault: x.name === 'band' || x.name === 'dance' }))"
    :branchKey
    :index
    :globalIndex
    :label
    :canRemove="canRemoveItems"
    @remove="removeItem"
  >
    <slot />
  </RunOfShowRow>
</template>

<!-- The curfew-check vehicle field carries no value and renders nothing in the builder: its
message reaches the reader through the summary banner and the submit button's helper line,
both read from the host's own derived curfew-breach state, not from this field's own slot. -->
<template #curfew />

Reusing the add toolbar and rail chrome โ€‹

The container reuses ChoiceArraySectionCard for the add-per-branch toolbar and the used of maxOccurs count tag, and owns the dashed empty state plus the vertical rail line that connects consecutive rows. Every active occurrence renders through the single passthrough slot at the bottom:

vue
<ChoiceArraySectionCard
  v-show="!hide"
  id="add-toolbar"
  :label
  :description
  :error-message="errorMessage"
  :dataTestid
  :options
  :addChoiceOccurrence
  :canAddChoiceOccurrence
  :usedChoiceOccurrences
  :maxOccurs
/>

<section v-show="!hide" id="program-timeline" class="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-6 shadow-sm">
  <div
    v-if="!hasOccurrences"
    id="empty-program"
    class="flex items-center justify-center rounded-lg border border-dashed border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 p-6 text-center text-sm text-slate-500 dark:text-slate-400"
    :data-testid="dataTestid ? `${dataTestid}-empty` : undefined"
  >
    No items yet. Use Add above to build the evening's program.
  </div>
  <div v-else class="timeline flex flex-col">
    <slot />
  </div>
</section>

Writing the two affected order values โ€‹

Moving a row up or down swaps its own order with its immediate neighbor's, writing both through setFieldValue(path, value, false) so the write never re-triggers validation on its own. The engine re-sorts the rendered occurrences by the new values with no remount, so the row and the curfew check update from the same write:

ts
function moveUp() {
  const neighbor = occurrences.value.find(occurrence => occurrence.order === myOrder.value - 1);
  if (!neighbor)
    return;
  setFieldValue(`${props.programPath}.${neighbor.key}.order`, myOrder.value, false);
  setFieldValue(`${props.path}.order`, myOrder.value - 1, false);
}

function moveDown() {
  const neighbor = occurrences.value.find(occurrence => occurrence.order === myOrder.value + 1);
  if (!neighbor)
    return;
  setFieldValue(`${props.programPath}.${neighbor.key}.order`, myOrder.value, false);
  setFieldValue(`${props.path}.order`, myOrder.value + 1, false);
}

The clock cascade: a running fold โ€‹

Each row's start is the event start plus the sum of every duration above it in the rendered order; the end is the start plus its own duration. This is read live off the form's own values through useFormContext, never written back with computedProps, and it is structurally the same running fold the checkout's split-payment ledger uses for a remaining amount instead of a clock. A breach is any amplified item (a mic'd speech, a band set, the dance floor) whose end falls after the curfew, lifted into the same frame so a curfew set past midnight is handled correctly:

ts
/**
 * Walks the occurrences in their rendered order, running the clock forward from the event start.
 * Every entry's `startMinutes`/`endMinutes` stay in one unwrapped frame (they can exceed 1440 for
 * a program that crosses midnight); `formatTime` is what wraps a value back to a 24-hour label.
 * This is the clock analogue of the split-payment ledger's running-remainder fold.
 */
export function computeTimeline(
  occurrences: ProgramOccurrenceSource[],
  eventStartMinutes: number,
): ProgramTimelineResult {
  const byOrder = [...occurrences].sort((a, b) => a.order - b.order);
  let clock = eventStartMinutes;
  const entries: ProgramTimelineEntry[] = byOrder.map((occurrence) => {
    const startMinutes = clock;
    clock += occurrence.durationMinutes;
    return {
      key: occurrence.key,
      branchKey: occurrence.branchKey,
      amplified: occurrence.amplified,
      startMinutes,
      endMinutes: clock,
    };
  });
  return { entries, programEndMinutes: clock };
}

/**
 * Lifts a raw minutes-from-midnight clock reading (an anchor like the curfew) into the same
 * unwrapped frame the timeline's `startMinutes`/`endMinutes` use, exactly like a venue-close time
 * past midnight: a reading lower than the event start is on the next calendar day, not earlier
 * the same day.
 */
export function liftToEventFrame(minutes: number, eventStartMinutes: number): number {
  return minutes < eventStartMinutes ? minutes + 1440 : minutes;
}

/** Names every amplified occurrence that ends after the curfew, in the same unwrapped frame. */
export function findCurfewBreaches(
  timeline: ProgramTimelineResult,
  curfewMinutes: number,
  eventStartMinutes: number,
): string[] {
  const curfewAbsolute = liftToEventFrame(curfewMinutes, eventStartMinutes);
  return timeline.entries
    .filter(entry => entry.amplified && entry.endMinutes > curfewAbsolute)
    .map(entry => entry.key);
}

The venue-close candor box โ€‹

The summary panel shows the program's total length against venue close as a plain indicator, never as a rule that blocks submit, because the total is a duration sum independent of order:

vue
<div class="mt-4 rounded-lg border border-slate-200 dark:border-slate-700 p-3" data-testid="run-of-show-venue-overflow">
  <div class="flex justify-between text-sm text-slate-700 dark:text-slate-300">
    <span>Program ends</span>
    <strong style="font-variant-numeric: tabular-nums">{{ programEndTime }}</strong>
  </div>
  <div class="flex justify-between text-sm text-slate-700 dark:text-slate-300">
    <span>Venue closes</span>
    <strong style="font-variant-numeric: tabular-nums">{{ venueCloseTime }}</strong>
  </div>
  <div
    class="mt-2 rounded-lg p-2 text-sm"
    :class="venueOverflowMinutes > 0
      ? 'border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950 text-amber-800 dark:text-amber-200'
      : 'border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950 text-emerald-900 dark:text-emerald-200'"
  >
    <template v-if="venueOverflowMinutes > 0">
      Program runs {{ venueOverflowMinutes }} minutes past venue close. This is a duration total, not an order problem: reordering cannot fix it.
    </template>
    <template v-else>
      Within venue hours ({{ -venueOverflowMinutes }} minutes to spare).
    </template>
  </div>
</div>

The default program ships with exactly one item past the curfew: the band set, which ends at 23:55 against a 23:00 curfew. Moving it up once, past the dinner course directly above it, brings its end back to 23:00 or earlier with no field edited, flipping the form from invalid to valid. That is the one documented move this page's default data is tuned around.

What It Demonstrates โ€‹

  • A repeatable ordered choice (maxOccurs > 1 combined with choice) as the page's entire subject, with six branch flavours sharing one row component via a common type
  • Template-driven reordering: up/down buttons writing two order values through useFormContext().setFieldValue, with no remount
  • A computed clock cascade read live off the form's values: durations in, clock times out, the same running-fold shape as the checkout's split-payment ledger
  • A hard-blocking, order-sensitive curfew rule built on a hidden vehicle leaf plus a validateField re-trigger, the same pattern the checkout's split-payment coverage check uses
  • Two non-blocking convention warnings (cake after dinner, no leading break) as derived display flags that never affect meta.valid
  • A live summary panel: the timeline at a glance, the program's end versus venue close, curfew status naming offenders by position, and counts per item kind

How It Works โ€‹

The program choice declares displayOrder: 'added' and preserveOrder: true together, so the engine's rendered occurrence order is driven by a persisted order child rather than an in-memory-only insertion order. Each row's up/down control finds its order-neighbor and swaps the two order values with setFieldValue(path, value, false), the same two-write pattern the checkout's split-payment rows use. The clock cascade and the curfew check both read the form's own values through useFormContext inside a computed, never through computedProps, so nothing derived is ever written back into submitted data.

The curfew itself is a single hidden leaf with no value of its own (minOccurs: 0, so it never fails on its own absence). Its computedProps touches every occurrence and every schedule anchor, then calls validateField to re-run its own validation closure, which reports a pass or a list of breaching positions. The submit button gates on the form's live aggregate meta.valid directly, with no manual bypass. The two convention checks (cake after dinner, no leading break) are plain functions over the same computed timeline, consulted only by each row for its own amber styling; they never touch a validation rule or meta.valid, so a reader can submit with a convention warning still showing.

Released under the MIT License.