People and screens · Console and scorer apps

Console and scorer apps

The screens where staff run an event and fix data, and where scorers score a match: how a change is shown, sent, kept and confirmed.

Design section
Section 7, agreed 6 Oct 2026
Main code
omnium-console, packages-ts/omnium-bridge, frontend-admin
Main tables
timeline_item, domain_event (server); outbox in IndexedDB (new, browser)
Read time
about 25 minutes

In one minute

People change omnium data on two kinds of screen. Staff use the console to run an event and fix data. Scorers use scorer apps (we call them desks) to score a match event by event. Both run inside an iframe of the admin panel and talk to omnium only through the bridge, a message protocol between the app and the panel.

Today a change waits for the server, is never retried, and gets a new key on every send. A live update can wipe what someone is typing. The whole console is one 594 KB JavaScript file.

Agreed (Section 7, V1 to V10 and F1 to F7): every app sends through one shared outbox in the browser (IndexedDB). It makes the key once, saves the change before sending, sends one at a time per match, and retries with the same key. A change shows on the screen at once, and every field ends in one of five states: idle, sending, saved, refused or conflict. A field you are typing in never takes a live update. Pages load only their own code, and long lists draw only the rows on screen.

None of the agreed parts is built yet. This page keeps the two apart everywhere.

594 KB
one console JS file, 186 KB gzipped (vite build, laptop, 7 Oct)
0
automatic retries of a change today (main.tsx:33)
under 0.1 s
agreed target: click to screen (design doc, V6)
under 1 s
agreed target: save to tick (design doc, V6)

What this part does

This part is everything a person touches. It decides how a screen shows data, how a change is sent and confirmed, and how the screen stays fast and safe under pressure. A wrong answer here means an operator loses a change, trusts old data, or slows down when speed matters most.

The design section answered seven questions:

#QuestionWhy it matters
1When does a change show on the screen: at once, or after the server answers?Waiting makes every click feel slow. Showing it early must never hide a change that failed.
2What happens to a change when the network drops?A person must never lose work, and never send it twice.
3How does a screen stay up to date while others change the same data?Old data leads to wrong fixes. A live update must not wipe what someone is typing.
4How does the screen show a conflict or a refusal?The person must see what changed and what to do, in plain words.
5How fast must screens open, with 10,000 players or 600 matches in a day?Operators work under time pressure during a live event.
6How does fast data entry work: keyboard, tab order, one-click actions?Typing a full scorecard must take minutes, not an hour.
7One app or many, and how do scorer apps share the same rules?Each new app must not build its own sending, retries and conflicts.

Who uses which screen

PersonScreenWhat they do there
Operator (staff)Console: Overview, Schedule, Match page, Medals, Players, Integrations, Runs, FeedsRun one event: fix a score, a start time, a medal count, an entry; watch imports
OperatorScorecard entry (new)Type a full scorecard by hand, row by row
AdminSport releases (new, from Section 6)Roll out a new version of a sport's rules
ScorerDesk (scorer app) for one matchScore a match event by event: a ball, a punch, a move, a split
ViewerAny console screen, read onlyLook, never change

How it works

A sketch titled 'The console talks to omnium only through the panel'. An operator stick figure clicks into a yellow box 'Console (iframe)', marked 'no token, no address'. The console sits inside a large frame labelled 'Admin panel (browser tab)'. Inside the same frame is a yellow box 'Bridge host', marked 'holds the sign-in'. An arrow from the console to the host says 'command, read, watch'; an arrow back says 'answer, push'. A note under them says 'postMessage, one origin only'. From the bridge host an arrow labelled 'HTTP + sign-in' goes right to a yellow box 'Admin API'. The admin API has an arrow labelled 'save command' down to a blue cylinder 'Postgres', which lists timeline_item and domain_event. A dashed arrow labelled 'change stream (SSE)' goes from Postgres back to the bridge host.
Built today. The app never sees a token or omnium's address; the panel signs every call. The change stream is served by the admin API, which reads domain_event once a second.

The console and the desks are separate web apps. The admin panel draws each one in an iframe and answers its messages. This is how one change travels today, step by step:

  1. The panel opens the app. It asks omnium for a session (GET /apps/session), draws the iframe, and waits for the app to say ready (frontend-admin/src/components/bridge/AppFrame.tsx:286-293).
  2. The handshake. The app posts ready; the panel answers hello with the session: the event or fixture, the person's name, canWrite, and the read models and commands this app may use (packages-ts/omnium-bridge/src/host.ts:260-268). The app accepts messages only from the origins its build trusts (app.ts:90-116).
  3. The screen reads and watches. A screen asks for a named read model, such as games.medal_table or results.fixture. Most reads are watched: the panel re-reads them when data changes and pushes the new value (omnium-console/src/lib/bridge.ts:148-212).
  4. The person makes a change. The screen calls send(code, input). The bridge wraps it as a command message with a key and posts it to the panel (app.ts:323-337).
  5. The panel sends it to omnium. It checks canWrite, checks the command is offered, then makes POST /workflows/{workflow}/subjects/{kind}/{ref}/commands with the person's sign-in (host.ts:307-321, http.ts:211-213).
  6. omnium runs the command. The workflow engine locks the subject, checks the key, runs the rules, and saves. Every accepted command writes a row to timeline_item and one to domain_event in the same transaction. The page Commands and the workflow engine explains this road.
  7. The answer comes back. Accepted, duplicate, or refused with the rule's own sentence. The panel posts it to the app, and the screen shows a toast.
  8. Other screens catch up. The panel listens to the change stream. When a change lands, it waits 250 ms for a burst to settle, re-reads the watches the change touched, and pushes only values that changed (host.ts:188-235).

