Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions frontend/apps/rd-console/src/ContextHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* freezes on Unofficial/Final, and resets when there's no live heat — everywhere, not just
* on the live screen, which now drives its clock from the same source.
*/
import { StatusPill, RaceClock } from '@gridfpv/components';
import { StatusPill, RaceClock, toast } from '@gridfpv/components';
import type { HeatSummary } from '@gridfpv/types';
import type { Session } from './lib/session.svelte.js';
import { useRaceClock } from './lib/raceClock.svelte.js';
Expand Down Expand Up @@ -58,13 +58,27 @@
// Touching `currentEvent` makes it re-read the instant the event settles. Rounds come straight off
// the event, so the `$derived` name re-resolves the moment either heats or the event changes.
let heats = $state<HeatSummary[]>([]);
// A FAILED heats read must be visible (#340): swallowing it into an empty array made the header
// silently render the raw heat id with no hint anything was wrong. Track a load-error flag
// (keeping the last good data rather than wiping it), show a compact "Couldn't load — retry"
// state in place of the heat name, and toast once on the transition into the error state.
let heatsError = $state(false);
let heatsRetryNonce = $state(0);
function retryHeats(): void {
heatsRetryNonce += 1;
}
$effect(() => {
void session.currentEvent;
void session.protocolState;
void heatsRetryNonce;
session
.listHeats()
.then((h) => (heats = h))
.catch(() => (heats = []));
.then((h) => ((heats = h), (heatsError = false)))
.catch(() => {
if (!heatsError)
toast.error('Couldn’t load the heats directory — heat names may not resolve.');
heatsError = true;
});
});
const heatName = $derived(
heat ? heatNameById(heat, heats, session.currentEvent?.rounds ?? []) : ''
Expand Down Expand Up @@ -112,7 +126,19 @@
<span class="ctx-sep" aria-hidden="true"></span>
<div class="ctx-heat">
<span class="ctx-heat-label">Heat</span>
<span class="ctx-heat-id">{heatName}</span>
{#if heatsError}
<!-- The heats read failed (#340): show the error state (with retry) instead of silently
falling back to the raw heat id. -->
<button
type="button"
class="ctx-load-error"
onclick={retryHeats}
title="Couldn’t load the heats directory — heat names may not resolve. Click to retry."
>Couldn’t load — retry</button
>
{:else}
<span class="ctx-heat-id">{heatName}</span>
{/if}
</div>
{#if phase}
<span class="ctx-phase"><StatusPill {phase} size="sm" /></span>
Expand Down Expand Up @@ -240,6 +266,18 @@
font-size: var(--gf-font-size-sm);
color: var(--gf-text-faint);
}
/* The heats-directory load-error state (#340): compact, in place of the heat name. */
.ctx-load-error {
background: var(--gf-danger-soft);
border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border));
border-radius: var(--gf-radius-sm);
padding: var(--gf-space-1) var(--gf-space-2);
color: var(--gf-text);
font-family: inherit;
font-size: var(--gf-font-size-xs);
cursor: pointer;
white-space: nowrap;
}
.ctx-phase {
display: inline-flex;
}
Expand Down
43 changes: 37 additions & 6 deletions frontend/apps/rd-console/src/lib/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
* refs resolved to callsigns and class/round ids to their labels (CLAUDE.md: never leak a raw id) —
* while preserving the raw ref alongside so it stays traceable.
*/
import type { ClassStanding, CompetitorRef, HeatResult, RankEntry } from '@gridfpv/types';
import type {
ClassStanding,
CompetitorRef,
HeatResult,
Placement,
RankEntry
} from '@gridfpv/types';

/** Serialize a value to pretty JSON; the bigint replacer is a defensive no-op now
* that wire numerics are plain `number`s. */
Expand All @@ -16,13 +22,26 @@ export function toExportJson(value: unknown): string {
/** A standings/ranking row with its competitor ref resolved to a friendly name (raw ref kept). */
type WithName<T> = Omit<T, 'competitor'> & { competitor: string; competitor_ref: CompetitorRef };

/**
* A scored heat's placement with the competitor resolved to its friendly name. The raw
* source-local ref stays alongside as `competitor_ref` (the same traceability convention as the
* standings rows), replacing the wire's `CompetitorKey`.
*/
type PlacementWithName = Omit<Placement, 'competitor'> & {
competitor: string;
competitor_ref: CompetitorRef;
};

/** A {@link HeatResult} with every placement's competitor resolved (raw ref kept alongside). */
export type HeatResultExport = Omit<HeatResult, 'places'> & { places: PlacementWithName[] };

/** The friendly, human-usable results payload (whichever views are present). */
export interface ResultsExport {
class_standings?: { class: string; standings: WithName<ClassStanding>[] };
round_ranking?: { round: string; ranking: WithName<RankEntry>[] };
/** The legacy event-level projection, carried through as-is. */
standings?: RankEntry[];
heatResult?: HeatResult;
/** The legacy event-level projection — competitor refs resolved like every other view (#341). */
standings?: WithName<RankEntry>[];
heatResult?: HeatResultExport;
}

/** The inputs the Results screen passes to {@link buildResultsExport}. */
Expand Down Expand Up @@ -60,8 +79,20 @@ export function buildResultsExport(input: ResultsExportInput): ResultsExport {
if (input.roundRanking) {
out.round_ranking = { round: input.roundLabel ?? '—', ranking: withName(input.roundRanking) };
}
if (input.standings) out.standings = input.standings;
if (input.heatResult) out.heatResult = input.heatResult;
// The legacy event-level projections resolve too (#341): the standings rows through the same
// `withName`, and the heat result's placements — whose wire competitor is a `CompetitorKey`
// (`{ adapter, competitor }`) — through the resolver on the key's ref, raw ref kept alongside.
if (input.standings) out.standings = withName(input.standings);
if (input.heatResult) {
out.heatResult = {
...input.heatResult,
places: input.heatResult.places.map((p) => ({
...p,
competitor: input.resolveCompetitor(p.competitor.competitor),
competitor_ref: p.competitor.competitor
}))
};
}
return out;
}

Expand Down
17 changes: 13 additions & 4 deletions frontend/apps/rd-console/src/screens/EventRounds.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -903,19 +903,28 @@
// is always timed): both read `winSeconds` and would build a NaN/0-µs window if it were left blank.
const needsRaceTime = $derived(winKind === 'Timed' || winKind === 'BestOfN');
const raceTimeValid = $derived(Number.isFinite(Number(winSeconds)) && Number(winSeconds) >= 1);
// The "Laps" field backs First-to-N and Best-of-N: clearing it left `winLaps` blank and the
// builder silently clamped it to 1 (#340) — block submit instead, the race-time pattern (#329).
const needsLaps = $derived(winKind === 'FirstToLaps' || winKind === 'BestOfN');
const lapsValid = $derived(Number.isFinite(Number(winLaps)) && Number(winLaps) >= 1);
// Same for the FromRanking "Take top" cut: a cleared field silently saved `top_n: 1` (#340).
const seedTopNValid = $derived(Number.isFinite(Number(seedTopN)) && Number(seedTopN) >= 1);

// The form is submittable once it has a label, a single eligible class, a format, and — when
// seeding from a ranking — at least one chosen source round (the multi-select, issue #51). When the
// win condition is timed (Timed / Best-of-N) a valid race time is also required (else the heat
// would run forever / build a degenerate window).
// seeding from a ranking — at least one chosen source round (the multi-select, issue #51) plus a
// valid "Take top" cut. When the win condition is timed (Timed / Best-of-N) a valid race time is
// also required (else the heat would run forever / build a degenerate window), and a lap-target
// condition (First-to-N / Best-of-N) requires a valid lap count (else 1 would silently save).
const canSubmit = $derived(
isOpenPractice
? canSubmitOpenPractice
: label.trim().length > 0 &&
selectedClass !== '' &&
format.length > 0 &&
(!needsRaceTime || raceTimeValid) &&
(seedKind === 'FromRoster' || (seedKind === 'FromRanking' && seedSources.size > 0))
(!needsLaps || lapsValid) &&
(seedKind === 'FromRoster' ||
(seedKind === 'FromRanking' && seedSources.size > 0 && seedTopNValid))
);

async function submit() {
Expand Down
57 changes: 53 additions & 4 deletions frontend/apps/rd-console/src/screens/LiveRaceControl.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@
// re-fetched whenever the stream advances (so a freshly-staged OR freshly-scheduled heat appears).
let catalog = $state<ChannelCatalogEntry[]>([]);
let heats = $state<HeatSummary[]>([]);
// A FAILED pilots/heats directory read must be visible (#340): swallowing it into an empty array
// left every ref/heat-id rendering raw with no hint anything was wrong. Track a load-error flag
// per read (keeping the last good data rather than wiping it), surface a "Couldn't load — retry"
// state + a toast (the Results-screen pattern), and let the RD retry explicitly via the nonce.
let pilotsError = $state(false);
let heatsError = $state(false);
let directoryRetryNonce = $state(0);
const directoryError = $derived(pilotsError || heatsError);
function retryDirectory(): void {
directoryRetryNonce += 1;
}
/** Toast once on the transition INTO the error state (the effects re-run on every stream tick). */
function noteDirectoryError(alreadyFailing: boolean): void {
if (!alreadyFailing)
toast.error('Couldn’t load the pilot/heat directory — names may show as raw ids.');
}
$effect(() => {
session
.listChannels()
Expand All @@ -84,10 +100,14 @@
// (the backend force-emits one when a heat is scheduled), so touching it refreshes the picker the
// moment a heat appears — without changing `current_heat` (no focus steal).
void session.protocolState;
void directoryRetryNonce;
session
.listHeats()
.then((h) => (heats = h))
.catch(() => (heats = []));
.then((h) => ((heats = h), (heatsError = false)))
.catch(() => {
noteDirectoryError(directoryError);
heatsError = true;
});
});

// The current heat's ref → channel-label map (race redesign Slice 4b). Empty for a sim/free-text
Expand All @@ -111,10 +131,14 @@
let pilots = $state<Pilot[]>([]);
$effect(() => {
void session.protocolState;
void directoryRetryNonce;
session
.listPilots()
.then((p) => (pilots = p))
.catch(() => (pilots = []));
.then((p) => ((pilots = p), (pilotsError = false)))
.catch(() => {
noteDirectoryError(directoryError);
pilotsError = true;
});
});
const pilotById = $derived(new Map<PilotId, Pilot>(pilots.map((p) => [p.id, p])));
// A competitor ref → its bound pilot id from the live `progress`, which carries an **explicit**
Expand Down Expand Up @@ -636,6 +660,15 @@
<ErrorBanner error={session.lastCommandError} ondismiss={() => session.clearCommandError()} />
{/if}

{#if directoryError}
<!-- A failed pilots/heats directory read (#340): without it, names silently fall back to raw
refs with no hint anything went wrong. Visible error + explicit retry (Results pattern). -->
<div class="dir-error" role="alert">
<p>Couldn’t load the pilot/heat directory — names may show as raw ids.</p>
<Button variant="secondary" size="sm" onclick={retryDirectory}>Try again</Button>
</div>
{/if}

{#if canControl}
<div class="controls" role="group" aria-label="Heat transitions">
<span class="controls-label">Transitions</span>
Expand Down Expand Up @@ -898,6 +931,22 @@
}

/* ── Transition controls ─────────────────────────────────────────────────── */
/* The directory-load error state (#340): visible, with an explicit retry. */
.dir-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gf-space-3);
padding: var(--gf-space-3) var(--gf-space-4);
border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border));
border-radius: var(--gf-radius-md);
background: var(--gf-danger-soft);
}
.dir-error p {
margin: 0;
color: var(--gf-text);
font-size: var(--gf-font-size-sm);
}
.controls {
display: flex;
flex-direction: column;
Expand Down
Loading