Files
website/client/src/routes/admin/views/EventEditor.jsx
wtclaude 1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:38 -05:00

1554 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Reading the list…</div>
}
if (entry.state === 'failed') {
return (
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
{entry.reason} — type the value by hand.
</div>
)
}
if (!entry.options.length) {
return (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
{label} has nothing to offer right now — type the value by hand.
</div>
)
}
const grouped = entry.options.some((o) => o.group)
const groups = grouped
? [...new Set(entry.options.map((o) => o.group || 'Other'))]
: []
return (
<select
className="input"
style={{ fontSize: '0.75rem', marginTop: 4 }}
value=""
disabled={disabled}
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
>
<option value="">{`Pick from ${label}…`}</option>
{grouped
? groups.map((g) => (
<optgroup key={g} label={g}>
{entry.options.filter((o) => (o.group || 'Other') === g).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))
: entry.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)
}
/**
* 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 (
<div style={{ marginTop: 4 }}>
<input
className="input"
style={{ fontSize: '0.75rem' }}
value={term}
disabled={disabled}
placeholder={`Search ${label}…`}
onChange={(e) => setTerm(e.target.value)}
/>
{state.status === 'loading' && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Searching…</div>
)}
{state.status === 'failed' && (
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
{state.reason} — type the value by hand.
</div>
)}
{state.status === 'ok' && state.options.length === 0 && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
Nothing matches “{term}”.
</div>
)}
{state.status === 'ok' && state.options.length > 0 && (
<ul style={{ listStyle: 'none', margin: '4px 0 0', padding: 0, maxHeight: 160, overflowY: 'auto' }}>
{state.options.map((o) => (
<li key={o.value}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', width: '100%', textAlign: 'left', marginBottom: 2 }}
onClick={() => { onPick(o.value); setTerm('') }}
>
{o.label}
{o.group && <span className="dim"> · {o.group}</span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}
/**
* 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 = (
<select
{...common}
value={value === true ? 'true' : value === false ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Not set{param.required ? '' : ' — the action decides'}</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)
} else if (param.type === 'datetime') {
control = (
<input
{...common}
type="datetime-local"
value={datetimeInputValue(asText)}
onChange={(e) => onChange(e.target.value)}
/>
)
} else if (param.type === 'int' || param.type === 'float') {
control = (
<input
{...common}
type="number"
step={param.type === 'int' ? '1' : 'any'}
value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)}
/>
)
} else if (param.type === 'url') {
control = (
<input {...common} type="url" value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)} />
)
} else {
control = (
<input {...common} value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)} />
)
}
return (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">
{param.name}
{param.required && <span style={{ color: '#d98b84' }}> *</span>}
<span className="dim" style={{ fontWeight: 'normal' }}> · {param.type}</span>
</span>
{control}
{param.description && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>{param.description}</span>
)}
{param.source && (
sourceEntry?.searchable
? (
<SearchableOptions
sourceId={param.source}
label={sourceEntry.label || param.source}
disabled={disabled}
onPick={onChange}
/>
)
: (
<ParamOptions
entry={sourceEntry}
label={sourceEntry?.label || param.source}
disabled={disabled}
onPick={onChange}
/>
)
)}
</label>
)
}
/**
* 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 (
<div style={{ marginTop: 10 }}>
<span className="field-label">Only when</span>
<pre className="dim" style={{ fontSize: '0.76rem', margin: 0, whiteSpace: 'pre-wrap' }}>
{advance.whereText}
</pre>
<p className="sans" style={{ fontSize: '0.74rem', color: '#d9c184', margin: '6px 0 0' }}>
This condition nests, and the builder only holds one <code>and</code>/<code>or</code> 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.
</p>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 6 }} disabled={disabled}
onClick={() => onChange(blankWhere())}>
Clear it and start again
</button>
</div>
)
}
const setRow = (i, patch) =>
onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
return (
<div style={{ marginTop: 10 }}>
<span className="field-label">Only when</span>
{rows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
firings that match.
</p>
)}
{rows.length > 1 && (
<label style={{ display: 'block', margin: '6px 0' }}>
<span className="field-label">Match</span>
<select className="input" style={{ maxWidth: 220, fontSize: '0.76rem' }} value={advance.whereOp}
disabled={disabled}
onChange={(e) => onChange({ whereOp: e.target.value })}>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
)}
{rows.map((row, i) => {
const declared = variables.find((v) => v.name === row.variable)
const usable = operatorsForType(operators, declared?.type)
const valueless = row.cmp === 'present' || row.cmp === 'absent'
return (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
<select className="input" style={{ flex: '1 1 150px', fontSize: '0.76rem' }} value={row.variable}
disabled={disabled}
onChange={(e) => setRow(i, { variable: e.target.value, cmp: '' })}>
<option value="">Which variable…</option>
{variables.map((v) => <option key={v.name} value={v.name}>{v.name} ({v.type})</option>)}
</select>
<select className="input" style={{ flex: '0 1 130px', fontSize: '0.76rem' }} value={row.cmp}
disabled={disabled || !row.variable}
onChange={(e) => setRow(i, { cmp: e.target.value })}>
<option value="">is…</option>
{usable.map((o) => <option key={o.cmp} value={o.cmp}>{o.label}</option>)}
</select>
{!valueless && (
<input className="input" style={{ flex: '1 1 140px', fontSize: '0.76rem' }} value={row.value ?? ''}
disabled={disabled || !row.cmp}
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'Yew, Britain, Vesper' : ''}
onChange={(e) => setRow(i, { value: e.target.value })} />
)}
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled}
onClick={() => onChange({ whereRows: rows.filter((_, j) => j !== i) })}>
✕
</button>
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 8 }}
disabled={disabled || !variables.length}
onClick={() => onChange({ whereRows: [...rows, { variable: '', cmp: '', value: '' }] })}>
Add a clause
</button>
{!variables.length && advance.on && (
<span className="sans dim" style={{ fontSize: '0.74rem', marginLeft: 8 }}>
This trigger declares no variables, so there is nothing to narrow on.
</span>
)}
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>
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.
</p>
</div>
)
}
/**
* 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 (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 12, opacity: stale ? 0.55 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.88rem' }}>
What this plan draws
{stale && <span className="dim" style={{ fontWeight: 'normal' }}> · recalculating…</span>}
</h3>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{report.steps} step{report.steps === 1 ? '' : 's'}
</span>
</div>
{nothing && (
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.8rem' }}>
Nothing in this plan spends a capped resource.
</p>
)}
{report.cost.length > 0 && (
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse', marginTop: 6 }}>
<tbody>
{report.cost.map((c) => (
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
<td style={{ paddingRight: 12 }}>{labelOf(c.dimension)}</td>
<td style={{ paddingRight: 12 }}>{c.total} {unitOf(c.dimension)}</td>
<td className="dim">
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
</td>
</tr>
))}
</tbody>
</table>
)}
{report.unpriced.length > 0 && (
<div style={{ marginTop: 8, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.78rem' }}>
<strong>These totals are incomplete.</strong>
</p>
<ul className="sans" style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '0.78rem' }}>
{report.unpriced.map((u, i) => (
<li key={`${u.phase}-${u.seq}-${u.code}-${i}`}>
<code className="dim" style={{ fontSize: '0.76rem' }}>phase {u.phase + 1} · step {u.seq + 1}</code>
{' — '}{u.message}
</li>
))}
</ul>
</div>
)}
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.74rem' }}>
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.
</p>
</div>
)
}
/**
* 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 (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Start a run now</h3>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
<input type="checkbox" checked={rehearsal} onChange={(e) => setRehearsal(e.target.checked)} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
<strong>Rehearsal.</strong>{' '}
<span className="dim">
A real run — every world change actually happens — with its announcements ceilinged to
staff, so no player is told. This is not the dry run.
</span>
</span>
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Scope (optional)</span>
<input className="input" value={scope} onChange={(e) => setScope(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
Passed to the module verbatim. Core never parses it.
</span>
</label>
<label>
<span className="field-label">Run params (optional)</span>
<textarea className="input" rows={3} spellCheck={false}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.78rem' }}
value={paramsText} onChange={(e) => setParamsText(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
What the concurrency key’s <code>{'{placeholders}'}</code> are filled from.
</span>
</label>
</div>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84', margin: '10px 0 0' }}>{problem}</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={go}>
Start it
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={onCancel}>
Cancel
</button>
</div>
</div>
)
}
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 <ErrorState message={error} />
if (loading || !form) return <Loading />
const archived = event?.state === 'archived'
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
{isNew ? 'New event' : event?.title}
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
{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'}
</>
)}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{mayAuthor && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={save}>
{isNew ? 'Create draft' : 'Save'}
</button>
)}
{/* 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 && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={verify}>
Dry run
</button>
)}
{/* 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 && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={publish}>
Publish
</button>
)}
{!isNew && isAdmin && event?.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => setStarting((s) => !s)}>
Start now
</button>
)}
</div>
</div>
{!mayAuthor && (
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
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.
</p>
)}
{archived && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d9c184' }}>
This definition is archived. It is kept so its past runs can still be explained, and it
cannot be edited or run again.
</p>
)}
{/* ── §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 && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.84rem' }}>
<strong>This version has not been dry-run.</strong> 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.
</p>
</div>
)}
{report && (
<div
className="panel-flat"
style={{
padding: '10px 14px',
marginBottom: 14,
borderLeft: `3px solid ${report.report.ok ? '#8fc79a' : '#d98b84'}`,
}}
>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
<strong>
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
</strong>{' '}
<span className="dim">
· {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'}
</span>
</p>
{report.report.findings.length > 0 && (
<ul className="sans" style={{ margin: '0 0 6px', paddingLeft: 18, fontSize: '0.82rem' }}>
{report.report.findings.map((f, i) => (
<li key={`${f.phase}-${f.seq}-${f.code}-${i}`} style={{ color: f.level === 'warning' ? '#d9c184' : undefined }}>
{f.phase !== null && (
<code className="dim" style={{ fontSize: '0.78rem' }}>
{f.phase} · step {f.seq + 1}
{f.actionId ? ` · ${f.actionId}` : ''}
</code>
)}
{f.phase !== null && ' — '}
{f.message}
</li>
))}
</ul>
)}
{/* 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 && (
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{report.report.cost.map((c) => (
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
<td style={{ paddingRight: 12 }}><code style={{ fontSize: '0.78rem' }}>{c.dimension}</code></td>
<td style={{ paddingRight: 12 }}>{c.total}</td>
<td className="dim">
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
</td>
</tr>
))}
</tbody>
</table>
)}
{report.report.findings.length === 0 && report.report.cost.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
Nothing this event does costs a capped resource.
</p>
)}
</div>
)}
{starting && (
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
{problems.length > 0 && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d98b84' }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>That did not save:</p>
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.82rem' }}>
{problems.map((p) => <li key={p}>{p}</li>)}
</ul>
</div>
)}
{/* ── Basics ── */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Title</span>
<input className="input" value={form.title} onChange={(e) => set({ title: e.target.value })} />
</label>
<label>
<span className="field-label">Series</span>
<select className="select" value={form.seriesId} onChange={(e) => set({ seriesId: e.target.value })}>
<option value="">Not part of a series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label>
<span className="field-label">Timezone</span>
<input className="input" value={form.timezone} onChange={(e) => set({ timezone: e.target.value })} />
</label>
<label>
<span className="field-label">Grace window (seconds)</span>
<input className="input" type="number" min="0" value={form.graceSeconds}
onChange={(e) => set({ graceSeconds: e.target.value })} />
</label>
<label>
<span className="field-label">Concurrency key</span>
<input className="input" value={form.concurrencyKey} placeholder="invasion:{region}"
onChange={(e) => set({ concurrencyKey: e.target.value })} />
</label>
</div>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Summary</span>
<input className="input" value={form.summary} onChange={(e) => set({ summary: e.target.value })} />
</label>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Storyline</span>
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
</label>
{/* 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. */}
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<input
type="checkbox"
checked={Boolean(form.listed)}
onChange={(e) => set({ listed: e.target.checked })}
/>
<span className="field-label" style={{ margin: 0 }}>Show on the public calendar</span>
</label>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
The grace window is how late this event may still start: past it an occurrence becomes
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap — <code>{'{placeholders}'}</code> are filled from the run&rsquo;s own
params. An event that is <em>not</em> 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.
</p>
</div>
{/* ── 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.
*/}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Schedule</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Repeats</span>
<select className="select" value={form.scheduleKind} disabled={archived}
onChange={(e) => set({ scheduleKind: e.target.value })}>
{SCHEDULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{form.scheduleKind === 'once' && (
<label>
<span className="field-label">Date and time</span>
<input className="input" type="datetime-local" value={form.scheduleAt} disabled={archived}
onChange={(e) => set({ scheduleAt: e.target.value.slice(0, 16) })} />
</label>
)}
{form.scheduleKind === 'monthly' && (
<>
<label>
<span className="field-label">Week</span>
<select className="select" value={form.scheduleNth} disabled={archived}
onChange={(e) => set({ scheduleNth: e.target.value })}>
{MONTHLY_NTHS.map((n) => <option key={n.value} value={n.value}>{n.label}</option>)}
</select>
</label>
<label>
<span className="field-label">Weekday</span>
<select className="select" value={form.scheduleWeekday} disabled={archived}
onChange={(e) => set({ scheduleWeekday: e.target.value })}>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{d.charAt(0).toUpperCase() + d.slice(1)}</option>
))}
</select>
</label>
</>
)}
{(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && (
<label>
<span className="field-label">Time</span>
<input className="input" type="time" value={form.scheduleTime} disabled={archived}
onChange={(e) => set({ scheduleTime: e.target.value })} />
</label>
)}
</div>
{form.scheduleKind === 'weekly' && (
<div style={{ marginTop: 12 }}>
<span className="field-label">Days</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{WEEKDAYS.map((day) => {
const on = (form.scheduleDays || []).includes(day)
return (
<button key={day} type="button" className="pill" disabled={archived}
aria-pressed={on}
style={{ fontSize: '0.72rem', opacity: on ? 1 : 0.45 }}
onClick={() => set({
scheduleDays: on
? form.scheduleDays.filter((d) => d !== day)
: WEEKDAYS.filter((d) => d === day || form.scheduleDays.includes(d)),
})}>
{day.charAt(0).toUpperCase() + day.slice(1, 3)}
</button>
)
})}
</div>
</div>
)}
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
{describeSchedule(scheduleFromForm(form), form.timezone)}
</p>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
Times are the event&rsquo;s own, in <code>{form.timezone}</code> — not the reader&rsquo;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.
</p>
</div>
{/* ── 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. */}
<CapMeter report={priced} budgets={budgets} stale={priceStale} />
{/* ── The phase timeline ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
onClick={() => set({ phases: [...form.phases, blankPhase(form.phases)] })}>
Add phase
</button>
</div>
{form.phases.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
No phases yet. An event needs at least one phase with at least one step before it can be
published.
</p>
)}
{form.phases.map((phase, pi) => (
<div key={pi} className="panel-flat" style={{ padding: 14, marginBottom: 12, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Phase {pi + 1} — label</span>
<input className="input" value={phase.label} onChange={(e) => setPhase(pi, { label: e.target.value })} />
</label>
<label style={{ flex: '0 1 180px' }}>
<span className="field-label">Key</span>
<input className="input" value={phase.key} onChange={(e) => setPhase(pi, { key: e.target.value })} />
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === 0} onClick={() => movePhase(pi, -1)}>↑</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === form.phases.length - 1} onClick={() => movePhase(pi, 1)}>↓</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => set({ phases: form.phases.filter((_, i) => i !== pi) })}>Remove</button>
</div>
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
The key is what the run console groups by and what &ldquo;phase 3 has not started&rdquo; names, so it
cannot change once runs exist.
</p>
{/* §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 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Draws{' '}
{(drawByPhase.get(pi) || [])
.map((d) => `${d.total} ${budgets.find((b) => b.id === d.dimension)?.label || d.dimension}`)
.join(' · ')}
</p>
)}
{/* ── 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. */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">This phase advances</span>
<select className="input" value={phase.advance?.kind || ''}
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{phase.advance?.kind === 'after' && (
<label style={{ flex: '0 1 160px' }}>
<span className="field-label">Delay</span>
<input className="input" value={phase.advance.after}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
placeholder="30m" />
</label>
)}
{phase.advance?.kind === 'on' && (
<>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Trigger</span>
{/* Changing the trigger CLEARS the predicate, for the same
reason changing a step's action replaces its params:
every clause names a variable of the old trigger, and the
save would refuse each of them by name. Keeping them
would look like tolerance and behave like a form that
cannot be saved. */}
<select className="input" value={phase.advance.on}
onChange={(e) => setPhase(pi, {
advance: { ...phase.advance, on: e.target.value, ...blankWhere() },
})}>
<option value="">Choose a trigger…</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
</select>
</label>
<label style={{ flex: '0 1 110px' }}>
<span className="field-label">How many</span>
<input className="input" type="number" min="1" value={phase.advance.count}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
</label>
</>
)}
</div>
{phase.advance?.kind === 'on' && (
<WhereBuilder
advance={phase.advance}
trigger={triggerById.get(phase.advance.on)}
operators={catalog?.operators || []}
disabled={archived}
onChange={(patch) => setPhase(pi, { advance: { ...phase.advance, ...patch } })}
/>
)}
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
{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.'}
</p>
</div>
<div style={{ marginTop: 12 }}>
{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 (
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Step {si + 1} — action</span>
<select className="select" value={step.actionId} onChange={(e) => changeAction(pi, si, e.target.value)}>
<option value="">Pick an action…</option>
{actions.map((a) => <option key={a.id} value={a.id}>{a.label} — {a.id}</option>)}
{/* A step whose module has been uninstalled keeps its
action id, so the select must be able to show a value
that is not in the catalog rather than silently
resetting the step to nothing. */}
{step.actionId && !action && <option value={step.actionId}>{step.actionId} (not installed)</option>}
</select>
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">If it fails</span>
<select className="select" value={step.onFailure} onChange={(e) => setStep(pi, si, { onFailure: e.target.value })}>
<option value="">
Default{action ? ` — ${catalog?.onFailureByRisk?.[action.risk] || 'pause'}` : ''}
</option>
{(catalog?.onFailure || []).map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === 0} onClick={() => moveStep(pi, si, -1)}>↑</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === phase.steps.length - 1} onClick={() => moveStep(pi, si, 1)}>↓</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => setPhase(pi, { steps: phase.steps.filter((_, j) => j !== si) })}>Remove</button>
</div>
</div>
{step.dormant && (
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '8px 0 0' }}>{DORMANT_NOTE}</p>
)}
{action && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
{action.description} <span style={{ opacity: 0.75 }}>· risk: {action.risk} · reversible: {action.reversible}</span>
</p>
)}
{/* ── 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. */}
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<span className="field-label" style={{ marginBottom: 0 }}>Params</span>
{!mode.forced && action && (action.params || []).length > 0 && (
<button type="button" className="pill" style={{ fontSize: '0.7rem' }}
onClick={() => setStep(pi, si, {
paramsMode: mode.mode === PARAM_JSON ? PARAM_FORM : PARAM_JSON,
})}>
{mode.mode === PARAM_JSON ? 'Edit as a form' : 'Edit as JSON'}
</button>
)}
</div>
{mode.forced && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
Opened as JSON: {mode.reason}. Nothing has been dropped — what is here is
exactly what was authored.
</p>
)}
{mode.mode === PARAM_FORM ? (
<div style={{ marginTop: 8 }}>
{(action?.params || []).map((p) => (
<ParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
onChange={(raw) => setStep(pi, si, { paramsText: setParam(step, p.name, raw, p.type) })}
/>
))}
{action && (action.params || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
This action takes no params.
</p>
)}
{!action && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
Pick an action and its fields appear here, straight from what the module
declared.
</p>
)}
</div>
) : (
<>
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem', marginTop: 8 }}
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
{action && (action.params || []).length > 0 && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
<code>{action.id}</code> declares{' '}
{(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
</span>
)}
</>
)}
</div>
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
onClick={() => setPhase(pi, { steps: [...phase.steps, blankStep(actions[0])] })}>
Add step
</button>
</div>
</div>
))}
{!isNew && versions.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginTop: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Versions</h3>
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
definition while a run is live is free — <strong>the live run keeps the version it
pinned</strong> and is unaffected by anything on this screen.
</p>
<table className="adm-table">
<tbody>
{versions.map((v) => (
<tr key={v.id}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>v{v.version}{v.current && <span className="dim"> · current</span>}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{new Date(v.publishedAt).toLocaleString()}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{v.publishedByUsername || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}