The agreed design keeps this road and adds three things in the browser. Changes go through a shared outbox. They show at once with a field state. A field registry protects typing from live pushes. The rest of this page explains each one.

Screens, and the command behind each action

Every button or field that changes data sends exactly one named command. Nothing else writes those records. This table maps the console's actions to commands, checked in omnium-console/src/lib/api.ts on 7 Oct.

ScreenWhat the person doesCommand sent todayAgreed change
MedalsTypes a count for a countrygames.set_medals (api.ts:132)Five field states
MedalsDrags the ordergames.reorder_medals (api.ts:137)Already shown before the answer
MedalsSets a sport's medalsgames.set_sport_medals (api.ts:150)Five field states
MedalsHands a count back to the feedgames.hand_back_medals (api.ts:158)none
MedalsAdds, removes or hides a countrygames.add_country, games.remove_country, games.set_country_visibilitynone
Match pageRenames, moves the start, changes the venueresults.set_name, results.set_start, results.set_venueOne results.edit_fixture when several fields change at once (V10)
Match pageSets status or officialresults.set_statusSame
Match pageTypes the resultresults.set_resultSame
Match pageSays the match decides a medalresults.set_medal_eventSame
Match pageAdds, edits or removes a sideresults.add_side, results.edit_side, results.remove_sideFive field states
Match pagePicks who wonTwo results.edit_side, one per side (FixturePage.tsx:227-234)not decided in Section 7
Match pageHands fields back to the feedresults.hand_backnone
Scorecard entry (new)Types one row of a scorecarddoes not existOne results.set_line per row (Section 5, new)
PlayersAdds or edits a playergames.add_player, games.edit_playerFive field states
PlayersEnters or withdraws a sport, sets an entry eventgames.enter_sport, games.withdraw_sport, games.set_entry_eventsame
PlayersJoins or leaves a teamgames.join_team, games.leave_teamsame
ScheduleAdds a fixture, builds the schedulegames.add_fixture, games.build_scheduleLong days draw only visible rows
IntegrationsRuns, stops, starts an importgames.run_integration, games.stop_integration, games.start_integrationnone

The desks send their sport's own commands, for example cricket.delivery, boxing.judge_score, chess.move and swimming.split, plus core.correct and core.void (checked in boxing-scoring-ui/src on 7 Oct).

The console and the desks, side by side

The user asked how things will work "at both places". This is the difference today, and what the design makes the same.

ConsoleDesks (scorer apps)
WhoOperators, admins, viewersScorers
SubjectOne event, or one fixtureOne fixture
Where the code isSeparate repo omnium-console (Vite 6, React 19, TypeScript, react-router 7, TanStack Query 5)boxing-scoring-ui on one laptop, not in git
SportsAll, through games.* and results.*Boxing, cricket, swimming, chess, and a racing folder
Shows a changeAfter the server answers, except the medal dragBoxing and cricket: at once, sent in the background (App.tsx:415-440). Chess, swimming, racing: wait, showing "Sending…" (shared/desk.tsx:104)
Order of sendsEach save waits for its answerBoxing and cricket: several in flight at once, no order
AgreedSame outbox, same five states, same UI kitSame outbox, a visible "waiting to send" count, big touch targets, number keys; moved into the repo (V9)

A worked example

Example · A staff member fixes a score while offline for 20 seconds

The setting. The hockey men's final, India v Japan, ends 3-1. The feed sent 2-1. Asha, an operator, opens the match page to fix it. Her venue Wi-Fi drops just as she saves, and comes back 20 seconds later. All times below are an example, not a measurement.

What happens today (built).

  1. 12:04:00.0. Asha types 3-1 and presses Enter. TextField calls onSave (FixturePage.tsx:910-913). The Save button spins.
  2. The page calls editFixture, which sends results.set_result with scoreline: "3-1" (api.ts:781-783). The bridge makes a fresh key (app.ts:332).
  3. The panel's fetch fails at once: there is no network. The bridge answers "omnium is not answering." (http.ts:165-166).
  4. retry: 0 means nothing tries again (main.tsx:33). A red toast says "Not saved", and is gone after 10 s (ui.tsx:492). The field still shows 3-1, but nothing is saved.
  5. If Asha missed the toast, the wrong score stays live. If she saves again after the Wi-Fi is back, the bridge makes a new key. Here that is harmless, because the first send never reached omnium.
  6. The dangerous case is a dropped answer: the first send was saved, but its answer was lost. A second save with a new key is then a second command. For a set-score command the result is the same score twice. For an "add" command, or a desk's ball, it is a second record.

