import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import {
formFromDefinition,
payloadFromForm,
blankPhase,
blankAdvance,
blankWhere,
whereFormFrom,
ADVANCE_KINDS,
blankStep,
describeSchedule,
scheduleFromForm,
SCHEDULE_KINDS,
MONTHLY_NTHS,
WEEKDAYS,
PARAM_FORM,
PARAM_JSON,
paramsMode,
paramValue,
setParam,
datetimeInputValue,
priceBodyFrom,
worthPricing,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
// **A vertical timeline, not a node graph**, and that is a decision about what
// the engine can actually do rather than a matter of taste. The condition
// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at
// depth five — so a canvas would promise power this project has never handed an
// operator. Phases in order, each with its steps in order, says exactly what the
// runner does with them.
//
// **Core renders no game word here.** Every label on a step comes from the
// action's own registration — its `label`, its params' names, their descriptions
// and their examples — so an installed module's vocabulary appears without core
// knowing any of it, and `check:modules` already fails core's build on a UO
// identifier.
//
// **The params are a FORM as of Phase 13**, one control per declared param,
// rendered from a schema core does not understand — §I's *"the condition
// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
// a step opens in it automatically when the form could not hold what the step
// carries. That rule is the condition builder's own, ported rather than
// reinvented — dropping a param the action does not declare and flattening
// `A and (B or C)` are the same mistake, a save that looks clean and means
// something else.
//
// **The meter beside the timeline is not a lighter dry run.** `POST
// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
// whether the landmark exists, whether the shard is up. It answers the half core
// can answer alone, which is what a plan would SPEND, and it can therefore run on
// a debounce while somebody types. The dry run stays the thing that asks the
// modules, and the screen labels them apart.
/**
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
*
* **A refusal renders as a warning and leaves the field usable**, which is the
* contract rather than a nicety: a source is answered by a module that may be
* talking to a sidecar, and an authoring form a shard outage can make unusable
* would be a worse failure than the typo the dropdown exists to prevent. The
* operator very often knows the value they want to type.
*
* The picker WRITES INTO THE JSON box rather than replacing it, because the box
* is still the field until the schema-driven form arrives — so this is the one
* affordance that can exist today and be right afterwards: the values come from
* the module, and the exact spelling is never typed by hand. When the JSON does
* not parse the picker says so rather than silently doing nothing, because
* "clicked and nothing happened" is the one behaviour a form must never have.
*/
function ParamOptions({ entry, label, disabled, onPick }) {
if (!entry || entry.state === 'loading') {
return
Reading the list…
}
if (entry.state === 'failed') {
return (
{entry.reason} — type the value by hand.
)
}
if (!entry.options.length) {
return (
{label} has nothing to offer right now — type the value by hand.
)
}
const grouped = entry.options.some((o) => o.group)
const groups = grouped
? [...new Set(entry.options.map((o) => o.group || 'Other'))]
: []
return (
)
}
/**
* A source too large for a dropdown, as a search box (Phase 13).
*
* **The first source that needed this made it unavoidable.** Phase 12b's spawner
* target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat
* list drops two thirds of the world and says nothing about which two thirds —
* an author picking from it would be choosing from a truncation they cannot see.
* The sidecar half of that shipped in 12b; this is what asks.
*
* `searchable` on the answer decides which control is drawn, rather than the
* length of the list: inferring it from a truncated answer reads correctly right
* up until a small deployment's list happens to fit, at which point the same
* source is a dropdown on one shard and a search box on another.
*
* A term is sent on a debounce and the answer is dropped if it is not the one
* for the term still in the box — a slow source answering after a faster one
* would otherwise repaint the list under the author's cursor with results for
* something they have finished typing.
*/
function SearchableOptions({ sourceId, label, disabled, onPick }) {
const [term, setTerm] = useState('')
const [state, setState] = useState({ status: 'idle', options: [] })
const latest = useRef('')
useEffect(() => {
const wanted = term.trim()
latest.current = wanted
if (!wanted) {
setState({ status: 'idle', options: [] })
return undefined
}
setState((s) => ({ ...s, status: 'loading' }))
const timer = setTimeout(async () => {
try {
const answer = await api.admin.eventOptions(sourceId, wanted)
if (latest.current !== wanted) return
setState(
answer?.ok
? { status: 'ok', options: answer.options || [] }
: { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
)
} catch (err) {
if (latest.current !== wanted) return
setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' })
}
}, 250)
return () => clearTimeout(timer)
}, [term, sourceId])
return (
)
}
/**
* One declared param, as the control its type implies (Phase 13).
*
* **Core does not know what any of this means and that is the design.** The
* label is the param's own name, the help is its own description, the placeholder
* is its own example, and the values behind it come from the module that declared
* the source — `check:modules` fails core's build on a UO identifier, so there is
* nowhere for a game word to be written here even by accident.
*
* Two of the controls are worth their own sentence:
*
* • **A boolean is a three-value select, not a checkbox.** A checkbox cannot say
* *"not set"*, and for an OPTIONAL boolean that is a real third state — the
* action's own default. A checkbox would post `false` for every param an author
* never touched.
* • **A source-backed param keeps its free-text field.** The dropdown writes
* into it; it does not replace it. §F: a source is answered by a module that may
* be talking to a sidecar, and an authoring form a shard outage can make
* unusable is a worse failure than the typo the dropdown exists to prevent.
*/
function ParamField({ param, value, sourceEntry, disabled, onChange }) {
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
const asText = value === undefined || value === null ? '' : String(value)
let control
if (param.type === 'boolean') {
control = (
)
} else if (param.type === 'datetime') {
control = (
onChange(e.target.value)}
/>
)
} else if (param.type === 'int' || param.type === 'float') {
control = (
onChange(e.target.value)}
/>
)
} else if (param.type === 'url') {
control = (
onChange(e.target.value)} />
)
} else {
control = (
onChange(e.target.value)} />
)
}
return (
)
}
/**
* The advance condition, as a builder rather than as JSON (Phase 13).
*
* **This is the engagement condition builder**, and being the same one is the
* point rather than a saving: the grammar is `engagement/conditions.js`, the
* server validates a phase gate with it, and the run console's diagnosis panel
* renders its sentence from the same labels. A second editor here would be a
* second opinion about a grammar core owns — exactly what §I refuses on the read
* side, where the sentence is rendered on the server for the same reason.
*
* It offers the FLAT half of the grammar — one `and`/`or` over a list of
* comparisons — because that is what a dropdown per operator can render
* honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one
* choice: leave it, or clear it and start again. Flattening `A and (B or C)`
* into `A and B and C` changes which firings release the phase, and an author
* would have no way to know the save had done it.
*/
function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
const variables = trigger?.variables || []
const rows = advance.whereRows || []
if (advance.whereEditable === false) {
return (
Only when
{advance.whereText}
This condition nests, and the builder only holds one and/or over a
flat list. It is kept exactly as authored and posted back unchanged — flattening it would
change which firings release the phase without saying so.
)
})}
{!variables.length && advance.on && (
This trigger declares no variables, so there is nothing to narrow on.
)}
A list operator takes comma-separated values. Every literal is read as the type the trigger
declared, and a variable it does not have comes back named from the save.
)
}
/**
* The live cap meter (§I, Phase 13).
*
* Two facts per dimension — what this plan draws, and what the deployment allows
* — because that is all a cap is. §I says the same thing about the run console's
* meter: *"a meter per dimension rather than a sentence, because unlike a gate a
* cap is two numbers and a name and needs no grammar rendered to be read."*
*
* **It says what it does not know.** A step core could not price makes every
* total below an under-count, and an author reading a number smaller than what
* will happen is worse off than one reading no number at all. So `unpriced` is
* rendered as prominently as the totals, not tucked underneath them.
*
* And it does not claim to be the dry run: the caption says so, because a green
* meter beside a plan whose landmarks do not exist would otherwise read as a
* pass.
*/
function CapMeter({ report, budgets, stale }) {
const labelOf = (id) => budgets.find((b) => b.id === id)?.label || id
const unitOf = (id) => budgets.find((b) => b.id === id)?.unit || ''
if (!report) return null
const nothing = report.cost.length === 0 && report.unpriced.length === 0
return (
Arithmetic only — nothing was dispatched, so this does not know whether the places and things
these steps name exist. The dry run asks the modules that do.
)
}
/**
* Starting a run, with the three things the route has always taken (Phase 13).
*
* **Two of them were unreachable from this screen until now**, and one of those
* is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from
* the RUN's own params, so an event whose key names one could not be started
* correctly from the UI at all — every manual run rendered the same key and the
* second one was refused as an overlap.
*
* **Rehearsal is a real run.** §I: the world changes are real, the announcements
* are ceilinged to `staff`. It is not a dry run and the dialog says so, because
* the two words are near enough to swap in a hurry.
*/
function StartDialog({ onStart, onCancel, busy }) {
const [rehearsal, setRehearsal] = useState(false)
const [scope, setScope] = useState('')
const [paramsText, setParamsText] = useState('{}')
const [problem, setProblem] = useState(null)
const go = () => {
let params = null
const text = paramsText.trim()
if (text && text !== '{}') {
try {
params = JSON.parse(text)
} catch (err) {
setProblem(`The params are not valid JSON (${err.message})`)
return
}
if (!params || typeof params !== 'object' || Array.isArray(params)) {
setProblem('The params must be a JSON object')
return
}
}
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
}
return (
Start a run now
{problem && (
{problem}
)}
)
}
const DORMANT_NOTE =
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
export default function EventEditor() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isNew = id === 'new'
const [form, setForm] = useState(null)
const [event, setEvent] = useState(null)
const [catalog, setCatalog] = useState(null)
const [series, setSeries] = useState([])
const [versions, setVersions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [problems, setProblems] = useState([])
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
// The dry run's answer (Phase 6). Cleared on every save and every publish,
// because a report is a statement about a spec and both of those change it —
// a stale green report beside an edited plan is worse than no report.
const [report, setReport] = useState(null)
// Phase 13. The meter's answer, and whether the plan has moved since it was
// asked — `stale` is what stops the number reading as current while a request
// is in flight, which on a debounce it very often is not.
const [priced, setPriced] = useState(null)
const [priceStale, setPriceStale] = useState(false)
// The start dialog is open. Not a modal: this screen is long, and a dialog
// that covers the plan hides the thing being started.
const [starting, setStarting] = useState(false)
const isAdmin = user?.role === 'admin'
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
// — the run console is their whole job and a definition is what a run is OF. But
// authoring is `admin` + `editor`, so Save has to follow the route it calls.
// Offering it and letting the server answer 403 is the shape §K calls "a gate
// nobody notices was missing", only inverted: a button that does nothing.
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async () => {
const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()])
setCatalog(cat)
setSeries(ser.series || [])
if (isNew) {
setEvent(null)
setVersions([])
setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
return
}
const [{ event: loaded }, { versions: history }] = await Promise.all([
api.admin.getEvent(id),
api.admin.listEventVersions(id),
])
setEvent(loaded)
setVersions(history || [])
setForm(formFromDefinition(loaded))
}, [id, isNew])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load()
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load])
const actions = useMemo(() => catalog?.actions || [], [catalog])
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
// Phase 5. Served with the actions on the same route, so an EDITOR sees the
// same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
// an editor writing a trigger id from memory into a field the save path then
// refuses is the failure this avoids.
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
// Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }.
//
// Resolved LAZILY, one request per source, and only for the sources the steps
// on this page actually name. A definition uses two or three of them; a
// deployment with a game module installed may register a dozen, and asking a
// shard for eight hundred creature names to draw a form that needs none of
// them is a page that opens slowly for no one's benefit.
const [sources, setSources] = useState({})
// **The guard is a ref, and it has to be.** `setSources` QUEUES its updater
// rather than running it, so a "have I already asked for this?" check written
// inside the updater cannot be read on the next line — it has not run yet. The
// first draft did exactly that and the field sat on *Reading the list…* for
// ever, having never made the request at all: state is the wrong tool for a
// question that must be answered synchronously, at the call.
const requested = useRef(new Set())
const loadSource = useCallback(async (sourceId) => {
if (requested.current.has(sourceId)) return
requested.current.add(sourceId)
setSources((s) => ({ ...s, [sourceId]: { state: 'loading', options: [] } }))
try {
const answer = await api.admin.eventOptions(sourceId)
setSources((s) => ({
...s,
[sourceId]: answer?.ok
// `searchable` is the SOURCE's answer about itself (Phase 12b), not a
// guess from how long the list came back. A source bounded at 2,000
// that happens to have 40 entries on this deployment is still the one
// that has to be searched on the shard where it has 6,707.
? {
state: 'ok',
label: answer.label,
searchable: Boolean(answer.searchable),
options: answer.options || [],
}
: { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
}))
} catch (err) {
// The route answers a refusal with a 200, so reaching here means the
// REQUEST failed rather than the source — and the field's behaviour is the
// same either way: it degrades to free text and says why.
setSources((s) => ({
...s,
[sourceId]: { state: 'failed', options: [], reason: err.message || 'this list could not be read' },
}))
}
}, [])
// Phase 7. Every option source the steps on this page name, resolved once.
//
// An effect rather than a lookup at render time, because resolving one is a
// request and a request started during render is a render with a side effect.
// `sources` is deliberately NOT a dependency: `loadSource` keeps its own ref of
// what it has already asked for, so re-running this on every answer would be a
// pass over the same set, changing nothing.
useEffect(() => {
const wanted = new Set()
for (const phase of form?.phases || []) {
for (const step of phase.steps || []) {
for (const p of actionById.get(step.actionId)?.params || []) {
if (p.source) wanted.add(p.source)
}
}
}
for (const id of wanted) loadSource(id)
}, [form, actionById, loadSource])
/**
* The live cap meter (§I, Phase 13).
*
* **A debounce and a generation counter, and the counter is not optional.**
* Requests started 400ms apart do not necessarily answer in that order, and an
* older answer landing last would leave the meter showing the cost of a plan
* the author has already changed — stale in the one direction that matters,
* silently, with nothing on the screen to say so.
*
* A failure leaves the LAST answer standing rather than blanking the meter or
* raising an error box: this is an aid to authoring, and the plan is still
* saveable, dry-runnable and publishable without it. The staleness marker is
* how the screen stays honest about it.
*/
const priceGeneration = useRef(0)
useEffect(() => {
if (!form || !worthPricing(form)) {
setPriced(null)
setPriceStale(false)
return undefined
}
setPriceStale(true)
const generation = ++priceGeneration.current
const timer = setTimeout(async () => {
try {
const answer = await api.admin.priceEvent(priceBodyFrom(form))
if (priceGeneration.current !== generation) return
setPriced(answer)
setPriceStale(false)
} catch {
if (priceGeneration.current !== generation) return
// Left stale on purpose: a number the screen cannot vouch for is shown
// dimmed rather than replaced by nothing.
setPriceStale(true)
}
}, 400)
return () => clearTimeout(timer)
}, [form])
/** The meter's per-phase rollup, by ordinal, for the timeline. */
const drawByPhase = useMemo(
() => new Map((priced?.phases || []).map((p) => [p.phase, p.draw])),
[priced],
)
const budgets = useMemo(() => catalog?.budgets || [], [catalog])
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const setPhase = (pi, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
}))
const setStep = (pi, si, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
),
}))
const movePhase = (pi, delta) =>
setForm((f) => {
const next = [...f.phases]
const to = pi + delta
if (to < 0 || to >= next.length) return f
;[next[pi], next[to]] = [next[to], next[pi]]
return { ...f, phases: next }
})
const moveStep = (pi, si, delta) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => {
if (i !== pi) return p
const steps = [...p.steps]
const to = si + delta
if (to < 0 || to >= steps.length) return p
;[steps[si], steps[to]] = [steps[to], steps[si]]
return { ...p, steps }
}),
}))
/**
* Changing a step's action REPLACES its params with the new action's examples.
*
* The alternative — keeping what was typed — leaves an object whose keys belong
* to a different action, and the save refuses it with "x is not a param of y"
* for every one of them. Replacing is the honest move and it is not
* destructive in any way an author minds: they have just said this step does
* something else.
*/
const changeAction = (pi, si, actionId) => {
const action = actionById.get(actionId)
const fresh = blankStep(action)
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' })
}
const save = async () => {
setBusy(true)
setProblems([])
setNotice(null)
// A report describes a spec, and saving changes it. A green report left
// standing beside an edited plan is worse than no report at all.
setReport(null)
// The declared variable types, so the builder's literals are coerced to
// them before the request rather than compared as strings by a server that
// will rightly refuse them.
const built = payloadFromForm(form, { triggersById: triggerById })
if (!built.ok) {
setProblems(built.errors)
setBusy(false)
return
}
try {
if (isNew) {
const result = await api.admin.createEvent(built.payload)
navigate(`/admin/events/${result.event.id}`, { replace: true })
} else {
const result = await api.admin.updateEvent(id, built.payload)
setEvent(result.event)
setForm(formFromDefinition(result.event))
setNotice('Saved.')
}
} catch (err) {
// The server answers with every problem rather than the first, so an author
// fixing a spec does it in one pass rather than six round trips.
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
const publish = async () => {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
// The re-pin count is said out loud, because an editor who does not know
// their fix reached next Friday finds out on Friday.
setNotice(
result.repinned
? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
: `Published as v${result.version}.`,
)
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
/**
* The dry run (Phase 6).
*
* `admin, editor` — it dispatches nothing. What it verifies follows the
* definition's state, and the server says which: a `ready` definition is
* checked against its PUBLISHED version, because that is the only thing that
* ever actually runs and it is that pass §K's gate is about; a draft is checked
* against the working spec the author is still holding.
*
* Findings arrive with a 200 — the request succeeded, the plan has problems —
* so they are rendered rather than thrown into the error box.
*/
const verify = async () => {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.verifyEvent(id)
setReport(result)
if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event))
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
/**
* Start a run, with what the dialog collected (Phase 13).
*
* The route has taken `rehearsal`, `scope` and `params` since Phase 10 and this
* screen sent `{}` — so rehearsal, the affordance §I asks for by name, was
* unreachable, and an event whose concurrency key names a `{placeholder}` could
* not be started correctly at all: every manual run rendered the same key and
* the second was refused as an overlap with the first.
*/
const start = async (body) => {
setBusy(true)
setProblems([])
try {
const result = await api.admin.startEventRun(id, body)
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setProblems(err.body?.errors || [err.message])
setStarting(false)
} finally {
setBusy(false)
}
}
// **The error is checked BEFORE the form.** `!form` is true for every failed
// load, so testing it first put the error state permanently behind a spinner:
// the screen that could not load said "Loading…" for ever and named nothing.
if (error) return
if (loading || !form) return
const archived = event?.state === 'archived'
return (
{isNew ? 'New event' : event?.title}
{isNew ? (
'The slug is derived from the title once and frozen afterwards — the public event page lives at it.'
) : (
<>
{event?.slug} · {event?.state}
{event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'}
>
)}
{mayAuthor && (
)}
{/* The dry run is admin+editor, deliberately wider than publish: an
author should be able to find out what their event would cost
before asking an admin to commit the deployment to it. */}
{!isNew && mayAuthor && (
)}
{/* Publish and start are admin ONLY (§N2) and not the same gate as the
live controls: publishing commits a definition a schedule will later
start unattended. */}
{!isNew && isAdmin && (
)}
{!isNew && isAdmin && event?.state === 'ready' && (
)}
{!mayAuthor && (
You can read this definition and watch its runs. Editing and publishing an event are an
admin or editor's, and starting one is an admin's alone — live control of a run already in
flight is yours.
)}
{archived && (
This definition is archived. It is kept so its past runs can still be explained, and it
cannot be edited or run again.
)}
{/* ── §K's gate, said where it can still be acted on ──
A published version that nobody has dry-run will not start on its
schedule. The alternative to saying so here is an operator finding out
on the Friday it did not run, so it is a banner rather than a log line —
and only for a definition that actually HAS a schedule to be held. */}
{!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && (
This version has not been dry-run. Scheduled occurrences are held until it
is — an event that starts while nobody is watching gets one review, and this is it.
Starting it by hand is unaffected.
)}
{report && (
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
{' '}
· {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '}
{/* Which spec was checked. The two answer different questions, and a
report that did not say would be read as the other one. */}
{report.target === 'version' ? `published v${report.version}` : 'the working draft'}
{report.recorded && ' · recorded, so scheduled occurrences may now start'}
)}
{/* The whole-plan cost, which is the finding no other path can make: a
step that fits on its own and does not fit alongside its siblings. */}
{report.report.cost.length > 0 && (
{/* Announcement, not permission. Publishing is what makes an event
RUNNABLE, so without this switch a surprise invasion would have to be
advertised a fortnight in advance in order to be allowed to happen. */}
The grace window is how late this event may still start: past it an occurrence becomes
missed rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap — {'{placeholders}'} are filled from the run’s own
params. An event that is not shown publicly still schedules, still runs and is
still on this calendar — it is simply not announced, and its lifecycle mails carry no
link because there is no page to link to.
{/* ── Schedule ── */}
{/*
Four closed shapes rendered as a form, never a cron string. A cron
expression is the one field an operator cannot proofread, and the whole
point of the closed set is that this panel can be read back in English —
which is what the preview line under it does.
*/}
Times are the event’s own, in {form.timezone} — not the reader’s. A
recurring schedule goes live when the definition is published and stops when it is
archived; occurrences become real runs a fortnight before they happen, and the
calendar forecasts the rest. A time that daylight saving skips moves forward to the next
one that exists, and an hour that happens twice takes the first.
{/* ── The live cap meter (Phase 13) ──
Above the timeline rather than under it, because what a plan draws is a
fact about the whole plan and an author scrolling twelve steps to find
out they are over is an author who finds out too late. */}
{/* ── The phase timeline ── */}
Phases
{form.phases.length === 0 && (
No phases yet. An event needs at least one phase with at least one step before it can be
published.
)}
{form.phases.map((phase, pi) => (
The key is what the run console groups by and what “phase 3 has not started” names, so it
cannot change once runs exist.
{/* §I asks the timeline for "phases in order, each with its steps, its
advance condition, its cap draw and its failure policy". This is
the cap draw, and it is the phase's own rather than a share of the
total: an author moving a step between phases is asking exactly
this question. */}
{(drawByPhase.get(pi) || []).length > 0 && (
)}
{/* ── The advance condition (Phase 5) ──
A gate is an ADDITIONAL condition and never a replacement, which is
what the caption has to say: a phase whose steps are still running
is not advanced by a boss that spawned early. */}
{phase.advance?.kind
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
: 'The phase advances the moment every one of its steps is finished.'}
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
// Phase 13. Which editor this step gets, and — when it is not the
// author's choice — why.
const mode = paramsMode(step, action)
return (
)}
{/* ── The params (Phase 13) ──
A form of the action's own declaration, with the JSON box
kept as the escape hatch. A step the form cannot hold
without losing something opens in JSON and says why — the
condition builder's rule, and the same one, because
dropping an undeclared param and flattening a nested
condition are the same failure: a save that looks clean
and means something else. */}
Pick an action and its fields appear here, straight from what the module
declared.
)}
) : (
<>
)
})}
))}
{!isNew && versions.length > 0 && (
Versions
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
definition while a run is live is free — the live run keeps the version it
pinned and is unaffected by anything on this screen.