feat(events): the minimal admin surface (Phase 3)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 5m26s
PR Checks / client-build (pull_request) Successful in 8m30s

Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.

Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.

Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-02 08:39:35 -05:00
parent 2ba397eff7
commit 7b570c8ea1
20 changed files with 3775 additions and 8 deletions

View File

@@ -53,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -120,6 +121,20 @@ export const NAV = [
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
{
// Its own top-level group rather than a row under Content, and staff-wide
// rather than admin-only. Both follow EVENTS.md §K: every read here is
// `staff`, and the moderator's entire power over this feature is the run
// console — the thing they open when an event is doing something wrong at
// 2am. Hiding it from them would leave the one role that exists for incident
// response unable to see the incident. The narrower gates live on the
// actions: authoring is admin+editor and publish/start are admin only, both
// enforced server-side and mirrored on the buttons.
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
],
},
{
title: 'System',
items: [
@@ -202,6 +217,8 @@ const TITLES = {
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
'/admin/events/new': 'New event',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -222,6 +239,10 @@ function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
// belong to the same section as far as the page title is concerned.
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
if (pathname.startsWith('/admin/events/')) return 'Event'
return 'Admin'
}