What happens with the agreed design (to build).

  1. 12:04:00.0. Asha presses Enter. useField hands the draft to useChange().send(). The outbox writes one row to IndexedDB before anything is sent: key 7f3c…, subject fixture:01a0…, code results.edit_fixture (or results.set_result for one field), input scoreline: "3-1", and saw = the version Asha's screen showed (F1).
  2. 12:04:00.0 plus a few ms. onMutate writes 3-1 into the TanStack cache, so every place on screen that shows this match now shows 3-1. The field shows the sending dot (F3, V2). The click was answered on the device, inside the 0.1 s target.
  3. The send loop for this match takes its oldest row and sends it. No network. The row stays. The loop waits 0.2 s, then 0.4 s, 0.8 s and so on, with a little randomness, and sends the same key each time (F2).
  4. 12:04:05. A send has failed for 5 s, so a bar appears: "Offline. 1 change waiting, it will send when you are back." Asha can keep working. A second fix to another match would go into its own loop.
  5. 12:04:20. The Wi-Fi is back. The next retry goes out with key 7f3c…. With the doubling waits above and no other trigger, it fires at about 25 s after the first try (estimate: 0.2 + 0.4 + 0.8 + 1.6 + 3.2 + 6.4 + 12.8 s). Whether a browser "back online" event should send at once is not stated in the design doc.
  6. omnium checks the key (engine.py:119-122). It is new, so the command runs and the answer is accepted. The field shows a tick for 2 s, then idle. The row is deleted from IndexedDB.
  7. If the first send had in fact reached omnium before the drop, the server finds the key in timeline_item and answers duplicate. The outbox treats that like accepted. One fix, saved once.
  8. The change stream (stopped while offline) reconnects and resumes from its last place. Every other screen showing this match catches up.

Two branches.

  • Priya changed it first. During the 20 s, Priya set the score to 4-1 on another screen. Asha's change carries saw = the old version, so omnium answers conflict. The send loop for this match pauses. The field shows: "Priya set this to 4-1 at 12:04. Keep theirs, or use yours." Nothing is saved until Asha chooses.
  • Asha reloads the page while offline. The row is in IndexedDB, not in memory. When the console starts again, the outbox drains, and Asha sees "1 change from before was sent".

Low-level design

The bridge messages (built)

The bridge is one protocol, version 1, with fixed message shapes. Every message carries omnium: "bridge", so other messages on the page are ignored.

// packages-ts/omnium-bridge/src/protocol.ts:124-146 (trimmed)
export type AppMessage =
  | { type: "ready"; protocol: number; app: string; version: string }
  | { type: "read"; id: string; name: string; params: Params; subject?: SubjectRef }
  | { type: "watch"; id: string; name: string; params: Params; subject?: SubjectRef }
  | { type: "unwatch"; id: string; watchId: string }
  | { type: "command"; id: string; code: string; input: Record<string, unknown>;
      key: string; subject?: SubjectRef; dryRun?: boolean }
  | { type: "state"; id: string; op: "get" | "put"; scope: string; /* ... */ }
  | { type: "open"; id: string; target: OpenTarget; ref: string };

The app sends these to the panel. id matches a request to its answer. key is the command's idempotency key: a random id that lets the server spot the same command sent twice. subject lets an event's console address one of that event's fixtures.

// packages-ts/omnium-bridge/src/protocol.ts:93-109 (trimmed)
export interface CommandResult<T = Record<string, unknown>> {
  accepted: boolean;
  duplicate: boolean;
  seq: number | null;
  message: string | null;   // a rule's own sentence when it said no
  refusal: string | null;
  reason?: string | null;   // a fixed word, like likely_duplicate
  touches?: string[] | null; // what it changed, for live re-reads
  value: T | null;
  // counts, notes, dryRun, fixtureStatus
}

This is what every screen gets back. Today there are three outcomes: accepted, duplicate, refused. The agreed conflict and try_again answers come from Section 4 and are not in this type yet.

How a screen sends a change today (built)

A change passes through four layers. Each is short.

// omnium-console/src/lib/bridge.ts:113-127
export async function send<T = Record<string, unknown>>(
  code: string, input: Record<string, unknown>, subject?: SubjectRef,
): Promise<CommandResult<T>> {
  const sent: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(input)) {
    if (value !== undefined) sent[key] = value;
  }
  const result = await bridge().command<T>(code, sent, subject ? { subject } : {});
  if (!result.accepted) {
    throw new Refused(result.message ?? "omnium did not take that change.", result.reason ?? null);
  }
  return result;
}

The console's one send(). It drops empty fields and turns a refusal into an error that carries the rule's sentence. Note what it does not pass: a key. So the bridge makes one.

// packages-ts/omnium-bridge/src/app.ts:323-337
async command<T>(code: string, input: Record<string, unknown> = {},
                 options: Subjected & { key?: string; dryRun?: boolean } = {}) {
  const result = await this.request({
    type: "command", code, input,
    key: options.key ?? this.newKey(),   // a new key on every call
    subject: options.subject, dryRun: options.dryRun,
  });
  return result.value as CommandResult<T>;
}

Line 332 is the root of the retry problem. A key exists end to end, but a second call makes a second key. The server cannot tell a retry from a new change.

// omnium-console/src/lib/api.ts:773-788
export const editFixture = async (id: string, body: FixtureEdit): Promise<Fixture> => {
  const subject = fixtureRef(id);
  if (body.name !== undefined) await send("results.set_name", { name: body.name }, subject);
  if (body.start !== undefined) await send("results.set_start", { start: body.start }, subject);
  if ("venueId" in body) await send("results.set_venue", { venueId: body.venueId ?? null }, subject);
  if (body.status !== undefined || body.official !== undefined) {
    await send("results.set_status", { status: body.status, official: body.official }, subject);
  }
  if ("scoreline" in body) await send("results.set_result", { scoreline: body.scoreline || null }, subject);
  if ("medal" in body) await send("results.set_medal_event", { medal: body.medal ?? null }, subject);
  return readNow(readFixture(id));
};

