From e39345a4cecb0aa509e277fe66d00d2e4d54b306 Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Sat, 26 Sep 2026 19:18:59 +0000
Subject: [PATCH 1/5] =?UTF-8?q?feat(server,console,docs):=20RaceGOW=20at?=
=?UTF-8?q?=20home=20=E2=80=94=20a=20solo=20track-run=20preset,=20and=20th?=
=?UTF-8?q?e=20first=20overlay=20(#527)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
RaceGOW is an at-home whoop competition: fly the season's track, submit your
fastest 3 consecutive laps with a video that shows a timer. GridFPV already
ran that event shape, six RD screens deep. This is a start button over it.
- `EventMeta.preset` (+ `CreateEventRequest`): the console preset that built
an event, `"racegow"` here. Persisted like any meta; nothing server-side
consults it.
- `#/racegow`: the RaceGOW page. Track, pilot (or a new callsign inline),
timer, channel → `createRaceGowRun` builds the event through the wizard's
own commands: Whoop class, one-pilot roster with a fixed channel, one
time-trial round, `rounds=0`, `BestConsecutive{n:3}`, a 10-minute ceiling
(a ranking-only round with no race time is refused), a fixed 1 s hold.
- `#/racegow/run`: one button (FillRound → SetCurrentHeat → Stage → Start;
Stop = ForceEnd → Finalize), the live clock, this run's laps off the served
lap list with the best-3 window highlighted, the served round-wide best as
the headline, a copy-and-paste submission line, the runs table, the overlay
URL, and a link into the full console.
- `#/overlay/racegow`: the first overlay of the streaming design — a bare,
transparent (`html[data-overlay]`), read-only route following the active
event: run clock, recent laps, best 3 this run and today.
- Hub card; design-doc notes (streaming §5, roadmap); unit tests for the
helpers and all three screens; `e2e/racegow.spec.ts` drives the whole loop
on the Simulator, including the overlay and reopening a run.
No engine change: every run is an ordinary heat of an ordinary event, with
marshaling, audit and export behind it.
Co-Authored-By: Claude Fable 5.1
---
bindings/CreateEventRequest.ts | 6 +-
bindings/EventMeta.ts | 9 +
crates/server/src/app.rs | 2 +
crates/server/src/control_handler.rs | 7 +
crates/server/src/events.rs | 21 +
crates/server/src/open_practice.rs | 1 +
crates/server/src/round_engine.rs | 1 +
.../server/tests/min_lap_floor_conformance.rs | 1 +
crates/server/tests/multi_round_event_live.rs | 1 +
docs/roadmap.html | 16 +
docs/streaming.html | 17 +
frontend/apps/rd-console/src/App.svelte | 42 +
frontend/apps/rd-console/src/app.css | 9 +
frontend/apps/rd-console/src/lib/racegow.ts | 236 ++++++
frontend/apps/rd-console/src/lib/route.ts | 51 +-
.../apps/rd-console/src/lib/session.svelte.ts | 11 +
.../rd-console/src/screens/HomeHub.svelte | 45 +-
.../src/screens/RaceGowOverlay.svelte | 269 +++++++
.../rd-console/src/screens/RaceGowPage.svelte | 442 ++++++++++
.../rd-console/src/screens/RaceGowRun.svelte | 761 ++++++++++++++++++
.../apps/rd-console/tests/HomeHub.test.ts | 16 +-
.../rd-console/tests/RaceGowOverlay.test.ts | 96 +++
.../apps/rd-console/tests/RaceGowPage.test.ts | 168 ++++
.../apps/rd-console/tests/RaceGowRun.test.ts | 217 +++++
.../apps/rd-console/tests/racegow.test.ts | 217 +++++
frontend/apps/rd-console/tests/route.test.ts | 39 +
frontend/e2e/racegow.spec.ts | 151 ++++
27 files changed, 2843 insertions(+), 9 deletions(-)
create mode 100644 frontend/apps/rd-console/src/lib/racegow.ts
create mode 100644 frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
create mode 100644 frontend/apps/rd-console/src/screens/RaceGowPage.svelte
create mode 100644 frontend/apps/rd-console/src/screens/RaceGowRun.svelte
create mode 100644 frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
create mode 100644 frontend/apps/rd-console/tests/RaceGowPage.test.ts
create mode 100644 frontend/apps/rd-console/tests/RaceGowRun.test.ts
create mode 100644 frontend/apps/rd-console/tests/racegow.test.ts
create mode 100644 frontend/e2e/racegow.spec.ts
diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts
index ccb648b1..0163711d 100644
--- a/bindings/CreateEventRequest.ts
+++ b/bindings/CreateEventRequest.ts
@@ -29,4 +29,8 @@ description?: string,
/**
* Optional organizer name, stored on [`EventMeta::organizer`].
*/
-organizer?: string, };
+organizer?: string,
+/**
+ * Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`).
+ */
+preset?: string, };
diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts
index 4c333dd1..5510e94b 100644
--- a/bindings/EventMeta.ts
+++ b/bindings/EventMeta.ts
@@ -55,6 +55,15 @@ description?: string,
* Optional organizer name (the running club / person). Omitted from the wire when unset.
*/
organizer?: string,
+/**
+ * The **console preset** that created this event, when one did — `"racegow"` for a solo
+ * RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win
+ * condition) built by the console through the ordinary create/configure commands; the marker
+ * only records *which* preset shaped it, so the console can open the event on the preset's
+ * own surface (the solo run screen) instead of the full workspace. Free-text on the wire and
+ * absent for an ordinary event; nothing server-side consults it. Omitted when unset.
+ */
+preset?: string,
/**
* The application-level timers this event **selects** (issue #73) — the per-event reference
* into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 4e5cc67c..a0b94d4b 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -4381,6 +4381,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap();
assert!(registry.resolve(&created.id).is_some());
@@ -4420,6 +4421,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap();
let (status, _) = delete_event_req(registry.clone(), &created.id.0, None).await;
diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs
index 1c6fdfb4..9f603422 100644
--- a/crates/server/src/control_handler.rs
+++ b/crates/server/src/control_handler.rs
@@ -4281,6 +4281,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -4430,6 +4431,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -5197,6 +5199,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -5511,6 +5514,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -5978,6 +5982,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -6151,6 +6156,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
@@ -6234,6 +6240,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap()
.id;
diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs
index 7feb812d..cc5c0ef4 100644
--- a/crates/server/src/events.rs
+++ b/crates/server/src/events.rs
@@ -105,6 +105,15 @@ pub struct EventMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub organizer: Option,
+ /// The **console preset** that created this event, when one did — `"racegow"` for a solo
+ /// RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win
+ /// condition) built by the console through the ordinary create/configure commands; the marker
+ /// only records *which* preset shaped it, so the console can open the event on the preset's
+ /// own surface (the solo run screen) instead of the full workspace. Free-text on the wire and
+ /// absent for an ordinary event; nothing server-side consults it. Omitted when unset.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub preset: Option,
/// The application-level timers this event **selects** (issue #73) — the per-event reference
/// into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive
/// (`#[serde(default)]`) so an event persisted before #73 reads back with an empty list; new
@@ -1627,6 +1636,10 @@ pub struct CreateEventRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub organizer: Option,
+ /// Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub preset: Option,
}
impl CreateEventRequest {
@@ -1640,6 +1653,7 @@ impl CreateEventRequest {
location: None,
description: None,
organizer: None,
+ preset: None,
}
}
}
@@ -3030,6 +3044,7 @@ impl EventRegistry {
location: normalize_optional(&request.location),
description: normalize_optional(&request.description),
organizer: normalize_optional(&request.organizer),
+ preset: normalize_optional(&request.preset),
timers: default_timer_selection(),
primary_timer: None,
roster: Vec::new(),
@@ -4421,6 +4436,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.unwrap();
event_id = created.id.clone();
@@ -4463,6 +4479,7 @@ mod tests {
location: Some("Main field".to_string()),
description: None,
organizer: Some("GridFPV Club".to_string()),
+ preset: Some("racegow".to_string()),
})
.unwrap();
created_id = created.id.clone();
@@ -4503,6 +4520,10 @@ mod tests {
assert_eq!(restored.date.as_deref(), Some("2026-06-20"));
assert_eq!(restored.location.as_deref(), Some("Main field"));
assert_eq!(restored.organizer.as_deref(), Some("GridFPV Club"));
+ // The preset marker is ordinary meta: stored on create, restored on reload (the console
+ // opens a `racegow` event on the solo run screen, so losing it would demote a track run
+ // to a plain event after a restart).
+ assert_eq!(restored.preset.as_deref(), Some("racegow"));
assert_eq!(
restored.timers,
vec![TimerId("rh-1".into()), TimerId(MOCK_TIMER_ID.into())]
diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs
index fcc35c9e..afe4899b 100644
--- a/crates/server/src/open_practice.rs
+++ b/crates/server/src/open_practice.rs
@@ -127,6 +127,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
timers: vec![],
primary_timer: None,
roster: vec![],
diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs
index 19888d8a..d975959e 100644
--- a/crates/server/src/round_engine.rs
+++ b/crates/server/src/round_engine.rs
@@ -3066,6 +3066,7 @@ mod tests {
location: None,
description: None,
organizer: None,
+ preset: None,
timers: vec![],
primary_timer: None,
roster: vec![],
diff --git a/crates/server/tests/min_lap_floor_conformance.rs b/crates/server/tests/min_lap_floor_conformance.rs
index e5096779..cea43803 100644
--- a/crates/server/tests/min_lap_floor_conformance.rs
+++ b/crates/server/tests/min_lap_floor_conformance.rs
@@ -135,6 +135,7 @@ fn rig() -> Rig {
location: None,
description: None,
organizer: None,
+ preset: None,
})
.expect("event created");
let event = meta.id.clone();
diff --git a/crates/server/tests/multi_round_event_live.rs b/crates/server/tests/multi_round_event_live.rs
index 3193d0e4..f202f1d2 100644
--- a/crates/server/tests/multi_round_event_live.rs
+++ b/crates/server/tests/multi_round_event_live.rs
@@ -607,6 +607,7 @@ async fn build_event(
location: None,
description: None,
organizer: None,
+ preset: None,
},
)
.await;
diff --git a/docs/roadmap.html b/docs/roadmap.html
index db2798b0..b8cdb5b4 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -125,6 +125,22 @@ 2. The phases
GET /about + the console build-stamp; CONTRACT_VERSION stays
the independent wire integer).
+
+ Status (2026-09-26): v0.4.0 is released (the non-prerelease
+ v0.4.0 , 2026-09-07),
+ and the first work after it is a preset , not a phase:
+ RaceGOW at home — a solo track-run surface over the existing
+ engine. An event marked EventMeta.preset = "racegow" is built by the
+ console through the wizard's own commands (Whoop class, one pilot with a fixed channel,
+ an open-ended Time Trials round scored by best 3 consecutive laps, a 10-minute
+ ceiling), then driven from one screen (#/racegow/run: fill → stage → start,
+ stop → finalize) with its result as a copy-and-paste submission line. It also ships the
+ first overlay of the streaming design,
+ #/overlay/racegow, because the competition requires a timer in the video.
+ The point of building it as a preset: it proves the primitives carry a real
+ competition's rules with zero engine change, and every RaceGOW run remains an
+ ordinary event with marshaling, audit and results behind it.
+
v0.1 — Walking skeleton
diff --git a/docs/streaming.html b/docs/streaming.html
index a2a160ca..02e88796 100644
--- a/docs/streaming.html
+++ b/docs/streaming.html
@@ -308,6 +308,23 @@ 5. Overlay catalogue & theming
Each gets a fuller spec as it is built; the list confirms the protocol exposes what they need.
+
+
+ As built (2026-09-26): the first overlay is the RaceGOW timer,
+ #/overlay/racegow. It is a route of the RD console SPA rather than a
+ separate app — the "one codebase, profiled three ways" frontend — rendered bare, on a
+ transparent document (html[data-overlay] lifts the console canvas), in the
+ .gridfpv-overlay token context. It is exactly the §2 shape: a read-only
+ client on the same session as the console, following the program (§2.1) — the
+ Director's active event — with no binding of its own, because a solo pilot has one
+ run open. It shows the run clock, the last few laps off the served lap-list
+ projection, and the best 3-consecutive window this run and today (the served round
+ standings). It exists because RaceGOW's rules require a timer in view in the
+ submitted video; the pilot adds it to OBS as a browser source over their DVR. Nothing
+ in §3 (broadcast control) is built.
+
+
+
5.1 Theming & chapter branding
Theming is presentation, never data. Overlays render from
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index 9826cc27..783556f1 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -45,6 +45,10 @@
import Marshaling from './screens/Marshaling.svelte';
import Results from './screens/Results.svelte';
import EventAudit from './screens/EventAudit.svelte';
+ import RaceGowPage from './screens/RaceGowPage.svelte';
+ import RaceGowRun from './screens/RaceGowRun.svelte';
+ import RaceGowOverlay from './screens/RaceGowOverlay.svelte';
+ import { isRaceGowEvent } from './lib/racegow.js';
import { openAudit } from './lib/auditFilter.svelte.js';
import type { Timer } from '@gridfpv/types';
import {
@@ -115,6 +119,11 @@
};
/** Back out of an event-scoped tune: the event workspace's Timers tab — where the RD came from. */
const goEventTimersTab = () => navigate({ kind: 'workspace', tab: 'timers' });
+ // The RaceGOW preset's two surfaces: the page that starts a run, and the solo run screen for
+ // the active one. Both are app-level routes (no workspace shell) — a pilot alone in a garage
+ // wants a start button, not a race director's console; the workspace stays one click away.
+ const goRaceGow = () => navigate({ kind: 'racegow' });
+ const goRaceGowRun = () => navigate({ kind: 'racegow-run' });
// The workspace's app-route concept is "Events" (where event entry/switch happens).
const route$page = $derived(route.kind === 'page' ? route.page : 'home');
@@ -346,6 +355,38 @@
{/if}
+{:else if route.kind === 'overlay'}
+
+
+
+
+{:else if route.kind === 'racegow'}
+
+ goPage('timers')} />
+
+{:else if route.kind === 'racegow-run'}
+
+
+ {#if isRaceGowEvent(session.currentEvent)}
+ navigate({ kind: 'workspace', tab: 'live' })}
+ ontimers={() => goPage('timers')}
+ />
+ {:else}
+ goPage('timers')}
+ />
+ {/if}
+
{:else if route.kind === 'page'}
+
+
+
+
+
+
+
+
RaceGOW
+
+ {fmt(raceGowCount)}
+ {raceGowCount === 1 ? 'track run' : 'track runs'}
+ · time your best 3 laps at home
+
+
→
+
+
+
+
+
diff --git a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
new file mode 100644
index 00000000..6fcbe0e6
--- /dev/null
+++ b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
@@ -0,0 +1,269 @@
+
+
+
+
+
+ GridFPV
+ {#if active && event}
+ {event.name}{callsign ? ` · ${callsign}` : ''}
+ {:else}
+ waiting for a run…
+ {/if}
+
+
+ {#if active}
+
+
+
+ {#if phase === 'Running'}
+ LIVE
+ {:else if phase === 'Armed'}
+ READY
+ {:else if phase === 'Unofficial' || phase === 'Final'}
+ STOPPED
+ {:else}
+ —
+ {/if}
+
+
+
+
+
+ Best {RACEGOW_LAPS} this run
+
+ {best ? formatMicros(best.totalMicros) : '—'}
+
+
+
+ Best {RACEGOW_LAPS} today
+
+ {sessionBest !== undefined ? formatMicros(sessionBest) : '—'}
+
+
+
+
+
+ {#each recent as lap, i (lap.end_ref)}
+
+ L{recentOffset + i + 1}
+ {formatMicros(lap.duration_micros)}
+
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
new file mode 100644
index 00000000..4683205b
--- /dev/null
+++ b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
@@ -0,0 +1,442 @@
+
+
+
+
+
+
+
+
+
+
RaceGOW
+
+ Time your track runs at home. Fly as many packs as you like — GridFPV keeps every lap and
+ finds your fastest 3 laps in a row , the time RaceGOW asks for.
+
+
+
+
+ {#if isRaceGowEvent(session.currentEvent)}
+
+
+
+
+ Open now
+ {session.currentEvent.name}
+
+
Back to the run
+
+
+ {/if}
+
+
+
+
+
+
+ {#if runs === undefined}
+ Loading…
+ {:else if runs.length === 0}
+ No runs yet. Your first one shows up here.
+ {:else}
+
+
+ A run is an ordinary event: rename or delete it on the Events page.
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
new file mode 100644
index 00000000..d9cc3b03
--- /dev/null
+++ b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
@@ -0,0 +1,761 @@
+
+
+
+
+
+
+
+
+
+
{event?.name ?? 'RaceGOW run'}
+
+ {callsign} on {timerLabel}
+ {#if timer}
+ ·
+ {timerDown ? 'timer not connected' : 'timer connected'}
+
+ {/if}
+
+
+
+ Open in the full console
+
+
+
+ {#if !round}
+
+ The event was changed in the full console. Add a Time Trials round scored by best 3 laps, or
+ start a new run.
+
+ {/if}
+ {#if timerDown}
+
+ Laps cannot arrive until it is.
+ {#snippet actions()}
+ Timers
+ {/snippet}
+
+ {/if}
+
session.clearCommandError()} />
+ {#if actionError}
+ (actionError = undefined)}>
+ {actionError}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if view === 'idle'}
+ {runRows.length === 0
+ ? 'Ready when you are'
+ : `${runRows.length} ${runRows.length === 1 ? 'run' : 'runs'} flown`}
+ {:else if view === 'ready'}
+ Run {runRows.length} is drawn
+ {:else if view === 'staged'}
+ Run {runRows.length} · staged
+ {:else if view === 'arming'}
+ Run {runRows.length} · timer going live…
+ {:else if view === 'running'}
+ Run {runRows.length} · flying
+ {:else}
+ Run {runRows.length} · stopped
+ {/if}
+
+
+
+
+ {#if view === 'idle'}
+
+ {busy ? 'Starting…' : runRows.length === 0 ? 'Start run' : 'Fly again'}
+
+ {:else if view === 'ready' || view === 'staged'}
+
+ {busy ? 'Starting…' : 'Start run'}
+
+ {:else if view === 'arming'}
+ Going live…
+ {:else if view === 'running'}
+
+ {busy ? 'Stopping…' : 'Stop run'}
+
+ {:else}
+
+ {busy ? 'Saving…' : 'Save run'}
+
+ {/if}
+
+
+
+
+
+ This run · best {RACEGOW_LAPS} laps
+
+ {currentBest ? formatMicros(currentBest.totalMicros) : '—'}
+
+ {#if currentBest}
+ {currentBest.laps.map((l) => formatMicros(l)).join(' · ')}
+ {:else if view === 'running' || view === 'arming'}
+ Fly {RACEGOW_LAPS} laps in a row to get a time.
+ {/if}
+
+
+
+ {#each currentLaps as lap, i (lap.end_ref)}
+
+ {lapLabel(i)}
+ {formatMicros(lap.duration_micros)}
+
+ {/each}
+ {#if currentLaps.length === 0 && heat}
+ No laps yet.
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {headlineMicros !== undefined ? formatMicros(headlineMicros) : '—'}
+
+ {#if bestRun?.best}
+
+ Run {bestRun.number}, laps {bestRun.best.start + 1}–{bestRun.best.start +
+ bestRun.best.laps.length}: {bestRun.best.laps
+ .map((l) => formatMicros(l))
+ .join(' · ')}
+
+ {:else}
+
Save a run with {RACEGOW_LAPS} laps in a row and it shows here.
+ {/if}
+ {#if submission}
+
For the RaceGOW form
+
+
+ copy(submission, 'Submission text')}
+ >
+ Copy
+
+
+ {/if}
+
+
+
+
+
+
+ RaceGOW wants a timer in view. Add this page to OBS as a Browser source
+ over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps
+ and the best 3 so far, on a transparent background.
+
+
{overlay}
+
+
copy(overlay, 'Overlay URL')}>
+ Copy URL
+
+
Open overlay
+
+
+
+
+
+
+ {#if runRows.length === 0}
+ Every run you fly on this track is listed here with its best 3 laps.
+ {:else}
+
+
+
+ Run
+ State
+ Laps
+ Best {RACEGOW_LAPS}
+
+
+
+ {#each runRows as row (row.heat)}
+
+ Run {row.number}
+ {phaseLabel(row.phase)}
+ {row.laps}
+ {row.best ? formatMicros(row.best.totalMicros) : '—'}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts
index 34898f55..38679b5e 100644
--- a/frontend/apps/rd-console/tests/HomeHub.test.ts
+++ b/frontend/apps/rd-console/tests/HomeHub.test.ts
@@ -99,13 +99,15 @@ describe('HomeHub (app-level landing, #118)', () => {
const onclasses = vi.fn();
const onevents = vi.fn();
const ontimers = vi.fn();
- render(HomeHub, { session, onpilots, onclasses, onevents, ontimers });
+ const onracegow = vi.fn();
+ render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow });
- // Four navigable cards.
+ // Five navigable cards: the four pages, plus RaceGOW (the solo track-run preset).
expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'RaceGOW' })).toBeInTheDocument();
// Summaries settle per card (count + unit are separate spans): 2 pilots, 3 classes, 2 events,
// 3 timers · 2 connected (Mock is Ready + the live RH is Connected; the Configured RH is excluded).
@@ -119,6 +121,10 @@ describe('HomeHub (app-level landing, #118)', () => {
expect(within(classesCard).getByText('classes')).toBeInTheDocument();
await waitFor(() => expect(within(eventsCard).getByText('2')).toBeInTheDocument());
expect(within(eventsCard).getByText('events')).toBeInTheDocument();
+ // RaceGOW counts only the events the preset made: neither of these two carries the marker.
+ const raceGowCard = screen.getByRole('heading', { name: 'RaceGOW' }).closest('button')!;
+ await waitFor(() => expect(within(raceGowCard).getByText('0')).toBeInTheDocument());
+ expect(within(raceGowCard).getByText('track runs')).toBeInTheDocument();
await waitFor(() => expect(within(timersCard).getByText('timers')).toBeInTheDocument());
expect(within(timersCard).getByText(/2 connected/)).toBeInTheDocument();
});
@@ -129,7 +135,8 @@ describe('HomeHub (app-level landing, #118)', () => {
const onclasses = vi.fn();
const onevents = vi.fn();
const ontimers = vi.fn();
- render(HomeHub, { session, onpilots, onclasses, onevents, ontimers });
+ const onracegow = vi.fn();
+ render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow });
await fireEvent.click(screen.getByRole('heading', { name: 'Pilots' }).closest('button')!);
await fireEvent.click(screen.getByRole('heading', { name: 'Classes' }).closest('button')!);
@@ -155,7 +162,8 @@ describe('HomeHub (app-level landing, #118)', () => {
onpilots: vi.fn(),
onclasses: vi.fn(),
onevents: vi.fn(),
- ontimers: vi.fn()
+ ontimers: vi.fn(),
+ onracegow: vi.fn()
});
const timersCard = screen.getByRole('heading', { name: 'Timers' }).closest('button')!;
diff --git a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
new file mode 100644
index 00000000..55c48a69
--- /dev/null
+++ b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
@@ -0,0 +1,96 @@
+/**
+ * RaceGowOverlay — the OBS browser source: idle with no RaceGOW run active, and the clock, recent
+ * laps, this run's best window and the served session best once one is flying.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/svelte';
+import { waitFor } from '@testing-library/dom';
+import type { EventMeta, LapList, LiveRaceState } from '@gridfpv/types';
+import RaceGowOverlay from '../src/screens/RaceGowOverlay.svelte';
+import { makeTestSession } from './support.js';
+
+const RUN: EventMeta = {
+ id: 'track1-ab12',
+ name: 'RaceGOW6 Track 1',
+ created_at: 0,
+ persistent: true,
+ preset: 'racegow',
+ timers: ['rh-1'],
+ roster: ['p1'],
+ classes: ['mgp-whoop'],
+ rounds: [
+ {
+ id: 'racegow-x1',
+ label: 'RaceGOW',
+ classes: ['mgp-whoop'],
+ format: 'timed_qual',
+ params: { rounds: '0' },
+ win_condition: { BestConsecutive: { n: 3 } },
+ seeding: 'FromRoster'
+ }
+ ]
+} as unknown as EventMeta;
+
+const LAPS: LapList = {
+ competitors: [
+ {
+ competitor: { adapter: 'rotorhazard', competitor: 'p1' },
+ laps: [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9].map((s, i) => ({
+ number: i + 1,
+ duration_micros: Math.round(s * 1_000_000),
+ at: (i + 1) * 10_000_000,
+ start_ref: i,
+ end_ref: i + 1
+ }))
+ }
+ ]
+};
+
+describe('RaceGowOverlay', () => {
+ it('renders idle with no run active, and marks the document as an overlay', () => {
+ const { session } = makeTestSession({ noEnter: true });
+ render(RaceGowOverlay, { session });
+ expect(screen.getByText('waiting for a run…')).toBeInTheDocument();
+ expect(document.documentElement.dataset.overlay).toBe('racegow');
+ });
+
+ it('shows the track, the pilot, LIVE, the recent laps and both bests while flying', async () => {
+ const live: LiveRaceState = {
+ phase: 'Running',
+ current_heat: 'racegow-x1-3',
+ active_pilots: ['p1'],
+ race_started_at: 1_000_000
+ };
+ const { session } = makeTestSession({
+ event: RUN,
+ live,
+ listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]),
+ roundStandingsImpl: vi.fn(async () => [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_800_000,
+ laps: 8,
+ metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
+ }
+ ]),
+ heatFetches: { 'racegow-x1-3': { laps: LAPS } }
+ });
+ render(RaceGowOverlay, { session });
+
+ expect(await screen.findByText('RaceGOW6 Track 1 · Recon')).toBeInTheDocument();
+ expect(screen.getByText('LIVE')).toBeInTheDocument();
+ expect(screen.getByRole('timer', { name: /Run time/ })).toBeInTheDocument();
+ // Only the last six of eight laps fit the frame; they keep their real lap numbers.
+ const laps = await screen.findByRole('list', { name: 'Recent laps' });
+ await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6));
+ expect(within(laps).getByText('L3')).toBeInTheDocument();
+ expect(within(laps).getByText('L8')).toBeInTheDocument();
+ expect(within(laps).queryByText('L2')).not.toBeInTheDocument();
+ // This run's best window (laps 2–4 = 20.600) and the served session best.
+ expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600');
+ await waitFor(() =>
+ expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('20.500')
+ );
+ });
+});
diff --git a/frontend/apps/rd-console/tests/RaceGowPage.test.ts b/frontend/apps/rd-console/tests/RaceGowPage.test.ts
new file mode 100644
index 00000000..4b62ef7f
--- /dev/null
+++ b/frontend/apps/rd-console/tests/RaceGowPage.test.ts
@@ -0,0 +1,168 @@
+/**
+ * RaceGowPage — the RaceGOW landing: the form defaults sensibly off the directories, refuses a
+ * timer with no channels (pointing at the Timers page), builds the run through the session's
+ * ordinary commands, and lists earlier runs to reopen.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/svelte';
+import { fireEvent, waitFor } from '@testing-library/dom';
+import type { EventMeta, Pilot, Timer } from '@gridfpv/types';
+import RaceGowPage from '../src/screens/RaceGowPage.svelte';
+import { makeTestSession } from './support.js';
+
+const RECON: Pilot = { id: 'p1', callsign: 'Recon', vtx_types: [] };
+const RH: Timer = {
+ id: 'rh-1',
+ name: 'Garage RH',
+ kind: { Rotorhazard: { url: 'http://rh.local:5000' } },
+ status: 'Connected',
+ channel_capability: 'Flexible',
+ node_count: 1,
+ available_channels: [5658, 5880],
+ manual_connect: false,
+ calibration: [],
+ disabled_nodes: []
+};
+const BARE_RH: Timer = { ...RH, id: 'rh-2', name: 'Bare RH', available_channels: [] };
+const MOCK: Timer = {
+ id: 'mock',
+ name: 'Mock',
+ kind: { Mock: { laps: 3, lap_ms: 30000 } },
+ status: 'Ready',
+ channel_capability: 'Flexible',
+ node_count: 8,
+ available_channels: [5658],
+ manual_connect: false,
+ calibration: [],
+ disabled_nodes: []
+};
+const OLD_RUN: EventMeta = {
+ id: 'track1-old1',
+ name: 'RaceGOW6 Track 1',
+ created_at: 1000,
+ persistent: true,
+ preset: 'racegow',
+ timers: ['rh-1'],
+ roster: ['p1'],
+ classes: ['mgp-whoop']
+};
+const CLUB: EventMeta = {
+ id: 'club-1',
+ name: 'Club night',
+ created_at: 2000,
+ persistent: true,
+ timers: ['rh-1'],
+ roster: [],
+ classes: []
+};
+
+function setup(opts: { pilots?: Pilot[]; timers?: Timer[]; events?: EventMeta[] } = {}) {
+ const { session } = makeTestSession({
+ noEnter: true,
+ listPilotsImpl: vi.fn(async () => opts.pilots ?? [RECON]),
+ listTimersImpl: vi.fn(async () => opts.timers ?? [MOCK, RH]),
+ listChannelsImpl: vi.fn(async () => [
+ { band: 'Raceband', channel: 'R1', mhz: 5658 },
+ { band: 'Raceband', channel: 'R7', mhz: 5880 }
+ ])
+ });
+ vi.spyOn(session, 'listEvents').mockResolvedValue(opts.events ?? [OLD_RUN, CLUB]);
+ const props = { onhome: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() };
+ return { session, props };
+}
+
+describe('RaceGowPage', () => {
+ it('defaults to the only pilot and the connected RotorHazard, with its first channel', async () => {
+ const { session, props } = setup();
+ render(RaceGowPage, { session, ...props });
+ const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement;
+ await waitFor(() => expect(pilot.value).toBe('p1'));
+ const timer = screen.getByLabelText('Timer') as HTMLSelectElement;
+ expect(timer.value).toBe('rh-1');
+ const channel = screen.getByLabelText('Your channel') as HTMLSelectElement;
+ expect(channel.value).toBe('5658');
+ // Channel options carry the friendly band + channel, with the MHz beside it.
+ expect(within(channel).getByRole('option', { name: /Raceband R7/ })).toBeInTheDocument();
+ expect(screen.getByLabelText('Track')).toHaveValue('RaceGOW6 Track 1');
+ });
+
+ it('builds the run through the ordinary commands and hands off to the run screen', async () => {
+ const { session, props } = setup();
+ const created: EventMeta = { ...OLD_RUN, id: 'track1-new1' };
+ const create = vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created);
+ const timers = vi.spyOn(session, 'setEventTimers').mockResolvedValue(created);
+ const primary = vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created);
+ const classes = vi.spyOn(session, 'setEventClasses').mockResolvedValue(created);
+ const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created);
+ const members = vi.spyOn(session, 'setClassMembership').mockResolvedValue(created);
+ const round = vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never);
+ render(RaceGowPage, { session, ...props });
+
+ const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement;
+ await waitFor(() => expect(pilot.value).toBe('p1'));
+ await fireEvent.change(screen.getByLabelText('Your channel'), { target: { value: '5880' } });
+ await fireEvent.input(screen.getByLabelText('Track'), {
+ target: { value: 'RaceGOW6 Track 2' }
+ });
+ await fireEvent.click(screen.getByRole('button', { name: 'Set up run' }));
+
+ await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
+ expect(create).toHaveBeenCalledWith('RaceGOW6 Track 2', { preset: 'racegow' });
+ expect(timers).toHaveBeenCalledWith(['rh-1']);
+ expect(primary).toHaveBeenCalledWith('rh-1');
+ expect(classes).toHaveBeenCalledWith(['mgp-whoop']);
+ expect(roster).toHaveBeenCalledWith(['p1']);
+ expect(members).toHaveBeenCalledWith('mgp-whoop', [{ pilot: 'p1', channel: 5880 }]);
+ expect(round).toHaveBeenCalledTimes(1);
+ });
+
+ it('adds a new pilot inline when there is none, then builds the run for them', async () => {
+ const { session, props } = setup({ pilots: [] });
+ const createPilot = vi
+ .spyOn(session, 'createPilot')
+ .mockResolvedValue({ id: 'p9', callsign: 'Newbie', vtx_types: [] });
+ const created: EventMeta = { ...OLD_RUN, id: 'x', roster: ['p9'] };
+ vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created);
+ vi.spyOn(session, 'setEventTimers').mockResolvedValue(created);
+ vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created);
+ vi.spyOn(session, 'setEventClasses').mockResolvedValue(created);
+ const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created);
+ vi.spyOn(session, 'setClassMembership').mockResolvedValue(created);
+ vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never);
+ render(RaceGowPage, { session, ...props });
+
+ // No pilots → the picker sits on "New pilot" and the callsign field is up.
+ const callsign = await screen.findByLabelText('Callsign');
+ await fireEvent.input(callsign, { target: { value: 'Newbie' } });
+ await fireEvent.click(screen.getByRole('button', { name: 'Set up run' }));
+
+ await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
+ expect(createPilot).toHaveBeenCalledWith({ callsign: 'Newbie', vtx_types: [] });
+ expect(roster).toHaveBeenCalledWith(['p9']);
+ });
+
+ it('refuses a timer with no channels and points at the Timers page', async () => {
+ const { session, props } = setup({ timers: [BARE_RH] });
+ const create = vi.spyOn(session, 'createEventAndEnter');
+ render(RaceGowPage, { session, ...props });
+ await screen.findByText('This timer has no channels set yet.');
+ expect(screen.getByRole('button', { name: 'Set up run' })).toBeDisabled();
+ expect(screen.queryByLabelText('Your channel')).not.toBeInTheDocument();
+ await fireEvent.click(screen.getByRole('button', { name: 'Timers page' }));
+ expect(props.ontimers).toHaveBeenCalledTimes(1);
+ expect(create).not.toHaveBeenCalled();
+ });
+
+ it('lists only RaceGOW runs, and reopens one through chooseEvent', async () => {
+ const { session, props } = setup();
+ const choose = vi.spyOn(session, 'chooseEvent').mockResolvedValue(OLD_RUN);
+ render(RaceGowPage, { session, ...props });
+ const runs = await screen.findByRole('list', { name: 'Your runs' });
+ expect(within(runs).getAllByRole('listitem')).toHaveLength(1);
+ expect(within(runs).getByText('RaceGOW6 Track 1')).toBeInTheDocument();
+ expect(within(runs).queryByText('Club night')).not.toBeInTheDocument();
+ await fireEvent.click(within(runs).getByRole('button', { name: 'Open RaceGOW6 Track 1' }));
+ await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
+ expect(choose).toHaveBeenCalledWith(OLD_RUN);
+ });
+});
diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
new file mode 100644
index 00000000..8e84ccda
--- /dev/null
+++ b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
@@ -0,0 +1,217 @@
+/**
+ * RaceGowRun — the solo run screen: the one button issues the right command sequence for each
+ * phase, the laps and best-3 window render off the served lap list, and the round-wide best comes
+ * from the served standings. The e2e (`e2e/racegow.spec.ts`) drives the real loop; this pins what
+ * the screen sends and shows.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/svelte';
+import { fireEvent, waitFor } from '@testing-library/dom';
+import type { EventMeta, HeatSummary, LapList, LiveRaceState, RoundStanding } from '@gridfpv/types';
+import RaceGowRun from '../src/screens/RaceGowRun.svelte';
+import { makeTestSession } from './support.js';
+
+const EVENT: EventMeta = {
+ id: 'track1-ab12',
+ name: 'RaceGOW6 Track 1',
+ created_at: 0,
+ persistent: true,
+ preset: 'racegow',
+ timers: ['rh-1'],
+ primary_timer: 'rh-1',
+ roster: ['p1'],
+ classes: ['mgp-whoop'],
+ classes_membership: [{ class: 'mgp-whoop', pilots: [{ pilot: 'p1', channel: 5880 }] }],
+ rounds: [
+ {
+ id: 'racegow-x1',
+ label: 'RaceGOW',
+ classes: ['mgp-whoop'],
+ format: 'timed_qual',
+ params: { rounds: '0' },
+ win_condition: { BestConsecutive: { n: 3 } },
+ seeding: 'FromRoster',
+ channel_mode: 'Static'
+ }
+ ]
+} as unknown as EventMeta;
+
+function lapList(durations: number[]): LapList {
+ return {
+ competitors: [
+ {
+ competitor: { adapter: 'rotorhazard', competitor: 'p1' },
+ laps: durations.map((d, i) => ({
+ number: i + 1,
+ duration_micros: d,
+ at: (i + 1) * 10_000_000,
+ start_ref: i,
+ end_ref: i + 1
+ }))
+ }
+ ]
+ };
+}
+
+function heat(id: string, phase: HeatSummary['phase'], current = false): HeatSummary {
+ return {
+ heat: id,
+ name: `RaceGOW Heat ${id.slice(-1)}`,
+ lineup: ['p1'],
+ round: 'racegow-x1',
+ class: 'mgp-whoop',
+ phase,
+ is_current: current
+ };
+}
+
+const idle: LiveRaceState = { phase: 'Scheduled' };
+
+function setup(opts: {
+ live?: LiveRaceState;
+ heats?: HeatSummary[];
+ laps?: Record;
+ standings?: RoundStanding[];
+}) {
+ const heats = opts.heats ?? [];
+ const listHeatsImpl = vi.fn(async () => heats);
+ const roundStandingsImpl = vi.fn(async () => opts.standings ?? []);
+ const heatFetches = Object.fromEntries(
+ Object.entries(opts.laps ?? {}).map(([id, d]) => [id, { laps: lapList(d) }])
+ );
+ const t = makeTestSession({
+ event: EVENT,
+ live: opts.live ?? idle,
+ listHeatsImpl,
+ roundStandingsImpl,
+ listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]),
+ heatFetches
+ });
+ return { ...t, listHeatsImpl, roundStandingsImpl };
+}
+
+const props = { onhome: vi.fn(), onback: vi.fn(), onconsole: vi.fn(), ontimers: vi.fn() };
+
+describe('RaceGowRun', () => {
+ it('idle: "Start run" draws the next heat, focuses it, stages and starts it', async () => {
+ // The fill lands a Scheduled heat the screen must then find in the heats list.
+ const drawn = heat('racegow-x1-1', 'Scheduled');
+ const { session, sendSpy, listHeatsImpl } = setup({ heats: [] });
+ listHeatsImpl.mockResolvedValue([drawn]);
+ render(RaceGowRun, { session, ...props });
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Start run' }));
+
+ await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(4));
+ expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
+ { FillRound: { round: 'racegow-x1', mode: 'Next' } },
+ { SetCurrentHeat: { heat: 'racegow-x1-1' } },
+ { Stage: { heat: 'racegow-x1-1' } },
+ { Start: { heat: 'racegow-x1-1' } }
+ ]);
+ });
+
+ it('a drawn-but-unstarted heat is started without drawing another', async () => {
+ const { session, sendSpy } = setup({
+ live: { phase: 'Scheduled', current_heat: 'racegow-x1-1' },
+ heats: [heat('racegow-x1-1', 'Scheduled', true)]
+ });
+ render(RaceGowRun, { session, ...props });
+ await fireEvent.click(await screen.findByRole('button', { name: 'Start run' }));
+ await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2));
+ expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
+ { Stage: { heat: 'racegow-x1-1' } },
+ { Start: { heat: 'racegow-x1-1' } }
+ ]);
+ });
+
+ it('running: shows the laps and this run’s best 3 window; "Stop run" ends and finalizes', async () => {
+ const { session, sendSpy } = setup({
+ live: {
+ phase: 'Running',
+ current_heat: 'racegow-x1-1',
+ active_pilots: ['p1'],
+ race_started_at: 1_000_000
+ },
+ heats: [heat('racegow-x1-1', 'Running', true)],
+ laps: { 'racegow-x1-1': [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000] }
+ });
+ render(RaceGowRun, { session, ...props });
+
+ // The served laps render, and the best window (laps 2–4 = 20.600) is called out.
+ const laps = await screen.findByRole('list', { name: "This run's laps" });
+ await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(5));
+ expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600');
+ const rows = within(laps).getAllByRole('listitem');
+ expect(rows[1]).toHaveClass('in-window');
+ expect(rows[3]).toHaveClass('in-window');
+ expect(rows[0]).not.toHaveClass('in-window');
+ expect(rows[4]).not.toHaveClass('in-window');
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Stop run' }));
+ await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2));
+ expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
+ { ForceEnd: { heat: 'racegow-x1-1' } },
+ { Finalize: { heat: 'racegow-x1-1' } }
+ ]);
+ });
+
+ it('stopped but unsaved: "Save run" only finalizes', async () => {
+ const { session, sendSpy } = setup({
+ live: { phase: 'Unofficial', current_heat: 'racegow-x1-1' },
+ heats: [heat('racegow-x1-1', 'Unofficial', true)]
+ });
+ render(RaceGowRun, { session, ...props });
+ await fireEvent.click(await screen.findByRole('button', { name: 'Save run' }));
+ await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1));
+ expect(sendSpy.mock.calls[0][0]).toEqual({ Finalize: { heat: 'racegow-x1-1' } });
+ });
+
+ it('after saved runs: the served best headlines, the runs table names the best run, and the submission line is ready', async () => {
+ const { session } = setup({
+ live: { phase: 'Final', current_heat: 'racegow-x1-2' },
+ heats: [heat('racegow-x1-1', 'Final'), heat('racegow-x1-2', 'Final', true)],
+ laps: {
+ 'racegow-x1-1': [8_000_000, 8_000_000, 8_000_000],
+ 'racegow-x1-2': [7_000_000, 7_000_000, 7_000_000, 9_000_000]
+ },
+ standings: [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 7_000_000,
+ laps: 4,
+ metric: { BestConsecutive: { n: 3, micros: 21_000_000 } }
+ }
+ ]
+ });
+ render(RaceGowRun, { session, ...props });
+
+ // The next action is another run.
+ expect(await screen.findByRole('button', { name: 'Fly again' })).toBeInTheDocument();
+ // The headline is the SERVED round-wide best.
+ await waitFor(() =>
+ expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('21.000')
+ );
+ // The runs table: two saved runs, run 2 the best.
+ const table = await screen.findByRole('table', { name: 'Runs' });
+ await waitFor(() => expect(within(table).getAllByRole('row')).toHaveLength(3));
+ const [, run1, run2] = within(table).getAllByRole('row');
+ expect(run1).toHaveTextContent('Run 1');
+ expect(run1).toHaveTextContent('24.000');
+ expect(run2).toHaveTextContent('Run 2');
+ expect(run2).toHaveTextContent('21.000');
+ expect(run2).toHaveClass('best');
+ // The submission line names the track, the pilot, the time and the run.
+ const submission = screen.getByLabelText('Submission text') as HTMLTextAreaElement;
+ expect(submission.value).toContain('RaceGOW6 Track 1 — Recon');
+ expect(submission.value).toContain('Best 3 consecutive laps: 21.000 s (7.000 / 7.000 / 7.000)');
+ expect(submission.value).toContain('Run 2 of 2, laps 1–3');
+ });
+
+ it('offers the overlay URL for OBS', async () => {
+ const { session } = setup({});
+ render(RaceGowRun, { session, ...props });
+ expect(await screen.findByLabelText('Overlay URL')).toHaveTextContent('#/overlay/racegow');
+ });
+});
diff --git a/frontend/apps/rd-console/tests/racegow.test.ts b/frontend/apps/rd-console/tests/racegow.test.ts
new file mode 100644
index 00000000..18b1b214
--- /dev/null
+++ b/frontend/apps/rd-console/tests/racegow.test.ts
@@ -0,0 +1,217 @@
+/**
+ * Unit tests for the RaceGOW helpers (`lib/racegow.ts`): the best-consecutive window the screens
+ * slide over served laps, the preset's round shape, the create orchestration's command order, and
+ * the submission line. The browser e2e (`e2e/racegow.spec.ts`) proves the whole loop against a
+ * real Director; this pins the arithmetic and the composition it relies on.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import type { EventMeta, LapList, RoundStanding } from '@gridfpv/types';
+import {
+ bestConsecutive,
+ createRaceGowRun,
+ isRaceGowEvent,
+ overlayUrl,
+ raceGowRound,
+ raceGowRoundRequest,
+ runLaps,
+ servedBestMicros,
+ submissionText,
+ RACEGOW_CLASS_ID,
+ RACEGOW_PRESET
+} from '../src/lib/racegow.js';
+import type { Session } from '../src/lib/session.svelte.js';
+
+describe('bestConsecutive', () => {
+ it('is undefined with fewer laps than the window', () => {
+ expect(bestConsecutive([], 3)).toBeUndefined();
+ expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined();
+ });
+
+ it('finds the fastest 3-lap window and where it starts', () => {
+ // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower.
+ const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000];
+ expect(bestConsecutive(laps)).toEqual({
+ start: 1,
+ laps: [7_000_000, 6_800_000, 6_800_000],
+ totalMicros: 20_600_000
+ });
+ });
+
+ it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => {
+ const laps = [5_000_000, 5_000_000, 5_000_000, 5_000_000];
+ expect(bestConsecutive(laps)?.start).toBe(0);
+ });
+
+ it('honours a different window size, and refuses a zero one', () => {
+ expect(bestConsecutive([3_000_000, 1_000_000, 2_000_000], 1)).toEqual({
+ start: 1,
+ laps: [1_000_000],
+ totalMicros: 1_000_000
+ });
+ expect(bestConsecutive([1, 2, 3], 0)).toBeUndefined();
+ });
+});
+
+describe('runLaps / servedBestMicros', () => {
+ it('flattens the lap list in competitor order (a solo run has one)', () => {
+ const list: LapList = {
+ competitors: [
+ {
+ competitor: { adapter: 'rotorhazard', competitor: 'p1' },
+ laps: [
+ { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 },
+ { number: 2, duration_micros: 6_900_000, at: 2, start_ref: 1, end_ref: 2 }
+ ]
+ }
+ ]
+ };
+ expect(runLaps(list).map((l) => l.duration_micros)).toEqual([7_000_000, 6_900_000]);
+ expect(runLaps(undefined)).toEqual([]);
+ });
+
+ it('reads the served best-consecutive window off the standings, or nothing', () => {
+ const rows: RoundStanding[] = [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_800_000,
+ laps: 6,
+ metric: { BestConsecutive: { n: 3, micros: 20_600_000 } }
+ }
+ ];
+ expect(servedBestMicros(rows)).toBe(20_600_000);
+ expect(
+ servedBestMicros([{ ...rows[0], metric: { BestConsecutive: { n: 3, micros: null } } }])
+ ).toBeUndefined();
+ expect(servedBestMicros([{ ...rows[0], metric: { BestLap: { micros: 1 } } }])).toBeUndefined();
+ expect(servedBestMicros([])).toBeUndefined();
+ });
+});
+
+describe('the preset shape', () => {
+ it('marks and finds a RaceGOW event by its preset', () => {
+ const plain = { id: 'e1', name: 'Club night', preset: undefined } as unknown as EventMeta;
+ const run = { id: 'e2', name: 'Track 1', preset: RACEGOW_PRESET } as unknown as EventMeta;
+ expect(isRaceGowEvent(plain)).toBe(false);
+ expect(isRaceGowEvent(run)).toBe(true);
+ expect(isRaceGowEvent(undefined)).toBe(false);
+ });
+
+ it('scores best 3 consecutive laps, open-ended, on the Whoop class, under a 10-minute ceiling', () => {
+ const req = raceGowRoundRequest();
+ expect(req.format).toBe('timed_qual');
+ expect(req.params).toEqual({ rounds: '0' });
+ expect(req.win_condition).toEqual({ BestConsecutive: { n: 3 } });
+ expect(req.classes).toEqual([RACEGOW_CLASS_ID]);
+ expect(req.seeding).toBe('FromRoster');
+ expect(req.channel_mode).toBe('Static');
+ // Ranking-only conditions never end a heat, so the Director requires a race time; this one is
+ // a ceiling far past a whoop pack, not a race window.
+ expect(req.time_limit_secs).toBe(600);
+ // A fixed one-second hold: no field of pilots is waiting on a random tone.
+ expect(req.start_procedure).toEqual({
+ mode: 'randomized-delay',
+ min_delay_ms: 1000,
+ max_delay_ms: 1000
+ });
+ });
+
+ it('finds the scoring round by format, not by label', () => {
+ const meta = {
+ rounds: [
+ { id: 'r0', label: 'Warm-up', format: 'open_practice' },
+ { id: 'r1', label: 'Renamed by hand', format: 'timed_qual' }
+ ]
+ } as unknown as EventMeta;
+ expect(raceGowRound(meta)?.id).toBe('r1');
+ expect(raceGowRound(undefined)).toBeUndefined();
+ });
+});
+
+describe('createRaceGowRun', () => {
+ function fakeSession() {
+ const calls: string[] = [];
+ const meta = { id: 'e1', name: 'RaceGOW6 Track 1', preset: RACEGOW_PRESET } as EventMeta;
+ const session = {
+ currentEvent: meta,
+ createEventAndEnter: vi.fn(async (name: string, fields?: unknown) => {
+ calls.push(`create:${name}:${JSON.stringify(fields)}`);
+ return meta;
+ }),
+ setEventTimers: vi.fn(async (ids: string[]) => (calls.push(`timers:${ids}`), meta)),
+ setPrimaryTimer: vi.fn(async (id: string) => (calls.push(`primary:${id}`), meta)),
+ setEventClasses: vi.fn(async (ids: string[]) => (calls.push(`classes:${ids}`), meta)),
+ setEventRoster: vi.fn(async (ids: string[]) => (calls.push(`roster:${ids}`), meta)),
+ setClassMembership: vi.fn(async (cls: string, slots: unknown[]) => {
+ calls.push(`members:${cls}:${JSON.stringify(slots)}`);
+ return meta;
+ }),
+ createRound: vi.fn(async (req: { format: string }) => {
+ calls.push(`round:${req.format}`);
+ return { id: 'r1' };
+ })
+ };
+ return { calls, session: session as unknown as Session, meta };
+ }
+
+ it('creates, then configures timer → primary → class → roster → channel slot → round', async () => {
+ const { calls, session, meta } = fakeSession();
+ const result = await createRaceGowRun(session, {
+ track: ' RaceGOW6 Track 1 ',
+ pilot: 'p1',
+ timer: 'rh-1',
+ channel: 5880
+ });
+ expect(result).toBe(meta);
+ expect(calls).toEqual([
+ 'create:RaceGOW6 Track 1:{"preset":"racegow"}',
+ 'timers:rh-1',
+ 'primary:rh-1',
+ `classes:${RACEGOW_CLASS_ID}`,
+ 'roster:p1',
+ `members:${RACEGOW_CLASS_ID}:[{"pilot":"p1","channel":5880}]`,
+ 'round:timed_qual'
+ ]);
+ });
+
+ it('seats the pilot without a channel when the timer offers none', async () => {
+ const { calls, session } = fakeSession();
+ await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'mock' });
+ expect(calls).toContain(`members:${RACEGOW_CLASS_ID}:[{"pilot":"p1"}]`);
+ });
+
+ it('stops at a cancelled token prompt and reports it as undefined', async () => {
+ const { calls, session } = fakeSession();
+ (session.setEventRoster as unknown as ReturnType).mockResolvedValueOnce(
+ undefined
+ );
+ const result = await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'rh-1' });
+ expect(result).toBeUndefined();
+ expect(calls.some((c) => c.startsWith('members:'))).toBe(false);
+ expect(calls.some((c) => c.startsWith('round:'))).toBe(false);
+ });
+});
+
+describe('submissionText / overlayUrl', () => {
+ it('writes the time, the three laps, which run and when, and what timed it', () => {
+ const text = submissionText({
+ track: 'RaceGOW6 Track 1',
+ callsign: 'Recon',
+ best: { start: 2, laps: [7_012_000, 7_201_000, 7_224_000], totalMicros: 21_437_000 },
+ run: 3,
+ runs: 5,
+ flownAt: new Date(2026, 8, 26, 14, 3),
+ timedBy: 'Garage RH (RotorHazard)'
+ });
+ expect(text).toContain('RaceGOW6 Track 1 — Recon');
+ expect(text).toContain('Best 3 consecutive laps: 21.437 s (7.012 / 7.201 / 7.224)');
+ expect(text).toContain('Run 3 of 5, laps 3–5');
+ expect(text).toContain('Timed by GridFPV on Garage RH (RotorHazard)');
+ });
+
+ it('points the overlay at the console origin + path, on the overlay hash', () => {
+ expect(overlayUrl({ origin: 'http://192.168.1.20:8080', pathname: '/' })).toBe(
+ 'http://192.168.1.20:8080/#/overlay/racegow'
+ );
+ });
+});
diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts
index a8b11da5..91aa7748 100644
--- a/frontend/apps/rd-console/tests/route.test.ts
+++ b/frontend/apps/rd-console/tests/route.test.ts
@@ -333,3 +333,42 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => {
}
});
});
+
+describe('RaceGOW + overlay routes', () => {
+ it('parses the RaceGOW page, its run screen, and the overlay', () => {
+ expect(parseHash('#/racegow')).toEqual({ kind: 'racegow' });
+ expect(parseHash('#/racegow/run')).toEqual({ kind: 'racegow-run' });
+ expect(parseHash('#/RaceGOW/Run')).toEqual({ kind: 'racegow-run' });
+ expect(parseHash('#/overlay/racegow')).toEqual({ kind: 'overlay', overlay: 'racegow' });
+ });
+
+ it('degrades a malformed RaceGOW sub-route to the page, and an unknown overlay to the hub', () => {
+ expect(parseHash('#/racegow/whatever')).toEqual({ kind: 'racegow' });
+ expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE);
+ expect(parseHash('#/overlay/leaderboard')).toEqual(DEFAULT_ROUTE);
+ });
+
+ it('round-trips each through formatHash', () => {
+ const routes: Route[] = [
+ { kind: 'racegow' },
+ { kind: 'racegow-run' },
+ { kind: 'overlay', overlay: 'racegow' }
+ ];
+ for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r);
+ expect(formatHash({ kind: 'racegow' })).toBe('#/racegow');
+ expect(formatHash({ kind: 'racegow-run' })).toBe('#/racegow/run');
+ expect(formatHash({ kind: 'overlay', overlay: 'racegow' })).toBe('#/overlay/racegow');
+ });
+
+ it('reconciles the run screen to the RaceGOW page with no active event, and keeps the rest', () => {
+ expect(reconcileRoute({ kind: 'racegow-run' }, false)).toEqual({ kind: 'racegow' });
+ expect(reconcileRoute({ kind: 'racegow-run' }, true)).toEqual({ kind: 'racegow-run' });
+ expect(reconcileRoute({ kind: 'racegow' }, false)).toEqual({ kind: 'racegow' });
+ // An overlay sits in an OBS scene whether or not a run is on: never bounced.
+ expect(reconcileRoute({ kind: 'overlay', overlay: 'racegow' }, false)).toEqual({
+ kind: 'overlay',
+ overlay: 'racegow'
+ });
+ expect(resolveInitialRoute('#/racegow/run', false)).toEqual({ kind: 'racegow' });
+ });
+});
diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts
new file mode 100644
index 00000000..929c7153
--- /dev/null
+++ b/frontend/e2e/racegow.spec.ts
@@ -0,0 +1,151 @@
+/**
+ * RaceGOW — the solo track-run loop, end to end, in real headless chromium against a real
+ * Director: hub → RaceGOW page → set up a run on the Simulator → Start run → laps climb → Stop
+ * run → the best 3-lap window and the submission line → Fly again → two runs in the table. Then
+ * the OBS overlay page renders the same run, read-only, on a transparent document.
+ *
+ * This spec boots its **own** Director (a fresh registry: no pilots, no events — the first-run
+ * state a pilot installing GridFPV for RaceGOW is in) with the built-in Simulator emitting six
+ * laps a run, and drives real clicks. Nothing is mocked.
+ */
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { expect, test } from '@playwright/test';
+
+import { type Director, startDirector } from '../test-harness/director.js';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const dist = resolve(here, '..', 'apps', 'rd-console', 'dist');
+
+const TRACK = 'RaceGOW6 Track 1';
+const PILOT = 'Recon';
+/** Six sim laps per run: enough for a 3-lap window with room either side. */
+const SIM_LAPS = 6;
+
+let director: Director;
+
+test.beforeAll(async () => {
+ director = await startDirector({ token: false, assets: dist, simLaps: SIM_LAPS, simLapMs: 250 });
+});
+
+test.afterAll(async () => {
+ await director?.stop();
+});
+
+test('a pilot sets up a track run, flies two runs, and gets a submission line', async ({
+ page
+}) => {
+ await page.goto(director.baseUrl);
+
+ // ── Hub → RaceGOW ────────────────────────────────────────────────────────────────────────
+ await expect(page.getByRole('heading', { name: 'RaceGOW' })).toBeVisible({ timeout: 15_000 });
+ await page.getByRole('heading', { name: 'RaceGOW' }).click();
+ await expect(page).toHaveURL(/#\/racegow$/);
+ const form = page.getByRole('form', { name: 'New track run' });
+ await expect(form).toBeVisible();
+
+ // ── The form: a fresh Director has no pilots, so the callsign field is up; the built-in
+ // Simulator is the only timer, and it carries Raceband, so a channel is offered. ─────────
+ await expect(form.getByLabel('Track')).toHaveValue(TRACK);
+ await form.getByLabel('Callsign').fill(PILOT);
+ await expect(form.getByLabel('Timer')).toHaveValue('mock');
+ await expect(form.getByLabel('Your channel')).toBeVisible();
+ await form.getByRole('button', { name: 'Set up run' }).click();
+
+ // ── The run screen, on the event the preset built ───────────────────────────────────────
+ await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(`${PILOT} on Mock (Simulator)`)).toBeVisible();
+
+ // The event really exists server-side, shaped by the preset: one Whoop-class round, scored by
+ // best 3 consecutive laps, open-ended, with the pilot on the roster.
+ const events = (await (await fetch(`${director.baseUrl}/events`)).json()) as {
+ id: string;
+ name: string;
+ preset?: string;
+ roster: string[];
+ classes: string[];
+ rounds?: { format: string; params: Record; win_condition: unknown }[];
+ }[];
+ expect(events).toHaveLength(1);
+ const event = events[0];
+ expect(event.name).toBe(TRACK);
+ expect(event.preset).toBe('racegow');
+ expect(event.roster).toHaveLength(1);
+ expect(event.classes).toEqual(['mgp-whoop']);
+ expect(event.rounds).toHaveLength(1);
+ expect(event.rounds![0].format).toBe('timed_qual');
+ expect(event.rounds![0].params).toEqual({ rounds: '0' });
+ expect(event.rounds![0].win_condition).toEqual({ BestConsecutive: { n: 3 } });
+
+ // ── Run 1: Start run → the sim flies its laps → Stop run ─────────────────────────────────
+ const run = page.getByRole('region', { name: 'Current run' });
+ await run.getByRole('button', { name: 'Start run' }).click();
+ // The Simulator emits laps once the heat is Running (after the fixed 1 s hold).
+ const laps = run.getByRole('list', { name: "This run's laps" });
+ await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, {
+ timeout: 20_000
+ });
+ // With ≥ 3 laps the live best-3 readout has a time.
+ await expect(run.getByLabel("This run's best 3 consecutive laps")).not.toHaveText('—');
+ await run.getByRole('button', { name: 'Stop run' }).click();
+
+ // Stopped + saved: the headline is the served round-wide best, the table lists run 1 as saved,
+ // and the submission line is ready with the track and the pilot.
+ await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', {
+ timeout: 15_000
+ });
+ const table = page.getByRole('table', { name: 'Runs' });
+ await expect(table.getByRole('row').filter({ hasText: 'Run 1' })).toContainText('saved');
+ const submission = page.getByLabel('Submission text');
+ await expect(submission).toHaveValue(new RegExp(`${TRACK} — ${PILOT}`));
+ await expect(submission).toHaveValue(/Best 3 consecutive laps: \d+\.\d{3} s/);
+ await expect(submission).toHaveValue(/Run 1 of 1/);
+
+ // ── Run 2: Fly again draws the next heat and runs it ─────────────────────────────────────
+ await run.getByRole('button', { name: 'Fly again' }).click();
+ await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, {
+ timeout: 20_000
+ });
+ await run.getByRole('button', { name: 'Stop run' }).click();
+ await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 });
+ await expect(table.getByRole('row').filter({ hasText: 'Run 2' })).toContainText('saved');
+ await expect(submission).toHaveValue(/of 2/);
+
+ // The server agrees: two Final heats on the round, and the standings carry a best-3 window.
+ const heats = (await (await fetch(`${director.baseUrl}/events/${event.id}/heats`)).json()) as {
+ phase: string;
+ }[];
+ expect(heats.map((h) => h.phase)).toEqual(['Final', 'Final']);
+ const roundId = (
+ (await (await fetch(`${director.baseUrl}/events`)).json()) as { rounds: { id: string }[] }[]
+ )[0].rounds[0].id;
+ const standings = (await (
+ await fetch(`${director.baseUrl}/events/${event.id}/rounds/${roundId}/standings`)
+ ).json()) as { metric: { BestConsecutive?: { n: number; micros: number | null } } }[];
+ expect(standings).toHaveLength(1);
+ expect(standings[0].metric.BestConsecutive?.n).toBe(3);
+ expect(standings[0].metric.BestConsecutive?.micros).not.toBeNull();
+
+ // ── The overlay: the OBS browser source follows the active run, read-only ───────────────
+ await page.goto(`${director.baseUrl}/#/overlay/racegow`);
+ await expect(page.getByLabel('RaceGOW overlay')).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('timer', { name: /Run time/ })).toBeVisible();
+ await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', {
+ timeout: 15_000
+ });
+ // The document is flagged transparent for the keyer.
+ await expect(page.locator('html')).toHaveAttribute('data-overlay', 'racegow');
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // ── Back on the RaceGOW page, the run is listed and reopens ─────────────────────────────
+ await page.goto(`${director.baseUrl}/#/racegow`);
+ const runs = page.getByRole('list', { name: 'Your runs' });
+ await expect(runs.getByRole('listitem')).toHaveCount(1, { timeout: 15_000 });
+ await runs.getByRole('button', { name: `Open ${TRACK}` }).click();
+ await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: TRACK })).toBeVisible();
+});
From 14919feac4f7391f60c9155bcde134f36b4e9713 Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Sat, 26 Sep 2026 19:24:07 +0000
Subject: [PATCH 2/5] =?UTF-8?q?chore(deps):=20rustls=200.23.45=20=E2=80=94?=
=?UTF-8?q?=20RUSTSEC-2026-0285=20(TLS=201.3=20handshake=20across=20encryp?=
=?UTF-8?q?tion=20levels)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A new advisory (published 2026-09-14, medium) against rustls 0.23.40, which
turned the cargo-audit gate red on the first push after it. Bumped in both
lockfiles (root + src-tauri) so the tauri-lock gate stays in step. No
manifest change; rustls is a transitive dependency.
Co-Authored-By: Claude Fable 5.1
---
Cargo.lock | 16 ++++++++--------
src-tauri/Cargo.lock | 4 ++--
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 257db550..0a9e5a43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -268,7 +268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1305,14 +1305,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.40"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"rustls-pki-types",
@@ -1332,9 +1332,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -1606,7 +1606,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2102,7 +2102,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 2846031e..1e208098 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3107,9 +3107,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.43"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"rustls-pki-types",
From 7555eeccf548201282b8b9d0382bfc5777e282b6 Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Sun, 4 Oct 2026 00:31:30 +0000
Subject: [PATCH 3/5] =?UTF-8?q?feat(console):=20RaceGOW=20overlay=20in=20e?=
=?UTF-8?q?ither=20contrast=20=E2=80=94=20light=20text=20on=20dark,=20or?=
=?UTF-8?q?=20dark=20text=20on=20light?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The OBS overlay (`#/overlay/racegow`) now takes a theme: the default dark glass panel for goggle
DVR, or `#/overlay/racegow/light` — dark text on a light panel — for a bright room camera. The
run screen's "Timer in your video" card grows a two-way picker above the URL, so the copied URL
carries the choice.
The light variant is the component library's `[data-theme='light']` token set scoped to the
overlay, plus the page's own glass values flipped. A mistyped theme segment still renders the
dark overlay rather than bouncing the scene to the hub.
Co-Authored-By: Claude Fable 5.1
---
frontend/apps/rd-console/src/App.svelte | 2 +-
frontend/apps/rd-console/src/lib/racegow.ts | 14 +++--
frontend/apps/rd-console/src/lib/route.ts | 26 +++++++--
.../src/screens/RaceGowOverlay.svelte | 35 +++++++++++-
.../rd-console/src/screens/RaceGowRun.svelte | 53 ++++++++++++++++++-
.../apps/rd-console/tests/RaceGowRun.test.ts | 9 +++-
.../apps/rd-console/tests/racegow.test.ts | 6 +++
frontend/apps/rd-console/tests/route.test.ts | 25 ++++++++-
frontend/e2e/racegow.spec.ts | 6 +++
9 files changed, 161 insertions(+), 15 deletions(-)
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index 783556f1..e31c9e55 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -359,7 +359,7 @@
-
+
{:else if route.kind === 'racegow'}
diff --git a/frontend/apps/rd-console/src/lib/racegow.ts b/frontend/apps/rd-console/src/lib/racegow.ts
index 6853579f..b857f9b1 100644
--- a/frontend/apps/rd-console/src/lib/racegow.ts
+++ b/frontend/apps/rd-console/src/lib/racegow.ts
@@ -35,6 +35,7 @@ import type {
TimerId
} from '@gridfpv/types';
import type { Session } from './session.svelte.js';
+import { formatHash, type OverlayTheme } from './route.js';
/** The `EventMeta.preset` marker a RaceGOW run carries. */
export const RACEGOW_PRESET = 'racegow';
@@ -193,9 +194,16 @@ export function servedBestMicros(standings: readonly RoundStanding[]): number |
return undefined;
}
-/** The OBS browser-source URL for the RaceGOW overlay, from the console's own location. */
-export function overlayUrl(loc: { origin: string; pathname: string }): string {
- return `${loc.origin}${loc.pathname}#/overlay/racegow`;
+/**
+ * The OBS browser-source URL for the RaceGOW overlay, from the console's own location. `theme`
+ * picks the panel's contrast for the footage underneath — light text on a dark panel (default)
+ * or dark text on a light one (`#/overlay/racegow/light`, see `route.ts`).
+ */
+export function overlayUrl(
+ loc: { origin: string; pathname: string },
+ theme: OverlayTheme = 'dark'
+): string {
+ return `${loc.origin}${loc.pathname}${formatHash({ kind: 'overlay', overlay: 'racegow', theme })}`;
}
/** What the submission line needs — everything a RaceGOW form asks beside the clip. */
diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts
index c8212116..7b54b827 100644
--- a/frontend/apps/rd-console/src/lib/route.ts
+++ b/frontend/apps/rd-console/src/lib/route.ts
@@ -24,6 +24,7 @@
* - `#/racegow` → the RaceGOW page (your track runs; start a new one)
* - `#/racegow/run` → the solo run screen for the active RaceGOW event
* - `#/overlay/racegow` → the RaceGOW OBS browser-source overlay (transparent, read-only)
+ * - `#/overlay/racegow/light` → the same overlay, dark text on a light panel
*
* **The route IS the tuning scope (#411).** Tuning is layered — a timer has its own baseline
* calibration, and an event will eventually carry its own tune over the top — and an RD dragging a
@@ -101,12 +102,18 @@ export type Route =
* follows the Director's active event (streaming.html §2.1, "follow the program"). Never
* reconciled away — with nothing on the timer it renders idle, which is what a browser source
* sitting in a scene should do.
+ *
+ * `theme` picks the panel's contrast for the footage it sits over: light text on a dark panel
+ * (the default, `#/overlay/
`) or dark text on a light one (`#/overlay//light`).
+ * Absent means dark; it is only ever present as `'light'`, so a route compares structurally.
*/
- | { kind: 'overlay'; overlay: OverlayName };
+ | { kind: 'overlay'; overlay: OverlayName; theme?: OverlayTheme };
/** The overlay pages the console serves (streaming.html §2). */
export type OverlayName = 'racegow';
const OVERLAYS: readonly OverlayName[] = ['racegow'];
+/** An overlay's contrast: light text over a dark panel, or dark text over a light one. */
+export type OverlayTheme = 'dark' | 'light';
export const WORKSPACE_TABS: readonly WorkspaceTab[] = [
'classes-roster',
@@ -197,12 +204,17 @@ export function parseHash(hash: string): Route {
return segments[1]?.toLowerCase() === 'run' ? { kind: 'racegow-run' } : { kind: 'racegow' };
}
- // `#/overlay/` — an OBS browser source. An unknown overlay name is the hub, not a blank
- // page: there is nothing overlay-shaped to land on.
+ // `#/overlay/[/light]` — an OBS browser source. An unknown overlay name is the hub, not a
+ // blank page: there is nothing overlay-shaped to land on. The optional third segment picks the
+ // light panel; anything else (including an explicit `dark`) is the default dark panel, so a
+ // mistyped theme still renders an overlay rather than bouncing the scene to the hub.
if (head === 'overlay') {
const name = segments[1]?.toLowerCase();
if (name && (OVERLAYS as readonly string[]).includes(name)) {
- return { kind: 'overlay', overlay: name as OverlayName };
+ const overlay = name as OverlayName;
+ return segments[2]?.toLowerCase() === 'light'
+ ? { kind: 'overlay', overlay, theme: 'light' }
+ : { kind: 'overlay', overlay };
}
return DEFAULT_ROUTE;
}
@@ -223,7 +235,11 @@ export function formatHash(route: Route): string {
if (route.kind === 'workspace') return `#/event/${route.tab}`;
if (route.kind === 'racegow') return '#/racegow';
if (route.kind === 'racegow-run') return '#/racegow/run';
- if (route.kind === 'overlay') return `#/overlay/${route.overlay}`;
+ if (route.kind === 'overlay') {
+ return route.theme === 'light'
+ ? `#/overlay/${route.overlay}/light`
+ : `#/overlay/${route.overlay}`;
+ }
if (route.kind === 'tune') {
const timer = encodeURIComponent(route.timer);
// The scope is the route (#411): an event-scoped tune nests under its event, so the trail back
diff --git a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
index 6fcbe0e6..92521e4a 100644
--- a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
+++ b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
@@ -11,6 +11,11 @@
*
* It renders idle — the brand and "waiting for a run" — with no RaceGOW event active, which is
* what a browser source sitting in a scene should do rather than an error.
+ *
+ * `theme` is the panel's contrast for the footage it sits over: light text on a dark glass
+ * panel (the default — reads over most DVR), or dark text on a light one for a bright room
+ * camera. The light variant is the component library's `data-theme="light"` token set scoped to
+ * this page, plus this page's own glass values flipped.
*/
import { RaceClock, formatMicros } from '@gridfpv/components';
import type { HeatId, Lap, LiveRaceState, Pilot, RoundStanding } from '@gridfpv/types';
@@ -26,7 +31,9 @@
type ConsecutiveWindow
} from '../lib/racegow.js';
- let { session }: { session: Session } = $props();
+ import type { OverlayTheme } from '../lib/route.js';
+
+ let { session, theme = 'dark' }: { session: Session; theme?: OverlayTheme } = $props();
// The page keys on transparency: flag the document while mounted (app.css lifts the canvas).
$effect(() => {
@@ -101,7 +108,12 @@
}
-
+
GridFPV
@@ -266,4 +278,23 @@
color: #fff;
font-weight: 700;
}
+
+ /* Dark text on a light panel (`#/overlay/racegow/light`). The semantic tokens flip through the
+ * library's `[data-theme='light']` set; only this page's own glass values need restating. */
+ .overlay[data-theme='light'] .panel {
+ background: rgba(255, 255, 255, 0.82);
+ border-color: rgba(0, 0, 0, 0.14);
+ text-shadow: none;
+ }
+ .overlay[data-theme='light'] .clock-row :global(.gridfpv-race-clock) {
+ color: var(--gf-text);
+ }
+ .overlay[data-theme='light'] .fpv,
+ .overlay[data-theme='light'][data-flying='true'] .phase {
+ color: var(--gf-brand-600);
+ }
+ .overlay[data-theme='light'] .lap.in-window {
+ background: rgba(0, 0, 0, 0.08);
+ color: var(--gf-text);
+ }
diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
index d9cc3b03..a8bec3ab 100644
--- a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
+++ b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
@@ -41,6 +41,7 @@
RACEGOW_LAPS,
type ConsecutiveWindow
} from '../lib/racegow.js';
+ import { formatHash, type OverlayTheme } from '../lib/route.js';
let {
session,
@@ -287,8 +288,13 @@
});
});
+ // The overlay's contrast, chosen here so the copied URL carries it: light text on a dark panel
+ // for goggle DVR, dark text on a light panel for a bright room camera (`route.ts`).
+ let overlayTheme = $state
('dark');
const overlay = $derived(
- typeof location !== 'undefined' ? overlayUrl(location) : '#/overlay/racegow'
+ typeof location !== 'undefined'
+ ? overlayUrl(location, overlayTheme)
+ : formatHash({ kind: 'overlay', overlay: 'racegow', theme: overlayTheme })
);
async function copy(text: string, what: string): Promise {
@@ -495,6 +501,26 @@
over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps
and the best 3 so far, on a transparent background.
+
+ (overlayTheme = 'dark')}
+ >
+ Light text on dark
+
+ (overlayTheme = 'light')}
+ >
+ Dark text on light
+
+
{overlay}
copy(overlay, 'Overlay URL')}>
@@ -726,6 +752,31 @@
flex-direction: column;
gap: var(--gf-space-3);
}
+ .theme-pick {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ border-radius: var(--gf-radius-sm);
+ border: 1px solid var(--gf-border);
+ background: var(--gf-surface-sunken);
+ align-self: flex-start;
+ }
+ .theme-opt {
+ appearance: none;
+ border: 0;
+ border-radius: calc(var(--gf-radius-sm) - 2px);
+ padding: var(--gf-space-1) var(--gf-space-3);
+ background: transparent;
+ color: var(--gf-text-muted);
+ font: inherit;
+ font-size: var(--gf-font-size-sm);
+ cursor: pointer;
+ }
+ .theme-opt[aria-checked='true'] {
+ background: var(--gf-elevated);
+ color: var(--gf-text);
+ font-weight: 600;
+ }
.open-link {
color: var(--gf-accent);
font-size: var(--gf-font-size-sm);
diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
index 8e84ccda..c54d3154 100644
--- a/frontend/apps/rd-console/tests/RaceGowRun.test.ts
+++ b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
@@ -209,9 +209,14 @@ describe('RaceGowRun', () => {
expect(submission.value).toContain('Run 2 of 2, laps 1–3');
});
- it('offers the overlay URL for OBS', async () => {
+ it('offers the overlay URL for OBS, in either contrast', async () => {
const { session } = setup({});
render(RaceGowRun, { session, ...props });
- expect(await screen.findByLabelText('Overlay URL')).toHaveTextContent('#/overlay/racegow');
+ const url = await screen.findByLabelText('Overlay URL');
+ expect(url).toHaveTextContent(/#\/overlay\/racegow$/);
+ await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
+ expect(url).toHaveTextContent(/#\/overlay\/racegow\/light$/);
+ await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' }));
+ expect(url).toHaveTextContent(/#\/overlay\/racegow$/);
});
});
diff --git a/frontend/apps/rd-console/tests/racegow.test.ts b/frontend/apps/rd-console/tests/racegow.test.ts
index 18b1b214..fb9158a9 100644
--- a/frontend/apps/rd-console/tests/racegow.test.ts
+++ b/frontend/apps/rd-console/tests/racegow.test.ts
@@ -214,4 +214,10 @@ describe('submissionText / overlayUrl', () => {
'http://192.168.1.20:8080/#/overlay/racegow'
);
});
+
+ it('carries the light theme in the overlay URL, and leaves the dark default bare', () => {
+ const loc = { origin: 'http://192.168.1.20:8080', pathname: '/' };
+ expect(overlayUrl(loc, 'light')).toBe('http://192.168.1.20:8080/#/overlay/racegow/light');
+ expect(overlayUrl(loc, 'dark')).toBe('http://192.168.1.20:8080/#/overlay/racegow');
+ });
});
diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts
index 91aa7748..4787e57d 100644
--- a/frontend/apps/rd-console/tests/route.test.ts
+++ b/frontend/apps/rd-console/tests/route.test.ts
@@ -342,6 +342,22 @@ describe('RaceGOW + overlay routes', () => {
expect(parseHash('#/overlay/racegow')).toEqual({ kind: 'overlay', overlay: 'racegow' });
});
+ it('parses the overlay theme: /light is the light panel, anything else is the dark default', () => {
+ expect(parseHash('#/overlay/racegow/light')).toEqual({
+ kind: 'overlay',
+ overlay: 'racegow',
+ theme: 'light'
+ });
+ expect(parseHash('#/overlay/racegow/LIGHT')).toEqual({
+ kind: 'overlay',
+ overlay: 'racegow',
+ theme: 'light'
+ });
+ // An explicit `dark`, or a typo, still renders the overlay — never bounces the scene to the hub.
+ expect(parseHash('#/overlay/racegow/dark')).toEqual({ kind: 'overlay', overlay: 'racegow' });
+ expect(parseHash('#/overlay/racegow/lite')).toEqual({ kind: 'overlay', overlay: 'racegow' });
+ });
+
it('degrades a malformed RaceGOW sub-route to the page, and an unknown overlay to the hub', () => {
expect(parseHash('#/racegow/whatever')).toEqual({ kind: 'racegow' });
expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE);
@@ -352,9 +368,16 @@ describe('RaceGOW + overlay routes', () => {
const routes: Route[] = [
{ kind: 'racegow' },
{ kind: 'racegow-run' },
- { kind: 'overlay', overlay: 'racegow' }
+ { kind: 'overlay', overlay: 'racegow' },
+ { kind: 'overlay', overlay: 'racegow', theme: 'light' }
];
for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r);
+ expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'light' })).toBe(
+ '#/overlay/racegow/light'
+ );
+ expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'dark' })).toBe(
+ '#/overlay/racegow'
+ );
expect(formatHash({ kind: 'racegow' })).toBe('#/racegow');
expect(formatHash({ kind: 'racegow-run' })).toBe('#/racegow/run');
expect(formatHash({ kind: 'overlay', overlay: 'racegow' })).toBe('#/overlay/racegow');
diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts
index 929c7153..10a966cf 100644
--- a/frontend/e2e/racegow.spec.ts
+++ b/frontend/e2e/racegow.spec.ts
@@ -141,6 +141,12 @@ test('a pilot sets up a track run, flies two runs, and gets a submission line',
await expect(page.locator('html')).toHaveAttribute('data-overlay', 'racegow');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+ // The light variant: same overlay, dark text on a light panel, still transparent underneath.
+ await page.goto(`${director.baseUrl}/#/overlay/racegow/light`);
+ await expect(page.getByLabel('RaceGOW overlay')).toHaveAttribute('data-theme', 'light');
+ await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
// ── Back on the RaceGOW page, the run is listed and reopens ─────────────────────────────
await page.goto(`${director.baseUrl}/#/racegow`);
const runs = page.getByRole('list', { name: 'Your runs' });
From 74b26a673eed2ea763b59a7aea1c0bd9d3947d27 Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Sun, 11 Oct 2026 13:35:51 +0000
Subject: [PATCH 4/5] feat(console): RaceGOW moves off the hub and onto the
Events page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hub had grown a RaceGOW card beside Events, which read as a second kind of
thing when a track run is an ordinary event. It now lives where events do:
- "+ New event" (and the first-run call to action) opens a dialog that first
asks Club race / RaceGOW track run. Club race is the default with the name
form already up, so that path is still name → Create & enter; the track-run
choice hands off to the RaceGOW page's own form (#/racegow).
- A run the preset built lists on the Events page with an accent "RaceGOW"
badge, and entering it routes to the solo run screen (#/racegow/run) rather
than the workspace — the events-page effect in App.svelte checks the preset.
- The run screen and the RaceGOW page gain an Events crumb that leaves the run
locally first (leaveToPicker), or the Events page would route straight back
into the run. The RaceGOW page's trail is now Home › Events › RaceGOW.
- The hub is back to its four cards.
Tests: HomeHub asserts no RaceGOW card; EventPicker proves the badge, the
club-race default and the track-run hand-off; the e2e spec now walks hub →
Events → new event → track run, and reopens the run from the Events list.
Co-Authored-By: Claude Fable 5.1
---
frontend/apps/rd-console/src/App.svelte | 34 ++-
.../rd-console/src/screens/EventPicker.svelte | 258 ++++++++++++++----
.../rd-console/src/screens/HomeHub.svelte | 49 +---
.../rd-console/src/screens/RaceGowPage.svelte | 16 +-
.../rd-console/src/screens/RaceGowRun.svelte | 4 +
.../apps/rd-console/tests/EventPicker.test.ts | 67 ++++-
.../apps/rd-console/tests/HomeHub.test.ts | 18 +-
.../apps/rd-console/tests/RaceGowPage.test.ts | 2 +-
.../apps/rd-console/tests/RaceGowRun.test.ts | 8 +-
frontend/e2e/racegow.spec.ts | 38 ++-
10 files changed, 363 insertions(+), 131 deletions(-)
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index e31c9e55..2a970902 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -122,6 +122,7 @@
// The RaceGOW preset's two surfaces: the page that starts a run, and the solo run screen for
// the active one. Both are app-level routes (no workspace shell) — a pilot alone in a garage
// wants a start button, not a race director's console; the workspace stays one click away.
+ // The page is reached from the Events page ("+ New event" → RaceGOW track run), not the hub.
const goRaceGow = () => navigate({ kind: 'racegow' });
const goRaceGowRun = () => navigate({ kind: 'racegow-run' });
// The workspace's app-route concept is "Events" (where event entry/switch happens).
@@ -214,9 +215,16 @@
// the active event appears while we're on the Events page, flip the route into the workspace so
// the hash becomes `#/event/live`. This is the routing counterpart of the old `route = 'events'`
// → workspace transition that used to be implicit in `{#if session.currentEvent}`.
+ //
+ // A RaceGOW track run is an ordinary event in that list, but its surface is the solo run screen:
+ // the preset marker on the meta is what sends it to `#/racegow/run` instead of the workspace
+ // (the workspace stays one click away from there, via "Open in console").
$effect(() => {
- if (session.currentEvent && route.kind === 'page' && route.page === 'events') {
- navigate({ kind: 'workspace', tab: 'live' });
+ const current = session.currentEvent;
+ if (current && route.kind === 'page' && route.page === 'events') {
+ navigate(
+ isRaceGowEvent(current) ? { kind: 'racegow-run' } : { kind: 'workspace', tab: 'live' }
+ );
}
});
@@ -284,7 +292,9 @@
function leaveToPicker() {
session.leaveEvent();
// Close the wizard if open, and land back on the Events page (the app route the "Switch event"
- // action conceptually returns to). Setting the hash to `#/events` keeps the URL honest.
+ // action conceptually returns to). Setting the hash to `#/events` keeps the URL honest. The
+ // RaceGOW surfaces' Events crumb takes this same path: with the run still active locally, the
+ // Events page would route straight back into the run screen.
wizardOpen = false;
goPage('events');
}
@@ -363,7 +373,13 @@
{:else if route.kind === 'racegow'}
- goPage('timers')} />
+ goPage('timers')}
+ />
{:else if route.kind === 'racegow-run'}
+ RaceGOW
+ {/if}
{#if ev.id === activeEventId}
Active
{/if}
@@ -304,65 +334,109 @@
(newError = undefined)}>
{#snippet footer()}
(newOpen = false)} disabled={creating}>Cancel
-
- {creating ? 'Creating…' : 'Create & enter'}
-
+ {#if newKind === 'racegow'}
+ Set up track run
+ {:else}
+
+ {creating ? 'Creating…' : 'Create & enter'}
+
+ {/if}
{/snippet}
@@ -602,8 +676,10 @@
color: var(--gf-text-faint);
font-size: var(--gf-font-size-xs);
}
- /* The "Active" pill (#91): sits between the name and the arrow, never shrinking. */
- .active-pill {
+ /* The "Active" pill (#91) and the RaceGOW kind pill: between the name and the arrow, never
+ shrinking. */
+ .active-pill,
+ .kind-pill {
flex-shrink: 0;
}
.event-main {
@@ -698,6 +774,72 @@
flex-direction: column;
gap: var(--gf-space-3);
}
+ /* The kind chooser: two radio cards side by side (stacked when narrow), the chosen one with
+ the accent border. The native radio stays visible — it is the control, not decoration. */
+ .kind {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
+ gap: var(--gf-space-3);
+ margin: 0;
+ padding: 0;
+ border: none;
+ min-width: 0;
+ }
+ .kind-legend {
+ grid-column: 1 / -1;
+ padding: 0;
+ margin-bottom: var(--gf-space-1);
+ font-size: var(--gf-font-size-sm);
+ font-weight: var(--gf-font-weight-medium);
+ color: var(--gf-text-secondary);
+ }
+ .kind-opt {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--gf-space-3);
+ padding: var(--gf-space-3);
+ border: 1px solid var(--gf-border);
+ border-radius: var(--gf-radius-md);
+ background: var(--gf-surface-alt);
+ cursor: pointer;
+ transition:
+ border-color var(--gf-motion-fast) var(--gf-ease-out),
+ background var(--gf-motion-fast) var(--gf-ease-out);
+ }
+ .kind-opt:hover {
+ border-color: var(--gf-accent);
+ }
+ .kind-opt.selected {
+ border-color: var(--gf-accent);
+ background: var(--gf-accent-soft);
+ }
+ .kind-opt input {
+ width: 1.1rem;
+ height: 1.1rem;
+ margin-top: 0.15rem;
+ accent-color: var(--gf-accent);
+ flex-shrink: 0;
+ cursor: pointer;
+ }
+ .kind-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .kind-title {
+ font-size: var(--gf-font-size-sm);
+ font-weight: var(--gf-font-weight-semibold);
+ color: var(--gf-text);
+ }
+ .kind-sub {
+ font-size: var(--gf-font-size-xs);
+ color: var(--gf-text-muted);
+ }
+ .kind-hint {
+ margin: 0;
+ font-size: var(--gf-font-size-sm);
+ color: var(--gf-text-muted);
+ }
.details {
border-top: 1px solid var(--gf-border-subtle);
padding-top: var(--gf-space-3);
diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte
index 3c2b7fe2..6ecb9b95 100644
--- a/frontend/apps/rd-console/src/screens/HomeHub.svelte
+++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte
@@ -10,6 +10,10 @@
* count (`GET /events`), and timer count + how many are connected (`GET /timers`). A failed read
* just shows a dash — the hub never blocks on a summary.
*
+ * There is deliberately no RaceGOW card here: a RaceGOW track run is an ordinary event, so it
+ * starts from the Events page ("+ New event" → RaceGOW track run) and its runs sit in the Events
+ * list with a badge, rather than the hub growing a card per preset.
+ *
* Big text + dark surfaces by design: this hub is glanced at on a laptop outdoors in the sun, so
* the cards are large, high-contrast tap targets ("readable in sunlight?" is the gate).
*/
@@ -17,23 +21,19 @@
import type { Timer } from '@gridfpv/types';
import type { Session } from '../lib/session.svelte.js';
import { isTimerConnected } from '../lib/timers.js';
- import { isRaceGowEvent } from '../lib/racegow.js';
let {
session,
onpilots,
onclasses,
onevents,
- ontimers,
- onracegow
+ ontimers
}: {
session: Session;
onpilots: () => void;
onclasses: () => void;
onevents: () => void;
ontimers: () => void;
- /** The RaceGOW page — the solo track-run preset (`lib/racegow.ts`). */
- onracegow: () => void;
} = $props();
// Each summary loads independently and best-effort; `undefined` while loading, `null` on a
@@ -41,7 +41,6 @@
let pilotCount = $state(undefined);
let classCount = $state(undefined);
let eventCount = $state(undefined);
- let raceGowCount = $state(undefined);
let timerCount = $state(undefined);
let connectedTimers = $state(undefined);
@@ -63,14 +62,8 @@
.catch(() => (classCount = null));
void session
.listEvents()
- .then((events) => {
- eventCount = events.length;
- raceGowCount = events.filter(isRaceGowEvent).length;
- })
- .catch(() => {
- eventCount = null;
- raceGowCount = null;
- });
+ .then((events) => (eventCount = events.length))
+ .catch(() => (eventCount = null));
void session
.listTimers()
.then((timers) => {
@@ -138,34 +131,6 @@
-
-
-
-
-
-
-
-
-
RaceGOW
-
- {fmt(raceGowCount)}
- {raceGowCount === 1 ? 'track run' : 'track runs'}
- · time your best 3 laps at home
-
-
→
-
-
-
-
-
diff --git a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
index 4683205b..7dcee487 100644
--- a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
+++ b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
@@ -2,8 +2,9 @@
/**
* RaceGowPage — the RaceGOW landing (`#/racegow`): start a new track run, or reopen one.
*
- * An app-level page like Pilots / Timers, reached from the hub. It does not need an event; it
- * *creates* one. The form asks only what the preset cannot decide for itself — which track,
+ * An app-level page like Pilots / Timers, reached from the Events page ("+ New event" → RaceGOW
+ * track run). It does not need an event; it *creates* one, and that event then lists on the
+ * Events page like any other. The form asks only what the preset cannot decide for itself — which track,
* who is flying, on which timer and channel — and {@link createRaceGowRun} builds the rest
* through the ordinary event commands (see `lib/racegow.ts`). Every run it made before is listed
* below the form so a pilot coming back for another pack reopens the same track.
@@ -22,11 +23,14 @@
let {
session,
onhome,
+ onevents,
onrun,
ontimers
}: {
session: Session;
onhome: () => void;
+ /** The Events page — where this page is reached from, and where its runs are listed. */
+ onevents: () => void;
/** Called once a run is created (or reopened) and entered — the shell shows the run screen. */
onrun: () => void;
/** The Timers page — where a timer's channels are configured when it offers none. */
@@ -175,7 +179,13 @@
-
+
diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
index a8bec3ab..30830801 100644
--- a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
+++ b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
@@ -46,12 +46,15 @@
let {
session,
onhome,
+ onevents,
onback,
onconsole,
ontimers
}: {
session: Session;
onhome: () => void;
+ /** The Events page, where this run lists with the other events (leaves the run locally). */
+ onevents: () => void;
/** Back to the RaceGOW page (all runs, start another track). */
onback: () => void;
/** Open this event in the full RD console (Race control). */
@@ -334,6 +337,7 @@
({ event: null })),
deleteEventImpl: vi.fn(async () => undefined)
});
- render(EventPicker, { session, onhome: noop });
+ render(EventPicker, { session, onhome: noop, onracegow: noop });
}
/** Open the hard-delete dialog for EVENT and return its scope. */
@@ -63,7 +63,7 @@ describe('EventPicker — the brand home root (#118)', () => {
listEventsImpl: vi.fn(async () => [EVENT]),
getActiveEventImpl: vi.fn(async () => ({ event: null }))
});
- render(EventPicker, { session, onhome });
+ render(EventPicker, { session, onhome, onracegow: noop });
// The button's accessible name comes from its wordmark content; the title is the tooltip.
const brand = await screen.findByTitle('Home — GridFPV hub');
await fireEvent.click(brand);
@@ -119,7 +119,7 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4
listEventsImpl: vi.fn(async () => []),
getActiveEventImpl: vi.fn(async () => ({ event: null }))
});
- render(EventPicker, { session, onhome: noop });
+ render(EventPicker, { session, onhome: noop, onracegow: noop });
}
it('offers a create-your-first-event call to action when no events exist', async () => {
@@ -150,3 +150,64 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4
expect(screen.queryByRole('button', { name: /Practice/ })).toBeNull();
});
});
+
+/**
+ * RaceGOW lives on the Events page, not the hub: "+ New event" asks club race vs track run, and a
+ * run the preset built lists here with a badge. The club path must stay one click — the kind
+ * chooser defaults to a race, with the name form already up.
+ */
+describe('EventPicker — a RaceGOW track run is a kind of new event', () => {
+ const RUN: EventMeta = {
+ id: 'track1-run',
+ name: 'RaceGOW6 Track 1',
+ created_at: 1000,
+ persistent: true,
+ preset: 'racegow',
+ timers: ['rh-1'],
+ roster: ['p1'],
+ classes: ['mgp-whoop']
+ };
+
+ function renderWithRun() {
+ const onracegow = vi.fn();
+ const { session } = makeTestSession({
+ noEnter: true,
+ listEventsImpl: vi.fn(async () => [EVENT, RUN]),
+ getActiveEventImpl: vi.fn(async () => ({ event: null }))
+ });
+ render(EventPicker, { session, onhome: noop, onracegow });
+ return { onracegow };
+ }
+
+ it('badges the run the preset built, and only that one', async () => {
+ renderWithRun();
+ // Anchored: the delete button's name is "Delete ", and a loose match would hit both.
+ const runRow = await screen.findByRole('button', { name: /^RaceGOW6 Track 1/ });
+ expect(within(runRow).getByText('RaceGOW')).toBeInTheDocument();
+ const clubRow = screen.getByRole('button', { name: /^Friday Night Series/ });
+ expect(within(clubRow).queryByText('RaceGOW')).toBeNull();
+ });
+
+ it('the new-event dialog defaults to a club race, with the name form up', async () => {
+ renderWithRun();
+ await fireEvent.click(await screen.findByRole('button', { name: '+ New event' }));
+ const form = await screen.findByRole('form', { name: 'New event' });
+ expect(within(form).getByRole('radio', { name: 'Club race' })).toBeChecked();
+ expect(within(form).getByRole('radio', { name: 'RaceGOW track run' })).not.toBeChecked();
+ expect(within(form).getByLabelText('Event name')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Create & enter' })).toBeInTheDocument();
+ });
+
+ it('choosing a track run swaps the form for a hand-off to the RaceGOW page', async () => {
+ const { onracegow } = renderWithRun();
+ await fireEvent.click(await screen.findByRole('button', { name: '+ New event' }));
+ const form = await screen.findByRole('form', { name: 'New event' });
+ await fireEvent.click(within(form).getByRole('radio', { name: 'RaceGOW track run' }));
+ // The name form steps aside: the RaceGOW page's own form asks what a run needs.
+ await waitFor(() => expect(within(form).queryByLabelText('Event name')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Create & enter' })).toBeNull();
+ await fireEvent.click(screen.getByRole('button', { name: 'Set up track run' }));
+ expect(onracegow).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+});
diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts
index 38679b5e..61338cc9 100644
--- a/frontend/apps/rd-console/tests/HomeHub.test.ts
+++ b/frontend/apps/rd-console/tests/HomeHub.test.ts
@@ -99,15 +99,15 @@ describe('HomeHub (app-level landing, #118)', () => {
const onclasses = vi.fn();
const onevents = vi.fn();
const ontimers = vi.fn();
- const onracegow = vi.fn();
- render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow });
+ render(HomeHub, { session, onpilots, onclasses, onevents, ontimers });
- // Five navigable cards: the four pages, plus RaceGOW (the solo track-run preset).
+ // Four navigable cards — the four pages. RaceGOW (the solo track-run preset) is NOT a card:
+ // a track run is an event, so it starts from the Events page and lists there with a badge.
expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: 'RaceGOW' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'RaceGOW' })).toBeNull();
// Summaries settle per card (count + unit are separate spans): 2 pilots, 3 classes, 2 events,
// 3 timers · 2 connected (Mock is Ready + the live RH is Connected; the Configured RH is excluded).
@@ -121,10 +121,6 @@ describe('HomeHub (app-level landing, #118)', () => {
expect(within(classesCard).getByText('classes')).toBeInTheDocument();
await waitFor(() => expect(within(eventsCard).getByText('2')).toBeInTheDocument());
expect(within(eventsCard).getByText('events')).toBeInTheDocument();
- // RaceGOW counts only the events the preset made: neither of these two carries the marker.
- const raceGowCard = screen.getByRole('heading', { name: 'RaceGOW' }).closest('button')!;
- await waitFor(() => expect(within(raceGowCard).getByText('0')).toBeInTheDocument());
- expect(within(raceGowCard).getByText('track runs')).toBeInTheDocument();
await waitFor(() => expect(within(timersCard).getByText('timers')).toBeInTheDocument());
expect(within(timersCard).getByText(/2 connected/)).toBeInTheDocument();
});
@@ -135,8 +131,7 @@ describe('HomeHub (app-level landing, #118)', () => {
const onclasses = vi.fn();
const onevents = vi.fn();
const ontimers = vi.fn();
- const onracegow = vi.fn();
- render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow });
+ render(HomeHub, { session, onpilots, onclasses, onevents, ontimers });
await fireEvent.click(screen.getByRole('heading', { name: 'Pilots' }).closest('button')!);
await fireEvent.click(screen.getByRole('heading', { name: 'Classes' }).closest('button')!);
@@ -162,8 +157,7 @@ describe('HomeHub (app-level landing, #118)', () => {
onpilots: vi.fn(),
onclasses: vi.fn(),
onevents: vi.fn(),
- ontimers: vi.fn(),
- onracegow: vi.fn()
+ ontimers: vi.fn()
});
const timersCard = screen.getByRole('heading', { name: 'Timers' }).closest('button')!;
diff --git a/frontend/apps/rd-console/tests/RaceGowPage.test.ts b/frontend/apps/rd-console/tests/RaceGowPage.test.ts
index 4b62ef7f..8c6fa381 100644
--- a/frontend/apps/rd-console/tests/RaceGowPage.test.ts
+++ b/frontend/apps/rd-console/tests/RaceGowPage.test.ts
@@ -67,7 +67,7 @@ function setup(opts: { pilots?: Pilot[]; timers?: Timer[]; events?: EventMeta[]
])
});
vi.spyOn(session, 'listEvents').mockResolvedValue(opts.events ?? [OLD_RUN, CLUB]);
- const props = { onhome: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() };
+ const props = { onhome: vi.fn(), onevents: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() };
return { session, props };
}
diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
index c54d3154..2506190b 100644
--- a/frontend/apps/rd-console/tests/RaceGowRun.test.ts
+++ b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
@@ -90,7 +90,13 @@ function setup(opts: {
return { ...t, listHeatsImpl, roundStandingsImpl };
}
-const props = { onhome: vi.fn(), onback: vi.fn(), onconsole: vi.fn(), ontimers: vi.fn() };
+const props = {
+ onhome: vi.fn(),
+ onevents: vi.fn(),
+ onback: vi.fn(),
+ onconsole: vi.fn(),
+ ontimers: vi.fn()
+};
describe('RaceGowRun', () => {
it('idle: "Start run" draws the next heat, focuses it, stages and starts it', async () => {
diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts
index 10a966cf..43b4ffc7 100644
--- a/frontend/e2e/racegow.spec.ts
+++ b/frontend/e2e/racegow.spec.ts
@@ -1,6 +1,7 @@
/**
* RaceGOW — the solo track-run loop, end to end, in real headless chromium against a real
- * Director: hub → RaceGOW page → set up a run on the Simulator → Start run → laps climb → Stop
+ * Director: hub → Events → "+ New event" → RaceGOW track run → set up a run on the Simulator →
+ * Start run → laps climb → Stop
* run → the best 3-lap window and the submission line → Fly again → two runs in the table. Then
* the OBS overlay page renders the same run, read-only, on a transparent document.
*
@@ -38,9 +39,16 @@ test('a pilot sets up a track run, flies two runs, and gets a submission line',
}) => {
await page.goto(director.baseUrl);
- // ── Hub → RaceGOW ────────────────────────────────────────────────────────────────────────
- await expect(page.getByRole('heading', { name: 'RaceGOW' })).toBeVisible({ timeout: 15_000 });
- await page.getByRole('heading', { name: 'RaceGOW' }).click();
+ // ── Hub → Events → "+ New event" → RaceGOW track run ───────────────────────────────────
+ // There is no RaceGOW card on the hub: a track run is an event, so it starts where events
+ // do. A fresh Director has none, so the first-run call to action is the way in.
+ await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: 'RaceGOW' })).toHaveCount(0);
+ await page.getByRole('heading', { name: 'Events' }).click();
+ await page.getByRole('button', { name: 'Create your first event' }).click();
+ const newEvent = page.getByRole('form', { name: 'New event' });
+ await newEvent.getByRole('radio', { name: 'RaceGOW track run' }).check();
+ await page.getByRole('button', { name: 'Set up track run' }).click();
await expect(page).toHaveURL(/#\/racegow$/);
const form = page.getByRole('form', { name: 'New track run' });
await expect(form).toBeVisible();
@@ -147,7 +155,27 @@ test('a pilot sets up a track run, flies two runs, and gets a submission line',
await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 });
await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
- // ── Back on the RaceGOW page, the run is listed and reopens ─────────────────────────────
+ // ── The run's Events crumb leaves the run and lands on the Events page, where the run lists
+ // with its badge; entering it there opens the run screen again (not the workspace): the
+ // preset marker routes it. ──────────────────────────────────────────────────────────────
+ await page.goto(`${director.baseUrl}/#/racegow/run`);
+ await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 });
+ await page
+ .getByRole('navigation', { name: 'Breadcrumb' })
+ .getByRole('button', { name: 'Events' })
+ .click();
+ await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({
+ timeout: 15_000
+ });
+ // Anchored: the row's delete button is named "Delete " and would match a loose regex.
+ const row = page.getByRole('button', { name: new RegExp(`^${TRACK}`) });
+ await expect(row).toBeVisible();
+ await expect(row.getByText('RaceGOW', { exact: true })).toBeVisible();
+ await row.click();
+ await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: TRACK })).toBeVisible();
+
+ // ── The RaceGOW page still lists every run and reopens one ──────────────────────────────
await page.goto(`${director.baseUrl}/#/racegow`);
const runs = page.getByRole('list', { name: 'Your runs' });
await expect(runs.getByRole('listitem')).toHaveCount(1, { timeout: 15_000 });
From 20b58e23fc8a6c69bfee53a4dba5973e1a1cf041 Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Mon, 12 Oct 2026 03:04:58 +0000
Subject: [PATCH 5/5] feat(console,desktop): drop the RaceGOW mode, keep the
overlay for any event, fix the desktop port
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The RaceGOW preset was tried for real and was worse than an ordinary event. It is gone:
`EventMeta.preset` / `CreateEventRequest.preset`, the `#/racegow` page and `#/racegow/run`
solo run screen, the club-race / track-run chooser and the RaceGOW badge on the Events page,
`lib/racegow.ts`, their unit tests and the `racegow` browser spec, and the user-doc links. A
one-pilot event with an open-ended Time Trials round scored by best 3 consecutive laps does
the same job with nothing special.
The overlay stays, and now works for ANY event. `#/overlay/race` (RaceOverlay.svelte, helpers
in lib/overlay.ts) follows the Director's active event with no preset check: the race clock
(a countdown when the round has a fixed end, as the console's clocks do), the phase, the heat's
friendly name, and a row per pilot off the served lap list — laps, last, best, or the best-N
window when the round scores BestConsecutive, plus the served round-wide best. A one-pilot heat
gets the long form with its recent laps. It re-reads the event meta when the heat or phase
moves (a round created on the console never reaches a second client otherwise) and polls the
active event so an OBS scene left open all evening follows an event switch. Race-day audio is
not mounted on an overlay route — a browser source must not play a second copy of the tones.
The event workspace gains an **Overlays** tab (`#/event/overlays`, EventOverlays.svelte): the
catalogue, each page's URL with a contrast picker, Copy URL, open in a new tab, and a live
preview frame of the same route — the way to see an overlay from inside any event.
The desktop app binds a FIXED port. It bound `127.0.0.1:0` and took whatever the OS gave it,
which changed on every restart and broke every OBS browser source holding an overlay URL. It
now binds `127.0.0.1:8080` (the hosted binary's default; `GRIDFPV_ADDR` overrides) and falls
back to an ephemeral port — logged loudly — only when that port is taken.
Co-Authored-By: Claude Fable 5.1
---
bindings/CreateEventRequest.ts | 6 +-
bindings/EventMeta.ts | 9 -
crates/server/src/app.rs | 2 -
crates/server/src/control_handler.rs | 7 -
crates/server/src/events.rs | 21 -
crates/server/src/open_practice.rs | 1 -
crates/server/src/round_engine.rs | 1 -
.../server/tests/min_lap_floor_conformance.rs | 1 -
crates/server/tests/multi_round_event_live.rs | 1 -
docs/clients.html | 7 +-
docs/decisions.html | 5 +-
docs/roadmap.html | 24 +-
docs/streaming.html | 25 +-
frontend/apps/rd-console/src/App.svelte | 93 +-
frontend/apps/rd-console/src/lib/overlay.ts | 154 ++++
frontend/apps/rd-console/src/lib/racegow.ts | 244 ------
frontend/apps/rd-console/src/lib/route.ts | 56 +-
.../apps/rd-console/src/lib/session.svelte.ts | 9 +-
.../src/screens/EventOverlays.svelte | 194 +++++
.../rd-console/src/screens/EventPicker.svelte | 258 ++----
.../rd-console/src/screens/HomeHub.svelte | 4 -
.../src/screens/RaceGowOverlay.svelte | 300 -------
.../rd-console/src/screens/RaceGowPage.svelte | 452 ----------
.../rd-console/src/screens/RaceGowRun.svelte | 816 ------------------
.../rd-console/src/screens/RaceOverlay.svelte | 548 ++++++++++++
.../rd-console/tests/EventOverlays.test.ts | 45 +
.../apps/rd-console/tests/EventPicker.test.ts | 67 +-
.../apps/rd-console/tests/HomeHub.test.ts | 4 +-
.../rd-console/tests/RaceGowOverlay.test.ts | 96 ---
.../apps/rd-console/tests/RaceGowPage.test.ts | 168 ----
.../apps/rd-console/tests/RaceGowRun.test.ts | 228 -----
.../apps/rd-console/tests/RaceOverlay.test.ts | 187 ++++
.../apps/rd-console/tests/overlay.test.ts | 208 +++++
.../apps/rd-console/tests/racegow.test.ts | 223 -----
frontend/apps/rd-console/tests/route.test.ts | 69 +-
frontend/e2e/overlay.spec.ts | 124 +++
frontend/e2e/racegow.spec.ts | 185 ----
src-tauri/README.md | 31 +-
src-tauri/src/lib.rs | 134 ++-
39 files changed, 1770 insertions(+), 3237 deletions(-)
create mode 100644 frontend/apps/rd-console/src/lib/overlay.ts
delete mode 100644 frontend/apps/rd-console/src/lib/racegow.ts
create mode 100644 frontend/apps/rd-console/src/screens/EventOverlays.svelte
delete mode 100644 frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
delete mode 100644 frontend/apps/rd-console/src/screens/RaceGowPage.svelte
delete mode 100644 frontend/apps/rd-console/src/screens/RaceGowRun.svelte
create mode 100644 frontend/apps/rd-console/src/screens/RaceOverlay.svelte
create mode 100644 frontend/apps/rd-console/tests/EventOverlays.test.ts
delete mode 100644 frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
delete mode 100644 frontend/apps/rd-console/tests/RaceGowPage.test.ts
delete mode 100644 frontend/apps/rd-console/tests/RaceGowRun.test.ts
create mode 100644 frontend/apps/rd-console/tests/RaceOverlay.test.ts
create mode 100644 frontend/apps/rd-console/tests/overlay.test.ts
delete mode 100644 frontend/apps/rd-console/tests/racegow.test.ts
create mode 100644 frontend/e2e/overlay.spec.ts
delete mode 100644 frontend/e2e/racegow.spec.ts
diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts
index 0163711d..ccb648b1 100644
--- a/bindings/CreateEventRequest.ts
+++ b/bindings/CreateEventRequest.ts
@@ -29,8 +29,4 @@ description?: string,
/**
* Optional organizer name, stored on [`EventMeta::organizer`].
*/
-organizer?: string,
-/**
- * Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`).
- */
-preset?: string, };
+organizer?: string, };
diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts
index 5510e94b..4c333dd1 100644
--- a/bindings/EventMeta.ts
+++ b/bindings/EventMeta.ts
@@ -55,15 +55,6 @@ description?: string,
* Optional organizer name (the running club / person). Omitted from the wire when unset.
*/
organizer?: string,
-/**
- * The **console preset** that created this event, when one did — `"racegow"` for a solo
- * RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win
- * condition) built by the console through the ordinary create/configure commands; the marker
- * only records *which* preset shaped it, so the console can open the event on the preset's
- * own surface (the solo run screen) instead of the full workspace. Free-text on the wire and
- * absent for an ordinary event; nothing server-side consults it. Omitted when unset.
- */
-preset?: string,
/**
* The application-level timers this event **selects** (issue #73) — the per-event reference
* into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index a0b94d4b..4e5cc67c 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -4381,7 +4381,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap();
assert!(registry.resolve(&created.id).is_some());
@@ -4421,7 +4420,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap();
let (status, _) = delete_event_req(registry.clone(), &created.id.0, None).await;
diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs
index 9f603422..1c6fdfb4 100644
--- a/crates/server/src/control_handler.rs
+++ b/crates/server/src/control_handler.rs
@@ -4281,7 +4281,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -4431,7 +4430,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -5199,7 +5197,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -5514,7 +5511,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -5982,7 +5978,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -6156,7 +6151,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
@@ -6240,7 +6234,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap()
.id;
diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs
index cc5c0ef4..7feb812d 100644
--- a/crates/server/src/events.rs
+++ b/crates/server/src/events.rs
@@ -105,15 +105,6 @@ pub struct EventMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub organizer: Option,
- /// The **console preset** that created this event, when one did — `"racegow"` for a solo
- /// RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win
- /// condition) built by the console through the ordinary create/configure commands; the marker
- /// only records *which* preset shaped it, so the console can open the event on the preset's
- /// own surface (the solo run screen) instead of the full workspace. Free-text on the wire and
- /// absent for an ordinary event; nothing server-side consults it. Omitted when unset.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- #[ts(optional)]
- pub preset: Option,
/// The application-level timers this event **selects** (issue #73) — the per-event reference
/// into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive
/// (`#[serde(default)]`) so an event persisted before #73 reads back with an empty list; new
@@ -1636,10 +1627,6 @@ pub struct CreateEventRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub organizer: Option,
- /// Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`).
- #[serde(default, skip_serializing_if = "Option::is_none")]
- #[ts(optional)]
- pub preset: Option,
}
impl CreateEventRequest {
@@ -1653,7 +1640,6 @@ impl CreateEventRequest {
location: None,
description: None,
organizer: None,
- preset: None,
}
}
}
@@ -3044,7 +3030,6 @@ impl EventRegistry {
location: normalize_optional(&request.location),
description: normalize_optional(&request.description),
organizer: normalize_optional(&request.organizer),
- preset: normalize_optional(&request.preset),
timers: default_timer_selection(),
primary_timer: None,
roster: Vec::new(),
@@ -4436,7 +4421,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
})
.unwrap();
event_id = created.id.clone();
@@ -4479,7 +4463,6 @@ mod tests {
location: Some("Main field".to_string()),
description: None,
organizer: Some("GridFPV Club".to_string()),
- preset: Some("racegow".to_string()),
})
.unwrap();
created_id = created.id.clone();
@@ -4520,10 +4503,6 @@ mod tests {
assert_eq!(restored.date.as_deref(), Some("2026-06-20"));
assert_eq!(restored.location.as_deref(), Some("Main field"));
assert_eq!(restored.organizer.as_deref(), Some("GridFPV Club"));
- // The preset marker is ordinary meta: stored on create, restored on reload (the console
- // opens a `racegow` event on the solo run screen, so losing it would demote a track run
- // to a plain event after a restart).
- assert_eq!(restored.preset.as_deref(), Some("racegow"));
assert_eq!(
restored.timers,
vec![TimerId("rh-1".into()), TimerId(MOCK_TIMER_ID.into())]
diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs
index afe4899b..fcc35c9e 100644
--- a/crates/server/src/open_practice.rs
+++ b/crates/server/src/open_practice.rs
@@ -127,7 +127,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
timers: vec![],
primary_timer: None,
roster: vec![],
diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs
index d975959e..19888d8a 100644
--- a/crates/server/src/round_engine.rs
+++ b/crates/server/src/round_engine.rs
@@ -3066,7 +3066,6 @@ mod tests {
location: None,
description: None,
organizer: None,
- preset: None,
timers: vec![],
primary_timer: None,
roster: vec![],
diff --git a/crates/server/tests/min_lap_floor_conformance.rs b/crates/server/tests/min_lap_floor_conformance.rs
index cea43803..e5096779 100644
--- a/crates/server/tests/min_lap_floor_conformance.rs
+++ b/crates/server/tests/min_lap_floor_conformance.rs
@@ -135,7 +135,6 @@ fn rig() -> Rig {
location: None,
description: None,
organizer: None,
- preset: None,
})
.expect("event created");
let event = meta.id.clone();
diff --git a/crates/server/tests/multi_round_event_live.rs b/crates/server/tests/multi_round_event_live.rs
index f202f1d2..3193d0e4 100644
--- a/crates/server/tests/multi_round_event_live.rs
+++ b/crates/server/tests/multi_round_event_live.rs
@@ -607,7 +607,6 @@ async fn build_event(
location: None,
description: None,
organizer: None,
- preset: None,
},
)
.await;
diff --git a/docs/clients.html b/docs/clients.html
index db31894c..6e3413d1 100644
--- a/docs/clients.html
+++ b/docs/clients.html
@@ -166,8 +166,11 @@ The native desktop app — the release form (as built; PRs #195, #204)
The console's release form is a Tauri 2 desktop app
(src-tauri/) that embeds the Director in-process . On launch it
runs the same gridfpv_app::director::run_director the hosted
- gridfpv binary runs, on a tokio task bound to a loopback ephemeral
- port (127.0.0.1:0), then opens a native window pointed at
+ gridfpv binary runs, on a tokio task bound to a fixed loopback
+ port (127.0.0.1:8080, GRIDFPV_ADDR to override; it was
+ an ephemeral 127.0.0.1:0 until 2026-10-12, which broke every OBS browser source
+ holding an overlay URL on each restart — an ephemeral port is now only the fallback when the
+ fixed one is taken), then opens a native window pointed at
http://127.0.0.1:<port>/. The window loads the Director's own HTTP origin
directly, so the SPA's same-origin API (/snapshot/…, /control/…) and
the realtime WebSocket work with no cross-origin shims. Because it is
diff --git a/docs/decisions.html b/docs/decisions.html
index 11374dd2..3da7b283 100644
--- a/docs/decisions.html
+++ b/docs/decisions.html
@@ -296,7 +296,10 @@ D9 · The native desktop app embeds the Director, ships portable-only, store
The release form is a Tauri 2 desktop app (src-tauri/; PRs
#195, #204) that embeds the Director in-process . On launch it runs the
same gridfpv_app::director::run_director the hosted binary runs, on a
- tokio task bound to a loopback ephemeral port (127.0.0.1:0), and
+ tokio task bound to a fixed loopback port (127.0.0.1:8080;
+ originally an ephemeral 127.0.0.1:0, changed 2026-10-12 because OBS browser
+ sources hold the overlay URL and a port that moved on every restart broke them — ephemeral is
+ now the logged fallback when the fixed port is taken), and
opens a native window pointed at http://127.0.0.1:<port>/. The
frontend is baked into the binary via the embed-assets cargo
feature, so the executable is a single self-contained file. Distribution is
diff --git a/docs/roadmap.html b/docs/roadmap.html
index b8cdb5b4..1d7e8d2b 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -126,20 +126,18 @@ 2. The phases
the independent wire integer).
- Status (2026-09-26): v0.4.0 is released (the non-prerelease
+ Status (2026-10-12): v0.4.0 is released (the non-prerelease
v0.4.0 , 2026-09-07),
- and the first work after it is a preset , not a phase:
- RaceGOW at home — a solo track-run surface over the existing
- engine. An event marked EventMeta.preset = "racegow" is built by the
- console through the wizard's own commands (Whoop class, one pilot with a fixed channel,
- an open-ended Time Trials round scored by best 3 consecutive laps, a 10-minute
- ceiling), then driven from one screen (#/racegow/run: fill → stage → start,
- stop → finalize) with its result as a copy-and-paste submission line. It also ships the
- first overlay of the streaming design,
- #/overlay/racegow, because the competition requires a timer in the video.
- The point of building it as a preset: it proves the primitives carry a real
- competition's rules with zero engine change, and every RaceGOW run remains an
- ordinary event with marshaling, audit and results behind it.
+ and the first work after it is the first piece of the
+ streaming design: the race overlay
+ (#/overlay/race), an OBS browser source that follows the active event — any
+ event — and puts the clock, the heat and every pilot's laps in the video, reached from the
+ event workspace's new Overlays tab. It was first built as a "RaceGOW"
+ solo-run preset with its own screens; that mode was dropped (an ordinary event with one
+ pilot and a best-3-consecutive time-trial round is the better shape, and needs nothing new)
+ and the overlay kept and generalised. The desktop app also moved from an ephemeral port to
+ a fixed one (127.0.0.1:8080), because a browser source holds
+ the Director's address.
diff --git a/docs/streaming.html b/docs/streaming.html
index 02e88796..f98a4b38 100644
--- a/docs/streaming.html
+++ b/docs/streaming.html
@@ -310,18 +310,23 @@ 5. Overlay catalogue & theming
- As built (2026-09-26): the first overlay is the RaceGOW timer,
- #/overlay/racegow. It is a route of the RD console SPA rather than a
- separate app — the "one codebase, profiled three ways" frontend — rendered bare, on a
- transparent document (html[data-overlay] lifts the console canvas), in the
+ As built (2026-09-26, generalised 2026-10-12): the first overlay is the race
+ timer, #/overlay/race. It is a route of the RD console SPA rather
+ than a separate app — the "one codebase, profiled three ways" frontend — rendered bare, on
+ a transparent document (html[data-overlay] lifts the console canvas), in the
.gridfpv-overlay token context. It is exactly the §2 shape: a read-only
client on the same session as the console, following the program (§2.1) — the
- Director's active event — with no binding of its own, because a solo pilot has one
- run open. It shows the run clock, the last few laps off the served lap-list
- projection, and the best 3-consecutive window this run and today (the served round
- standings). It exists because RaceGOW's rules require a timer in view in the
- submitted video; the pilot adds it to OBS as a browser source over their DVR. Nothing
- in §3 (broadcast control) is built.
+ Director's active event, whatever kind of event it is — with no binding
+ of its own yet. It shows the race clock (a countdown when the round has a fixed end, as
+ the console's clocks do), the phase, the current heat's friendly name, and a row per pilot
+ off the served lap-list projection (laps, last, best — or the best n in a row when
+ the round scores BestConsecutive, plus the served round-wide best); a
+ one-pilot heat gets the long form with its recent laps. It began life as the RaceGOW
+ timer, tied to a solo-run preset; the preset was removed (an ordinary event does the job
+ better) and the overlay was made to follow any event. The console reaches it from
+ the event workspace's Overlays tab (#/event/overlays): the
+ catalogue, each page's URL with a contrast picker, and a live preview frame of the same
+ route. Nothing in §3 (broadcast control) is built.
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index 2a970902..7ba556a1 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -45,10 +45,8 @@
import Marshaling from './screens/Marshaling.svelte';
import Results from './screens/Results.svelte';
import EventAudit from './screens/EventAudit.svelte';
- import RaceGowPage from './screens/RaceGowPage.svelte';
- import RaceGowRun from './screens/RaceGowRun.svelte';
- import RaceGowOverlay from './screens/RaceGowOverlay.svelte';
- import { isRaceGowEvent } from './lib/racegow.js';
+ import EventOverlays from './screens/EventOverlays.svelte';
+ import RaceOverlay from './screens/RaceOverlay.svelte';
import { openAudit } from './lib/auditFilter.svelte.js';
import type { Timer } from '@gridfpv/types';
import {
@@ -63,8 +61,11 @@
const session = new Session();
// App-wide race-day audio: tones + lap callouts follow the live stream on EVERY page (the
- // race must be audible while the RD is on rounds/marshaling/results, not just Live control).
- mountRaceDayAudio(session);
+ // race must be audible while the RD is on rounds/marshaling/results, not just Live control) —
+ // except an **overlay**. An overlay is a browser source inside OBS (or the Overlays tab's
+ // preview frame): a second copy of the start tones and callouts coming out of the stream, a
+ // beat behind the console's, is exactly what a transparent timer panel must not do.
+ if (parseHash(location.hash).kind !== 'overlay') mountRaceDayAudio(session);
// Role seam (#55/#80): a `?role=readonly` query selects the read-only-pilot tier, which hides
// every mutating control (the Marshaling actions, etc.). The Director is the enforced boundary;
@@ -119,12 +120,6 @@
};
/** Back out of an event-scoped tune: the event workspace's Timers tab — where the RD came from. */
const goEventTimersTab = () => navigate({ kind: 'workspace', tab: 'timers' });
- // The RaceGOW preset's two surfaces: the page that starts a run, and the solo run screen for
- // the active one. Both are app-level routes (no workspace shell) — a pilot alone in a garage
- // wants a start button, not a race director's console; the workspace stays one click away.
- // The page is reached from the Events page ("+ New event" → RaceGOW track run), not the hub.
- const goRaceGow = () => navigate({ kind: 'racegow' });
- const goRaceGowRun = () => navigate({ kind: 'racegow-run' });
// The workspace's app-route concept is "Events" (where event entry/switch happens).
const route$page = $derived(route.kind === 'page' ? route.page : 'home');
@@ -215,16 +210,9 @@
// the active event appears while we're on the Events page, flip the route into the workspace so
// the hash becomes `#/event/live`. This is the routing counterpart of the old `route = 'events'`
// → workspace transition that used to be implicit in `{#if session.currentEvent}`.
- //
- // A RaceGOW track run is an ordinary event in that list, but its surface is the solo run screen:
- // the preset marker on the meta is what sends it to `#/racegow/run` instead of the workspace
- // (the workspace stays one click away from there, via "Open in console").
$effect(() => {
- const current = session.currentEvent;
- if (current && route.kind === 'page' && route.page === 'events') {
- navigate(
- isRaceGowEvent(current) ? { kind: 'racegow-run' } : { kind: 'workspace', tab: 'live' }
- );
+ if (session.currentEvent && route.kind === 'page' && route.page === 'events') {
+ navigate({ kind: 'workspace', tab: 'live' });
}
});
@@ -235,7 +223,8 @@
| 'live'
| 'marshaling'
| 'results'
- | 'audit';
+ | 'audit'
+ | 'overlays';
const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [
{
id: 'classes-roster',
@@ -265,6 +254,14 @@
label: 'Timers',
key: '6',
icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z'
+ },
+ // The OBS browser-source pages (streaming.html §2): their URLs, colors and a live preview. A
+ // broadcast surface sits with the equipment, after Timers; key '8', the next free digit.
+ {
+ id: 'overlays',
+ label: 'Overlays',
+ key: '8',
+ icon: 'M3 5h18v11H3zM8 20h8M12 16v4'
}
];
// The active workspace tab is derived from the route (the hash is the source of truth, #118):
@@ -292,9 +289,7 @@
function leaveToPicker() {
session.leaveEvent();
// Close the wizard if open, and land back on the Events page (the app route the "Switch event"
- // action conceptually returns to). Setting the hash to `#/events` keeps the URL honest. The
- // RaceGOW surfaces' Events crumb takes this same path: with the run still active locally, the
- // Events page would route straight back into the run screen.
+ // action conceptually returns to). Setting the hash to `#/events` keeps the URL honest.
wizardOpen = false;
goPage('events');
}
@@ -367,43 +362,10 @@
{:else if route.kind === 'overlay'}
+ the Director's active event — whatever event that is — through the same session as everything
+ else. One overlay exists so far (`race`); the route's name picks the page. -->
-
-
-{:else if route.kind === 'racegow'}
-
- goPage('timers')}
- />
-
-{:else if route.kind === 'racegow-run'}
-
-
- {#if isRaceGowEvent(session.currentEvent)}
- navigate({ kind: 'workspace', tab: 'live' })}
- ontimers={() => goPage('timers')}
- />
- {:else}
- goPage('timers')}
- />
- {/if}
+
{:else if route.kind === 'page'}
+
+
+
+
+ {/each}
+
+
+
diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte
index c645aa33..01f634d2 100644
--- a/frontend/apps/rd-console/src/screens/EventPicker.svelte
+++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte
@@ -5,12 +5,8 @@
* In the two-level IA the home hub is the landing and this is the **Events page** (Home ›
* Events) — the select/create surface. On load it reads the open event list (`listEvents()`,
* no token) and renders the events the RD has created. Selecting one enters its workspace.
- * "+ New event" opens a `Dialog` that first asks **what kind** of event: a **club race** (a name
- * → `createEvent(name)`, which obtains the RD token lazily → enters it, then hands off to the
- * guided setup wizard, #97), or a **RaceGOW track run** (the solo preset, `lib/racegow.ts`),
- * which hands off to the RaceGOW page's own short form. A RaceGOW run is an ordinary event, so
- * it lists here like any other — with a badge — and entering one opens the solo run screen
- * (the shell routes on the preset marker) rather than the race director's workspace.
+ * "+ New event" opens a name `Dialog` → `createEvent(name)` (which obtains the RD token
+ * lazily) → enters it, then hands off to the guided setup wizard (#97).
*
* **The list can be empty, and that is a first run, not an error** (#414): the built-in
* in-memory Practice event is gone, so a brand-new Director genuinely has zero events. The
@@ -32,18 +28,14 @@
import type { Session, CreateEventFields } from '../lib/session.svelte.js';
import Brand from '../Brand.svelte';
import Breadcrumbs from '../Breadcrumbs.svelte';
- import { isRaceGowEvent } from '../lib/racegow.js';
let {
session,
onhome,
- onracegow,
onsetup = undefined
}: {
session: Session;
onhome: () => void;
- /** The RaceGOW page — where a track run (the solo preset) is set up and started. */
- onracegow: () => void;
/** Called after a brand-new event is created + entered, when the RD opted to set it up (the
* guided wizard, race redesign Slice 7). The shell opens the wizard once the workspace mounts. */
onsetup?: () => void;
@@ -116,13 +108,6 @@
// The "new event" dialog. Name-only is the one-click default (#72, Slice 1b C); the
// optional descriptive fields live behind a collapsible "Add details" section.
let newOpen = $state(false);
- /**
- * What the dialog is making: a club **race** (the default — the name form and the wizard), or
- * a RaceGOW **track run**, which the RaceGOW page's own form sets up. The choice sits at the
- * top of the one dialog rather than behind a second button, so the club path stays one click.
- */
- type NewKind = 'race' | 'racegow';
- let newKind = $state
('race');
let newName = $state('');
let newDate = $state('');
let newLocation = $state('');
@@ -194,7 +179,6 @@
}
function openNew() {
- newKind = 'race';
newName = '';
newDate = '';
newLocation = '';
@@ -216,18 +200,8 @@
return Object.keys(fields).length > 0 ? fields : undefined;
}
- /** The RaceGOW choice: close the dialog and hand off to the RaceGOW page's form. */
- function startRaceGow() {
- newOpen = false;
- onracegow();
- }
-
async function submitNew(e: Event) {
e.preventDefault();
- if (newKind === 'racegow') {
- startRaceGow();
- return;
- }
const name = newName.trim();
if (!name || creating) return;
creating = true;
@@ -305,10 +279,6 @@
{ev.name}
{rowSubtitle(ev)}
- {#if isRaceGowEvent(ev)}
-
- RaceGOW
- {/if}
{#if ev.id === activeEventId}
Active
{/if}
@@ -334,109 +304,65 @@
(newError = undefined)}>
{#snippet footer()}
(newOpen = false)} disabled={creating}>Cancel
- {#if newKind === 'racegow'}
- Set up track run
- {:else}
-
- {creating ? 'Creating…' : 'Create & enter'}
-
- {/if}
+
+ {creating ? 'Creating…' : 'Create & enter'}
+
{/snippet}
@@ -676,10 +602,8 @@
color: var(--gf-text-faint);
font-size: var(--gf-font-size-xs);
}
- /* The "Active" pill (#91) and the RaceGOW kind pill: between the name and the arrow, never
- shrinking. */
- .active-pill,
- .kind-pill {
+ /* The "Active" pill (#91): sits between the name and the arrow, never shrinking. */
+ .active-pill {
flex-shrink: 0;
}
.event-main {
@@ -774,72 +698,6 @@
flex-direction: column;
gap: var(--gf-space-3);
}
- /* The kind chooser: two radio cards side by side (stacked when narrow), the chosen one with
- the accent border. The native radio stays visible — it is the control, not decoration. */
- .kind {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
- gap: var(--gf-space-3);
- margin: 0;
- padding: 0;
- border: none;
- min-width: 0;
- }
- .kind-legend {
- grid-column: 1 / -1;
- padding: 0;
- margin-bottom: var(--gf-space-1);
- font-size: var(--gf-font-size-sm);
- font-weight: var(--gf-font-weight-medium);
- color: var(--gf-text-secondary);
- }
- .kind-opt {
- display: flex;
- align-items: flex-start;
- gap: var(--gf-space-3);
- padding: var(--gf-space-3);
- border: 1px solid var(--gf-border);
- border-radius: var(--gf-radius-md);
- background: var(--gf-surface-alt);
- cursor: pointer;
- transition:
- border-color var(--gf-motion-fast) var(--gf-ease-out),
- background var(--gf-motion-fast) var(--gf-ease-out);
- }
- .kind-opt:hover {
- border-color: var(--gf-accent);
- }
- .kind-opt.selected {
- border-color: var(--gf-accent);
- background: var(--gf-accent-soft);
- }
- .kind-opt input {
- width: 1.1rem;
- height: 1.1rem;
- margin-top: 0.15rem;
- accent-color: var(--gf-accent);
- flex-shrink: 0;
- cursor: pointer;
- }
- .kind-text {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .kind-title {
- font-size: var(--gf-font-size-sm);
- font-weight: var(--gf-font-weight-semibold);
- color: var(--gf-text);
- }
- .kind-sub {
- font-size: var(--gf-font-size-xs);
- color: var(--gf-text-muted);
- }
- .kind-hint {
- margin: 0;
- font-size: var(--gf-font-size-sm);
- color: var(--gf-text-muted);
- }
.details {
border-top: 1px solid var(--gf-border-subtle);
padding-top: var(--gf-space-3);
diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte
index 6ecb9b95..a7edbb4b 100644
--- a/frontend/apps/rd-console/src/screens/HomeHub.svelte
+++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte
@@ -10,10 +10,6 @@
* count (`GET /events`), and timer count + how many are connected (`GET /timers`). A failed read
* just shows a dash — the hub never blocks on a summary.
*
- * There is deliberately no RaceGOW card here: a RaceGOW track run is an ordinary event, so it
- * starts from the Events page ("+ New event" → RaceGOW track run) and its runs sit in the Events
- * list with a badge, rather than the hub growing a card per preset.
- *
* Big text + dark surfaces by design: this hub is glanced at on a laptop outdoors in the sun, so
* the cards are large, high-contrast tap targets ("readable in sunlight?" is the gate).
*/
diff --git a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
deleted file mode 100644
index 92521e4a..00000000
--- a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte
+++ /dev/null
@@ -1,300 +0,0 @@
-
-
-
-
-
- GridFPV
- {#if active && event}
- {event.name}{callsign ? ` · ${callsign}` : ''}
- {:else}
- waiting for a run…
- {/if}
-
-
- {#if active}
-
-
-
- {#if phase === 'Running'}
- LIVE
- {:else if phase === 'Armed'}
- READY
- {:else if phase === 'Unofficial' || phase === 'Final'}
- STOPPED
- {:else}
- —
- {/if}
-
-
-
-
-
- Best {RACEGOW_LAPS} this run
-
- {best ? formatMicros(best.totalMicros) : '—'}
-
-
-
- Best {RACEGOW_LAPS} today
-
- {sessionBest !== undefined ? formatMicros(sessionBest) : '—'}
-
-
-
-
-
- {#each recent as lap, i (lap.end_ref)}
-
- L{recentOffset + i + 1}
- {formatMicros(lap.duration_micros)}
-
- {/each}
-
- {/if}
-
-
-
-
diff --git a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
deleted file mode 100644
index 7dcee487..00000000
--- a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte
+++ /dev/null
@@ -1,452 +0,0 @@
-
-
-
-
-
-
-
-
-
-
RaceGOW
-
- Time your track runs at home. Fly as many packs as you like — GridFPV keeps every lap and
- finds your fastest 3 laps in a row , the time RaceGOW asks for.
-
-
-
-
- {#if isRaceGowEvent(session.currentEvent)}
-
-
-
-
- Open now
- {session.currentEvent.name}
-
-
Back to the run
-
-
- {/if}
-
-
-
-
-
-
- {#if runs === undefined}
- Loading…
- {:else if runs.length === 0}
- No runs yet. Your first one shows up here.
- {:else}
-
-
- A run is an ordinary event: rename or delete it on the Events page.
-
- {/if}
-
-
-
-
-
diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
deleted file mode 100644
index 30830801..00000000
--- a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte
+++ /dev/null
@@ -1,816 +0,0 @@
-
-
-
-
-
-
-
-
-
-
{event?.name ?? 'RaceGOW run'}
-
- {callsign} on {timerLabel}
- {#if timer}
- ·
- {timerDown ? 'timer not connected' : 'timer connected'}
-
- {/if}
-
-
-
- Open in the full console
-
-
-
- {#if !round}
-
- The event was changed in the full console. Add a Time Trials round scored by best 3 laps, or
- start a new run.
-
- {/if}
- {#if timerDown}
-
- Laps cannot arrive until it is.
- {#snippet actions()}
- Timers
- {/snippet}
-
- {/if}
-
session.clearCommandError()} />
- {#if actionError}
- (actionError = undefined)}>
- {actionError}
-
- {/if}
-
-
-
-
-
-
-
- {#if view === 'idle'}
- {runRows.length === 0
- ? 'Ready when you are'
- : `${runRows.length} ${runRows.length === 1 ? 'run' : 'runs'} flown`}
- {:else if view === 'ready'}
- Run {runRows.length} is drawn
- {:else if view === 'staged'}
- Run {runRows.length} · staged
- {:else if view === 'arming'}
- Run {runRows.length} · timer going live…
- {:else if view === 'running'}
- Run {runRows.length} · flying
- {:else}
- Run {runRows.length} · stopped
- {/if}
-
-
-
-
- {#if view === 'idle'}
-
- {busy ? 'Starting…' : runRows.length === 0 ? 'Start run' : 'Fly again'}
-
- {:else if view === 'ready' || view === 'staged'}
-
- {busy ? 'Starting…' : 'Start run'}
-
- {:else if view === 'arming'}
- Going live…
- {:else if view === 'running'}
-
- {busy ? 'Stopping…' : 'Stop run'}
-
- {:else}
-
- {busy ? 'Saving…' : 'Save run'}
-
- {/if}
-
-
-
-
-
- This run · best {RACEGOW_LAPS} laps
-
- {currentBest ? formatMicros(currentBest.totalMicros) : '—'}
-
- {#if currentBest}
- {currentBest.laps.map((l) => formatMicros(l)).join(' · ')}
- {:else if view === 'running' || view === 'arming'}
- Fly {RACEGOW_LAPS} laps in a row to get a time.
- {/if}
-
-
-
- {#each currentLaps as lap, i (lap.end_ref)}
-
- {lapLabel(i)}
- {formatMicros(lap.duration_micros)}
-
- {/each}
- {#if currentLaps.length === 0 && heat}
- No laps yet.
- {/if}
-
-
-
-
-
-
-
-
-
-
- {headlineMicros !== undefined ? formatMicros(headlineMicros) : '—'}
-
- {#if bestRun?.best}
-
- Run {bestRun.number}, laps {bestRun.best.start + 1}–{bestRun.best.start +
- bestRun.best.laps.length}: {bestRun.best.laps
- .map((l) => formatMicros(l))
- .join(' · ')}
-
- {:else}
-
Save a run with {RACEGOW_LAPS} laps in a row and it shows here.
- {/if}
- {#if submission}
-
For the RaceGOW form
-
-
- copy(submission, 'Submission text')}
- >
- Copy
-
-
- {/if}
-
-
-
-
-
-
- RaceGOW wants a timer in view. Add this page to OBS as a Browser source
- over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps
- and the best 3 so far, on a transparent background.
-
-
- (overlayTheme = 'dark')}
- >
- Light text on dark
-
- (overlayTheme = 'light')}
- >
- Dark text on light
-
-
-
{overlay}
-
-
copy(overlay, 'Overlay URL')}>
- Copy URL
-
-
Open overlay
-
-
-
-
-
-
- {#if runRows.length === 0}
- Every run you fly on this track is listed here with its best 3 laps.
- {:else}
-
-
-
- Run
- State
- Laps
- Best {RACEGOW_LAPS}
-
-
-
- {#each runRows as row (row.heat)}
-
- Run {row.number}
- {phaseLabel(row.phase)}
- {row.laps}
- {row.best ? formatMicros(row.best.totalMicros) : '—'}
-
- {/each}
-
-
- {/if}
-
-
-
-
-
diff --git a/frontend/apps/rd-console/src/screens/RaceOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte
new file mode 100644
index 00000000..dd112268
--- /dev/null
+++ b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte
@@ -0,0 +1,548 @@
+
+
+
+
+
+ GridFPV
+ {#if active && event}
+ {event.name}{heatName ? ` · ${heatName}` : ''}
+ {:else if event}
+ {event.name} · waiting for a race…
+ {:else}
+ waiting for a race…
+ {/if}
+
+
+ {#if active}
+
+ {#if remainingMs !== undefined}
+
+ {formatMicros(clock.elapsedMs * 1000)}
+ {:else}
+
+ {/if}
+ {phaseLabel}
+
+
+ {#if solo}
+
{solo.name}
+
+ {#if windowN !== undefined}
+
+ Best {windowN} this heat
+
+ {micros(solo.window?.totalMicros)}
+
+
+
+ Best {windowN} today
+
+ {micros(solo.roundBest)}
+
+
+ {:else}
+
+ Laps
+ {solo.laps}
+
+
+ Best lap
+ {micros(solo.best)}
+
+ {/if}
+
+
+
+ {#each recent as lap, i (recentOffset + i)}
+
+ L{recentOffset + i + 1}
+ {formatMicros(lap)}
+
+ {/each}
+
+ {:else if rows.length > 0}
+
+
+
+
+ Pilot
+ Laps
+ Last
+ {windowN === undefined ? 'Best' : `Best ${windowN}`}
+
+
+
+ {#each rows as row, i (row.ref)}
+
+ {i + 1}
+ {row.name}
+ {row.laps}
+ {micros(row.last)}
+
+ {windowN === undefined ? micros(row.best) : micros(row.window?.totalMicros)}
+
+
+ {/each}
+
+
+ {:else}
+
No pilots in this heat yet.
+ {/if}
+ {/if}
+
+
+
+
diff --git a/frontend/apps/rd-console/tests/EventOverlays.test.ts b/frontend/apps/rd-console/tests/EventOverlays.test.ts
new file mode 100644
index 00000000..730fd12f
--- /dev/null
+++ b/frontend/apps/rd-console/tests/EventOverlays.test.ts
@@ -0,0 +1,45 @@
+/**
+ * EventOverlays — the in-event Overlays tab: lists the race overlay with its URL for OBS, swaps the
+ * contrast into the URL, copies it, and frames a live preview of the same route.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import EventOverlays from '../src/screens/EventOverlays.svelte';
+import { makeTestSession } from './support.js';
+
+describe('EventOverlays', () => {
+ it('lists the race overlay with its OBS URL on the console origin, in either contrast', async () => {
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ expect(screen.getByText('Race timer')).toBeInTheDocument();
+ const url = screen.getByLabelText('Race timer URL');
+ expect(url).toHaveTextContent(
+ new RegExp(`^${location.origin}${location.pathname}#/overlay/race$`)
+ );
+ await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
+ expect(url).toHaveTextContent(/#\/overlay\/race\/light$/);
+ await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' }));
+ expect(url).toHaveTextContent(/#\/overlay\/race$/);
+ });
+
+ it('frames the same route as a preview and links it to a new tab', async () => {
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ const frame = screen.getByTitle('Race timer preview');
+ expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race$/));
+ const link = screen.getByRole('link', { name: 'Open in a new tab' });
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('href', expect.stringMatching(/#\/overlay\/race$/));
+ await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
+ expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race\/light$/));
+ });
+
+ it('copies the URL to the clipboard', async () => {
+ const writeText = vi.fn(async () => {});
+ Object.assign(navigator, { clipboard: { writeText } });
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
+ expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/#\/overlay\/race$/));
+ });
+});
diff --git a/frontend/apps/rd-console/tests/EventPicker.test.ts b/frontend/apps/rd-console/tests/EventPicker.test.ts
index 87d654a6..d277641f 100644
--- a/frontend/apps/rd-console/tests/EventPicker.test.ts
+++ b/frontend/apps/rd-console/tests/EventPicker.test.ts
@@ -38,7 +38,7 @@ function renderPicker() {
getActiveEventImpl: vi.fn(async () => ({ event: null })),
deleteEventImpl: vi.fn(async () => undefined)
});
- render(EventPicker, { session, onhome: noop, onracegow: noop });
+ render(EventPicker, { session, onhome: noop });
}
/** Open the hard-delete dialog for EVENT and return its scope. */
@@ -63,7 +63,7 @@ describe('EventPicker — the brand home root (#118)', () => {
listEventsImpl: vi.fn(async () => [EVENT]),
getActiveEventImpl: vi.fn(async () => ({ event: null }))
});
- render(EventPicker, { session, onhome, onracegow: noop });
+ render(EventPicker, { session, onhome });
// The button's accessible name comes from its wordmark content; the title is the tooltip.
const brand = await screen.findByTitle('Home — GridFPV hub');
await fireEvent.click(brand);
@@ -119,7 +119,7 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4
listEventsImpl: vi.fn(async () => []),
getActiveEventImpl: vi.fn(async () => ({ event: null }))
});
- render(EventPicker, { session, onhome: noop, onracegow: noop });
+ render(EventPicker, { session, onhome: noop });
}
it('offers a create-your-first-event call to action when no events exist', async () => {
@@ -150,64 +150,3 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4
expect(screen.queryByRole('button', { name: /Practice/ })).toBeNull();
});
});
-
-/**
- * RaceGOW lives on the Events page, not the hub: "+ New event" asks club race vs track run, and a
- * run the preset built lists here with a badge. The club path must stay one click — the kind
- * chooser defaults to a race, with the name form already up.
- */
-describe('EventPicker — a RaceGOW track run is a kind of new event', () => {
- const RUN: EventMeta = {
- id: 'track1-run',
- name: 'RaceGOW6 Track 1',
- created_at: 1000,
- persistent: true,
- preset: 'racegow',
- timers: ['rh-1'],
- roster: ['p1'],
- classes: ['mgp-whoop']
- };
-
- function renderWithRun() {
- const onracegow = vi.fn();
- const { session } = makeTestSession({
- noEnter: true,
- listEventsImpl: vi.fn(async () => [EVENT, RUN]),
- getActiveEventImpl: vi.fn(async () => ({ event: null }))
- });
- render(EventPicker, { session, onhome: noop, onracegow });
- return { onracegow };
- }
-
- it('badges the run the preset built, and only that one', async () => {
- renderWithRun();
- // Anchored: the delete button's name is "Delete ", and a loose match would hit both.
- const runRow = await screen.findByRole('button', { name: /^RaceGOW6 Track 1/ });
- expect(within(runRow).getByText('RaceGOW')).toBeInTheDocument();
- const clubRow = screen.getByRole('button', { name: /^Friday Night Series/ });
- expect(within(clubRow).queryByText('RaceGOW')).toBeNull();
- });
-
- it('the new-event dialog defaults to a club race, with the name form up', async () => {
- renderWithRun();
- await fireEvent.click(await screen.findByRole('button', { name: '+ New event' }));
- const form = await screen.findByRole('form', { name: 'New event' });
- expect(within(form).getByRole('radio', { name: 'Club race' })).toBeChecked();
- expect(within(form).getByRole('radio', { name: 'RaceGOW track run' })).not.toBeChecked();
- expect(within(form).getByLabelText('Event name')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Create & enter' })).toBeInTheDocument();
- });
-
- it('choosing a track run swaps the form for a hand-off to the RaceGOW page', async () => {
- const { onracegow } = renderWithRun();
- await fireEvent.click(await screen.findByRole('button', { name: '+ New event' }));
- const form = await screen.findByRole('form', { name: 'New event' });
- await fireEvent.click(within(form).getByRole('radio', { name: 'RaceGOW track run' }));
- // The name form steps aside: the RaceGOW page's own form asks what a run needs.
- await waitFor(() => expect(within(form).queryByLabelText('Event name')).toBeNull());
- expect(screen.queryByRole('button', { name: 'Create & enter' })).toBeNull();
- await fireEvent.click(screen.getByRole('button', { name: 'Set up track run' }));
- expect(onracegow).toHaveBeenCalledTimes(1);
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- });
-});
diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts
index 61338cc9..34898f55 100644
--- a/frontend/apps/rd-console/tests/HomeHub.test.ts
+++ b/frontend/apps/rd-console/tests/HomeHub.test.ts
@@ -101,13 +101,11 @@ describe('HomeHub (app-level landing, #118)', () => {
const ontimers = vi.fn();
render(HomeHub, { session, onpilots, onclasses, onevents, ontimers });
- // Four navigable cards — the four pages. RaceGOW (the solo track-run preset) is NOT a card:
- // a track run is an event, so it starts from the Events page and lists there with a badge.
+ // Four navigable cards.
expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument();
- expect(screen.queryByRole('heading', { name: 'RaceGOW' })).toBeNull();
// Summaries settle per card (count + unit are separate spans): 2 pilots, 3 classes, 2 events,
// 3 timers · 2 connected (Mock is Ready + the live RH is Connected; the Configured RH is excluded).
diff --git a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
deleted file mode 100644
index 55c48a69..00000000
--- a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * RaceGowOverlay — the OBS browser source: idle with no RaceGOW run active, and the clock, recent
- * laps, this run's best window and the served session best once one is flying.
- */
-import { describe, expect, it, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/svelte';
-import { waitFor } from '@testing-library/dom';
-import type { EventMeta, LapList, LiveRaceState } from '@gridfpv/types';
-import RaceGowOverlay from '../src/screens/RaceGowOverlay.svelte';
-import { makeTestSession } from './support.js';
-
-const RUN: EventMeta = {
- id: 'track1-ab12',
- name: 'RaceGOW6 Track 1',
- created_at: 0,
- persistent: true,
- preset: 'racegow',
- timers: ['rh-1'],
- roster: ['p1'],
- classes: ['mgp-whoop'],
- rounds: [
- {
- id: 'racegow-x1',
- label: 'RaceGOW',
- classes: ['mgp-whoop'],
- format: 'timed_qual',
- params: { rounds: '0' },
- win_condition: { BestConsecutive: { n: 3 } },
- seeding: 'FromRoster'
- }
- ]
-} as unknown as EventMeta;
-
-const LAPS: LapList = {
- competitors: [
- {
- competitor: { adapter: 'rotorhazard', competitor: 'p1' },
- laps: [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9].map((s, i) => ({
- number: i + 1,
- duration_micros: Math.round(s * 1_000_000),
- at: (i + 1) * 10_000_000,
- start_ref: i,
- end_ref: i + 1
- }))
- }
- ]
-};
-
-describe('RaceGowOverlay', () => {
- it('renders idle with no run active, and marks the document as an overlay', () => {
- const { session } = makeTestSession({ noEnter: true });
- render(RaceGowOverlay, { session });
- expect(screen.getByText('waiting for a run…')).toBeInTheDocument();
- expect(document.documentElement.dataset.overlay).toBe('racegow');
- });
-
- it('shows the track, the pilot, LIVE, the recent laps and both bests while flying', async () => {
- const live: LiveRaceState = {
- phase: 'Running',
- current_heat: 'racegow-x1-3',
- active_pilots: ['p1'],
- race_started_at: 1_000_000
- };
- const { session } = makeTestSession({
- event: RUN,
- live,
- listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]),
- roundStandingsImpl: vi.fn(async () => [
- {
- competitor: 'p1',
- position: 1,
- best_lap_micros: 6_800_000,
- laps: 8,
- metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
- }
- ]),
- heatFetches: { 'racegow-x1-3': { laps: LAPS } }
- });
- render(RaceGowOverlay, { session });
-
- expect(await screen.findByText('RaceGOW6 Track 1 · Recon')).toBeInTheDocument();
- expect(screen.getByText('LIVE')).toBeInTheDocument();
- expect(screen.getByRole('timer', { name: /Run time/ })).toBeInTheDocument();
- // Only the last six of eight laps fit the frame; they keep their real lap numbers.
- const laps = await screen.findByRole('list', { name: 'Recent laps' });
- await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6));
- expect(within(laps).getByText('L3')).toBeInTheDocument();
- expect(within(laps).getByText('L8')).toBeInTheDocument();
- expect(within(laps).queryByText('L2')).not.toBeInTheDocument();
- // This run's best window (laps 2–4 = 20.600) and the served session best.
- expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600');
- await waitFor(() =>
- expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('20.500')
- );
- });
-});
diff --git a/frontend/apps/rd-console/tests/RaceGowPage.test.ts b/frontend/apps/rd-console/tests/RaceGowPage.test.ts
deleted file mode 100644
index 8c6fa381..00000000
--- a/frontend/apps/rd-console/tests/RaceGowPage.test.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * RaceGowPage — the RaceGOW landing: the form defaults sensibly off the directories, refuses a
- * timer with no channels (pointing at the Timers page), builds the run through the session's
- * ordinary commands, and lists earlier runs to reopen.
- */
-import { describe, expect, it, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/svelte';
-import { fireEvent, waitFor } from '@testing-library/dom';
-import type { EventMeta, Pilot, Timer } from '@gridfpv/types';
-import RaceGowPage from '../src/screens/RaceGowPage.svelte';
-import { makeTestSession } from './support.js';
-
-const RECON: Pilot = { id: 'p1', callsign: 'Recon', vtx_types: [] };
-const RH: Timer = {
- id: 'rh-1',
- name: 'Garage RH',
- kind: { Rotorhazard: { url: 'http://rh.local:5000' } },
- status: 'Connected',
- channel_capability: 'Flexible',
- node_count: 1,
- available_channels: [5658, 5880],
- manual_connect: false,
- calibration: [],
- disabled_nodes: []
-};
-const BARE_RH: Timer = { ...RH, id: 'rh-2', name: 'Bare RH', available_channels: [] };
-const MOCK: Timer = {
- id: 'mock',
- name: 'Mock',
- kind: { Mock: { laps: 3, lap_ms: 30000 } },
- status: 'Ready',
- channel_capability: 'Flexible',
- node_count: 8,
- available_channels: [5658],
- manual_connect: false,
- calibration: [],
- disabled_nodes: []
-};
-const OLD_RUN: EventMeta = {
- id: 'track1-old1',
- name: 'RaceGOW6 Track 1',
- created_at: 1000,
- persistent: true,
- preset: 'racegow',
- timers: ['rh-1'],
- roster: ['p1'],
- classes: ['mgp-whoop']
-};
-const CLUB: EventMeta = {
- id: 'club-1',
- name: 'Club night',
- created_at: 2000,
- persistent: true,
- timers: ['rh-1'],
- roster: [],
- classes: []
-};
-
-function setup(opts: { pilots?: Pilot[]; timers?: Timer[]; events?: EventMeta[] } = {}) {
- const { session } = makeTestSession({
- noEnter: true,
- listPilotsImpl: vi.fn(async () => opts.pilots ?? [RECON]),
- listTimersImpl: vi.fn(async () => opts.timers ?? [MOCK, RH]),
- listChannelsImpl: vi.fn(async () => [
- { band: 'Raceband', channel: 'R1', mhz: 5658 },
- { band: 'Raceband', channel: 'R7', mhz: 5880 }
- ])
- });
- vi.spyOn(session, 'listEvents').mockResolvedValue(opts.events ?? [OLD_RUN, CLUB]);
- const props = { onhome: vi.fn(), onevents: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() };
- return { session, props };
-}
-
-describe('RaceGowPage', () => {
- it('defaults to the only pilot and the connected RotorHazard, with its first channel', async () => {
- const { session, props } = setup();
- render(RaceGowPage, { session, ...props });
- const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement;
- await waitFor(() => expect(pilot.value).toBe('p1'));
- const timer = screen.getByLabelText('Timer') as HTMLSelectElement;
- expect(timer.value).toBe('rh-1');
- const channel = screen.getByLabelText('Your channel') as HTMLSelectElement;
- expect(channel.value).toBe('5658');
- // Channel options carry the friendly band + channel, with the MHz beside it.
- expect(within(channel).getByRole('option', { name: /Raceband R7/ })).toBeInTheDocument();
- expect(screen.getByLabelText('Track')).toHaveValue('RaceGOW6 Track 1');
- });
-
- it('builds the run through the ordinary commands and hands off to the run screen', async () => {
- const { session, props } = setup();
- const created: EventMeta = { ...OLD_RUN, id: 'track1-new1' };
- const create = vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created);
- const timers = vi.spyOn(session, 'setEventTimers').mockResolvedValue(created);
- const primary = vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created);
- const classes = vi.spyOn(session, 'setEventClasses').mockResolvedValue(created);
- const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created);
- const members = vi.spyOn(session, 'setClassMembership').mockResolvedValue(created);
- const round = vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never);
- render(RaceGowPage, { session, ...props });
-
- const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement;
- await waitFor(() => expect(pilot.value).toBe('p1'));
- await fireEvent.change(screen.getByLabelText('Your channel'), { target: { value: '5880' } });
- await fireEvent.input(screen.getByLabelText('Track'), {
- target: { value: 'RaceGOW6 Track 2' }
- });
- await fireEvent.click(screen.getByRole('button', { name: 'Set up run' }));
-
- await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
- expect(create).toHaveBeenCalledWith('RaceGOW6 Track 2', { preset: 'racegow' });
- expect(timers).toHaveBeenCalledWith(['rh-1']);
- expect(primary).toHaveBeenCalledWith('rh-1');
- expect(classes).toHaveBeenCalledWith(['mgp-whoop']);
- expect(roster).toHaveBeenCalledWith(['p1']);
- expect(members).toHaveBeenCalledWith('mgp-whoop', [{ pilot: 'p1', channel: 5880 }]);
- expect(round).toHaveBeenCalledTimes(1);
- });
-
- it('adds a new pilot inline when there is none, then builds the run for them', async () => {
- const { session, props } = setup({ pilots: [] });
- const createPilot = vi
- .spyOn(session, 'createPilot')
- .mockResolvedValue({ id: 'p9', callsign: 'Newbie', vtx_types: [] });
- const created: EventMeta = { ...OLD_RUN, id: 'x', roster: ['p9'] };
- vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created);
- vi.spyOn(session, 'setEventTimers').mockResolvedValue(created);
- vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created);
- vi.spyOn(session, 'setEventClasses').mockResolvedValue(created);
- const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created);
- vi.spyOn(session, 'setClassMembership').mockResolvedValue(created);
- vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never);
- render(RaceGowPage, { session, ...props });
-
- // No pilots → the picker sits on "New pilot" and the callsign field is up.
- const callsign = await screen.findByLabelText('Callsign');
- await fireEvent.input(callsign, { target: { value: 'Newbie' } });
- await fireEvent.click(screen.getByRole('button', { name: 'Set up run' }));
-
- await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
- expect(createPilot).toHaveBeenCalledWith({ callsign: 'Newbie', vtx_types: [] });
- expect(roster).toHaveBeenCalledWith(['p9']);
- });
-
- it('refuses a timer with no channels and points at the Timers page', async () => {
- const { session, props } = setup({ timers: [BARE_RH] });
- const create = vi.spyOn(session, 'createEventAndEnter');
- render(RaceGowPage, { session, ...props });
- await screen.findByText('This timer has no channels set yet.');
- expect(screen.getByRole('button', { name: 'Set up run' })).toBeDisabled();
- expect(screen.queryByLabelText('Your channel')).not.toBeInTheDocument();
- await fireEvent.click(screen.getByRole('button', { name: 'Timers page' }));
- expect(props.ontimers).toHaveBeenCalledTimes(1);
- expect(create).not.toHaveBeenCalled();
- });
-
- it('lists only RaceGOW runs, and reopens one through chooseEvent', async () => {
- const { session, props } = setup();
- const choose = vi.spyOn(session, 'chooseEvent').mockResolvedValue(OLD_RUN);
- render(RaceGowPage, { session, ...props });
- const runs = await screen.findByRole('list', { name: 'Your runs' });
- expect(within(runs).getAllByRole('listitem')).toHaveLength(1);
- expect(within(runs).getByText('RaceGOW6 Track 1')).toBeInTheDocument();
- expect(within(runs).queryByText('Club night')).not.toBeInTheDocument();
- await fireEvent.click(within(runs).getByRole('button', { name: 'Open RaceGOW6 Track 1' }));
- await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1));
- expect(choose).toHaveBeenCalledWith(OLD_RUN);
- });
-});
diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts
deleted file mode 100644
index 2506190b..00000000
--- a/frontend/apps/rd-console/tests/RaceGowRun.test.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * RaceGowRun — the solo run screen: the one button issues the right command sequence for each
- * phase, the laps and best-3 window render off the served lap list, and the round-wide best comes
- * from the served standings. The e2e (`e2e/racegow.spec.ts`) drives the real loop; this pins what
- * the screen sends and shows.
- */
-import { describe, expect, it, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/svelte';
-import { fireEvent, waitFor } from '@testing-library/dom';
-import type { EventMeta, HeatSummary, LapList, LiveRaceState, RoundStanding } from '@gridfpv/types';
-import RaceGowRun from '../src/screens/RaceGowRun.svelte';
-import { makeTestSession } from './support.js';
-
-const EVENT: EventMeta = {
- id: 'track1-ab12',
- name: 'RaceGOW6 Track 1',
- created_at: 0,
- persistent: true,
- preset: 'racegow',
- timers: ['rh-1'],
- primary_timer: 'rh-1',
- roster: ['p1'],
- classes: ['mgp-whoop'],
- classes_membership: [{ class: 'mgp-whoop', pilots: [{ pilot: 'p1', channel: 5880 }] }],
- rounds: [
- {
- id: 'racegow-x1',
- label: 'RaceGOW',
- classes: ['mgp-whoop'],
- format: 'timed_qual',
- params: { rounds: '0' },
- win_condition: { BestConsecutive: { n: 3 } },
- seeding: 'FromRoster',
- channel_mode: 'Static'
- }
- ]
-} as unknown as EventMeta;
-
-function lapList(durations: number[]): LapList {
- return {
- competitors: [
- {
- competitor: { adapter: 'rotorhazard', competitor: 'p1' },
- laps: durations.map((d, i) => ({
- number: i + 1,
- duration_micros: d,
- at: (i + 1) * 10_000_000,
- start_ref: i,
- end_ref: i + 1
- }))
- }
- ]
- };
-}
-
-function heat(id: string, phase: HeatSummary['phase'], current = false): HeatSummary {
- return {
- heat: id,
- name: `RaceGOW Heat ${id.slice(-1)}`,
- lineup: ['p1'],
- round: 'racegow-x1',
- class: 'mgp-whoop',
- phase,
- is_current: current
- };
-}
-
-const idle: LiveRaceState = { phase: 'Scheduled' };
-
-function setup(opts: {
- live?: LiveRaceState;
- heats?: HeatSummary[];
- laps?: Record;
- standings?: RoundStanding[];
-}) {
- const heats = opts.heats ?? [];
- const listHeatsImpl = vi.fn(async () => heats);
- const roundStandingsImpl = vi.fn(async () => opts.standings ?? []);
- const heatFetches = Object.fromEntries(
- Object.entries(opts.laps ?? {}).map(([id, d]) => [id, { laps: lapList(d) }])
- );
- const t = makeTestSession({
- event: EVENT,
- live: opts.live ?? idle,
- listHeatsImpl,
- roundStandingsImpl,
- listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]),
- heatFetches
- });
- return { ...t, listHeatsImpl, roundStandingsImpl };
-}
-
-const props = {
- onhome: vi.fn(),
- onevents: vi.fn(),
- onback: vi.fn(),
- onconsole: vi.fn(),
- ontimers: vi.fn()
-};
-
-describe('RaceGowRun', () => {
- it('idle: "Start run" draws the next heat, focuses it, stages and starts it', async () => {
- // The fill lands a Scheduled heat the screen must then find in the heats list.
- const drawn = heat('racegow-x1-1', 'Scheduled');
- const { session, sendSpy, listHeatsImpl } = setup({ heats: [] });
- listHeatsImpl.mockResolvedValue([drawn]);
- render(RaceGowRun, { session, ...props });
-
- await fireEvent.click(await screen.findByRole('button', { name: 'Start run' }));
-
- await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(4));
- expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
- { FillRound: { round: 'racegow-x1', mode: 'Next' } },
- { SetCurrentHeat: { heat: 'racegow-x1-1' } },
- { Stage: { heat: 'racegow-x1-1' } },
- { Start: { heat: 'racegow-x1-1' } }
- ]);
- });
-
- it('a drawn-but-unstarted heat is started without drawing another', async () => {
- const { session, sendSpy } = setup({
- live: { phase: 'Scheduled', current_heat: 'racegow-x1-1' },
- heats: [heat('racegow-x1-1', 'Scheduled', true)]
- });
- render(RaceGowRun, { session, ...props });
- await fireEvent.click(await screen.findByRole('button', { name: 'Start run' }));
- await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2));
- expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
- { Stage: { heat: 'racegow-x1-1' } },
- { Start: { heat: 'racegow-x1-1' } }
- ]);
- });
-
- it('running: shows the laps and this run’s best 3 window; "Stop run" ends and finalizes', async () => {
- const { session, sendSpy } = setup({
- live: {
- phase: 'Running',
- current_heat: 'racegow-x1-1',
- active_pilots: ['p1'],
- race_started_at: 1_000_000
- },
- heats: [heat('racegow-x1-1', 'Running', true)],
- laps: { 'racegow-x1-1': [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000] }
- });
- render(RaceGowRun, { session, ...props });
-
- // The served laps render, and the best window (laps 2–4 = 20.600) is called out.
- const laps = await screen.findByRole('list', { name: "This run's laps" });
- await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(5));
- expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600');
- const rows = within(laps).getAllByRole('listitem');
- expect(rows[1]).toHaveClass('in-window');
- expect(rows[3]).toHaveClass('in-window');
- expect(rows[0]).not.toHaveClass('in-window');
- expect(rows[4]).not.toHaveClass('in-window');
-
- await fireEvent.click(screen.getByRole('button', { name: 'Stop run' }));
- await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2));
- expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([
- { ForceEnd: { heat: 'racegow-x1-1' } },
- { Finalize: { heat: 'racegow-x1-1' } }
- ]);
- });
-
- it('stopped but unsaved: "Save run" only finalizes', async () => {
- const { session, sendSpy } = setup({
- live: { phase: 'Unofficial', current_heat: 'racegow-x1-1' },
- heats: [heat('racegow-x1-1', 'Unofficial', true)]
- });
- render(RaceGowRun, { session, ...props });
- await fireEvent.click(await screen.findByRole('button', { name: 'Save run' }));
- await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1));
- expect(sendSpy.mock.calls[0][0]).toEqual({ Finalize: { heat: 'racegow-x1-1' } });
- });
-
- it('after saved runs: the served best headlines, the runs table names the best run, and the submission line is ready', async () => {
- const { session } = setup({
- live: { phase: 'Final', current_heat: 'racegow-x1-2' },
- heats: [heat('racegow-x1-1', 'Final'), heat('racegow-x1-2', 'Final', true)],
- laps: {
- 'racegow-x1-1': [8_000_000, 8_000_000, 8_000_000],
- 'racegow-x1-2': [7_000_000, 7_000_000, 7_000_000, 9_000_000]
- },
- standings: [
- {
- competitor: 'p1',
- position: 1,
- best_lap_micros: 7_000_000,
- laps: 4,
- metric: { BestConsecutive: { n: 3, micros: 21_000_000 } }
- }
- ]
- });
- render(RaceGowRun, { session, ...props });
-
- // The next action is another run.
- expect(await screen.findByRole('button', { name: 'Fly again' })).toBeInTheDocument();
- // The headline is the SERVED round-wide best.
- await waitFor(() =>
- expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('21.000')
- );
- // The runs table: two saved runs, run 2 the best.
- const table = await screen.findByRole('table', { name: 'Runs' });
- await waitFor(() => expect(within(table).getAllByRole('row')).toHaveLength(3));
- const [, run1, run2] = within(table).getAllByRole('row');
- expect(run1).toHaveTextContent('Run 1');
- expect(run1).toHaveTextContent('24.000');
- expect(run2).toHaveTextContent('Run 2');
- expect(run2).toHaveTextContent('21.000');
- expect(run2).toHaveClass('best');
- // The submission line names the track, the pilot, the time and the run.
- const submission = screen.getByLabelText('Submission text') as HTMLTextAreaElement;
- expect(submission.value).toContain('RaceGOW6 Track 1 — Recon');
- expect(submission.value).toContain('Best 3 consecutive laps: 21.000 s (7.000 / 7.000 / 7.000)');
- expect(submission.value).toContain('Run 2 of 2, laps 1–3');
- });
-
- it('offers the overlay URL for OBS, in either contrast', async () => {
- const { session } = setup({});
- render(RaceGowRun, { session, ...props });
- const url = await screen.findByLabelText('Overlay URL');
- expect(url).toHaveTextContent(/#\/overlay\/racegow$/);
- await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
- expect(url).toHaveTextContent(/#\/overlay\/racegow\/light$/);
- await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' }));
- expect(url).toHaveTextContent(/#\/overlay\/racegow$/);
- });
-});
diff --git a/frontend/apps/rd-console/tests/RaceOverlay.test.ts b/frontend/apps/rd-console/tests/RaceOverlay.test.ts
new file mode 100644
index 00000000..5481aa49
--- /dev/null
+++ b/frontend/apps/rd-console/tests/RaceOverlay.test.ts
@@ -0,0 +1,187 @@
+/**
+ * RaceOverlay — the OBS browser source: idle with no heat on the timer; the long form (clock,
+ * recent laps, this heat's best window, the served round best) for a one-pilot heat of a
+ * best-consecutive round; and a row per pilot for a full heat of a race round.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/svelte';
+import { waitFor } from '@testing-library/dom';
+import type { EventMeta, HeatSummary, LapList, LiveRaceState, Pilot } from '@gridfpv/types';
+import RaceOverlay from '../src/screens/RaceOverlay.svelte';
+import { makeTestSession } from './support.js';
+
+const EVENT: EventMeta = {
+ id: 'club-ab12',
+ name: 'Tuesday Club Night',
+ created_at: 0,
+ persistent: true,
+ timers: ['rh-1'],
+ roster: ['p1', 'p2', 'p3'],
+ classes: ['mgp-whoop'],
+ rounds: [
+ {
+ id: 'tt-1',
+ label: 'Time trial',
+ classes: ['mgp-whoop'],
+ format: 'timed_qual',
+ params: { rounds: '0' },
+ win_condition: { BestConsecutive: { n: 3 } },
+ seeding: 'FromRoster'
+ },
+ {
+ id: 'race-1',
+ label: 'Mains',
+ classes: ['mgp-whoop'],
+ format: 'race',
+ params: {},
+ win_condition: { FirstToLaps: { n: 4 } },
+ seeding: 'FromRoster'
+ }
+ ]
+} as unknown as EventMeta;
+
+const HEATS: HeatSummary[] = [
+ {
+ heat: 'tt-1-3',
+ name: 'Time trial Heat 3',
+ lineup: ['p1'],
+ round: 'tt-1',
+ phase: 'Running',
+ is_current: true
+ },
+ {
+ heat: 'race-1-1',
+ name: 'Mains Heat 1',
+ lineup: ['p1', 'p2', 'p3'],
+ round: 'race-1',
+ phase: 'Running',
+ is_current: false
+ }
+];
+
+const PILOTS: Pilot[] = [
+ { id: 'p1', callsign: 'Recon', vtx_types: [] },
+ { id: 'p2', callsign: 'Bee', vtx_types: [] },
+ { id: 'p3', callsign: 'Cee', vtx_types: [] }
+] as unknown as Pilot[];
+
+function lapsOf(ref: string, seconds: number[]) {
+ return {
+ competitor: { adapter: 'rotorhazard', competitor: ref },
+ laps: seconds.map((s, i) => ({
+ number: i + 1,
+ duration_micros: Math.round(s * 1_000_000),
+ at: (i + 1) * 10_000_000,
+ start_ref: i,
+ end_ref: i + 1
+ }))
+ };
+}
+
+describe('RaceOverlay', () => {
+ it('renders idle with no event, and marks the document as an overlay', () => {
+ const { session } = makeTestSession({ noEnter: true });
+ render(RaceOverlay, { session });
+ expect(screen.getByText('waiting for a race…')).toBeInTheDocument();
+ expect(document.documentElement.dataset.overlay).toBe('race');
+ expect(screen.queryByRole('timer')).toBeNull();
+ });
+
+ it('names the event while it waits for a heat', () => {
+ const { session } = makeTestSession({ event: EVENT, live: { phase: 'Scheduled' } });
+ render(RaceOverlay, { session });
+ expect(screen.getByText('Tuesday Club Night · waiting for a race…')).toBeInTheDocument();
+ expect(screen.queryByRole('timer')).toBeNull();
+ });
+
+ it('shows a one-pilot best-consecutive heat in long form: name, LIVE, recent laps, both bests', async () => {
+ const SOLO: LapList = { competitors: [lapsOf('p1', [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9])] };
+ const live: LiveRaceState = {
+ phase: 'Running',
+ current_heat: 'tt-1-3',
+ active_pilots: ['p1'],
+ race_started_at: 1_000_000
+ };
+ const { session } = makeTestSession({
+ event: EVENT,
+ live,
+ listPilotsImpl: vi.fn(async () => PILOTS),
+ listHeatsImpl: vi.fn(async () => HEATS),
+ roundStandingsImpl: vi.fn(async () => [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_800_000,
+ laps: 8,
+ metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
+ }
+ ]),
+ heatFetches: { 'tt-1-3': { laps: SOLO } }
+ });
+ render(RaceOverlay, { session });
+
+ // The heat is named by the shared resolver's friendly name, never its id.
+ expect(await screen.findByText('Tuesday Club Night · Time trial Heat 3')).toBeInTheDocument();
+ expect(screen.getByText('LIVE')).toBeInTheDocument();
+ // No fixed end on this round: the clock counts up.
+ expect(screen.getByRole('timer', { name: /Race time/ })).toBeInTheDocument();
+ expect(await screen.findByText('Recon')).toBeInTheDocument();
+ // Only the last six of eight laps fit the frame; they keep their real lap numbers.
+ const laps = await screen.findByRole('list', { name: 'Recent laps' });
+ await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6));
+ expect(within(laps).getByText('L3')).toBeInTheDocument();
+ expect(within(laps).getByText('L8')).toBeInTheDocument();
+ expect(within(laps).queryByText('L2')).not.toBeInTheDocument();
+ // This heat's best window (laps 2–4 = 20.600) and the served round best.
+ expect(screen.getByLabelText("This heat's best 3 consecutive laps")).toHaveTextContent(
+ '20.600'
+ );
+ await waitFor(() =>
+ expect(screen.getByLabelText('Best 3 consecutive laps, all heats')).toHaveTextContent(
+ '20.500'
+ )
+ );
+ expect(screen.queryByRole('table')).toBeNull();
+ });
+
+ it('shows a full heat as a row per pilot in running order, with laps, last and best lap', async () => {
+ const FIELD: LapList = {
+ competitors: [lapsOf('p1', [7, 6.5]), lapsOf('p2', [8]), lapsOf('p3', [7.2, 7.1, 7.0])]
+ };
+ const live: LiveRaceState = {
+ phase: 'Running',
+ current_heat: 'race-1-1',
+ active_pilots: ['p1', 'p2', 'p3'],
+ running_order: ['p3', 'p1', 'p2'],
+ race_started_at: 1_000_000
+ };
+ const { session } = makeTestSession({
+ event: EVENT,
+ live,
+ listPilotsImpl: vi.fn(async () => PILOTS),
+ listHeatsImpl: vi.fn(async () => HEATS),
+ heatFetches: { 'race-1-1': { laps: FIELD } }
+ });
+ render(RaceOverlay, { session });
+
+ expect(await screen.findByText('Tuesday Club Night · Mains Heat 1')).toBeInTheDocument();
+ const table = await screen.findByRole('table', { name: 'Pilots in this heat' });
+ const rows = within(table).getAllByRole('row').slice(1); // minus the header
+ await waitFor(() => expect(within(rows[0]).getByText('Cee')).toBeInTheDocument());
+ expect(within(rows[1]).getByText('Recon')).toBeInTheDocument();
+ expect(within(rows[2]).getByText('Bee')).toBeInTheDocument();
+ // Cee: 3 laps, last 7.000, best 7.000. Recon: 2 laps, last 6.500, best 6.500.
+ await waitFor(() => expect(rows[0]).toHaveTextContent(/3.*7\.000.*7\.000/));
+ expect(rows[1]).toHaveTextContent(/2.*6\.500.*6\.500/);
+ expect(rows[2]).toHaveTextContent(/1.*8\.000.*8\.000/);
+ // A race round scores no consecutive window: the column is the best lap.
+ expect(within(table).getByRole('columnheader', { name: 'Best' })).toBeInTheDocument();
+ expect(screen.queryByRole('list', { name: 'Recent laps' })).toBeNull();
+ });
+
+ it('takes the light contrast from its theme prop', () => {
+ const { session } = makeTestSession({ noEnter: true });
+ render(RaceOverlay, { session, theme: 'light' });
+ expect(screen.getByLabelText('Race overlay')).toHaveAttribute('data-theme', 'light');
+ });
+});
diff --git a/frontend/apps/rd-console/tests/overlay.test.ts b/frontend/apps/rd-console/tests/overlay.test.ts
new file mode 100644
index 00000000..92ac263c
--- /dev/null
+++ b/frontend/apps/rd-console/tests/overlay.test.ts
@@ -0,0 +1,208 @@
+/**
+ * Unit tests for the overlay helpers (`lib/overlay.ts`): the best-consecutive window the race
+ * overlay slides over served laps, how it finds a heat's round and scoring window, the per-pilot
+ * served figures, the frame order, and the OBS URL. The browser e2e (`e2e/overlay.spec.ts`) proves
+ * the page against a real Director; this pins the arithmetic it relies on.
+ */
+import { describe, expect, it } from 'vitest';
+import type { EventMeta, HeatSummary, LapList, RoundStanding } from '@gridfpv/types';
+import {
+ bestConsecutive,
+ competitorLaps,
+ consecutiveWindow,
+ heatRound,
+ overlayOrder,
+ overlayUrl,
+ servedRoundBestMicros,
+ OVERLAY_CATALOG
+} from '../src/lib/overlay.js';
+
+describe('bestConsecutive', () => {
+ it('is undefined with fewer laps than the window', () => {
+ expect(bestConsecutive([], 3)).toBeUndefined();
+ expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined();
+ });
+
+ it('finds the fastest 3-lap window and where it starts', () => {
+ // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower.
+ const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000];
+ expect(bestConsecutive(laps, 3)).toEqual({
+ start: 1,
+ laps: [7_000_000, 6_800_000, 6_800_000],
+ totalMicros: 20_600_000
+ });
+ });
+
+ it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => {
+ const laps = [7_000_000, 7_000_000, 7_000_000, 7_000_000];
+ expect(bestConsecutive(laps, 3)?.start).toBe(0);
+ });
+
+ it('honours a different window size, and refuses a zero one', () => {
+ const laps = [8_000_000, 6_000_000, 7_000_000];
+ expect(bestConsecutive(laps, 1)).toEqual({
+ start: 1,
+ laps: [6_000_000],
+ totalMicros: 6_000_000
+ });
+ expect(bestConsecutive(laps, 2)).toEqual({
+ start: 1,
+ laps: [6_000_000, 7_000_000],
+ totalMicros: 13_000_000
+ });
+ expect(bestConsecutive(laps, 0)).toBeUndefined();
+ });
+});
+
+describe('consecutiveWindow / heatRound', () => {
+ const EVENT = {
+ id: 'e',
+ name: 'Club night',
+ created_at: 0,
+ persistent: true,
+ timers: [],
+ roster: [],
+ classes: [],
+ rounds: [
+ {
+ id: 'r-tt',
+ label: 'Time trial',
+ format: 'timed_qual',
+ win_condition: { BestConsecutive: { n: 3 } }
+ },
+ { id: 'r-race', label: 'Race', format: 'race', win_condition: { FirstToLaps: { n: 4 } } }
+ ]
+ } as unknown as EventMeta;
+ const HEATS = [
+ {
+ heat: 'h-1',
+ name: 'Time trial Heat 1',
+ lineup: ['p1'],
+ round: 'r-tt',
+ phase: 'Final',
+ is_current: false
+ },
+ {
+ heat: 'h-2',
+ name: 'Race Heat 1',
+ lineup: ['p1', 'p2'],
+ round: 'r-race',
+ phase: 'Running',
+ is_current: true
+ },
+ { heat: 'free', name: 'free', lineup: ['p1'], phase: 'Scheduled', is_current: false }
+ ] as HeatSummary[];
+
+ it('reads the window only off a BestConsecutive condition', () => {
+ expect(consecutiveWindow({ BestConsecutive: { n: 3 } })).toBe(3);
+ expect(consecutiveWindow('BestLap')).toBeUndefined();
+ expect(consecutiveWindow({ Timed: { window_micros: 120_000_000 } })).toBeUndefined();
+ expect(consecutiveWindow({ FirstToLaps: { n: 4 } })).toBeUndefined();
+ expect(consecutiveWindow(undefined)).toBeUndefined();
+ });
+
+ it('finds a heat’s round through its round tag, and nothing for an untagged heat', () => {
+ expect(heatRound(EVENT, HEATS, 'h-1')?.id).toBe('r-tt');
+ expect(heatRound(EVENT, HEATS, 'h-2')?.id).toBe('r-race');
+ expect(heatRound(EVENT, HEATS, 'free')).toBeUndefined();
+ expect(heatRound(EVENT, HEATS, 'gone')).toBeUndefined();
+ expect(heatRound(undefined, HEATS, 'h-1')).toBeUndefined();
+ });
+});
+
+describe('competitorLaps / servedRoundBestMicros / overlayOrder', () => {
+ const LAPS: LapList = {
+ competitors: [
+ {
+ competitor: { adapter: 'sim', competitor: 'p2' },
+ laps: [{ number: 1, duration_micros: 8_000_000, at: 1, start_ref: 0, end_ref: 1 }]
+ },
+ {
+ competitor: { adapter: 'sim', competitor: 'p1' },
+ laps: [
+ { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 },
+ { number: 2, duration_micros: 6_500_000, at: 2, start_ref: 1, end_ref: 2 }
+ ]
+ }
+ ]
+ };
+
+ it('picks one competitor’s served laps, in order, and nothing for a stranger', () => {
+ expect(competitorLaps(LAPS, 'p1').map((l) => l.duration_micros)).toEqual([
+ 7_000_000, 6_500_000
+ ]);
+ expect(competitorLaps(LAPS, 'p2')).toHaveLength(1);
+ expect(competitorLaps(LAPS, 'p9')).toEqual([]);
+ expect(competitorLaps(undefined, 'p1')).toEqual([]);
+ });
+
+ it('reads the served round figure per pilot: a best window, a best lap, or nothing for laps', () => {
+ const standings: RoundStanding[] = [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_500_000,
+ laps: 8,
+ metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
+ },
+ {
+ competitor: 'p2',
+ position: 2,
+ best_lap_micros: null,
+ laps: 1,
+ metric: { BestConsecutive: { n: 3, micros: null } }
+ },
+ {
+ competitor: 'p3',
+ position: 3,
+ best_lap_micros: 7_100_000,
+ laps: 3,
+ metric: { BestLap: { micros: 7_100_000 } }
+ },
+ {
+ competitor: 'p4',
+ position: 4,
+ best_lap_micros: null,
+ laps: 5,
+ metric: { MostLaps: { laps: 5 } }
+ }
+ ];
+ expect(servedRoundBestMicros(standings, 'p1')).toBe(20_500_000);
+ expect(servedRoundBestMicros(standings, 'p2')).toBeUndefined();
+ expect(servedRoundBestMicros(standings, 'p3')).toBe(7_100_000);
+ expect(servedRoundBestMicros(standings, 'p4')).toBeUndefined();
+ expect(servedRoundBestMicros(standings, 'p9')).toBeUndefined();
+ });
+
+ it('orders by the first source that names a pilot, appending the rest, without duplicates', () => {
+ expect(overlayOrder([['p2', 'p1'], ['p1', 'p3'], undefined, ['p4', 'p2']])).toEqual([
+ 'p2',
+ 'p1',
+ 'p3',
+ 'p4'
+ ]);
+ expect(overlayOrder([undefined, undefined])).toEqual([]);
+ });
+});
+
+describe('overlayUrl / the catalogue', () => {
+ it('points the overlay at the console origin + path, on the overlay hash', () => {
+ expect(overlayUrl({ origin: 'http://192.168.1.50:8080', pathname: '/' }, 'race')).toBe(
+ 'http://192.168.1.50:8080/#/overlay/race'
+ );
+ expect(overlayUrl({ origin: 'http://127.0.0.1:8080', pathname: '/console/' }, 'race')).toBe(
+ 'http://127.0.0.1:8080/console/#/overlay/race'
+ );
+ });
+
+ it('carries the light theme in the URL, and leaves the dark default bare', () => {
+ const loc = { origin: 'http://127.0.0.1:8080', pathname: '/' };
+ expect(overlayUrl(loc, 'race', 'light')).toBe('http://127.0.0.1:8080/#/overlay/race/light');
+ expect(overlayUrl(loc, 'race', 'dark')).toBe('http://127.0.0.1:8080/#/overlay/race');
+ });
+
+ it('lists the race timer as the one overlay built so far', () => {
+ expect(OVERLAY_CATALOG.map((o) => o.name)).toEqual(['race']);
+ expect(OVERLAY_CATALOG[0].title).toBe('Race timer');
+ });
+});
diff --git a/frontend/apps/rd-console/tests/racegow.test.ts b/frontend/apps/rd-console/tests/racegow.test.ts
deleted file mode 100644
index fb9158a9..00000000
--- a/frontend/apps/rd-console/tests/racegow.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * Unit tests for the RaceGOW helpers (`lib/racegow.ts`): the best-consecutive window the screens
- * slide over served laps, the preset's round shape, the create orchestration's command order, and
- * the submission line. The browser e2e (`e2e/racegow.spec.ts`) proves the whole loop against a
- * real Director; this pins the arithmetic and the composition it relies on.
- */
-import { describe, expect, it, vi } from 'vitest';
-import type { EventMeta, LapList, RoundStanding } from '@gridfpv/types';
-import {
- bestConsecutive,
- createRaceGowRun,
- isRaceGowEvent,
- overlayUrl,
- raceGowRound,
- raceGowRoundRequest,
- runLaps,
- servedBestMicros,
- submissionText,
- RACEGOW_CLASS_ID,
- RACEGOW_PRESET
-} from '../src/lib/racegow.js';
-import type { Session } from '../src/lib/session.svelte.js';
-
-describe('bestConsecutive', () => {
- it('is undefined with fewer laps than the window', () => {
- expect(bestConsecutive([], 3)).toBeUndefined();
- expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined();
- });
-
- it('finds the fastest 3-lap window and where it starts', () => {
- // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower.
- const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000];
- expect(bestConsecutive(laps)).toEqual({
- start: 1,
- laps: [7_000_000, 6_800_000, 6_800_000],
- totalMicros: 20_600_000
- });
- });
-
- it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => {
- const laps = [5_000_000, 5_000_000, 5_000_000, 5_000_000];
- expect(bestConsecutive(laps)?.start).toBe(0);
- });
-
- it('honours a different window size, and refuses a zero one', () => {
- expect(bestConsecutive([3_000_000, 1_000_000, 2_000_000], 1)).toEqual({
- start: 1,
- laps: [1_000_000],
- totalMicros: 1_000_000
- });
- expect(bestConsecutive([1, 2, 3], 0)).toBeUndefined();
- });
-});
-
-describe('runLaps / servedBestMicros', () => {
- it('flattens the lap list in competitor order (a solo run has one)', () => {
- const list: LapList = {
- competitors: [
- {
- competitor: { adapter: 'rotorhazard', competitor: 'p1' },
- laps: [
- { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 },
- { number: 2, duration_micros: 6_900_000, at: 2, start_ref: 1, end_ref: 2 }
- ]
- }
- ]
- };
- expect(runLaps(list).map((l) => l.duration_micros)).toEqual([7_000_000, 6_900_000]);
- expect(runLaps(undefined)).toEqual([]);
- });
-
- it('reads the served best-consecutive window off the standings, or nothing', () => {
- const rows: RoundStanding[] = [
- {
- competitor: 'p1',
- position: 1,
- best_lap_micros: 6_800_000,
- laps: 6,
- metric: { BestConsecutive: { n: 3, micros: 20_600_000 } }
- }
- ];
- expect(servedBestMicros(rows)).toBe(20_600_000);
- expect(
- servedBestMicros([{ ...rows[0], metric: { BestConsecutive: { n: 3, micros: null } } }])
- ).toBeUndefined();
- expect(servedBestMicros([{ ...rows[0], metric: { BestLap: { micros: 1 } } }])).toBeUndefined();
- expect(servedBestMicros([])).toBeUndefined();
- });
-});
-
-describe('the preset shape', () => {
- it('marks and finds a RaceGOW event by its preset', () => {
- const plain = { id: 'e1', name: 'Club night', preset: undefined } as unknown as EventMeta;
- const run = { id: 'e2', name: 'Track 1', preset: RACEGOW_PRESET } as unknown as EventMeta;
- expect(isRaceGowEvent(plain)).toBe(false);
- expect(isRaceGowEvent(run)).toBe(true);
- expect(isRaceGowEvent(undefined)).toBe(false);
- });
-
- it('scores best 3 consecutive laps, open-ended, on the Whoop class, under a 10-minute ceiling', () => {
- const req = raceGowRoundRequest();
- expect(req.format).toBe('timed_qual');
- expect(req.params).toEqual({ rounds: '0' });
- expect(req.win_condition).toEqual({ BestConsecutive: { n: 3 } });
- expect(req.classes).toEqual([RACEGOW_CLASS_ID]);
- expect(req.seeding).toBe('FromRoster');
- expect(req.channel_mode).toBe('Static');
- // Ranking-only conditions never end a heat, so the Director requires a race time; this one is
- // a ceiling far past a whoop pack, not a race window.
- expect(req.time_limit_secs).toBe(600);
- // A fixed one-second hold: no field of pilots is waiting on a random tone.
- expect(req.start_procedure).toEqual({
- mode: 'randomized-delay',
- min_delay_ms: 1000,
- max_delay_ms: 1000
- });
- });
-
- it('finds the scoring round by format, not by label', () => {
- const meta = {
- rounds: [
- { id: 'r0', label: 'Warm-up', format: 'open_practice' },
- { id: 'r1', label: 'Renamed by hand', format: 'timed_qual' }
- ]
- } as unknown as EventMeta;
- expect(raceGowRound(meta)?.id).toBe('r1');
- expect(raceGowRound(undefined)).toBeUndefined();
- });
-});
-
-describe('createRaceGowRun', () => {
- function fakeSession() {
- const calls: string[] = [];
- const meta = { id: 'e1', name: 'RaceGOW6 Track 1', preset: RACEGOW_PRESET } as EventMeta;
- const session = {
- currentEvent: meta,
- createEventAndEnter: vi.fn(async (name: string, fields?: unknown) => {
- calls.push(`create:${name}:${JSON.stringify(fields)}`);
- return meta;
- }),
- setEventTimers: vi.fn(async (ids: string[]) => (calls.push(`timers:${ids}`), meta)),
- setPrimaryTimer: vi.fn(async (id: string) => (calls.push(`primary:${id}`), meta)),
- setEventClasses: vi.fn(async (ids: string[]) => (calls.push(`classes:${ids}`), meta)),
- setEventRoster: vi.fn(async (ids: string[]) => (calls.push(`roster:${ids}`), meta)),
- setClassMembership: vi.fn(async (cls: string, slots: unknown[]) => {
- calls.push(`members:${cls}:${JSON.stringify(slots)}`);
- return meta;
- }),
- createRound: vi.fn(async (req: { format: string }) => {
- calls.push(`round:${req.format}`);
- return { id: 'r1' };
- })
- };
- return { calls, session: session as unknown as Session, meta };
- }
-
- it('creates, then configures timer → primary → class → roster → channel slot → round', async () => {
- const { calls, session, meta } = fakeSession();
- const result = await createRaceGowRun(session, {
- track: ' RaceGOW6 Track 1 ',
- pilot: 'p1',
- timer: 'rh-1',
- channel: 5880
- });
- expect(result).toBe(meta);
- expect(calls).toEqual([
- 'create:RaceGOW6 Track 1:{"preset":"racegow"}',
- 'timers:rh-1',
- 'primary:rh-1',
- `classes:${RACEGOW_CLASS_ID}`,
- 'roster:p1',
- `members:${RACEGOW_CLASS_ID}:[{"pilot":"p1","channel":5880}]`,
- 'round:timed_qual'
- ]);
- });
-
- it('seats the pilot without a channel when the timer offers none', async () => {
- const { calls, session } = fakeSession();
- await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'mock' });
- expect(calls).toContain(`members:${RACEGOW_CLASS_ID}:[{"pilot":"p1"}]`);
- });
-
- it('stops at a cancelled token prompt and reports it as undefined', async () => {
- const { calls, session } = fakeSession();
- (session.setEventRoster as unknown as ReturnType).mockResolvedValueOnce(
- undefined
- );
- const result = await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'rh-1' });
- expect(result).toBeUndefined();
- expect(calls.some((c) => c.startsWith('members:'))).toBe(false);
- expect(calls.some((c) => c.startsWith('round:'))).toBe(false);
- });
-});
-
-describe('submissionText / overlayUrl', () => {
- it('writes the time, the three laps, which run and when, and what timed it', () => {
- const text = submissionText({
- track: 'RaceGOW6 Track 1',
- callsign: 'Recon',
- best: { start: 2, laps: [7_012_000, 7_201_000, 7_224_000], totalMicros: 21_437_000 },
- run: 3,
- runs: 5,
- flownAt: new Date(2026, 8, 26, 14, 3),
- timedBy: 'Garage RH (RotorHazard)'
- });
- expect(text).toContain('RaceGOW6 Track 1 — Recon');
- expect(text).toContain('Best 3 consecutive laps: 21.437 s (7.012 / 7.201 / 7.224)');
- expect(text).toContain('Run 3 of 5, laps 3–5');
- expect(text).toContain('Timed by GridFPV on Garage RH (RotorHazard)');
- });
-
- it('points the overlay at the console origin + path, on the overlay hash', () => {
- expect(overlayUrl({ origin: 'http://192.168.1.20:8080', pathname: '/' })).toBe(
- 'http://192.168.1.20:8080/#/overlay/racegow'
- );
- });
-
- it('carries the light theme in the overlay URL, and leaves the dark default bare', () => {
- const loc = { origin: 'http://192.168.1.20:8080', pathname: '/' };
- expect(overlayUrl(loc, 'light')).toBe('http://192.168.1.20:8080/#/overlay/racegow/light');
- expect(overlayUrl(loc, 'dark')).toBe('http://192.168.1.20:8080/#/overlay/racegow');
- });
-});
diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts
index 4787e57d..e043a350 100644
--- a/frontend/apps/rd-console/tests/route.test.ts
+++ b/frontend/apps/rd-console/tests/route.test.ts
@@ -334,64 +334,65 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => {
});
});
-describe('RaceGOW + overlay routes', () => {
- it('parses the RaceGOW page, its run screen, and the overlay', () => {
- expect(parseHash('#/racegow')).toEqual({ kind: 'racegow' });
- expect(parseHash('#/racegow/run')).toEqual({ kind: 'racegow-run' });
- expect(parseHash('#/RaceGOW/Run')).toEqual({ kind: 'racegow-run' });
- expect(parseHash('#/overlay/racegow')).toEqual({ kind: 'overlay', overlay: 'racegow' });
+describe('overlay routes', () => {
+ it('parses the race overlay, case-insensitively', () => {
+ expect(parseHash('#/overlay/race')).toEqual({ kind: 'overlay', overlay: 'race' });
+ expect(parseHash('#/Overlay/RACE')).toEqual({ kind: 'overlay', overlay: 'race' });
});
it('parses the overlay theme: /light is the light panel, anything else is the dark default', () => {
- expect(parseHash('#/overlay/racegow/light')).toEqual({
+ expect(parseHash('#/overlay/race/light')).toEqual({
kind: 'overlay',
- overlay: 'racegow',
+ overlay: 'race',
theme: 'light'
});
- expect(parseHash('#/overlay/racegow/LIGHT')).toEqual({
+ expect(parseHash('#/overlay/race/LIGHT')).toEqual({
kind: 'overlay',
- overlay: 'racegow',
+ overlay: 'race',
theme: 'light'
});
// An explicit `dark`, or a typo, still renders the overlay — never bounces the scene to the hub.
- expect(parseHash('#/overlay/racegow/dark')).toEqual({ kind: 'overlay', overlay: 'racegow' });
- expect(parseHash('#/overlay/racegow/lite')).toEqual({ kind: 'overlay', overlay: 'racegow' });
+ expect(parseHash('#/overlay/race/dark')).toEqual({ kind: 'overlay', overlay: 'race' });
+ expect(parseHash('#/overlay/race/lite')).toEqual({ kind: 'overlay', overlay: 'race' });
});
- it('degrades a malformed RaceGOW sub-route to the page, and an unknown overlay to the hub', () => {
- expect(parseHash('#/racegow/whatever')).toEqual({ kind: 'racegow' });
+ it('degrades a bare or unknown overlay to the hub', () => {
expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE);
expect(parseHash('#/overlay/leaderboard')).toEqual(DEFAULT_ROUTE);
});
it('round-trips each through formatHash', () => {
const routes: Route[] = [
- { kind: 'racegow' },
- { kind: 'racegow-run' },
- { kind: 'overlay', overlay: 'racegow' },
- { kind: 'overlay', overlay: 'racegow', theme: 'light' }
+ { kind: 'overlay', overlay: 'race' },
+ { kind: 'overlay', overlay: 'race', theme: 'light' }
];
for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r);
- expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'light' })).toBe(
- '#/overlay/racegow/light'
+ expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'light' })).toBe(
+ '#/overlay/race/light'
);
- expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'dark' })).toBe(
- '#/overlay/racegow'
- );
- expect(formatHash({ kind: 'racegow' })).toBe('#/racegow');
- expect(formatHash({ kind: 'racegow-run' })).toBe('#/racegow/run');
- expect(formatHash({ kind: 'overlay', overlay: 'racegow' })).toBe('#/overlay/racegow');
+ expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'dark' })).toBe('#/overlay/race');
+ expect(formatHash({ kind: 'overlay', overlay: 'race' })).toBe('#/overlay/race');
});
- it('reconciles the run screen to the RaceGOW page with no active event, and keeps the rest', () => {
- expect(reconcileRoute({ kind: 'racegow-run' }, false)).toEqual({ kind: 'racegow' });
- expect(reconcileRoute({ kind: 'racegow-run' }, true)).toEqual({ kind: 'racegow-run' });
- expect(reconcileRoute({ kind: 'racegow' }, false)).toEqual({ kind: 'racegow' });
- // An overlay sits in an OBS scene whether or not a run is on: never bounced.
- expect(reconcileRoute({ kind: 'overlay', overlay: 'racegow' }, false)).toEqual({
+ it('never reconciles an overlay away — it sits in an OBS scene whether or not a race is on', () => {
+ expect(reconcileRoute({ kind: 'overlay', overlay: 'race' }, false)).toEqual({
+ kind: 'overlay',
+ overlay: 'race'
+ });
+ expect(resolveInitialRoute('#/overlay/race/light', false)).toEqual({
kind: 'overlay',
- overlay: 'racegow'
+ overlay: 'race',
+ theme: 'light'
+ });
+ });
+
+ it('has an Overlays workspace tab, reached like any other tab', () => {
+ expect(parseHash('#/event/overlays')).toEqual({ kind: 'workspace', tab: 'overlays' });
+ expect(formatHash({ kind: 'workspace', tab: 'overlays' })).toBe('#/event/overlays');
+ // With no active event the tab reconciles to Events like every workspace route.
+ expect(reconcileRoute({ kind: 'workspace', tab: 'overlays' }, false)).toEqual({
+ kind: 'page',
+ page: 'events'
});
- expect(resolveInitialRoute('#/racegow/run', false)).toEqual({ kind: 'racegow' });
});
});
diff --git a/frontend/e2e/overlay.spec.ts b/frontend/e2e/overlay.spec.ts
new file mode 100644
index 00000000..7cb1ab54
--- /dev/null
+++ b/frontend/e2e/overlay.spec.ts
@@ -0,0 +1,124 @@
+/**
+ * The race overlay, end to end, in real headless chromium against the worker's real Director: an
+ * ordinary event's heat is scheduled and started over the control path, then the OBS browser-source
+ * page (`#/overlay/race`) renders it — the event and heat names, the clock, a row per pilot with
+ * climbing laps — read-only, on a transparent document, in both contrasts. Then the workspace's
+ * **Overlays** tab offers that page's URL and frames a live preview of it.
+ *
+ * Nothing about the event is special: the overlay follows the Director's active event, whatever it
+ * is. Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack
+ * dump (browser console, page errors, the Director's log).
+ */
+import { expect, test } from './observability.js';
+
+const PILOTS = ['Ace', 'Bee', 'Cee'];
+const HEAT_ID = 'ov-1';
+const ROUND_LABEL = 'E2E-Overlay';
+
+test('the race overlay follows an ordinary event’s heat, and the Overlays tab hands out its URL', async ({
+ page,
+ director
+}) => {
+ // ── Make the event active, the way an RD does: open it from the Events page ────────────────
+ await page.goto('/');
+ await page.getByRole('button', { name: /Events/ }).click();
+ const liveNav = page.getByRole('button', { name: /Race control/ });
+ await expect(
+ page.getByRole('heading', { name: 'Choose an event' }).or(liveNav).first()
+ ).toBeVisible({ timeout: 15_000 });
+ if (
+ await page
+ .getByRole('heading', { name: 'Choose an event' })
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await page
+ .getByRole('button', { name: /Practice/ })
+ .first()
+ .click();
+ }
+ await expect(liveNav).toBeVisible();
+
+ // ── A round and a heat of three, focused and started over the open control path ────────────
+ const roundRes = await page.request.post(`${director.eventRoot}/rounds`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: {
+ label: ROUND_LABEL,
+ classes: [],
+ format: 'timed_qual',
+ params: { rounds: '1' },
+ win_condition: 'BestLap',
+ time_limit_secs: 60,
+ seeding: 'FromRoster',
+ channel_mode: 'PerHeat'
+ }
+ });
+ expect(roundRes.ok()).toBeTruthy();
+ const roundId = ((await roundRes.json()) as { id: string }).id;
+ for (const command of [
+ { ScheduleHeat: { heat: HEAT_ID, lineup: PILOTS, round: roundId } },
+ { SetCurrentHeat: { heat: HEAT_ID } },
+ { Stage: { heat: HEAT_ID } },
+ { Start: { heat: HEAT_ID } }
+ ]) {
+ const ack = await page.request.post(`${director.eventRoot}/control`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: command
+ });
+ expect(ack.ok(), JSON.stringify(command)).toBeTruthy();
+ }
+
+ // ── The overlay: the OBS browser source follows the active event's heat, read-only ─────────
+ await page.goto(`/#/overlay/race`);
+ const overlay = page.getByLabel('Race overlay');
+ await expect(overlay).toBeVisible({ timeout: 15_000 });
+ // The event and the heat, by their friendly names — never a raw id.
+ await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 });
+ // A 60 s race time: the primary clock counts down, as the console's does.
+ await expect(page.getByRole('timer', { name: /Time remaining/ })).toBeVisible({
+ timeout: 15_000
+ });
+ await expect(page.getByText('LIVE')).toBeVisible({ timeout: 15_000 });
+ // One row per pilot, named; their laps climb as the sim flies.
+ const table = page.getByRole('table', { name: 'Pilots in this heat' });
+ for (const pilot of PILOTS) {
+ await expect(table.getByText(pilot, { exact: true })).toBeVisible();
+ }
+ const lapCells = table.locator('tbody tr td:nth-child(3)');
+ const totalLaps = async () => {
+ const texts = await lapCells.allTextContents();
+ return texts.reduce((sum, t) => sum + (parseInt(t.trim(), 10) || 0), 0);
+ };
+ await expect
+ .poll(totalLaps, { timeout: 30_000, message: 'laps should appear on the overlay' })
+ .toBeGreaterThan(0);
+ // The document is flagged transparent for the keyer.
+ await expect(page.locator('html')).toHaveAttribute('data-overlay', 'race');
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // The light variant: same overlay, dark text on a light panel, still transparent underneath.
+ await page.goto(`/#/overlay/race/light`);
+ await expect(page.getByLabel('Race overlay')).toHaveAttribute('data-theme', 'light');
+ await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // ── The Overlays tab inside the event: the URL for OBS, and a live preview of the page ─────
+ await page.goto(`/#/event/overlays`);
+ await expect(page.getByRole('heading', { name: 'Overlays' })).toBeVisible({ timeout: 15_000 });
+ const url = page.getByLabel('Race timer URL');
+ await expect(url).toHaveText(new RegExp(`^${director.baseUrl}/#/overlay/race$`));
+ await page.getByRole('radio', { name: 'Dark text on light' }).click();
+ await expect(url).toHaveText(/#\/overlay\/race\/light$/);
+ // The preview frames the real route; inside it, the same heat is rendering.
+ const preview = page.frameLocator('iframe[title="Race timer preview"]');
+ await expect(preview.getByLabel('Race overlay')).toBeVisible({ timeout: 15_000 });
+ await expect(preview.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({
+ timeout: 15_000
+ });
+
+ // ── Tidy: end the heat so the worker's Director is quiet for the next spec ─────────────────
+ await page.request.post(`${director.eventRoot}/control`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: { ForceEnd: { heat: HEAT_ID } }
+ });
+});
diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts
deleted file mode 100644
index 43b4ffc7..00000000
--- a/frontend/e2e/racegow.spec.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-/**
- * RaceGOW — the solo track-run loop, end to end, in real headless chromium against a real
- * Director: hub → Events → "+ New event" → RaceGOW track run → set up a run on the Simulator →
- * Start run → laps climb → Stop
- * run → the best 3-lap window and the submission line → Fly again → two runs in the table. Then
- * the OBS overlay page renders the same run, read-only, on a transparent document.
- *
- * This spec boots its **own** Director (a fresh registry: no pilots, no events — the first-run
- * state a pilot installing GridFPV for RaceGOW is in) with the built-in Simulator emitting six
- * laps a run, and drives real clicks. Nothing is mocked.
- */
-import { resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { expect, test } from '@playwright/test';
-
-import { type Director, startDirector } from '../test-harness/director.js';
-
-const here = fileURLToPath(new URL('.', import.meta.url));
-const dist = resolve(here, '..', 'apps', 'rd-console', 'dist');
-
-const TRACK = 'RaceGOW6 Track 1';
-const PILOT = 'Recon';
-/** Six sim laps per run: enough for a 3-lap window with room either side. */
-const SIM_LAPS = 6;
-
-let director: Director;
-
-test.beforeAll(async () => {
- director = await startDirector({ token: false, assets: dist, simLaps: SIM_LAPS, simLapMs: 250 });
-});
-
-test.afterAll(async () => {
- await director?.stop();
-});
-
-test('a pilot sets up a track run, flies two runs, and gets a submission line', async ({
- page
-}) => {
- await page.goto(director.baseUrl);
-
- // ── Hub → Events → "+ New event" → RaceGOW track run ───────────────────────────────────
- // There is no RaceGOW card on the hub: a track run is an event, so it starts where events
- // do. A fresh Director has none, so the first-run call to action is the way in.
- await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible({ timeout: 15_000 });
- await expect(page.getByRole('heading', { name: 'RaceGOW' })).toHaveCount(0);
- await page.getByRole('heading', { name: 'Events' }).click();
- await page.getByRole('button', { name: 'Create your first event' }).click();
- const newEvent = page.getByRole('form', { name: 'New event' });
- await newEvent.getByRole('radio', { name: 'RaceGOW track run' }).check();
- await page.getByRole('button', { name: 'Set up track run' }).click();
- await expect(page).toHaveURL(/#\/racegow$/);
- const form = page.getByRole('form', { name: 'New track run' });
- await expect(form).toBeVisible();
-
- // ── The form: a fresh Director has no pilots, so the callsign field is up; the built-in
- // Simulator is the only timer, and it carries Raceband, so a channel is offered. ─────────
- await expect(form.getByLabel('Track')).toHaveValue(TRACK);
- await form.getByLabel('Callsign').fill(PILOT);
- await expect(form.getByLabel('Timer')).toHaveValue('mock');
- await expect(form.getByLabel('Your channel')).toBeVisible();
- await form.getByRole('button', { name: 'Set up run' }).click();
-
- // ── The run screen, on the event the preset built ───────────────────────────────────────
- await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
- await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 });
- await expect(page.getByText(`${PILOT} on Mock (Simulator)`)).toBeVisible();
-
- // The event really exists server-side, shaped by the preset: one Whoop-class round, scored by
- // best 3 consecutive laps, open-ended, with the pilot on the roster.
- const events = (await (await fetch(`${director.baseUrl}/events`)).json()) as {
- id: string;
- name: string;
- preset?: string;
- roster: string[];
- classes: string[];
- rounds?: { format: string; params: Record; win_condition: unknown }[];
- }[];
- expect(events).toHaveLength(1);
- const event = events[0];
- expect(event.name).toBe(TRACK);
- expect(event.preset).toBe('racegow');
- expect(event.roster).toHaveLength(1);
- expect(event.classes).toEqual(['mgp-whoop']);
- expect(event.rounds).toHaveLength(1);
- expect(event.rounds![0].format).toBe('timed_qual');
- expect(event.rounds![0].params).toEqual({ rounds: '0' });
- expect(event.rounds![0].win_condition).toEqual({ BestConsecutive: { n: 3 } });
-
- // ── Run 1: Start run → the sim flies its laps → Stop run ─────────────────────────────────
- const run = page.getByRole('region', { name: 'Current run' });
- await run.getByRole('button', { name: 'Start run' }).click();
- // The Simulator emits laps once the heat is Running (after the fixed 1 s hold).
- const laps = run.getByRole('list', { name: "This run's laps" });
- await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, {
- timeout: 20_000
- });
- // With ≥ 3 laps the live best-3 readout has a time.
- await expect(run.getByLabel("This run's best 3 consecutive laps")).not.toHaveText('—');
- await run.getByRole('button', { name: 'Stop run' }).click();
-
- // Stopped + saved: the headline is the served round-wide best, the table lists run 1 as saved,
- // and the submission line is ready with the track and the pilot.
- await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 });
- await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', {
- timeout: 15_000
- });
- const table = page.getByRole('table', { name: 'Runs' });
- await expect(table.getByRole('row').filter({ hasText: 'Run 1' })).toContainText('saved');
- const submission = page.getByLabel('Submission text');
- await expect(submission).toHaveValue(new RegExp(`${TRACK} — ${PILOT}`));
- await expect(submission).toHaveValue(/Best 3 consecutive laps: \d+\.\d{3} s/);
- await expect(submission).toHaveValue(/Run 1 of 1/);
-
- // ── Run 2: Fly again draws the next heat and runs it ─────────────────────────────────────
- await run.getByRole('button', { name: 'Fly again' }).click();
- await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, {
- timeout: 20_000
- });
- await run.getByRole('button', { name: 'Stop run' }).click();
- await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 });
- await expect(table.getByRole('row').filter({ hasText: 'Run 2' })).toContainText('saved');
- await expect(submission).toHaveValue(/of 2/);
-
- // The server agrees: two Final heats on the round, and the standings carry a best-3 window.
- const heats = (await (await fetch(`${director.baseUrl}/events/${event.id}/heats`)).json()) as {
- phase: string;
- }[];
- expect(heats.map((h) => h.phase)).toEqual(['Final', 'Final']);
- const roundId = (
- (await (await fetch(`${director.baseUrl}/events`)).json()) as { rounds: { id: string }[] }[]
- )[0].rounds[0].id;
- const standings = (await (
- await fetch(`${director.baseUrl}/events/${event.id}/rounds/${roundId}/standings`)
- ).json()) as { metric: { BestConsecutive?: { n: number; micros: number | null } } }[];
- expect(standings).toHaveLength(1);
- expect(standings[0].metric.BestConsecutive?.n).toBe(3);
- expect(standings[0].metric.BestConsecutive?.micros).not.toBeNull();
-
- // ── The overlay: the OBS browser source follows the active run, read-only ───────────────
- await page.goto(`${director.baseUrl}/#/overlay/racegow`);
- await expect(page.getByLabel('RaceGOW overlay')).toBeVisible({ timeout: 15_000 });
- await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 });
- await expect(page.getByRole('timer', { name: /Run time/ })).toBeVisible();
- await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', {
- timeout: 15_000
- });
- // The document is flagged transparent for the keyer.
- await expect(page.locator('html')).toHaveAttribute('data-overlay', 'racegow');
- await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
-
- // The light variant: same overlay, dark text on a light panel, still transparent underneath.
- await page.goto(`${director.baseUrl}/#/overlay/racegow/light`);
- await expect(page.getByLabel('RaceGOW overlay')).toHaveAttribute('data-theme', 'light');
- await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 });
- await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
-
- // ── The run's Events crumb leaves the run and lands on the Events page, where the run lists
- // with its badge; entering it there opens the run screen again (not the workspace): the
- // preset marker routes it. ──────────────────────────────────────────────────────────────
- await page.goto(`${director.baseUrl}/#/racegow/run`);
- await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 });
- await page
- .getByRole('navigation', { name: 'Breadcrumb' })
- .getByRole('button', { name: 'Events' })
- .click();
- await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({
- timeout: 15_000
- });
- // Anchored: the row's delete button is named "Delete " and would match a loose regex.
- const row = page.getByRole('button', { name: new RegExp(`^${TRACK}`) });
- await expect(row).toBeVisible();
- await expect(row.getByText('RaceGOW', { exact: true })).toBeVisible();
- await row.click();
- await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
- await expect(page.getByRole('heading', { name: TRACK })).toBeVisible();
-
- // ── The RaceGOW page still lists every run and reopens one ──────────────────────────────
- await page.goto(`${director.baseUrl}/#/racegow`);
- const runs = page.getByRole('list', { name: 'Your runs' });
- await expect(runs.getByRole('listitem')).toHaveCount(1, { timeout: 15_000 });
- await runs.getByRole('button', { name: `Open ${TRACK}` }).click();
- await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 });
- await expect(page.getByRole('heading', { name: TRACK })).toBeVisible();
-});
diff --git a/src-tauri/README.md b/src-tauri/README.md
index cfd75e44..0020030e 100644
--- a/src-tauri/README.md
+++ b/src-tauri/README.md
@@ -21,11 +21,20 @@ On launch the app:
1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted `gridfpv`
binary runs — via the shared `gridfpv_app::director::run_director` — as a background task.
- It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses a **true-portable
- data dir** (`gridfpv-data/` next to the executable, with a per-user app-data fallback) for
- created events' SQLite files, and serves the **bundled** `rd-console` dist as the SPA.
-2. Waits for the Director to report its OS-assigned port, then opens the main window pointed
- at `http://127.0.0.1:/`.
+ It binds **loopback on a fixed port** (`127.0.0.1:8080` by default; `GRIDFPV_ADDR`
+ overrides it), uses a **true-portable data dir** (`gridfpv-data/` next to the executable,
+ with a per-user app-data fallback) for created events' SQLite files, and serves the
+ **bundled** `rd-console` dist as the SPA.
+2. Waits for the Director to report the port it bound, then opens the main window pointed at
+ `http://127.0.0.1:/`.
+
+**Why a fixed port.** The app used to bind `127.0.0.1:0` and take whatever port the OS gave
+it, which changed on every restart — and an **OBS browser source** holding an overlay URL
+(`http://127.0.0.1:/#/overlay/race`) broke every time. The port is now the same one the
+hosted binary defaults to, and the same after every launch. If it is taken, the app falls back
+to an ephemeral port **and says so in the log** rather than refusing to start; set
+`GRIDFPV_ADDR=127.0.0.1:` to pick a different fixed port (or a LAN address for OBS on
+another machine — off loopback, set `GRIDFPV_RD_TOKEN` to gate control).
Because the window loads the Director's HTTP origin directly (rather than the bundled assets
over `tauri://`), the SPA's **same-origin** API calls (`/snapshot/...`, `/control/...`) and
@@ -43,8 +52,8 @@ The Director's serve entry point was extracted into one reusable function,
- the hosted `gridfpv` binary (`crates/app/src/main.rs`) — env-resolved address, Ctrl-C
shutdown, prints the startup banner; behavior unchanged; and
-- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:0`, per-user data dir, bundled
- assets, and an `on_ready` that reads the ephemeral port.
+- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:8080` (or `GRIDFPV_ADDR`), the
+ portable data dir, bundled assets, and an `on_ready` that reads the bound port.
So the two share identical Director wiring (event registry, optional RD token, sim
lap-source bridge, presence reconciler, router, CORS).
@@ -120,16 +129,16 @@ chmod +x src-tauri/target/release/gridfpv-desktop
./src-tauri/target/release/gridfpv-desktop
```
-A native window opens with the RD console, backed by the in-process Director on a private
-loopback port. The maintainer runs this over RDP (which provides a display). A `gridfpv-data/`
+A native window opens with the RD console, backed by the in-process Director on loopback
+port 8080 (`http://127.0.0.1:8080/`, the address OBS browser sources and other tabs can hold). The maintainer runs this over RDP (which provides a display). A `gridfpv-data/`
folder appears next to the binary, holding created events' SQLite files.
> **Headless note:** the GUI window needs a display, so on a headless VM the window itself
> can't render. The **server half** (the embedded Director) needs no display and is covered
> by an integration test — `crates/app/tests/director.rs ::
> run_director_serves_health_on_loopback_ephemeral_port` — which drives the exact
-> `run_director` entry point the app uses, binds a loopback ephemeral port, and asserts
-> `/health` answers over a real socket. The full app can also be smoke-launched headless with
+> `run_director` entry point the app uses (on an ephemeral port, so the test never collides
+> with a running Director) and asserts `/health` answers over a real socket. The full app can also be smoke-launched headless with
> `xvfb-run` (set `WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1` to avoid the
> WebKit GPU path hanging under Xvfb).
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7a383a3f..9cf9c7b6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -9,16 +9,32 @@
//!
//! 1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted
//! `gridfpv` binary runs — via the shared [`gridfpv_app::director::run_director`] — as a
-//! background task. It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses
-//! a **true-portable data dir** (`gridfpv-data/` beside the running executable, with a
+//! background task. It binds **loopback on a fixed, well-known port**
+//! ([`DEFAULT_ADDR`], `127.0.0.1:8080`, overridable with `GRIDFPV_ADDR`), uses a
+//! **true-portable data dir** (`gridfpv-data/` beside the running executable, with a
//! per-user app-data fallback when the exe dir isn't writable) for created events, and
//! serves the `rd-console` SPA **embedded into the binary** (`gridfpv-app`'s `embed-assets`
//! feature) — so this executable is self-contained, with no external dist folder beside it.
//! Loopback ⇒ the control path is open (no auth), per the GridFPV auth model (loopback =
//! trusted).
-//! 2. Waits (briefly) for the Director to report the OS-assigned port, then opens the main
+//! 2. Waits (briefly) for the Director to report the port it bound, then opens the main
//! window pointed at `http://127.0.0.1:/`.
//!
+//! # Why a fixed port, not an ephemeral one
+//!
+//! The app used to bind `127.0.0.1:0` and let the OS pick. That is fine for the window — the
+//! shell learns the port and points the webview at it — and wrong for everything else that
+//! holds the Director's address: an **OBS browser source** carrying an overlay URL
+//! (`#/overlay/race`), a bookmark, a second browser tab. Every restart handed out a new port
+//! and silently broke each of them, so the overlay in the pilot's recording went blank until
+//! they re-pasted a URL. The address is now stable across restarts, like the hosted binary's.
+//!
+//! If the fixed port is **taken** (another Director, a dev server), the app still comes up: it
+//! falls back to an ephemeral port and says so in the log, loudly, rather than refusing to
+//! start — a window with a one-off port beats no window. `GRIDFPV_ADDR` picks a different
+//! fixed port (or a LAN bind, for OBS on another machine — mind the auth model: off loopback,
+//! set `GRIDFPV_RD_TOKEN` to gate control).
+//!
//! Pointing the webview at the Director's HTTP origin (rather than loading the bundled
//! assets over the `tauri://` protocol) is deliberate: the SPA makes **same-origin**
//! relative calls to the protocol API (`/snapshot/...`, `/control/...`) and opens the
@@ -36,6 +52,7 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use tokio::sync::oneshot;
@@ -51,6 +68,35 @@ macro_rules! eprintln {
($($arg:tt)*) => { gridfpv_app::logging::record(::std::format_args!($($arg)*)) };
}
+/// The address the embedded Director binds by default: **loopback, port 8080** — the same
+/// port the hosted `gridfpv` binary defaults to, so the console's address is the one every
+/// guide already prints, and it is the **same after every restart** (see the module docs for
+/// why that matters: OBS browser sources hold the overlay URL).
+pub const DEFAULT_ADDR: SocketAddr =
+ SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST), 8080);
+
+/// The env var that overrides [`DEFAULT_ADDR`] — the same name the hosted binary honours.
+pub const ADDR_ENV: &str = "GRIDFPV_ADDR";
+
+/// Resolve the address to bind: `GRIDFPV_ADDR` when set and valid, else [`DEFAULT_ADDR`]. An
+/// unparsable value is reported and ignored rather than aborting the launch — the fixed
+/// default is always a usable answer.
+fn requested_addr() -> SocketAddr {
+ match std::env::var(ADDR_ENV) {
+ Ok(value) => match value.trim().parse::() {
+ Ok(addr) => addr,
+ Err(err) => {
+ eprintln!(
+ "gridfpv-desktop: {ADDR_ENV}={value:?} is not a valid socket address ({err}) — \
+ using {DEFAULT_ADDR}"
+ );
+ DEFAULT_ADDR
+ }
+ },
+ Err(_) => DEFAULT_ADDR,
+ }
+}
+
/// The env var that opts into the Linux WebKitGTK rendering workarounds (#476).
///
/// Accepts (case-insensitive, comma-separated):
@@ -187,33 +233,63 @@ pub fn run() {
.build()
.expect("failed to build the Director tokio runtime");
- // `oneshot` carries the OS-assigned ephemeral port from the Director's
- // `on_ready` (inside the runtime) back out to the setup thread so we can open the
- // window at the right URL.
+ // `oneshot` carries the port the Director actually bound from its `on_ready`
+ // (inside the runtime) back out to the setup thread so we can open the window at
+ // the right URL. Shared between the first attempt and the fallback below.
let (port_tx, port_rx) = oneshot::channel::();
+ let port_tx = Arc::new(Mutex::new(Some(port_tx)));
- // Spawn the Director on the runtime. Bind `127.0.0.1:0` ⇒ loopback + ephemeral
- // free port (no auth, per the auth model). It runs until the process exits; the
- // app quitting tears the runtime (and the server) down.
+ // Spawn the Director on the runtime, on the FIXED loopback address (no auth, per
+ // the auth model) — the same one after every restart, so an OBS browser source
+ // holding an overlay URL keeps working. It runs until the process exits; the app
+ // quitting tears the runtime (and the server) down.
+ let requested = requested_addr();
runtime.spawn(async move {
- let mut port_tx = Some(port_tx);
- let result = gridfpv_app::director::run_director(
- SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
- Some(data_dir),
- assets_dir,
- move |ready| {
- // Hand the bound ephemeral port back to the setup thread.
- if let Some(tx) = port_tx.take() {
- let _ = tx.send(ready.bound.port());
+ let serve = |addr: SocketAddr, data_dir: PathBuf, assets_dir: PathBuf| {
+ let port_tx = port_tx.clone();
+ gridfpv_app::director::run_director(
+ addr,
+ Some(data_dir),
+ assets_dir,
+ move |ready| {
+ // Hand the bound port back to the setup thread.
+ if let Some(tx) = port_tx.lock().unwrap().take() {
+ let _ = tx.send(ready.bound.port());
+ }
+ },
+ // No graceful-shutdown trigger: the server lives for the app's
+ // lifetime and is torn down when the process exits.
+ std::future::pending::<()>(),
+ )
+ };
+ let bound_fixed = port_tx.clone();
+ // The error is flattened to a `String` before the next `.await`: `Box`
+ // is not `Send`, and holding it across the fallback's await would make this
+ // future unspawnable.
+ let result = serve(requested, data_dir.clone(), assets_dir.clone())
+ .await
+ .map_err(|err| err.to_string());
+ // `run_director` returns early only on a failure. If it never reported a port,
+ // the bind itself failed (the port is taken, or the address is not local):
+ // fall back to an ephemeral port so the window still opens — and say so, since
+ // every URL anyone pasted elsewhere is now wrong until the port is freed.
+ let never_bound = bound_fixed.lock().unwrap().is_some();
+ match result {
+ Err(err) if never_bound => {
+ eprintln!(
+ "gridfpv-desktop: could not bind {requested} ({err}). Falling back to \
+ an EPHEMERAL loopback port — the console's address will differ from \
+ the usual one until whatever holds {requested} is stopped (or set \
+ {ADDR_ENV} to another fixed port). OBS browser sources pointing at \
+ the usual address will not connect in this session."
+ );
+ let fallback = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
+ if let Err(err) = serve(fallback, data_dir, assets_dir).await {
+ eprintln!("gridfpv-desktop: Director exited with error: {err}");
}
- },
- // No graceful-shutdown trigger: the server lives for the app's lifetime
- // and is torn down when the process exits.
- std::future::pending::<()>(),
- )
- .await;
- if let Err(err) = result {
- eprintln!("gridfpv-desktop: Director exited with error: {err}");
+ }
+ Err(err) => eprintln!("gridfpv-desktop: Director exited with error: {err}"),
+ Ok(()) => {}
}
});
@@ -222,6 +298,12 @@ pub fn run() {
let port = port_rx
.blocking_recv()
.expect("the embedded Director failed to report its bound port");
+ if port != requested.port() {
+ eprintln!(
+ "gridfpv-desktop: NOTE — serving on port {port}, not the configured \
+ {requested}; see the bind failure above"
+ );
+ }
let url = format!("http://{}:{port}/", Ipv4Addr::LOCALHOST);
eprintln!("gridfpv-desktop: embedded Director ready — opening {url}");