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
1554 lines
70 KiB
JavaScript
1554 lines
70 KiB
JavaScript
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’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’s own, in <code>{form.timezone}</code> — 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.
|
||
</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 “phase 3 has not started” 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>
|
||
)
|
||
}
|