One command per field, one after another. If the third one is refused, the first two are already saved. Decision V10 replaces this with one results.edit_fixture command: saved whole or not at all.

// omnium-console/src/pages/FixturePage.tsx:178-185
const edit = useMutation({
  mutationFn: (body: FixtureEdit) => editFixture(id, body),
  onSuccess: (next, body) => {
    apply(next);                                   // write the server's record into the cache
    toast.success(savedTitle(body, FIXTURE_WORDS), kept(next));
  },
  onError: fail("Not saved"),                      // a toast, nothing kept
});

A TanStack Query mutation. It has onSuccess and onError but no onMutate, so nothing shows until the server answers. main.tsx:33 sets mutations: { retry: 0 } for the whole app.

On the server side, the key is already honoured. The engine looks for an earlier command with the same key before it runs anything:

# packages/core/src/omnium_core/workflows/engine.py:116-122
await session.execute(sa.select(sa.func.pg_advisory_xact_lock(ledger.lock_key(subject_id))))
stream_id = await streams.stream_for(session, kind, subject_id)

if idempotency_key:
    prior = await _prior_seq(session, stream_id, actor, idempotency_key)
    if prior is not None:
        return CommandOutcome(accepted=True, code=code, duplicate=True, seq=prior)

A unique index on timeline_item (stream_id, source_code, idempotency_key) backs this up (packages/contract/src/omnium_contract/models/timeline.py:58-66). So the server is ready. Only the browser needs to keep the key. The line-by-line walk through commands, rules and actions is on Writing a workflow in code.

The outbox in the browser (agreed, F1 and F2)

A sketch titled 'The outbox in the browser'. A staff stick figure has an arrow labelled 'save' into a yellow box 'Screen', marked 'shows it at once'. From the screen an arrow labelled 'write first' goes into a blue cylinder 'IndexedDB outbox' holding three rows: 'match A: change 1', 'match A: change 2', 'match B: change 1'. Two arrows labelled 'oldest first' go from the cylinder to two yellow boxes, 'Send loop: match A' and 'Send loop: match B'. Both loops send into a yellow box 'Bridge', which sends to a yellow box 'omnium'. Red dashed arrows from the bridge back to each loop say 'no network: retry, same key'. A long curved arrow from omnium back to the cylinder says 'final answer: delete'.
Agreed, to build. Each match has its own send loop, so a slow match never holds up another.

IndexedDB is a small database built into every browser. Unlike memory, it survives a crash, a reload and a new deploy. The outbox is one store in it. This record shape is copied from the design doc:

// design doc, Section 7, "On the device" (new; not in the code)
// IndexedDB database "omnium", one store per app origin (the iframe has its own origin)
// store "outbox", key: change id (the command key, made once)
type Pending = {
  key: string;              // crypto.randomUUID(), sent on every retry (Section 4, C2)
  subject: string;          // "fixture:01a0..." - the queue is per subject
  code: string;             // "results.edit_side"
  input: object;
  saw?: Saw;                // the version the screen showed (Section 4, C3)
  createdAt: string;        // order within a subject
  tries: number;
  state: "waiting" | "sending";
};
// index on (subject, createdAt): the next change to send for each subject

The rules for the outbox:

RuleWhy
Write the row before sendingA crash between the click and the send loses nothing
The key is made once, when the row is writtenEvery retry is the same command to the server
One send loop per subject, oldest firstChanges to one match land in the order they were made (Section 4, C12)
Subjects run in parallelA slow match never holds up another
Delete on accepted, duplicate, refused or conflictThese are final answers
Keep on try_again or a network errorWait 0.2 s, 0.4 s, 0.8 s and so on, up to 30 s, plus a little randomness; send the same key again
A conflict pauses that subject's loopThe changes behind it may depend on it (Section 4, L5); the person chooses first
Show the waiting count; show "Offline, N changes waiting"When the browser reports no network, or a send has failed for 5 s

The design names the file packages-ts/omnium-bridge/src/outbox.ts (new). A sketch of one send loop, written from the rules above; it is not code from either repo:

// sketch of the agreed send loop (F2); names are illustrative
async function drain(subject: string) {
  for (;;) {
    const next = await outbox.oldest(subject);         // index (subject, createdAt)
    if (!next) return;
    try {
      const answer = await bridge.command(next.code, next.input,
                                          { key: next.key, subject: refOf(subject) });
      if (answer.conflict) { pause(subject, next, answer); return; }  // V3
      await outbox.delete(next.key);                    // accepted, duplicate, refused
      report(next, answer);                             // saved / refused state
    } catch (networkError) {
      await sleep(backoff(next.tries++));               // 0.2 s doubling to 30 s, plus jitter
    }
  }
}

Showing a change before it is confirmed (agreed, F3)

The console already uses TanStack Query, and every watched read lives in its cache. An optimistic update shows the change on screen before the server confirms it. TanStack Query does this with a mutation's onMutate step. The agreed flow:

WhenWhat the screen does
SendSave a snapshot of the cached record. Write the new value into the cache, so every place that shows it updates. Mark the field as sending.
AcceptedThe server's record replaces the cache. Tick for 2 s.
RefusedPut the snapshot back. Show the server's sentence under the field.
ConflictKeep the snapshot. Show both values and ask.
// sketch of F3 with the TanStack Query v5 API; not code from the repo
const edit = useMutation({
  mutationFn: (body: FixtureEdit) => change.send("results.edit_fixture", body, saw),
  onMutate: async (body) => {
    await client.cancelQueries({ queryKey: ["fixture", event, id] });
    const before = client.getQueryData<Fixture>(["fixture", event, id]);
    client.setQueryData(["fixture", event, id], { ...before, ...body }); // shows at once
    return { before };
  },
  onError: (_error, _body, context) => {
    client.setQueryData(["fixture", event, id], context?.before);       // roll back
  },
});

Today only the medal table drag works this way, and it rolls back by re-reading (MedalsPage.tsx:102-115).

The five field states (agreed, V2 and V3)

A sketch titled 'Five states of one field'. A white box 'Idle, the value' has an arrow labelled 'save' to a yellow box 'Sending, new value, pulsing dot'. From Sending, three arrows go to three boxes on the right: 'accepted' to a green box 'Saved, tick for 2 s'; 'rule said no' to a red box 'Refused, old value + reason'; 'changed first' to a red box 'Conflict, theirs and yours'. A note at the bottom says 'All three end back at Idle'.
Agreed, to build. Every editable field in every app has exactly these five states.
StateWhat it looks likeWhen
IdleThe valueNothing pending
SendingThe new value, a small pulsing dotIn the outbox, not answered yet
SavedA tick for 2 seconds, then idleAccepted or duplicate
RefusedThe old value back, the reason in red under the fieldA rule said no (Section 4, L18)
ConflictBoth values: theirs (who, when) and yours, with Keep theirs and Use yoursSomeone changed it first (Section 4, C3)

A refusal reads like this: "A medal needs a finished match. Men's 100m Final is still LIVE." It appears next to the field, not only in a toast. Today every refusal is a toast in the corner (FixturePage.tsx:172).

Keep theirs drops your change and shows their value. Use yours sends your value again. The design doc does not spell out how that second send is built; it must carry saw = their version, or it would conflict again.

The hooks that carry the states, as the design doc gives them:

// packages-ts/omnium-bridge/src/react.ts (new hooks; design doc, "The hooks")
type ChangeState = "idle" | "sending" | "saved" | "refused" | "conflict";

function useChange(subject: Subject) {
  // returns send(code, input, saw) and, per field, its state and message
  // send(): writes to the outbox, applies onMutate to the query cache, returns at once
  // the outbox answer updates the state: saved (tick 2 s), refused (sentence), conflict (theirs + yours)
}

function useField<T>(id: string, serverValue: T, version: number) {
  // draft, setDraft, focused; while focused or dirty, serverValue changes do not touch draft
  // changedMeanwhile = serverValue !== startedFrom; save() sends saw = version it started from
}

Today react.ts has useWatch, useRead and useCommand (react.ts:61-138). The console uses its own live() helper instead. The design keeps useCommand for old apps.

The typing guard (agreed, V4 and F4)

Today a text field copies the server value into its draft every time the value changes:

// omnium-console/src/pages/FixturePage.tsx:902-903
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);

If Priya saves the score while Asha is typing, the live push changes value, and Asha's half-typed text is replaced. The start field does the same (FixturePage.tsx:949-952).

One place already guards against this. The medal box keeps its draft while it has focus:

// omnium-console/src/pages/MedalsPage.tsx:1143-1145
useEffect(() => {
  if (!typing) setDraft(String(value));
}, [value, typing]);

The agreed useField makes that the rule for every field, and adds two things the medal box lacks:

  1. Each field reports focus and unsaved text to a small registry: its id and the value it started from.
  2. A live push still updates the cache. A field in the registry keeps its draft and compares the new value with the one it started from. If they differ, it shows "Changed to 3-1 by Priya while you typed".
  3. On save, the field sends saw = the version it started from. If the record moved, the server answers conflict, and the five states take over.

Live updates (built, and agreed changes in F5)

The panel's host re-reads affected watches one after another today:

// packages-ts/omnium-bridge/src/host.ts:215-229 (trimmed)
for (const [watchId, watch] of [...this.watches]) {
  if (!everything && watch.depends && !watch.depends.some((area) => touched.has(area))) continue;
  try {
    const { value, depends } = await this.readWith(watch, watch.name, watch.params); // waits each time
    watch.depends = depends;
    const text = JSON.stringify(value);
    if (!this.watches.has(watchId) || text === watch.last) continue;
    watch.last = text;
    this.post({ type: "push", watchId, value });
  } catch { /* kept: the next change reads it again */ }
}

A page with 7 watches waits for 7 reads in a row. F5 changes this loop to read 4 at a time.

The server ends each stream after 10 s, because the ECS Service Connect proxy cuts every request at 15 s:

# packages/core/src/omnium_core/settings.py:263-267
# Not 0 by default because of AWS: admin-api sits behind ECS Service Connect,
# whose proxy ends EVERY request after 15 seconds unless the service sets a
# timeout. A stream cut there shows "Reconnecting" and can drop a change.
# Ending first, cleanly, avoids both. Set 0 once that timeout is lifted.
stream_max_seconds: float = Field(default=10.0, ge=0)

F5 raises the proxy timeout for the admin service and sets stream_max_seconds to 0. The stream loop itself reads domain_event once a second (_POLL_SECONDS = 1.0, packages/admin/src/omnium_admin/routers/bridge.py:766).

The user asked for more: when two scorers work one match, the second screen should update as soon as the first changes something. Section 8 takes that as its main target: about half a second, pushed with the change itself rather than a re-read. See Bridge and live updates.

Pages that open fast (agreed, V6 and F6)

Every page is imported eagerly today, so the first screen downloads all of them:

// omnium-console/src/App.tsx:6-14
import OverviewPage from "./pages/OverviewPage";
import MedalsPage from "./pages/MedalsPage";
import IntegrationsPage from "./pages/IntegrationsPage";
// ... PlayersPage, PlayerPage, SchedulePage, FixturePage, RunsPage, FeedsPage

The agreed change uses React.lazy, which loads a page's code only when its route opens:

// sketch of F6; not in the repo
const MedalsPage = lazy(() => import("./pages/MedalsPage"));
// <Suspense fallback={<Loading what="Opening…" />}> around <Routes>
PieceTodayAgreed
Code per pageOne file: 594.39 KB JS (186.49 KB gzipped) and 111.96 KB CSS, measured with vite build on a laptop, 7 OctEach route loads its own code plus the shared kit
Long listsSchedule folds big days behind "Show all" (SchedulePage.tsx:820-827); Players shows 50 per page (PlayersPage.tsx:45)A virtual list (TanStack Virtual) draws only the rows on screen, about 30, whether the day has 90 units or 900
Next pageFetched on openData for a likely next page (a hovered row) fetched in advance

A virtual list keeps only the visible rows in the page and swaps them as you scroll. The comment at SchedulePage.tsx:823-825 explains why it is needed: drawing all 895 rows at once "takes long enough to feel like a freeze". The new wrapper is omnium-console/src/components/VirtualList.tsx (new), used by Schedule, Players and Runs.

The scorecard grid and the keyboard (agreed, V7)

The scorecard entry screen is new (omnium-console/src/pages/ScorecardPage.tsx, new). It is a grid: players down, the sport's line keys across. The columns come from the sport's line catalogue (Section 6, P1), so the same screen serves cricket, football or any sport set up in settings. No sport is written into the screen.

KeyWhereDoes
TabGridNext cell to the right
EnterGridNext row down
EscapeGridUndo the cell
PasteGridFill cells from a spreadsheet
Ctrl+KAnywhereSearch for any match, player or page
?AnywhereList the shortcuts
SMatch pageSave
EMatch pageEdit the next side

Each row saves as one results.set_line command through the outbox, and ticks as it saves. A 22-row cricket scorecard is 22 commands, in order. results.set_line does not exist yet; Section 5 defines it. Ctrl+K and "?" live in omnium-console/src/components/Palette.tsx (new).

Today the console has no shortcuts. Its only key handlers are Escape for drawers and dialogs (ui.tsx:407-410) and Enter or Escape in a field. The cricket desk is the only app with number keys (CricketApp.tsx:701-704).

Role-aware screens (agreed, V8)

The session already says whether this person may write:

// packages-ts/omnium-bridge/src/protocol.ts:83-84
/** Who the log will say it was. */
user: { name: string; role: string | null; canWrite: boolean };

The host refuses a command from a viewer (host.ts:308-310): "Your account can look but not change anything." But the console never reads canWrite (no match for it in omnium-console/src on 7 Oct). So a viewer sees every edit button and is refused only on save. V8 makes viewers get read-only pages with a note: "You can view, not change." Actions like approving a release appear only to those allowed. Section 13 owns the role rules.

One shared UI kit (agreed, V9)

The console's components live in one file, omnium-console/src/components/ui.tsx: Button, IconButton, Stat, Chip, Card, Tabs, Empty, Problem, Note, Loading, Skeleton, Drawer, useToast, useConfirm. The theme uses the Outfit font (src/styles/tokens.css:97). V9 moves this kit, the icons and the tokens into a new package, packages-ts/omnium-ui. The console and the desks both use it. The desks move into the omnium repo as apps/desks and deploy like the console.

Files the design changes

FileNew or todayChange
packages-ts/omnium-bridge/src/outbox.tsnewThe device queue (F1, F2)
packages-ts/omnium-bridge/src/app.tstodaycommand() takes the key from the outbox instead of making one (line 332)
packages-ts/omnium-bridge/src/host.tstodayrefresh() re-reads 4 watches at a time (F5)
packages-ts/omnium-bridge/src/react.tstodayNew hooks useChange() and useField()
packages-ts/omnium-uinewThe shared kit (V9)
omnium-console/src/lib/bridge.tstodaysend() goes through the outbox
omnium-console/src/lib/api.tstodayeditFixture sends one results.edit_fixture (V10)
omnium-console/src/pages/FixturePage.tsxtodayTextField and StartField use useField; five field states
omnium-console/src/pages/ScorecardPage.tsxnewThe entry grid (V7)
omnium-console/src/App.tsxtodayRoutes with React.lazy (F6)
omnium-console/src/components/Palette.tsxnewCtrl+K and the "?" list
omnium-console/src/components/VirtualList.tsxnewTanStack Virtual for Schedule, Players, Runs
packages/flows/src/omnium_flows/results/commands.pytodayNew results.edit_fixture and results.set_line
desks (boxing-scoring-ui, laptop only)todayMoved into the repo as apps/desks; send through the outbox
ECS Service Connect (infra)todayRaise the admin service's per-request timeout; set stream_max_seconds to 0

results.edit_fixture (agreed, V10)

packages/flows/src/omnium_flows/results/commands.py holds ten commands today: set_name, set_start, set_venue, set_status, set_result, set_medal_event, add_side, edit_side, remove_side, hand_back (lines 114-198). Each changes one thing. results.edit_fixture (new) takes several fields of one match in one input and runs as one command. One lock, one transaction: saved whole, or not at all. The match page shows one sending mark and one tick for it.

Speed tests (agreed, F7)

"Fast" is a Playwright test in a real browser, run in CI. From the design doc:

// omnium-console/tests/speed.spec.ts (new, Playwright, real Chrome)
test("a score edit shows at once and is confirmed quickly", async ({ page }) => {
  const t0 = Date.now(); await page.getByLabel("Score").fill("3"); await page.keyboard.press("Enter");
  await expect(page.getByTestId("field-score-state")).toHaveAttribute("data-state", "sending");
  expect(Date.now() - t0).toBeLessThan(100);
  await expect(page.getByTestId("field-score-state")).toHaveAttribute("data-state", "saved", { timeout: 1000 });
});

The targets: click to screen under 100 ms, save to tick under 1 s on a local server, page open under 2 s. The console has Playwright tests today (omnium-console/tests/, nine spec files including bridge.spec.ts and medals.spec.ts), but none measures speed, and none covers a dropped, slow or conflicting change.

Tests that prove it

TestProves
Go offline in the browser, make 6 changes, come backAll 6 saved in order, none twice (V1)
Reload the page with 3 changes waitingAll 3 sent after the reload (F1)
A rule refuses a changeOld value back, the sentence under the field (V2)
Two browsers change the same scoreThe second sees both values and chooses (V3)
Type in a field while another browser saves itThe draft stays; the notice shows (V4)
A 900-unit dayScrolling stays smooth; the first rows show within the page target (V6)
Type and paste a 22-row scorecard22 commands, all saved, in order (V7)
Open as a viewerNo edit controls (V8)
Save three fields of a match where the third is refusedNothing saved (V10)

Build order

The design doc gives this order. Each step fixes something on its own.

  1. The outbox and useChange in the bridge package; the console's send() goes through it. Fixes retries and lost changes.
  2. useField on every text field. Fixes typing being overwritten.
  3. results.edit_fixture; editFixture sends one command.
  4. The five field states on the match page, then Medals and Players.
  5. React.lazy routes and the virtual list.
  6. The proxy timeout and parallel re-reads.
  7. Viewers get read-only screens.
  8. The scorecard grid, Ctrl+K and shortcuts.
  9. The shared UI package; the desks move into the repo on it.

When things go wrong

Every case ends with no change lost, none saved twice, and the person knowing where they stand. This is the agreed behaviour. What happens today is in the worked example above and in "Built today, or still to build" below.

What goes wrongWhat the person seesWhat happens underneath
The network drops for 2 minutes during scoringA bar: "Offline. 6 changes waiting, they will send when you are back." They keep working.Each change is in the outbox with its key. On reconnect they go out in order, one at a time per match (V1).
The browser crashes, or the page reloads with changes waitingOn reopening: "3 changes from before were sent"The outbox is in IndexedDB, not memory. It drains on start (V1).
The server is slow (2 s)The change shows at once with the sending mark; the tick comes laterThe click is answered on the device; the outbox waits for the server (V2).
A rule refuses the changeThe old value comes back with the reason under the fieldA refused answer with the Section 4 sentence; the row is deleted (V2).
Two operators change the same score"Priya set this to 3-1 at 12:04. Keep theirs / Use yours"A conflict answer with the current value; nothing saved until they choose (V3).
Someone changes a score while I type in itMy text stays; a notice: "Changed to 3-1 by Priya while you typed"The live push does not touch a field with focus or unsaved text (V4).
A viewer opens a matchA read-only page, no edit buttons, "You can view, not change"The screen reads canWrite from the session (V8).
The console is deployed while a page is open"A new version is ready" with Reload; nothing waiting is lostThe outbox survives the reload; a new bundle loads only when asked (V1).
The change stream dropsA small "Reconnecting" only after 5 s; the screen catches upThe stream resumes from its last place (Section 4, L9; V5).
A day with 900 units opensThe first rows show at once; scrolling stays smoothOnly the rows on screen are drawn (V6).
A full cricket scorecard is typed by hand22 rows, Tab across, Enter down, paste from a sheet; each row ticksOne results.set_line per row, through the outbox (V7).
Several fields of a match edited at onceOne sending mark, one tickOne command for all fields: saved whole or not at all (V10).

Decisions

All 17 decisions are agreed (design doc, closed 6 Oct 2026).

#DecisionIn plain words
V1One shared client in the bridge package sends every change: key made once, saved on the device before sending, one at a time per match in order, retried with the same key until answered. Every app uses it.A dropped network, a crash or a reload never loses a change and never sends one twice.
V2A change shows at once with a sending mark; accepted shows a tick; refused puts the old value back with the server's sentence under the field.Clicks feel instant, and a refusal is read where it happened, not only in a toast.
V3A conflict shows both values (theirs with who and when, and yours) and asks the person to choose.Nobody's change disappears without them knowing.
V4A field with focus or unsaved text never takes a live update; it shows a notice that the value changed.Today another person's save can replace a half-typed score.
V5The panel re-reads affected screens in parallel, and the stream's 10-second cut-off is removed by fixing the proxy timeout.Other screens catch up in about a second, without gaps.
V6Each page loads only its own code; long lists draw only what is on screen. Targets: click under 0.1 s, save confirmed under 1 s, page open under 2 s, checked in tests.The console stays fast on a 900-unit day and with 10,000 players.
V7A scorecard entry grid (columns from the sport's line catalogue, Tab and Enter, paste from a sheet, one command per row), Ctrl+K search, and listed shortcuts on every screen.A full scorecard is typed in minutes.
V8Screens follow the role: viewers see read-only pages; restricted actions appear only to those allowed.No one meets a button that will refuse them.
V9One shared UI package for the console and the scorer apps; the desks move into the repo and deploy like the console.One look, one way of working, and no app that lives only on a laptop.
V10Editing several fields of a match at once sends one command.Saved whole or not at all.
F1The outbox lives in IndexedDB, keyed by the command key, indexed by subject and time; written before sending, deleted on any final answer.A change survives a crash, a reload and a deploy.
F2One send loop per subject, in order; subjects in parallel; backoff with randomness up to 30 s; a conflict pauses that subject's loop.Order is kept where it matters, and nothing waits on another match.
F3Optimistic display through TanStack Query's onMutate: snapshot, write to the cache, roll back on refusal.Every place that shows the value updates at once, and undoes cleanly.
F4A field registry protects focus and unsaved text from live pushes, and sends saw with the save.Typing is never overwritten, and a real clash comes back as a conflict.
F5The host re-reads affected watches 4 at a time; the stream's 10 s cut-off is removed by raising the proxy timeout.Other screens update in about a second, with no gap every 10 s.
F6Routes load with React.lazy; long lists use a virtual list; likely next pages are fetched in advance.Pages open fast, whatever the size of the day.
F7Speed targets are Playwright tests in a real browser, run in CI."Fast" is checked on every change, not hoped for.

Built today, or still to build

PieceTodayAgreed designStatus
Apps in an iframe, bridge protocol v1Built: AppFrame.tsx:66, :155; protocol.ts:8KeptBuilt today
Command keyMade per call, never reused (app.ts:332); server checks it (engine.py:119-122)Made once in the outbox, reused on every retryPartly built
RetriesNone (main.tsx:33)Same key, 0.2 s doubling to 30 s, with jitterAgreed, to build
Outbox in IndexedDBNone (only the theme is in localStorage, theme.ts:18)outbox.ts, one loop per subjectAgreed, to build
Optimistic displayOnly the medal drag (MedalsPage.tsx:102-115); desks show taps at onceonMutate on every changePartly built
Five field statesSpinner on Save, toast on errorIdle, sending, saved, refused, conflictAgreed, to build
Conflict answerNone for commands; only app state has a version checkBoth values and a choice (Section 4, C3)Agreed, to build
Typing guardMedal box only (MedalsPage.tsx:1143-1145); text fields reset (FixturePage.tsx:903)useField registry on every fieldPartly built
Parallel re-readsOne by one (host.ts:215-229)4 at a timeAgreed, to build
Stream cut-off10 s (settings.py:267)0, after the proxy timeout is raisedAgreed, to build
Code splittingOne 594 KB file (App.tsx:6-14 eager imports)React.lazy per routeAgreed, to build
Virtual listsNone; folded days, paged playersTanStack Virtual for Schedule, Players, RunsAgreed, to build
Scorecard grid, Ctrl+K, shortcutsNoneScorecardPage.tsx, Palette.tsxAgreed, to build
Role-aware screensHost refuses viewers (host.ts:308-310); console shows every controlRead-only pages for viewersPartly built
One command for several fieldsOne per field (api.ts:773-788)results.edit_fixtureAgreed, to build
Shared UI packageui.tsx inside the console onlypackages-ts/omnium-uiAgreed, to build
Desks in the repoLaptop only, not in gitapps/desks, deployed like the consoleAgreed, to build
Speed tests in CIPlaywright tests exist, none for speedspeed.spec.ts in CIAgreed, to build

Numbers

NumberWhatSource
594.39 KB (186.49 KB gzipped)The console's one JS filevite build of omnium-console on a laptop, 7 Oct 2026
111.96 KB (21.29 KB gzipped)The console's one CSS fileSame build
0Retries of a changeFrom the code: main.tsx:33
30 sHow long the app waits for any answerFrom the code: app.ts:36
5 sHow long the app waits for helloFrom the code: app.ts:35
250 msWait after a change before re-reading watchesFrom the code: host.ts:196
1 sHow often the stream reads domain_eventFrom the code: bridge.py:766
10 sHow long one stream stays openFrom the code: settings.py:267
15 sThe ECS Service Connect proxy's cutFrom the comment at settings.py:263-265 (not checked in AWS)
50Players per pageFrom the code: PlayersPage.tsx:45
895 rowsA big day's schedule that felt like a freezeFrom a code comment: SchedulePage.tsx:824
under 0.1 s, under 1 s, under 2 sClick, save-to-tick, page-open targetsDesign doc, V6 (targets, not measured)
4Watches re-read at a timeDesign doc, F5
0.2 s to 30 sRetry wait, doubling, with jitterDesign doc, F2
2 sHow long the tick showsDesign doc, field states
about 30Rows a virtual list drawsDesign doc, F6 (estimate)