From b62eb2b4cd240abdea4f51640e069dbd3d05f3ae Mon Sep 17 00:00:00 2001
From: Cailyn Sinclair
Date: Tue, 3 Nov 2026 06:54:20 -0800
Subject: [PATCH 1/4] Fix venue timezone displays and fallback qualification
highlights
---
.../CutoffTimeLimitPanel.help.test.tsx | 64 +++++
.../CutoffTimeLimitPanel.tsx | 18 +-
.../CompetitionResultsTable.tsx | 1 +
.../CompetitionResults/advancement.test.ts | 144 +++++++++++-
.../CompetitionResults/advancement.ts | 136 ++++++-----
.../CompetitionResults/resultSources.ts | 1 +
.../resultsProvider.test.ts | 219 ++++++++++++++++++
.../CompetitionResults/resultsProvider.ts | 65 ++++--
.../CompetitionRound.test.tsx | 59 ++++-
.../CompetitionRound/CompetitionRound.tsx | 22 +-
src/lib/time.test.ts | 47 ++++
src/lib/time.ts | 12 +-
12 files changed, 701 insertions(+), 87 deletions(-)
create mode 100644 src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx
create mode 100644 src/lib/time.test.ts
diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx
new file mode 100644
index 0000000..2842227
--- /dev/null
+++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx
@@ -0,0 +1,64 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { Round } from '@wca/helpers';
+import { readFileSync } from 'fs';
+import { createInstance } from 'i18next';
+import { load } from 'js-yaml';
+import { I18nextProvider, initReactI18next } from 'react-i18next';
+import { MemoryRouter } from 'react-router-dom';
+import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel';
+
+jest.mock('@/providers/WCIFProvider', () => ({
+ useWCIF: () => ({ wcif: undefined }),
+}));
+
+const round: Round = {
+ id: '333-r1',
+ format: 'a',
+ cutoff: { numberOfAttempts: 2, resultValue: 1200 },
+ timeLimit: null,
+ linkedRounds: null,
+ participationRuleset: null,
+ results: [],
+ extensions: [],
+};
+
+it('renders actual translated help markup and supports repeated dismissal and reopening', async () => {
+ const i18n = createInstance();
+ await i18n.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ resources: {
+ en: { translation: load(readFileSync('src/i18n/en/translation.yaml', 'utf8')) as object },
+ },
+ interpolation: { escapeValue: false },
+ });
+ const { unmount } = render(
+
+
+
+
+ ,
+ );
+ const help = screen.getByRole('button', { name: /help/i, expanded: false });
+ help.focus();
+ for (let attempt = 0; attempt < 2; attempt++) {
+ fireEvent.click(help);
+ expect(help).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('cutoffs', { selector: 'strong' })).toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent('');
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(help).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText('cutoffs', { selector: 'strong' })).not.toBeInTheDocument();
+ expect(help).toHaveFocus();
+ }
+ fireEvent.click(help);
+ fireEvent.click(help);
+ expect(help).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(help);
+ fireEvent.click(document.body);
+ expect(help).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(help);
+ unmount();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByText('cutoffs', { selector: 'strong' })).not.toBeInTheDocument();
+});
diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx
index cb01f7b..d630964 100644
--- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx
+++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx
@@ -1,6 +1,6 @@
import { Cutoff, Round, parseActivityCode } from '@wca/helpers';
import classNames from 'classnames';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Popover } from 'react-tiny-popover';
@@ -212,6 +212,15 @@ function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
+ useEffect(() => {
+ if (!open) return;
+ const dismissOnEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('keydown', dismissOnEscape);
+ return () => document.removeEventListener('keydown', dismissOnEscape);
+ }, [open]);
+
return (
}}
+ components={{ b: , bold: }}
/>
- }} />
+ , bold: }}
+ />
{cutoff &&
diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx
index 7eef876..1e04355 100644
--- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx
+++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx
@@ -19,6 +19,7 @@ export interface CompetitionRoundResult {
id: string | number;
personId: number | null;
personName?: string;
+ wcaId?: string | null;
ranking: number | null;
advancing: boolean;
advancingQuestionable: boolean;
diff --git a/src/containers/CompetitionResults/advancement.test.ts b/src/containers/CompetitionResults/advancement.test.ts
index e405dc9..833ca3b 100644
--- a/src/containers/CompetitionResults/advancement.test.ts
+++ b/src/containers/CompetitionResults/advancement.test.ts
@@ -116,7 +116,7 @@ describe('dual-round stored results', () => {
).size;
const limit =
condition.resultCondition.type === 'percent'
- ? Math.ceil((entrants * condition.resultCondition.value) / 100)
+ ? Math.floor((entrants * condition.resultCondition.value) / 100)
: condition.resultCondition.value!;
expect(
results.every(
@@ -126,3 +126,145 @@ describe('dual-round stored results', () => {
}
});
});
+
+const qualifiers = (
+ results: Round['results'],
+ condition: ResultCondition,
+ format: Round['format'] = 'a',
+) => {
+ const round = { ...makeRound('333-r1', results), format };
+ return getStoredRoundResults(round, {
+ sourceType: 'round',
+ sourceRoundIds: [round.id],
+ resultCondition: condition,
+ }).filter(({ advancing }) => advancing);
+};
+const rankedResults = (count: number) =>
+ Array.from({ length: count }, (_, i) => result(i + 1, i + 1, 800 + i * 100));
+
+describe('stored qualification rules', () => {
+ it.each([
+ [11, 8],
+ [15, 11],
+ [17, 12],
+ ])('rounds 75%% of %i scored entrants down to %i', (count, expected) => {
+ expect(
+ qualifiers(rankedResults(count), { type: 'percent', scope: 'average', value: 75 }),
+ ).toHaveLength(expected);
+ });
+
+ it('caps a configured top 10 at six of eight entrants', () => {
+ expect(
+ qualifiers(rankedResults(8), { type: 'ranking', scope: 'average', value: 10 }),
+ ).toHaveLength(6);
+ });
+
+ it('keeps a top-N tie together when it stays below the elimination cap', () => {
+ const results = rankedResults(20);
+ results[6].ranking = 6;
+ expect(
+ qualifiers(results, { type: 'ranking', scope: 'average', value: 6 }).map(
+ ({ personId }) => personId,
+ ),
+ ).toEqual([1, 2, 3, 4, 5, 6, 7]);
+ });
+
+ it.each([
+ { type: 'ranking', scope: 'average', value: 15 },
+ { type: 'percent', scope: 'average', value: 75 },
+ { type: 'resultAchieved', scope: 'average', value: 10000 },
+ { type: 'resultAchieved', scope: 'average', value: null },
+ ])('excludes a whole tied group crossing the cap for %j', (condition) => {
+ const results = rankedResults(20);
+ results[15].ranking = 15;
+ results[16].ranking = 15;
+ expect(qualifiers([...results].reverse(), condition)).toHaveLength(14);
+ });
+
+ it('counts failed results in the population but never qualifies all-DNF or DNS entrants', () => {
+ const results = rankedResults(16);
+ results[6].average = -1; // A successful single still makes this competitor eligible.
+ results.slice(7).forEach((entry, i) => {
+ entry.best = i % 2 ? -1 : -2;
+ entry.average = -1;
+ entry.ranking = 8;
+ });
+ expect(
+ qualifiers(results, { type: 'percent', scope: 'average', value: 50 }).map(
+ ({ personId }) => personId,
+ ),
+ ).toEqual([1, 2, 3, 4, 5, 6, 7]);
+ });
+
+ it('does not count empty, unranked roster placeholders as scored entrants', () => {
+ const results: Round['results'] = [
+ ...rankedResults(11),
+ { ...result(12, 12, 0), ranking: null, attempts: [] },
+ { ...result(13, 13, 0), ranking: null, attempts: [] },
+ ];
+ expect(qualifiers(results, { type: 'percent', scope: 'average', value: 75 })).toHaveLength(8);
+ });
+
+ it('treats a null threshold as any successful value in the requested scope', () => {
+ const results = rankedResults(8);
+ results[1].average = -1;
+ results[2].average = -2;
+ results[3].average = 0;
+ expect(
+ qualifiers(results, { type: 'resultAchieved', scope: 'average', value: null }).map(
+ ({ personId }) => personId,
+ ),
+ ).toEqual([1, 5, 6]);
+ expect(
+ qualifiers(results, { type: 'resultAchieved', scope: 'single', value: null }),
+ ).toHaveLength(6);
+ });
+
+ it('requires a value strictly better than a non-null threshold', () => {
+ expect(
+ qualifiers(rankedResults(8), {
+ type: 'resultAchieved',
+ scope: 'average',
+ value: 1000,
+ }).map(({ personId }) => personId),
+ ).toEqual([1, 2]);
+ });
+
+ it('allows success in either linked component without replacing displayed DNF values', () => {
+ const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 });
+ rounds[0].results[0] = { ...result(1, 2, -1), best: -1 };
+ rounds[1].results[0] = { ...result(1, 2, -1), best: 900 };
+ expect(
+ getStoredRoundResults(
+ rounds[0],
+ getAdvancementConditionForRound(rounds, rounds[0]),
+ rounds,
+ )[0],
+ ).toMatchObject({ ranking: 2, best: -1, average: -1, advancing: true });
+ });
+
+ it('keeps an empty component row unhighlighted without losing sibling eligibility', () => {
+ const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 });
+ rounds[0].results[0] = {
+ ...result(1, 2, 0),
+ ranking: null,
+ attempts: [],
+ };
+ const condition = getAdvancementConditionForRound(rounds, rounds[0]);
+ expect(getStoredRoundResults(rounds[0], condition, rounds)[0]).toMatchObject({
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+ advancing: false,
+ });
+ expect(getStoredRoundResults(rounds[1], condition, rounds)[0].advancing).toBe(true);
+ });
+
+ it('does not qualify a final or an entirely empty round', () => {
+ expect(getStoredRoundResults(makeRound('333-r1', rankedResults(8)), null)).toEqual(
+ expect.arrayContaining([expect.objectContaining({ advancing: false })]),
+ );
+ expect(qualifiers([], { type: 'ranking', scope: 'average', value: 10 })).toEqual([]);
+ });
+});
diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts
index d940a32..e7cbea9 100644
--- a/src/containers/CompetitionResults/advancement.ts
+++ b/src/containers/CompetitionResults/advancement.ts
@@ -8,80 +8,93 @@ const averagedFormats = new Set(['a', 'm']);
const getPrimaryRankingType = (round: Pick): RankingType =>
averagedFormats.has(round.format) ? 'average' : 'single';
-const getRankLimit = (
- resultCondition: RoundAdvancementCondition['resultCondition'],
- resultCount: number,
-) => {
- if (resultCondition.type === 'ranking') {
- return resultCondition.value;
- }
-
- if (resultCondition.type === 'percent') {
- return Math.ceil((resultCount * resultCondition.value) / 100);
- }
-
- return null;
+type QualificationResult = Pick & {
+ // Stable competitor identity across all source rounds, not the component result ID.
+ key: string;
};
-const getResultValue = (result: Round['results'][number], scope: RankingType) =>
- scope === 'average' ? result.average : result.best;
-
-const isAdvancingFromStoredResult = (
- round: Round,
- result: Round['results'][number],
+export const getQualifyingResultKeys = (
+ round: Pick,
advancementCondition: RoundAdvancementCondition | null,
- eventRounds: Round[],
-) => {
- if (!advancementCondition) {
- return false;
- }
+ sourceResults: QualificationResult[],
+): Set => {
+ if (!advancementCondition) return new Set();
const { resultCondition } = advancementCondition;
- // A dual-round percentage uses distinct competitors across both components,
- // including people who only competed in one. WCIF already supplies shared ranks.
- const sourceRounds =
- advancementCondition.sourceType === 'linkedRounds'
- ? eventRounds.filter((sourceRound) =>
- advancementCondition.sourceRoundIds.includes(sourceRound.id),
- )
- : [round];
- const sourceResults = sourceRounds.flatMap((sourceRound) => sourceRound.results);
- const rankedResultCount = new Set(
- sourceResults
- .filter((roundResult) => roundResult.ranking != null)
- .map((roundResult) => roundResult.personId),
- ).size;
- const rankLimit = getRankLimit(resultCondition, rankedResultCount);
-
- if (rankLimit != null) {
- return result.ranking != null && result.ranking <= rankLimit;
- }
-
- if (resultCondition.type !== 'resultAchieved') {
- return false;
- }
-
- if (resultCondition.value == null) {
- return false;
- }
+ const resultsByPerson = new Map();
+ sourceResults.forEach((result) => {
+ const personResults = resultsByPerson.get(result.key) ?? [];
+ personResults.push(result);
+ resultsByPerson.set(result.key, personResults);
+ });
- const scope = resultCondition.scope ?? getPrimaryRankingType(round);
- return sourceResults.some((sourceResult) => {
- const resultValue = getResultValue(sourceResult, scope);
- return (
- sourceResult.personId === result.personId &&
- resultValue > 0 &&
- resultValue < resultCondition.value!
+ // WCIF supplies official ranks shared across linked components. Count each
+ // scored competitor once; an empty, unranked roster row is not a result.
+ const rankingsByPerson = new Map();
+ resultsByPerson.forEach((results, key) => {
+ const rankings = results.flatMap(({ ranking }) =>
+ ranking != null && ranking > 0 ? [ranking] : [],
);
+ if (rankings.length > 0) rankingsByPerson.set(key, Math.min(...rankings));
});
+ const rankings = [...rankingsByPerson.values()].sort((a, b) => a - b);
+ const resultCount = rankings.length;
+ const maxQualifying = Math.floor(resultCount * 0.75);
+ // WCA Live's qualification algorithm excludes the entire tied group if it
+ // crosses the 75% cap (9p1). Never break an official tie by array order.
+ const firstNonQualifyingRanking = rankings[maxQualifying] ?? Infinity;
+ const rankLimit =
+ resultCondition.type === 'ranking'
+ ? resultCondition.value
+ : resultCondition.type === 'percent'
+ ? Math.floor((resultCount * resultCondition.value) / 100)
+ : null;
+ const scope = resultCondition.scope ?? getPrimaryRankingType(round);
+
+ return new Set(
+ [...resultsByPerson].flatMap(([key, results]) => {
+ const ranking = rankingsByPerson.get(key);
+ if (
+ ranking == null ||
+ ranking >= firstNonQualifyingRanking ||
+ // A DNF average is still eligible when either component has a successful
+ // single. Only all-DNF/DNS results are excluded by 9p4.
+ !results.some(({ best }) => best > 0)
+ ) {
+ return [];
+ }
+
+ const satisfiesCondition =
+ rankLimit != null
+ ? ranking <= rankLimit
+ : resultCondition.type === 'resultAchieved' &&
+ results.some((result) => {
+ const value = scope === 'average' ? result.average : result.best;
+ return value > 0 && (resultCondition.value == null || value < resultCondition.value);
+ });
+ return satisfiesCondition ? [key] : [];
+ }),
+ );
};
export const getStoredRoundResults = (
round: Round,
advancementCondition: RoundAdvancementCondition | null,
eventRounds: Round[] = [round],
-): CompetitionRoundResult[] =>
- round.results.map((result) => {
+): CompetitionRoundResult[] => {
+ const sourceRounds =
+ advancementCondition?.sourceType === 'linkedRounds'
+ ? eventRounds.filter(({ id }) => advancementCondition.sourceRoundIds.includes(id))
+ : [round];
+ const qualifyingKeys = getQualifyingResultKeys(
+ round,
+ advancementCondition,
+ sourceRounds.flatMap(({ results }) =>
+ results.map((result) => ({ ...result, key: String(result.personId) })),
+ ),
+ );
+
+ return round.results.map((result) => {
const resultWithRecords = result as typeof result & {
regionalSingleRecord?: string | null;
regionalAverageRecord?: string | null;
@@ -93,7 +106,7 @@ export const getStoredRoundResults = (
id: result.personId,
personId: result.personId,
ranking: result.ranking,
- advancing: isAdvancingFromStoredResult(round, result, advancementCondition, eventRounds),
+ advancing: result.ranking != null && qualifyingKeys.has(String(result.personId)),
advancingQuestionable: false,
attempts: result.attempts.map((attempt) => ({ result: attempt.value })),
best: result.best,
@@ -106,3 +119,4 @@ export const getStoredRoundResults = (
),
};
});
+};
diff --git a/src/containers/CompetitionResults/resultSources.ts b/src/containers/CompetitionResults/resultSources.ts
index ac0510a..2d9ece3 100644
--- a/src/containers/CompetitionResults/resultSources.ts
+++ b/src/containers/CompetitionResults/resultSources.ts
@@ -104,6 +104,7 @@ export const toCompetitionRoundResult = (
id: result.id,
personId: person?.registrantId ?? null,
personName: result.name,
+ wcaId: result.wca_id,
ranking: result.pos,
advancing: false,
advancingQuestionable: false,
diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts
index c6a467b..fa80165 100644
--- a/src/containers/CompetitionResults/resultsProvider.test.ts
+++ b/src/containers/CompetitionResults/resultsProvider.test.ts
@@ -741,3 +741,222 @@ describe('resultsProvider', () => {
]);
});
});
+
+const linkedCompetition = (): Competition => {
+ const competition = JSON.parse(JSON.stringify(wcif)) as Competition;
+ competition.persons.push({
+ ...persons[0],
+ registrantId: 4,
+ name: 'Dana Solver',
+ wcaId: '2010DANA01',
+ });
+ const rounds = competition.events[0].rounds;
+ rounds[0].results = competition.persons.map(({ registrantId }, index) => ({
+ personId: registrantId!,
+ ranking: index + 1,
+ best: 1500 + index * 100,
+ average: 1500 + index * 100,
+ attempts: [{ value: 1500 + index * 100, reconstruction: null }],
+ }));
+ rounds[0].linkedRounds = ['333-r1', '333-r2'];
+ rounds[1].linkedRounds = ['333-r1', '333-r2'];
+ rounds[2].participationRuleset = {
+ participationSource: {
+ type: 'linkedRounds',
+ roundIds: ['333-r1', '333-r2'],
+ resultCondition: { type: 'resultAchieved', scope: 'average', value: 1000 },
+ },
+ reservedPlaces: null,
+ };
+ return competition;
+};
+
+const linkedSourceResults = (
+ competition: Competition,
+ apiResults: WcaCompetitionResult[],
+ roundIndex = 0,
+ live: WcaLiveRound | null = null,
+) =>
+ getRoundResultsFromSources({
+ wcif: competition,
+ selectedRound: {
+ event: competition.events[0],
+ round: competition.events[0].rounds[roundIndex],
+ roundNumber: roundIndex + 1,
+ },
+ wcaApiResults: apiResults,
+ wcaLiveRound: live,
+ });
+
+const aliceSecondComponent = () =>
+ apiResult({
+ id: 9010,
+ pos: 1,
+ best: 800,
+ average: 900,
+ attempts: [800, 900, 1000],
+ name: 'Alice Solver',
+ wca_id: '2010ALIC01',
+ });
+
+describe('mixed-source linked qualification', () => {
+ it('uses a sibling API result to qualify a WCIF row without replacing component values', () => {
+ const competition = linkedCompetition();
+ const api = aliceSecondComponent();
+ expect(linkedSourceResults(competition, [api])[0]).toMatchObject({
+ personId: 1,
+ ranking: 1,
+ best: 1500,
+ average: 1500,
+ attempts: [{ result: 1500 }],
+ advancing: true,
+ });
+ expect(linkedSourceResults(competition, [api], 1)).toEqual([
+ expect.objectContaining({ id: 9010, average: 900, advancing: true }),
+ ]);
+ });
+
+ it('does not highlight an empty selected component when the sibling qualifies', () => {
+ const competition = linkedCompetition();
+ const round = competition.events[0].rounds[0];
+ round.results[0] = {
+ ...round.results[0],
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+ };
+ expect(linkedSourceResults(competition, [aliceSecondComponent()])[0]).toMatchObject({
+ ranking: null,
+ best: 0,
+ average: 0,
+ advancing: false,
+ });
+ expect(linkedSourceResults(competition, [aliceSecondComponent()], 1)[0].advancing).toBe(true);
+ });
+
+ it('keeps WCIF priority over a conflicting API result in a sibling component', () => {
+ const competition = linkedCompetition();
+ competition.events[0].rounds[1].results = [
+ { ...competition.events[0].rounds[0].results[0], best: 1100, average: 1200 },
+ ];
+ expect(linkedSourceResults(competition, [aliceSecondComponent()])[0].advancing).toBe(false);
+ });
+
+ it('allows a DNF component when the sibling API component has a successful single', () => {
+ const competition = linkedCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[0].results[0] = {
+ ...rounds[0].results[0],
+ best: -1,
+ average: -1,
+ attempts: [{ value: -1, reconstruction: null }],
+ };
+ rounds[2].participationRuleset!.participationSource = {
+ type: 'linkedRounds',
+ roundIds: ['333-r1', '333-r2'],
+ resultCondition: { type: 'ranking', scope: 'average', value: 2 },
+ };
+ expect(
+ linkedSourceResults(competition, [{ ...aliceSecondComponent(), average: -1 }])[0],
+ ).toMatchObject({ best: -1, average: -1, advancing: true });
+ });
+
+ it('counts API-only entrants in the distinct linked union for percentages', () => {
+ const competition = linkedCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[0].results = [rounds[0].results[0], { ...rounds[0].results[1], ranking: 3 }];
+ rounds[2].participationRuleset!.participationSource = {
+ type: 'linkedRounds',
+ roundIds: ['333-r1', '333-r2'],
+ resultCondition: { type: 'percent', scope: 'average', value: 50 },
+ };
+ const api = [
+ aliceSecondComponent(),
+ apiResult({ id: 9011, pos: 2, name: 'Casey Solver', wca_id: null }),
+ apiResult({ id: 9012, pos: 4, name: 'Dana Solver', wca_id: '2010DANA01' }),
+ ];
+ expect(linkedSourceResults(competition, api).map(({ advancing }) => advancing)).toEqual([
+ true,
+ false,
+ ]);
+ expect(
+ linkedSourceResults(competition, api, 1).map(({ personId, advancing }) => ({
+ personId,
+ advancing,
+ })),
+ ).toEqual([
+ { personId: 1, advancing: true },
+ { personId: 3, advancing: true },
+ { personId: 4, advancing: false },
+ ]);
+ });
+
+ it('deduplicates an API-only competitor by WCA ID without inventing a WCIF identity', () => {
+ const competition = linkedCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[0].results = [rounds[0].results[0], { ...rounds[0].results[1], ranking: 3 }];
+ rounds[2].participationRuleset!.participationSource = {
+ type: 'linkedRounds',
+ roundIds: ['333-r1', '333-r2'],
+ resultCondition: { type: 'ranking', scope: 'average', value: 10 },
+ };
+ const api = [
+ apiResult({ id: 9011, pos: 2, wca_id: '2010ELSE01', round_type_id: '1' }),
+ apiResult({ id: 9012, pos: 2, wca_id: '2010ELSE01', round_type_id: '2' }),
+ ];
+ const results = linkedSourceResults(competition, api);
+ expect(results.find(({ id }) => id === 9011)).toMatchObject({
+ personId: null,
+ wcaId: '2010ELSE01',
+ advancing: true,
+ });
+ expect(results.find(({ personId }) => personId === 2)?.advancing).toBe(false);
+ });
+
+ it('does not invent a linked union when an API row has no resolvable identity', () => {
+ const competition = linkedCompetition();
+ const results = linkedSourceResults(competition, [
+ aliceSecondComponent(),
+ apiResult({ id: 9011, name: 'Unmatched Competitor', wca_id: null }),
+ ]);
+ expect(results.every(({ advancing }) => !advancing)).toBe(true);
+ });
+
+ it('allows authoritative live advancement even when unresolved linked identity suppresses fallback', () => {
+ const competition = linkedCompetition();
+ const live = liveRound([
+ liveRoundResult({
+ id: 'live-alice',
+ person: { id: '1', registrantId: 1, name: 'Alice Solver' },
+ advancing: true,
+ advancingQuestionable: false,
+ }),
+ ]);
+ expect(
+ linkedSourceResults(
+ competition,
+ [apiResult({ id: 9011, name: 'Unmatched Competitor', wca_id: null })],
+ 0,
+ live,
+ ).find(({ personId }) => personId === 1),
+ ).toMatchObject({ advancing: true, advancingQuestionable: false });
+ });
+
+ it('keeps live advancement and questionable flags authoritative over computed qualification', () => {
+ const competition = linkedCompetition();
+ const live = liveRound([
+ liveRoundResult({
+ id: 'live-alice',
+ person: { id: '1', registrantId: 1, name: 'Alice Solver' },
+ advancing: false,
+ advancingQuestionable: true,
+ }),
+ ]);
+ expect(
+ linkedSourceResults(competition, [aliceSecondComponent()], 0, live).find(
+ ({ personId }) => personId === 1,
+ ),
+ ).toMatchObject({ advancing: false, advancingQuestionable: true });
+ });
+});
diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts
index a1baddf..c1bbba6 100644
--- a/src/containers/CompetitionResults/resultsProvider.ts
+++ b/src/containers/CompetitionResults/resultsProvider.ts
@@ -7,12 +7,12 @@ import { getAdvancementConditionForRound } from '@/lib/wcif';
import { PersonalRoundResult } from '../CompetitionPersonalResults/CompetitionPersonalResultsTable';
import { CompetitionRoundResult } from './CompetitionResultsTable';
import { normalizeResultRecordTag } from './ResultRecordBadge';
-import { getStoredRoundResults } from './advancement';
+import { getQualifyingResultKeys, getStoredRoundResults } from './advancement';
import {
findPersonForApiResult,
findUniquePersonByName,
- getApiRoundResults,
getWcaApiResultsByRoundId,
+ toCompetitionRoundResult,
} from './resultSources';
import { getRoundRosterResults } from './roundRoster';
@@ -26,7 +26,11 @@ export interface EventResults {
}
const getResultKey = (result: CompetitionRoundResult) =>
- result.personId == null ? `result-${result.id}` : `person-${result.personId}`;
+ result.personId != null
+ ? `person-${result.personId}`
+ : result.wcaId
+ ? `wca-${result.wcaId}`
+ : `result-${result.id}`;
const mergeRoundResultSources = (
...sources: CompetitionRoundResult[][]
@@ -99,6 +103,7 @@ const getLiveRoundResults = (wcif: Competition, wcaLiveRound: WcaLiveRound | nul
id: result.id,
personId: matchedPerson?.registrantId ?? result.person.registrantId,
personName: result.person.name,
+ wcaId: result.person.wcaId,
ranking: result.ranking,
advancing: result.advancing,
advancingQuestionable: result.advancingQuestionable,
@@ -125,22 +130,56 @@ export const getRoundResultsFromSources = ({
return [];
}
- const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound);
- const storedRoundResults = getStoredRoundResults(
- selectedRound.round,
- getAdvancementConditionForRound(selectedRound.event.rounds, selectedRound.round),
+ const advancementCondition = getAdvancementConditionForRound(
selectedRound.event.rounds,
+ selectedRound.round,
);
- const apiRoundResults = getApiRoundResults(wcif, selectedRound.round.id, wcaApiResults);
+ const sourceRoundIds =
+ advancementCondition?.sourceType === 'linkedRounds'
+ ? advancementCondition.sourceRoundIds
+ : [selectedRound.round.id];
+ const apiResultsByRound = getWcaApiResultsByRoundId(wcif, wcaApiResults ?? []);
+ // Apply the existing API < WCIF priority in each component before evaluating
+ // linked qualification. The selected table still shows only that component.
+ const fallbackResultsByRound = new Map(
+ selectedRound.event.rounds
+ .filter(({ id }) => id === selectedRound.round.id || sourceRoundIds.includes(id))
+ .map((round) => [
+ round.id,
+ mergeRoundResultSources(
+ (apiResultsByRound.get(round.id) ?? []).map((result) =>
+ toCompetitionRoundResult(wcif.persons, result),
+ ),
+ getStoredRoundResults(round, null),
+ ),
+ ]),
+ );
+ const sourceResults = sourceRoundIds.flatMap((id) => fallbackResultsByRound.get(id) ?? []);
+ // Without either identifier an API row cannot be deduplicated across linked
+ // components. Do not invent a person or overstate qualification in that case.
+ const hasUnresolvedLinkedIdentity =
+ advancementCondition?.sourceType === 'linkedRounds' &&
+ sourceResults.some((result) => result.personId == null && !result.wcaId);
+ const qualifyingKeys = getQualifyingResultKeys(
+ selectedRound.round,
+ hasUnresolvedLinkedIdentity ? null : advancementCondition,
+ sourceResults.map((result) => ({ ...result, key: getResultKey(result) })),
+ );
+ const fallbackResults = (fallbackResultsByRound.get(selectedRound.round.id) ?? []).map(
+ (result) => ({
+ ...result,
+ // Keep empty component/roster rows visually empty, even if the competitor
+ // qualifies through the other component. Live flags remain authoritative.
+ advancing: result.ranking != null && qualifyingKeys.has(getResultKey(result)),
+ }),
+ );
+ const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound);
- if (liveRoundResults.length === 0) {
- return mergeRoundResultSources(apiRoundResults, storedRoundResults);
- }
+ if (liveRoundResults.length === 0) return fallbackResults;
return mergeRoundResultSources(
getRoundRosterResults(wcif, selectedRound.round.id),
- apiRoundResults,
- storedRoundResults,
+ fallbackResults,
liveRoundResults,
);
};
diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx
index dc0cd6e..0b7f791 100644
--- a/src/containers/CompetitionRound/CompetitionRound.test.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx
@@ -195,7 +195,9 @@ jest.mock('@/providers/WCIFProvider', () => ({
setTitle: () => {},
}),
useWcifUtils: () => ({
- roundActivies: linkedRoundsCompetition.schedule.venues[0].rooms[0].activities,
+ roundActivies: linkedRoundsCompetition.schedule.venues.flatMap((venue) =>
+ venue.rooms.flatMap((room) => room.activities),
+ ),
}),
}));
@@ -210,6 +212,61 @@ function renderRound(roundId: string) {
}
describe('CompetitionRoundContainer', () => {
+ it.each([
+ ['Europe/Bucharest', '2026-10-31T07:00:00Z', '2026-10-31T07:22:30Z', 'Sat 09:00 AM - 09:25 AM'],
+ ['Asia/Calcutta', '2026-10-31T03:15:00Z', '2026-10-31T03:55:00Z', 'Sat 08:45 AM - 09:25 AM'],
+ ])('shows group times in the %s venue timezone', (timezone, startTime, endTime, expected) => {
+ const venue = linkedRoundsCompetition.schedule.venues[0];
+ const group = venue.rooms[0].activities[0].childActivities[0];
+ const original = {
+ timezone: venue.timezone,
+ startTime: group.startTime,
+ endTime: group.endTime,
+ };
+ const languages = jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']);
+ try {
+ venue.timezone = timezone;
+ group.startTime = startTime;
+ group.endTime = endTime;
+ renderRound('333-r2');
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ } finally {
+ venue.timezone = original.timezone;
+ group.startTime = original.startTime;
+ group.endTime = original.endTime;
+ languages.mockRestore();
+ }
+ });
+
+ it('keeps separate local ranges when the same group runs in different venue timezones', () => {
+ const originalVenue = linkedRoundsCompetition.schedule.venues[0];
+ const secondVenue = {
+ ...originalVenue,
+ id: 2,
+ timezone: 'Asia/Calcutta',
+ rooms: originalVenue.rooms.map((room) => ({
+ ...room,
+ id: 2,
+ activities: room.activities.map((activity) => ({
+ ...activity,
+ id: 20,
+ childActivities: activity.childActivities.map((child) => ({ ...child, id: 21 })),
+ })),
+ })),
+ };
+ const languages = jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']);
+ linkedRoundsCompetition.schedule.venues.push(secondVenue);
+ try {
+ renderRound('333-r2');
+ expect(screen.getByText('Sun 09:00 AM - 09:30 AM (America/Los_Angeles)')).toBeInTheDocument();
+ expect(screen.getByText('Sun 09:30 PM - 10:00 PM (Asia/Calcutta)')).toBeInTheDocument();
+ expect(screen.getAllByText('Group 1')).toHaveLength(1);
+ } finally {
+ linkedRoundsCompetition.schedule.venues.pop();
+ languages.mockRestore();
+ }
+ });
+
it('shows linked-round context for rounds in a dual-round advancement set', () => {
renderRound('333-r2');
diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx
index 812bc53..e0837e2 100644
--- a/src/containers/CompetitionRound/CompetitionRound.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.tsx
@@ -4,6 +4,7 @@ import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs';
import { Container } from '@/components/Container';
import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel';
import { LinkButton } from '@/components/LinkButton';
+import { getVenueForActivity } from '@/lib/activities';
import {
activityCodeToName,
parseActivityCodeFlexible,
@@ -53,6 +54,8 @@ export function CompetitionRoundContainer({
[eventRounds, round, roundId, t],
);
+ const findVenue = useMemo(() => (wcif ? getVenueForActivity(wcif) : undefined), [wcif]);
+
const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId);
const groups = rounds.flatMap((r) => r.childActivities);
const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))];
@@ -82,8 +85,16 @@ export function CompetitionRoundContainer({
{uniqueGroupCodes.map((value) => {
const { groupNumber } = parseActivityCodeFlexible(value);
const activities = groups.filter((g) => g.activityCode === value);
- const minStartTime = activities.map((a) => a.startTime).sort()[0];
- const maxEndTime = activities.map((a) => a.endTime).sort()[activities.length - 1];
+ const timeZones = [...new Set(activities.map((a) => findVenue?.(a)?.timezone))];
+ const timeRanges = timeZones.map((timeZone) => {
+ const venueActivities = activities.filter((a) => findVenue?.(a)?.timezone === timeZone);
+ const minStartTime = venueActivities.map((a) => a.startTime).sort()[0];
+ const maxEndTime = venueActivities
+ .map((a) => a.endTime)
+ .sort()
+ .at(-1)!;
+ return { timeZone, label: formatDateTimeRange(minStartTime, maxEndTime, 5, timeZone) };
+ });
return (
{t('common.activityCodeToName.group', { groupNumber })}
- {formatDateTimeRange(minStartTime, maxEndTime)}
+ {timeRanges.map(({ timeZone, label }) => (
+
+ {label}
+ {timeRanges.length > 1 && timeZone && ` (${timeZone})`}
+
+ ))}
);
diff --git a/src/lib/time.test.ts b/src/lib/time.test.ts
new file mode 100644
index 0000000..7795486
--- /dev/null
+++ b/src/lib/time.test.ts
@@ -0,0 +1,47 @@
+import { formatDateTimeRange, formmatDate } from './time';
+
+describe('venue-local date and time ranges', () => {
+ beforeEach(() => {
+ jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']);
+ jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('shows the Campulung venue time and preserves five-minute rounding', () => {
+ expect(
+ formatDateTimeRange('2026-10-31T07:00:00Z', '2026-10-31T07:22:30Z', 5, 'Europe/Bucharest'),
+ ).toBe('Sat 09:00 AM - 09:25 AM');
+ });
+
+ it('shows Saturday for Concours even when the viewer is still on Friday', () => {
+ expect(
+ formatDateTimeRange('2026-10-31T03:15:00Z', '2026-10-31T03:55:00Z', 5, 'Asia/Calcutta'),
+ ).toBe('Sat 08:45 AM - 09:25 AM');
+ expect(formmatDate('2026-10-31T03:15:00Z', 5, 'Asia/Calcutta')).toBe('Sat');
+ });
+
+ it('repeats weekdays when a range crosses venue midnight but not viewer midnight', () => {
+ expect(
+ formatDateTimeRange('2026-10-31T18:00:00Z', '2026-10-31T19:00:00Z', 5, 'Asia/Calcutta'),
+ ).toBe('Sat 11:30 PM - Sun 12:30 AM');
+ });
+
+ it('uses one weekday when a range crosses UTC midnight but not venue midnight', () => {
+ expect(
+ formatDateTimeRange('2026-10-30T23:30:00Z', '2026-10-31T00:30:00Z', 5, 'Asia/Calcutta'),
+ ).toBe('Sat 05:00 AM - 06:00 AM');
+ });
+
+ it('compares dates after rounding across midnight', () => {
+ expect(
+ formatDateTimeRange('2026-10-31T18:29:00Z', '2026-10-31T18:40:00Z', 5, 'Asia/Calcutta'),
+ ).toBe('Sun 12:00 AM - 12:10 AM');
+ });
+
+ it('uses the requested rounding interval for the weekday as well as the time', () => {
+ expect(
+ formatDateTimeRange('2026-10-31T18:29:00Z', '2026-10-31T18:30:00Z', 1, 'Asia/Calcutta'),
+ ).toBe('Sat 11:59 PM - Sun 12:00 AM');
+ });
+});
diff --git a/src/lib/time.ts b/src/lib/time.ts
index cd966c3..f7e06af 100644
--- a/src/lib/time.ts
+++ b/src/lib/time.ts
@@ -21,9 +21,10 @@ export const formatTime = (isoString: string, minutes: number = 5, timeZone?: st
}),
});
-export const formmatDate = (isoString: string, minutes: number = 5) =>
+export const formmatDate = (isoString: string, minutes: number = 5, timeZone?: string) =>
roundTime(new Date(isoString), minutes).toLocaleDateString([...navigator.languages], {
weekday: 'short',
+ timeZone,
});
export const formatDateTime = (isoString: string, minutes: number = 5, timeZone?: string) =>
@@ -48,11 +49,12 @@ export const formatDateTimeRange = (
minutes: number = 5,
timeZone?: string,
) => {
- const startDate = new Date(start);
- const endDate = new Date(end);
+ const startDate = roundTime(new Date(start), minutes);
+ const endDate = roundTime(new Date(end), minutes);
+ const dateFormatter = getNumericDateFormatter(timeZone);
- if (startDate.toLocaleDateString() === endDate.toLocaleDateString()) {
- return `${formmatDate(start)} ${formatTimeRange(start, end, minutes, timeZone)}`;
+ if (dateFormatter.format(startDate) === dateFormatter.format(endDate)) {
+ return `${formmatDate(start, minutes, timeZone)} ${formatTimeRange(start, end, minutes, timeZone)}`;
}
return `${formatDateTime(start, minutes, timeZone)} - ${formatDateTime(end, minutes, timeZone)}`;
From 6a52b5e86ae22b90e8afcfa981ad14707e195563 Mon Sep 17 00:00:00 2001
From: Cailyn Sinclair
Date: Tue, 3 Nov 2026 08:11:22 -0800
Subject: [PATCH 2/4] Fix UAT keyboard, schedule state and fallback advancement
defects
Resolve keyboard selection, recovery states, day controls and persisted collapse state. Align stored advancement with actual participant rosters and conservative provisional flags, and format event-aware threshold captions.
Validated source tree: 5e880755b7f1572f6bad99ac8f4236678cdc9554 (342 tests; type, lint, production and Storybook builds).
---
jest.config.ts | 3 +-
.../CompetitionListItem.tsx | 70 +++--
.../CompetitionSelect.test.tsx | 133 +++++++++
.../CompetitionSelect/CompetitionSelect.tsx | 30 +-
.../CutoffTimeLimitPanel.advancement.test.tsx | 113 ++++++++
.../CutoffTimeLimitPanel.stories.tsx | 70 +++++
.../CutoffTimeLimitPanel.tsx | 52 ++--
.../CompetitionPerson.test.tsx | 80 ++++++
.../CompetitionPerson/CompetitionPerson.tsx | 25 +-
.../CompetitionResults.test.tsx | 11 +-
.../FallbackAdvancement.stories.tsx | 120 ++++++++
.../CompetitionResults/advancement.test.ts | 198 ++++++++++++-
.../CompetitionResults/advancement.ts | 124 +++++++-
.../CompetitionResults/fixtures/README.md | 17 ++
.../resultsProvider.test.ts | 265 ++++++++++++++++++
.../CompetitionResults/resultsProvider.ts | 92 ++++--
.../CompetitionRound.test.tsx | 16 ++
.../CompetitionRound/CompetitionRound.tsx | 27 +-
.../Competitors/Competitors.test.tsx | 22 +-
src/containers/Competitors/Competitors.tsx | 12 +-
.../PersonalSchedule/Assignments.test.tsx | 49 ++++
.../PersonalSchedule/Assignments.tsx | 16 +-
.../PersonalSchedule/PersonalSchedule.tsx | 1 +
src/containers/Schedule/Schedule.test.tsx | 92 ++++++
src/containers/Schedule/Schedule.tsx | 66 +++--
.../UpcomingCompetitions.test.tsx | 38 +++
.../UpcomingCompetitions.tsx | 2 +-
src/hooks/UseCollapse/UseCollapse.test.tsx | 26 ++
src/hooks/UseCollapse/UseCollapse.ts | 33 +--
src/i18n/en/translation.yaml | 18 +-
.../CompetitionLayout.test.tsx | 26 ++
.../CompetitionLayout/CompetitionLayout.tsx | 5 +
src/layouts/RootLayout/Header.test.tsx | 42 +++
src/layouts/RootLayout/Header.tsx | 20 +-
src/pages/Home/Home.test.tsx | 69 +++++
src/pages/Home/index.tsx | 5 +-
36 files changed, 1793 insertions(+), 195 deletions(-)
create mode 100644 src/components/CompetitionSelect/CompetitionSelect.test.tsx
create mode 100644 src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.advancement.test.tsx
create mode 100644 src/containers/CompetitionPerson/CompetitionPerson.test.tsx
create mode 100644 src/containers/CompetitionResults/FallbackAdvancement.stories.tsx
create mode 100644 src/containers/PersonalSchedule/Assignments.test.tsx
create mode 100644 src/containers/Schedule/Schedule.test.tsx
create mode 100644 src/containers/UpcomingCompetitions/UpcomingCompetitions.test.tsx
create mode 100644 src/hooks/UseCollapse/UseCollapse.test.tsx
create mode 100644 src/layouts/RootLayout/Header.test.tsx
create mode 100644 src/pages/Home/Home.test.tsx
diff --git a/jest.config.ts b/jest.config.ts
index 339497d..b4a0f47 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -7,8 +7,9 @@ const config: Config = {
moduleNameMapper: {
'^@/(.*)$': '/src/$1',
},
+ transformIgnorePatterns: ['/node_modules/(?!@uidotdev/usehooks/)'],
transform: {
- '^.+\\.(ts|tsx)?$': 'ts-jest',
+ '^.+\\.[jt]sx?$': 'ts-jest',
},
};
diff --git a/src/components/CompetitionListItem/CompetitionListItem.tsx b/src/components/CompetitionListItem/CompetitionListItem.tsx
index 9edbc19..dd0c796 100644
--- a/src/components/CompetitionListItem/CompetitionListItem.tsx
+++ b/src/components/CompetitionListItem/CompetitionListItem.tsx
@@ -31,42 +31,40 @@ export const CompetitionListItem = ({
new Date(end_date).getTime() + 1000 * 60 * new Date().getTimezoneOffset(),
);
- return (
-
-
- {hasFlag(country_iso2) && (
-
- {' '}
- {getUnicodeFlagIcon(country_iso2)}{' '}
-
- )}
-
-
{name}
{' '}
-
- {formatDateRange(start_date, end_date)}
- {' '}–{' '}
- {city}
-
+ const Item = variant === 'dropdown' ? 'div' : 'li';
+ const content = (
+
-
+ {hasFlag(country_iso2) && (
+
{getUnicodeFlagIcon(country_iso2)}
+ )}
+
+
{name}
{' '}
+
+ {formatDateRange(start_date, end_date)}
+ {' '}–{' '}
+ {city}
+
+
+ {isLive && (
+
+
- {isLive && (
-
-
-
- )}
- {isBookmarked && !isLive && (
-
-
-
- )}
-
-
+ )}
+ {isBookmarked && !isLive && (
+
+
+
+ )}
+
);
+
+ return variant === 'dropdown' ? content :
{content};
};
diff --git a/src/components/CompetitionSelect/CompetitionSelect.test.tsx b/src/components/CompetitionSelect/CompetitionSelect.test.tsx
new file mode 100644
index 0000000..3c747a7
--- /dev/null
+++ b/src/components/CompetitionSelect/CompetitionSelect.test.tsx
@@ -0,0 +1,133 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { fetchSearchCompetition } from '@/lib/api';
+import { CompetitionSelect } from './CompetitionSelect';
+
+jest.mock('@/lib/api', () => ({ fetchSearchCompetition: jest.fn() }));
+jest.mock('@/providers/UserSettingsProvider', () => ({
+ useUserSettings: () => ({ effectiveTheme: 'light' }),
+}));
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+const competition = {
+ id: 'Campulung2026',
+ name: 'Campulung Moldovenesc Open 2026',
+ start_date: '2026-10-31',
+ end_date: '2026-10-31',
+ country_iso2: 'RO',
+ city: 'Campulung Moldovenesc',
+} as ApiCompetition;
+const mockSearch = jest.mocked(fetchSearchCompetition);
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockSearch.mockResolvedValue({ result: [competition] });
+});
+
+it('exposes a named combobox and real options with usable active-descendant IDs', async () => {
+ render(
, { wrapper: MemoryRouter });
+ const input = screen.getByRole('combobox', { name: 'common.competitionSelect.placeholder' });
+ await act(async () => {
+ await userEvent.type(input, 'Campulung');
+ });
+ const option = await screen.findByRole('option', { name: /Campulung Moldovenesc Open 2026/ });
+ await act(async () => {
+ await userEvent.keyboard('{ArrowDown}');
+ });
+ expect(input).toHaveAttribute('aria-activedescendant', option.id);
+ expect(option.id).not.toBe('');
+ expect(option).toHaveAttribute('aria-selected', 'false');
+ expect(option.querySelector('a')).toBeNull();
+});
+
+it.each(['keyboard', 'pointer'])('selects the same competition once by %s', async (method) => {
+ const onSelect = jest.fn();
+ render(
, { wrapper: MemoryRouter });
+ await act(async () => {
+ await userEvent.type(screen.getByRole('combobox'), 'Campulung');
+ });
+ await screen.findByText(competition.name);
+ if (method === 'keyboard')
+ await act(async () => {
+ await userEvent.keyboard('{ArrowDown}{Enter}');
+ });
+ else
+ await act(async () => {
+ await userEvent.click(screen.getByText(competition.name));
+ });
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith(competition.id);
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toHaveFocus();
+});
+
+it('dismisses with Escape and can search and select again without accidental navigation', async () => {
+ const onSelect = jest.fn();
+ render(
, { wrapper: MemoryRouter });
+ const input = screen.getByRole('combobox');
+ await act(async () => {
+ await userEvent.type(input, 'Campulung');
+ });
+ await screen.findByText(competition.name);
+ await act(async () => {
+ await userEvent.keyboard('{Escape}');
+ });
+ await act(async () => {
+ await userEvent.keyboard('{Enter}');
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(input).toHaveFocus();
+ await act(async () => {
+ await userEvent.clear(input);
+ });
+ await act(async () => {
+ await userEvent.type(input, 'Campulung');
+ });
+ await screen.findByRole('option');
+ await act(async () => {
+ await userEvent.keyboard('{ArrowDown}{Enter}');
+ });
+ expect(onSelect).toHaveBeenCalledWith(competition.id);
+});
+
+it('ignores a stale response after a newer query or after the search is cleared', async () => {
+ let resolveOld!: (value: { result: ApiCompetition[] }) => void;
+ mockSearch.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveOld = resolve;
+ }),
+ );
+ const onSelect = jest.fn();
+ render(
, { wrapper: MemoryRouter });
+ const input = screen.getByRole('combobox');
+ fireEvent.change(input, { target: { value: 'Old' } });
+ await waitFor(() => expect(mockSearch).toHaveBeenCalledWith('Old'));
+ fireEvent.change(input, { target: { value: 'Campulung' } });
+ await screen.findByText(competition.name);
+ await act(async () =>
+ resolveOld({ result: [{ ...competition, id: 'Old2026', name: 'Old competition' }] }),
+ );
+ expect(screen.queryByText('Old competition')).not.toBeInTheDocument();
+ await act(async () => {
+ await userEvent.clear(input);
+ });
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
+ expect(onSelect).not.toHaveBeenCalled();
+});
+
+it('cancels an unstarted search when navigation unmounts the control', async () => {
+ jest.useFakeTimers();
+ try {
+ const { unmount } = render(
, {
+ wrapper: MemoryRouter,
+ });
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Campulung' } });
+ unmount();
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(mockSearch).not.toHaveBeenCalled();
+ } finally {
+ jest.useRealTimers();
+ }
+});
diff --git a/src/components/CompetitionSelect/CompetitionSelect.tsx b/src/components/CompetitionSelect/CompetitionSelect.tsx
index ff58920..6d6e934 100644
--- a/src/components/CompetitionSelect/CompetitionSelect.tsx
+++ b/src/components/CompetitionSelect/CompetitionSelect.tsx
@@ -1,8 +1,12 @@
-import { useCallback, useRef } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import type { ClassNamesConfig, StylesConfig } from 'react-select';
+import {
+ components,
+ type ClassNamesConfig,
+ type OptionProps,
+ type StylesConfig,
+} from 'react-select';
import AsyncSelect from 'react-select/async';
-import { OptionProps } from 'react-select/dist/declarations/src';
import { fetchSearchCompetition } from '@/lib/api';
import { useUserSettings } from '@/providers/UserSettingsProvider';
import { CompetitionListItem } from '../CompetitionListItem';
@@ -139,6 +143,11 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp
className={className}
classNamePrefix="cg-select"
cacheOptions
+ aria-label={t('common.competitionSelect.placeholder')}
+ getOptionLabel={(option) => option.name}
+ getOptionValue={(option) => option.id}
+ blurInputOnSelect={false}
+ loadingMessage={() => t('common.loading')}
loadOptions={loadOptions}
placeholder={t('common.competitionSelect.placeholder')}
noOptionsMessage={({ inputValue }) =>
@@ -154,9 +163,11 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp
);
};
-export const CompetitionOption = ({ data }: OptionProps
) => {
- return ;
-};
+export const CompetitionOption = (props: OptionProps) => (
+
+
+
+);
function useDebounced(
fn: (arg: Input) => Promise } />
+ } />
+
+
+ ,
+ );
+}
+it.each(['', '/results', '/records'])(
+ 'explains a missing person and offers recovery on %s',
+ (suffix) => {
+ renderPerson(`/competitions/Test/persons/999${suffix}`);
+ expect(screen.getByRole('status')).toHaveTextContent('competition.person.notFound');
+ fireEvent.click(screen.getByRole('link', { name: 'competition.person.backToCompetitors' }));
+ expect(screen.getByText('Competition people')).toBeInTheDocument();
+ },
+);
+it('updates the title and content when moving between valid and missing people and Back', () => {
+ renderPerson('/competitions/Test/persons/1');
+ expect(screen.getByText('Person schedule')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Missing person' }));
+ expect(screen.queryByText('Person schedule')).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ expect(setTitle).toHaveBeenLastCalledWith('competition.person.notFound');
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+ expect(screen.getByText('Person schedule')).toBeInTheDocument();
+ expect(setTitle).toHaveBeenLastCalledWith('Valid person');
+});
diff --git a/src/containers/CompetitionPerson/CompetitionPerson.tsx b/src/containers/CompetitionPerson/CompetitionPerson.tsx
index f68aff2..c0249f0 100644
--- a/src/containers/CompetitionPerson/CompetitionPerson.tsx
+++ b/src/containers/CompetitionPerson/CompetitionPerson.tsx
@@ -1,7 +1,10 @@
import { Person } from '@wca/helpers';
import { Extension } from '@wca/helpers/lib/models/extension';
import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
+import { Container } from '@/components/Container';
+import { LinkButton } from '@/components/LinkButton';
import { CompetitionPersonalResultsContent } from '@/containers/CompetitionPersonalResults';
import { PersonalBestsContainer } from '@/containers/PersonalBests';
import { PersonalScheduleContent } from '@/containers/PersonalSchedule';
@@ -13,6 +16,7 @@ export interface CompetitionPersonContainerProps {
}
export function CompetitionPersonContainer({ registrantId }: CompetitionPersonContainerProps) {
+ const { t } = useTranslation();
const { competitionId, wcif, setTitle } = useWCIF();
const { pathname } = useLocation();
@@ -23,13 +27,24 @@ export function CompetitionPersonContainer({ registrantId }: CompetitionPersonCo
| undefined;
useEffect(() => {
- if (person) {
- setTitle(person.name);
+ if (wcif) {
+ setTitle(person?.name ?? t('competition.person.notFound'));
}
- }, [person, setTitle]);
+ }, [person, setTitle, t, wcif]);
- if (!wcif || !person) {
- return null;
+ if (!wcif) return null;
+
+ if (!person) {
+ return (
+
+ {t('competition.person.notFound')}
+
+
+ );
}
const activePage: PersonalPage = pathname.endsWith('/results')
diff --git a/src/containers/CompetitionResults/CompetitionResults.test.tsx b/src/containers/CompetitionResults/CompetitionResults.test.tsx
index 6f9e5f7..831821e 100644
--- a/src/containers/CompetitionResults/CompetitionResults.test.tsx
+++ b/src/containers/CompetitionResults/CompetitionResults.test.tsx
@@ -44,7 +44,7 @@ jest.mock('react-i18next', () => ({
if (key === 'competition.results.best') return 'Best';
if (key === 'competition.results.attempts') return 'Attempts';
if (key === 'competition.results.resultsSourceNote') {
- return 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.';
+ return 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.';
}
if (key === 'competition.results.allResults') return 'All results';
if (key === 'competition.results.unknownCompetitor') {
@@ -313,7 +313,7 @@ describe('CompetitionResultsContainer', () => {
expect(screen.queryByRole('table', { name: 'Results' })).not.toBeInTheDocument();
expect(
screen.getByText(
- 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
+ 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.',
),
).toBeInTheDocument();
});
@@ -358,7 +358,7 @@ describe('CompetitionResultsContainer', () => {
expect(screen.queryByLabelText('Attempts')).not.toBeInTheDocument();
expect(
screen.getByText(
- 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
+ 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.',
),
).toBeInTheDocument();
expect(
@@ -367,9 +367,10 @@ describe('CompetitionResultsContainer', () => {
.slice(1)
.map((row) => within(row).getAllByRole('cell')[0].textContent),
).toEqual(['1', '2']);
+ // The stored average-of-five fixture has only three attempts per result.
expect(
within(screen.getByRole('row', { name: /1 Blake Thompson/ })).getByText('1'),
- ).toHaveClass('bg-green-300');
+ ).toHaveClass('bg-yellow-200');
expect(
within(screen.getByRole('row', { name: /2 Nick Silvestri/ })).getByText('2'),
).not.toHaveClass('bg-green-300');
@@ -555,7 +556,7 @@ describe('CompetitionResultsContainer', () => {
).toHaveLength(2);
expect(
screen.getByText(
- 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
+ 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.',
),
).toBeInTheDocument();
});
diff --git a/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx b/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx
new file mode 100644
index 0000000..ecd8087
--- /dev/null
+++ b/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx
@@ -0,0 +1,120 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Competition, Round } from '@wca/helpers';
+import { makeStorybookCompetitionFixtureWithRoundUpdates } from '@/storybook/competitionFixtures';
+import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook';
+import { CompetitionResultsTable } from './CompetitionResultsTable';
+import { getRoundResultsFromSources } from './resultsProvider';
+
+const makeFixture = (partial: boolean, actualParticipants: boolean): Competition => {
+ const competition = makeStorybookCompetitionFixtureWithRoundUpdates({});
+ competition.id = 'SyntheticAdvancementFixture';
+ competition.persons = Array.from({ length: 10 }, (_, index) => ({
+ ...competition.persons[0],
+ registrantId: index + 1,
+ wcaId: null,
+ name: `Synthetic Solver ${index + 1}`,
+ assignments: [],
+ }));
+ const source: Round = {
+ ...competition.events[0].rounds[0],
+ cutoff: null,
+ format: 'a',
+ linkedRounds: null,
+ results: Array.from({ length: 10 }, (_, index) => {
+ const entered = !partial || index < 6;
+ return {
+ personId: index + 1,
+ ranking: entered ? index + 1 : null,
+ best: entered ? 1000 + index * 100 : 0,
+ average: partial ? 0 : 1000 + index * 100,
+ attempts: !entered
+ ? []
+ : Array.from({ length: partial ? 1 : 5 }, () => ({
+ value: 1000 + index * 100,
+ reconstruction: null,
+ })),
+ };
+ }),
+ };
+ const target: Round = {
+ ...competition.events[0].rounds[1],
+ linkedRounds: null,
+ participationRuleset: {
+ reservedPlaces: null,
+ participationSource: {
+ type: 'round',
+ roundId: source.id,
+ resultCondition: { type: 'percent', scope: 'average', value: 75 },
+ },
+ },
+ results: actualParticipants
+ ? [1, 2, 3, 5, 6, 8].map((personId) => ({
+ personId,
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+ }))
+ : [],
+ };
+ competition.events = [{ ...competition.events[0], rounds: [source, target] }];
+ return competition;
+};
+
+const meta = {
+ title: 'Components/Competition/FallbackAdvancement',
+ component: CompetitionResultsTable,
+ decorators: [makeCompetitionContainerDecorator()],
+ tags: ['autodocs'],
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+const fixtureStory = (
+ partial: boolean,
+ actualParticipants: boolean,
+ description: string,
+): Story => {
+ const competition = makeFixture(partial, actualParticipants);
+ const event = competition.events[0];
+ const round = event.rounds[0];
+ return {
+ parameters: { competitionFixture: competition },
+ args: {
+ competitionId: competition.id,
+ eventId: event.id,
+ round,
+ persons: competition.persons,
+ results: getRoundResultsFromSources({
+ wcif: competition,
+ selectedRound: { event, round, roundNumber: 1 },
+ wcaApiResults: [],
+ wcaLiveRound: null,
+ }),
+ },
+ decorators: [
+ (Story) => (
+
+ ),
+ ],
+ };
+};
+
+export const SyntheticPartialRoster = fixtureStory(
+ true,
+ false,
+ 'Synthetic partial snapshot: ten stored participants, six with one of five attempts. The six currently qualifying ranks are yellow, not settled green.',
+);
+export const SyntheticCompletedRoster = fixtureStory(
+ false,
+ false,
+ 'Synthetic completed snapshot: ten participants with five attempts each. Seven calculated qualifiers are green.',
+);
+export const SyntheticActualParticipants = fixtureStory(
+ false,
+ true,
+ 'Synthetic next-round roster: participants 1, 2, 3, 5, 6, and 8 are green. Actual participation overrides calculated eligibility.',
+);
diff --git a/src/containers/CompetitionResults/advancement.test.ts b/src/containers/CompetitionResults/advancement.test.ts
index 833ca3b..93d5526 100644
--- a/src/containers/CompetitionResults/advancement.test.ts
+++ b/src/containers/CompetitionResults/advancement.test.ts
@@ -1,6 +1,6 @@
import { ResultCondition, Round } from '@wca/helpers';
import { getAdvancementConditionForRound, getLinkedRoundIds } from '@/lib/wcif';
-import { getStoredRoundResults } from './advancement';
+import { getQualifyingResultKeys, getStoredRoundResults, hasExpectedAttempts } from './advancement';
import * as hongKongRounds from './fixtures/CubeAgainstCancerHongKong2026.json';
import * as fmcRounds from './fixtures/FMCEastMelbourne2026.json';
@@ -108,8 +108,13 @@ describe('dual-round stored results', () => {
average,
})),
);
- // Later participation may differ after withdrawals and replacements. Keep
- // this assertion on the stated qualifying condition, not the final roster.
+ // The actual target participants override the earlier eligibility estimate.
+ const targetIds = new Set(rounds[2].results.map(({ personId }) => personId));
+ expect(results.filter(({ advancing }) => advancing).map(({ personId }) => personId)).toEqual(
+ round.results
+ .filter(({ personId, ranking }) => ranking != null && targetIds.has(personId))
+ .map(({ personId }) => personId),
+ );
const condition = getAdvancementConditionForRound(rounds, round)!;
const entrants = new Set(
rounds.slice(0, 2).flatMap(({ results }) => results.map(({ personId }) => personId)),
@@ -118,9 +123,19 @@ describe('dual-round stored results', () => {
condition.resultCondition.type === 'percent'
? Math.floor((entrants * condition.resultCondition.value) / 100)
: condition.resultCondition.value!;
+ const qualifying = getQualifyingResultKeys(
+ round,
+ condition,
+ rounds
+ .slice(0, 2)
+ .flatMap(({ results }) =>
+ results.map((result) => ({ ...result, key: String(result.personId) })),
+ ),
+ );
expect(
- results.every(
- ({ ranking, advancing }) => advancing === (ranking != null && ranking <= limit),
+ round.results.every(
+ ({ personId, ranking }) =>
+ qualifying.has(String(personId)) === (ranking != null && ranking <= limit),
),
).toBe(true);
}
@@ -196,13 +211,18 @@ describe('stored qualification rules', () => {
).toEqual([1, 2, 3, 4, 5, 6, 7]);
});
- it('does not count empty, unranked roster placeholders as scored entrants', () => {
+ it('counts empty participant rows in the roster but never highlights them', () => {
const results: Round['results'] = [
- ...rankedResults(11),
- { ...result(12, 12, 0), ranking: null, attempts: [] },
- { ...result(13, 13, 0), ranking: null, attempts: [] },
+ ...rankedResults(6),
+ ...Array.from({ length: 4 }, (_, index) => ({
+ ...result(index + 7, 0, 0),
+ ranking: null,
+ attempts: [],
+ })),
];
- expect(qualifiers(results, { type: 'percent', scope: 'average', value: 75 })).toHaveLength(8);
+ const advancing = qualifiers(results, { type: 'percent', scope: 'average', value: 75 });
+ expect(advancing.map(({ personId }) => personId)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(advancing.every(({ advancingQuestionable }) => advancingQuestionable)).toBe(true);
});
it('treats a null threshold as any successful value in the requested scope', () => {
@@ -268,3 +288,161 @@ describe('stored qualification rules', () => {
expect(qualifiers([], { type: 'ranking', scope: 'average', value: 10 })).toEqual([]);
});
});
+
+const fullResult = (personId: number, ranking: number, average = 1000) => ({
+ ...result(personId, ranking, average),
+ attempts: Array.from({ length: 5 }, () => ({ value: average, reconstruction: null })),
+});
+const withTarget = (rounds: Round[]) => {
+ rounds[1].participationRuleset = {
+ reservedPlaces: null,
+ participationSource: {
+ type: 'round',
+ roundId: rounds[0].id,
+ resultCondition: { type: 'percent', scope: 'average', value: 75 },
+ },
+ };
+ return rounds;
+};
+
+describe('fallback qualification certainty and participation', () => {
+ it('settles calculated eligibility when all source attempts are complete', () => {
+ const rounds = withTarget([
+ makeRound(
+ '333-r1',
+ Array.from({ length: 8 }, (_, i) => fullResult(i + 1, i + 1)),
+ ),
+ makeRound('333-r2'),
+ ]);
+ const results = getStoredRoundResults(
+ rounds[0],
+ getAdvancementConditionForRound(rounds, rounds[0]),
+ rounds,
+ );
+ expect(results.filter(({ advancing }) => advancing)).toHaveLength(6);
+ expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true);
+ });
+
+ it('keeps every calculated qualifier provisional when even one row is partial', () => {
+ const rounds = withTarget([
+ makeRound(
+ '333-r1',
+ Array.from({ length: 8 }, (_, i) => fullResult(i + 1, i + 1)),
+ ),
+ makeRound('333-r2'),
+ ]);
+ rounds[0].results[7].attempts = [{ value: 1100, reconstruction: null }];
+ const results = getStoredRoundResults(
+ rounds[0],
+ getAdvancementConditionForRound(rounds, rounds[0]),
+ rounds,
+ );
+ expect(
+ results
+ .filter(({ advancing }) => advancing)
+ .every(({ advancingQuestionable }) => advancingQuestionable),
+ ).toBe(true);
+ expect(
+ results
+ .filter(({ advancing }) => !advancing)
+ .every(({ advancingQuestionable }) => !advancingQuestionable),
+ ).toBe(true);
+ });
+
+ it.each(['missing', 'empty', 'partial'])(
+ 'does not settle linked eligibility when a component is %s',
+ (state) => {
+ const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 });
+ rounds[0].results = Array.from({ length: 4 }, (_, i) => fullResult(i + 1, i + 1));
+ rounds[1].results = state === 'partial' ? [result(1, 1, 900)] : [];
+ const condition = getAdvancementConditionForRound(rounds, rounds[0]);
+ const sourceRounds = state === 'missing' ? [rounds[0], rounds[2]] : rounds;
+ expect(getStoredRoundResults(rounds[0], condition, sourceRounds)[0]).toMatchObject({
+ advancing: true,
+ advancingQuestionable: true,
+ });
+ },
+ );
+
+ it('uses actual next-round participants, including blank rows, over theoretical qualifiers', () => {
+ const rounds = withTarget([
+ makeRound(
+ '333-r1',
+ Array.from({ length: 8 }, (_, i) => result(i + 1, i + 1, 1000)),
+ ),
+ makeRound('333-r2', [
+ { ...result(1, 0, 0), ranking: null, attempts: [] },
+ { ...result(8, 0, 0), ranking: null, attempts: [] },
+ ]),
+ ]);
+ const results = getStoredRoundResults(
+ rounds[0],
+ getAdvancementConditionForRound(rounds, rounds[0]),
+ rounds,
+ );
+ expect(results.filter(({ advancing }) => advancing).map(({ personId }) => personId)).toEqual([
+ 1, 8,
+ ]);
+ expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true);
+ });
+
+ it('uses the union if a future WCIF target stage has linked components', () => {
+ // Current WCA dual rounds are first-round only. This covers WCIF's extensible links.
+ const rounds = withTarget([
+ makeRound(
+ '333-r1',
+ Array.from({ length: 8 }, (_, i) => result(i + 1, i + 1, 1000)),
+ ),
+ makeRound('333-r2', [result(1, 1, 1000)]),
+ makeRound('333-r3', [result(8, 1, 900)]),
+ ]);
+ rounds[1].linkedRounds = ['333-r2', '333-r3'];
+ rounds[2].linkedRounds = ['333-r2', '333-r3'];
+ expect(
+ getStoredRoundResults(rounds[0], getAdvancementConditionForRound(rounds, rounds[0]), rounds)
+ .filter(({ advancing }) => advancing)
+ .map(({ personId }) => personId),
+ ).toEqual([1, 8]);
+ });
+});
+
+describe('expected attempts in stored snapshots', () => {
+ it.each<[Round['format'], number]>([
+ ['1', 1],
+ ['2', 2],
+ ['3', 3],
+ ['5', 5],
+ ['a', 5],
+ ['m', 3],
+ ])('recognizes completed format %s and rejects one missing attempt', (format, count) => {
+ const round = { ...makeRound('333-r1'), format };
+ const attempts = Array.from({ length: count }, () => ({ result: -1 }));
+ expect(hasExpectedAttempts(round, { attempts })).toBe(true);
+ expect(hasExpectedAttempts(round, { attempts: attempts.slice(1) })).toBe(false);
+ expect(hasExpectedAttempts(round, { attempts: [...attempts.slice(1), { result: 0 }] })).toBe(
+ false,
+ );
+ expect(
+ hasExpectedAttempts(round, { attempts: [...attempts, { result: 0 }, { result: 0 }] }),
+ ).toBe(true);
+ });
+
+ it('recognizes a missed cutoff, but requires all attempts once the cutoff is met', () => {
+ const round = { ...makeRound('333-r1'), cutoff: { numberOfAttempts: 2, resultValue: 1000 } };
+ const hasAttempts = (values: number[]) =>
+ hasExpectedAttempts(round, { attempts: values.map((result) => ({ result })) });
+ expect(hasAttempts([1200])).toBe(false);
+ expect(hasAttempts([1200, -1, 0, 0, 0])).toBe(true);
+ expect(hasAttempts([-2, -2])).toBe(true);
+ expect(hasAttempts([1000, 1000])).toBe(true); // equality does not meet the cutoff
+ expect(hasAttempts([900, -1])).toBe(false);
+ expect(hasAttempts([900, -1, -2, 1000, 1100])).toBe(true);
+ expect(hasAttempts([900, -1, -2, 1000, 0])).toBe(false);
+ });
+
+ it('does not infer completion for an unsupported head-to-head format', () => {
+ expect(hasExpectedAttempts({ ...makeRound('333-r1'), format: 'h' }, { attempts: [] })).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts
index e7cbea9..10346db 100644
--- a/src/containers/CompetitionResults/advancement.ts
+++ b/src/containers/CompetitionResults/advancement.ts
@@ -1,5 +1,5 @@
-import { RankingType, Round } from '@wca/helpers';
-import { RoundAdvancementCondition } from '@/lib/wcif';
+import { RankingType, Round, getFormatExpectedSolves } from '@wca/helpers';
+import { RoundAdvancementCondition, getLinkedRoundIds } from '@/lib/wcif';
import { CompetitionRoundResult } from './CompetitionResultsTable';
import { normalizeResultRecordTag } from './ResultRecordBadge';
@@ -28,8 +28,9 @@ export const getQualifyingResultKeys = (
resultsByPerson.set(result.key, personResults);
});
- // WCIF supplies official ranks shared across linked components. Count each
- // scored competitor once; an empty, unranked roster row is not a result.
+ // WCIF supplies official ranks shared across linked components. Like Live,
+ // count every participant result row, including an empty one, exactly once.
+ // Scheduled group assignments are not evidence of current participation.
const rankingsByPerson = new Map();
resultsByPerson.forEach((results, key) => {
const rankings = results.flatMap(({ ranking }) =>
@@ -38,7 +39,7 @@ export const getQualifyingResultKeys = (
if (rankings.length > 0) rankingsByPerson.set(key, Math.min(...rankings));
});
const rankings = [...rankingsByPerson.values()].sort((a, b) => a - b);
- const resultCount = rankings.length;
+ const resultCount = resultsByPerson.size;
const maxQualifying = Math.floor(resultCount * 0.75);
// WCA Live's qualification algorithm excludes the entire tied group if it
// crosses the 75% cap (9p1). Never break an official tie by array order.
@@ -77,6 +78,97 @@ export const getQualifyingResultKeys = (
);
};
+type QualificationRound = {
+ round: Pick;
+ results: (QualificationResult & Pick)[];
+};
+
+export const hasExpectedAttempts = (
+ round: Pick,
+ result: Pick,
+): boolean => {
+ const maxAttempts = getFormatExpectedSolves(round.format);
+ if (maxAttempts === 0) return false;
+
+ // Published API results can be padded with zeros. A trailing zero is missing,
+ // not an entered attempt; an interior skipped attempt still occupies its slot.
+ const attempts = result.attempts.map(({ result }) => result);
+ while (attempts.at(-1) === 0) attempts.pop();
+ const meetsCutoff =
+ !round.cutoff ||
+ attempts
+ .slice(0, round.cutoff.numberOfAttempts)
+ .some((value) => value > 0 && value < round.cutoff!.resultValue);
+ const expected = meetsCutoff ? maxAttempts : round.cutoff!.numberOfAttempts;
+ return attempts.length === expected;
+};
+
+/**
+ * Stored snapshots can establish next-round participation or estimate eligibility.
+ * Live exports all result rows, including blank ones, so a populated target round
+ * is the participant list, not a reason to reapply the previous qualifying rule.
+ * https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/advancing.ex
+ *
+ * For incomplete snapshots we deliberately do not claim Live's clinching parity:
+ * all calculated qualifiers remain provisional until the source results are
+ * complete. WCIF has no Live edit history or organizer-only disqualification state.
+ */
+export const getFallbackAdvancement = (
+ round: Pick,
+ advancementCondition: RoundAdvancementCondition | null,
+ sourceRounds: QualificationRound[],
+ targetResults: (Pick & { identityResolved?: boolean })[] = [],
+ sourceIdentityComplete = true,
+): { advancingKeys: Set; questionableKeys: Set } => {
+ if (!advancementCondition) return { advancingKeys: new Set(), questionableKeys: new Set() };
+ const actualParticipantKeys = new Set(
+ targetResults
+ .filter(({ identityResolved }) => identityResolved !== false)
+ .map(({ key }) => key),
+ );
+ const targetIdentityComplete = targetResults.every(
+ ({ identityResolved }) => identityResolved !== false,
+ );
+ if (targetResults.length > 0 && targetIdentityComplete) {
+ return { advancingKeys: actualParticipantKeys, questionableKeys: new Set() };
+ }
+
+ const sourceResults = sourceRounds.flatMap(({ results }) => results);
+ const qualifyingKeys = sourceIdentityComplete
+ ? getQualifyingResultKeys(round, advancementCondition, sourceResults)
+ : new Set();
+ const complete =
+ targetIdentityComplete &&
+ sourceRounds.length === advancementCondition.sourceRoundIds.length &&
+ sourceRounds.every(
+ ({ round, results }) =>
+ results.length > 0 && results.every((result) => hasExpectedAttempts(round, result)),
+ );
+
+ // Unidentified target rows cannot prove that a known qualifier was excluded.
+ // Keep those estimates provisional, but settle the actual matches we do know.
+ return {
+ advancingKeys: new Set([...qualifyingKeys, ...actualParticipantKeys]),
+ questionableKeys: new Set(
+ [...qualifyingKeys].filter((key) => !complete && !actualParticipantKeys.has(key)),
+ ),
+ };
+};
+
+/** Never interpret a linked source component as evidence of later participation. */
+export const getAdvancementTargetRoundIds = (
+ eventRounds: Round[],
+ advancementCondition: RoundAdvancementCondition | null,
+): string[] => {
+ const targetRound = eventRounds.find(({ id }) => id === advancementCondition?.targetRoundId);
+ if (!targetRound || !advancementCondition) return [];
+ const linkedTargetIds = getLinkedRoundIds(eventRounds, targetRound);
+ return linkedTargetIds.length > 0 &&
+ !linkedTargetIds.some((id) => advancementCondition.sourceRoundIds.includes(id))
+ ? linkedTargetIds
+ : [targetRound.id];
+};
+
export const getStoredRoundResults = (
round: Round,
advancementCondition: RoundAdvancementCondition | null,
@@ -86,12 +178,21 @@ export const getStoredRoundResults = (
advancementCondition?.sourceType === 'linkedRounds'
? eventRounds.filter(({ id }) => advancementCondition.sourceRoundIds.includes(id))
: [round];
- const qualifyingKeys = getQualifyingResultKeys(
+ const targetRoundIds = getAdvancementTargetRoundIds(eventRounds, advancementCondition);
+ const { advancingKeys, questionableKeys } = getFallbackAdvancement(
round,
advancementCondition,
- sourceRounds.flatMap(({ results }) =>
- results.map((result) => ({ ...result, key: String(result.personId) })),
- ),
+ sourceRounds.map((sourceRound) => ({
+ round: sourceRound,
+ results: sourceRound.results.map((result) => ({
+ ...result,
+ key: String(result.personId),
+ attempts: result.attempts.map(({ value }) => ({ result: value })),
+ })),
+ })),
+ eventRounds
+ .filter(({ id }) => targetRoundIds.includes(id))
+ .flatMap(({ results }) => results.map(({ personId }) => ({ key: String(personId) }))),
);
return round.results.map((result) => {
@@ -102,12 +203,13 @@ export const getStoredRoundResults = (
averageRecordTag?: string | null;
};
+ const advancing = result.ranking != null && advancingKeys.has(String(result.personId));
return {
id: result.personId,
personId: result.personId,
ranking: result.ranking,
- advancing: result.ranking != null && qualifyingKeys.has(String(result.personId)),
- advancingQuestionable: false,
+ advancing,
+ advancingQuestionable: advancing && questionableKeys.has(String(result.personId)),
attempts: result.attempts.map((attempt) => ({ result: attempt.value })),
best: result.best,
average: result.average,
diff --git a/src/containers/CompetitionResults/fixtures/README.md b/src/containers/CompetitionResults/fixtures/README.md
index 4306821..33bb153 100644
--- a/src/containers/CompetitionResults/fixtures/README.md
+++ b/src/containers/CompetitionResults/fixtures/README.md
@@ -10,3 +10,20 @@ Only the relevant event rounds are retained (FMC and 3x3, respectively). Person
WCA Regulation 9v4 defines ranking by the better result of either round; 9v4a retains both component results; 9v5 prohibits elimination between components. Advancement percentages count distinct competitors across the linked source rounds. Later participants need not match the original qualification estimate because of withdrawals/replacements.
Published-results endpoint verified separately on 2026-11-03: Hong Kong uses `1`, `2`, `f` for its three 3x3 rounds; FMC East Melbourne returned no published results. WCIF is available for both.
+
+
+## Stored advancement semantics
+
+- Qualification uses the distinct union of participant result rows, including blank rows, but not scheduled group assignments or registrations. Official ranks, the 75% cap, and ties are preserved.
+- A populated target result roster takes precedence over calculated qualification. WCIF target membership takes precedence over published API membership; the latter is used only when the corresponding WCIF roster is empty. An unresolved API identity cannot prove that a known qualifier was excluded: known matches are settled, remaining eligible rows stay provisional.
+- Without target participation, all calculated qualifiers are provisional when any source component is missing/empty or any result lacks its expected attempts. Completion respects format, cutoff, and zero-padded API attempts. Complete snapshots settle the estimate; they do not establish future organizer decisions.
+- Selected Live rows override stored values and flags, including blank Live rows. Stored snapshots cannot reproduce all Live edit history, organizer-only state, or exact clinching simulation. Marking all calculated qualifiers provisional during incomplete rounds is deliberately conservative.
+- Caption tests use actual English resources for timed, FMC, multi-blind, null, and linked thresholds. Storybook stories prefixed `Synthetic` are isolated edge-case fixtures, not evidence of a real competition incident.
+
+Primary implementation references, inspected 2026-11-03:
+
+- [Live advancement and clinching](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/advancing.ex)
+- [Live open-round definition](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/round.ex): any result row, even blank, makes a round open.
+- [Live expected attempts](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/result.ex)
+- [Live WCIF export](https://github.com/thewca/wca-live/blob/main/lib/wca_live/synchronization/export.ex): includes blank result rows.
+- [WCIF2 result conditions and units](https://github.com/thewca/wcif/blob/latest/specification.md#resultcondition)
diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts
index fa80165..59fed34 100644
--- a/src/containers/CompetitionResults/resultsProvider.test.ts
+++ b/src/containers/CompetitionResults/resultsProvider.test.ts
@@ -960,3 +960,268 @@ describe('mixed-source linked qualification', () => {
).toMatchObject({ advancing: false, advancingQuestionable: true });
});
});
+
+const singleSourceCompetition = (): Competition => {
+ const competition = linkedCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[0].linkedRounds = null;
+ rounds[1].linkedRounds = null;
+ rounds[0].results = Array.from({ length: 8 }, (_, index) => ({
+ personId: index + 1,
+ ranking: index + 1,
+ best: 1000 + index * 100,
+ average: 1000 + index * 100,
+ attempts: Array.from({ length: 5 }, () => ({
+ value: 1000 + index * 100,
+ reconstruction: null,
+ })),
+ }));
+ rounds[1].participationRuleset = {
+ reservedPlaces: null,
+ participationSource: {
+ type: 'round',
+ roundId: '333-r1',
+ resultCondition: { type: 'percent', scope: 'average', value: 75 },
+ },
+ };
+ rounds[2].participationRuleset = null;
+ return competition;
+};
+const blankResult = (personId: number) => ({
+ personId,
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+});
+const advancingPersonIds = (results: ReturnType) =>
+ results.filter(({ advancing }) => advancing).map(({ personId }) => personId);
+
+describe('stored roster and actual-participant semantics', () => {
+ it('counts empty WCIF result rows but does not count group assignments in the denominator', () => {
+ const competition = singleSourceCompetition();
+ const round = competition.events[0].rounds[0];
+ round.results = [...round.results.slice(0, 6), ...[7, 8, 9, 10].map(blankResult)];
+ let results = linkedSourceResults(competition, []);
+ expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(
+ results
+ .filter(({ advancing }) => advancing)
+ .every(({ advancingQuestionable }) => advancingQuestionable),
+ ).toBe(true);
+ expect(
+ results
+ .slice(6)
+ .every(({ advancing, advancingQuestionable }) => !advancing && !advancingQuestionable),
+ ).toBe(true);
+
+ // Four people have group assignments, but only this stored result is a participant.
+ round.results = [round.results[0]];
+ results = linkedSourceResults(competition, []);
+ expect(results).toHaveLength(1);
+ expect(advancingPersonIds(results)).toEqual([]);
+ });
+
+ it('settles complete snapshots and marks partial snapshots provisional without changing values', () => {
+ const competition = singleSourceCompetition();
+ let results = linkedSourceResults(competition, []);
+ expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true);
+ competition.events[0].rounds[0].results[0].attempts = [{ value: 1000, reconstruction: null }];
+ results = linkedSourceResults(competition, []);
+ expect(results[0]).toMatchObject({
+ best: 1000,
+ average: 1000,
+ attempts: [{ result: 1000 }],
+ advancing: true,
+ advancingQuestionable: true,
+ });
+ expect(results[1]).toMatchObject({ advancing: true, advancingQuestionable: true });
+ });
+
+ it('uses a populated next-round WCIF roster, including blank rows, instead of qualification', () => {
+ const competition = singleSourceCompetition();
+ competition.events[0].rounds[1].results = [blankResult(1), blankResult(8)];
+ const results = linkedSourceResults(competition, []);
+ expect(advancingPersonIds(results)).toEqual([1, 8]);
+ expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true);
+ });
+
+ it('uses published next-round API participants when that WCIF roster is empty', () => {
+ const competition = singleSourceCompetition();
+ const api = [
+ apiResult({ name: 'Alice Solver', wca_id: '2010ALIC01', pos: 2 }),
+ apiResult({ name: 'Dana Solver', wca_id: '2010DANA01', pos: 1 }),
+ ];
+ const results = linkedSourceResults(competition, api);
+ expect(advancingPersonIds(results)).toEqual([1, 4]);
+ expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true);
+ });
+
+ it('does not restore API-only target participants when WCIF provides a newer roster', () => {
+ const competition = singleSourceCompetition();
+ competition.events[0].rounds[1].results = [blankResult(1)];
+ const results = linkedSourceResults(competition, [apiResult()]);
+ expect(advancingPersonIds(results)).toEqual([1]);
+ expect(results.find(({ personId }) => personId === 2)?.advancing).toBe(false);
+ });
+
+ it('does not mistake a group assignment in the target round for an actual participant', () => {
+ const competition = singleSourceCompetition();
+ const group = competition.schedule.venues[0].rooms[0].activities[0].childActivities![0];
+ group.activityCode = '333-r2-g1';
+ const results = linkedSourceResults(competition, []);
+ expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+
+ it('matches actual target API participants by stable WCA ID without creating WCIF identities', () => {
+ const competition = singleSourceCompetition();
+ const api = [
+ apiResult({
+ id: 9111,
+ round_type_id: '1',
+ name: 'External Person',
+ wca_id: '2010ELSE01',
+ pos: 9,
+ }),
+ apiResult({
+ id: 9222,
+ round_type_id: '2',
+ name: 'Different Published Name',
+ wca_id: '2010ELSE01',
+ pos: 1,
+ }),
+ ];
+ const results = linkedSourceResults(competition, api);
+ expect(results.filter(({ advancing }) => advancing)).toEqual([
+ expect.objectContaining({
+ id: 9111,
+ personId: null,
+ wcaId: '2010ELSE01',
+ advancing: true,
+ advancingQuestionable: false,
+ }),
+ ]);
+ });
+
+ it('does not treat an unresolved target identity as proof that known qualifiers were excluded', () => {
+ const competition = singleSourceCompetition();
+ const results = linkedSourceResults(competition, [
+ apiResult({ id: 9222, round_type_id: '2', wca_id: null, name: 'Unresolved Name' }),
+ ]);
+ expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(
+ results
+ .filter(({ advancing }) => advancing)
+ .every(({ advancingQuestionable }) => advancingQuestionable),
+ ).toBe(true);
+ });
+
+ it('settles known target matches while leaving other eligible rows provisional if target identity is incomplete', () => {
+ const competition = singleSourceCompetition();
+ const results = linkedSourceResults(competition, [
+ apiResult({ id: 9222, round_type_id: '2', wca_id: null, name: 'Unresolved Name' }),
+ apiResult({ id: 9333, round_type_id: '2', wca_id: '2010ALIC01', name: 'Alice Solver' }),
+ ]);
+ expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(results[0]).toMatchObject({ advancing: true, advancingQuestionable: false });
+ expect(results[1]).toMatchObject({ advancing: true, advancingQuestionable: true });
+ });
+
+ it('never treats a linked source row as evidence of target participation', () => {
+ const competition = singleSourceCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[1].linkedRounds = ['333-r1', '333-r2'];
+ rounds[1].results = [blankResult(8)];
+ expect(advancingPersonIds(linkedSourceResults(competition, []))).toEqual([8]);
+ });
+
+ it('uses known actual target participation even when linked source identity cannot be resolved', () => {
+ const competition = linkedCompetition();
+ competition.events[0].rounds[2].results = [blankResult(2)];
+ const results = linkedSourceResults(competition, [
+ apiResult({ name: 'Unresolved Person', wca_id: null }),
+ ]);
+ expect(advancingPersonIds(results)).toEqual([2]);
+ expect(results.find(({ personId }) => personId === 2)).toMatchObject({
+ advancingQuestionable: false,
+ });
+ });
+
+ it('keeps each component value while using the shared target participation set', () => {
+ const competition = linkedCompetition();
+ const rounds = competition.events[0].rounds;
+ rounds[1].results = [{ ...rounds[0].results[0], best: 800, average: 900 }];
+ rounds[2].results = [blankResult(1)];
+ expect(linkedSourceResults(competition, [])[0]).toMatchObject({
+ best: 1500,
+ average: 1500,
+ advancing: true,
+ advancingQuestionable: false,
+ });
+ expect(linkedSourceResults(competition, [], 1)[0]).toMatchObject({
+ best: 800,
+ average: 900,
+ advancing: true,
+ advancingQuestionable: false,
+ });
+ });
+
+ it('lets a blank selected Live row clear stale WCIF scores and qualifying flags', () => {
+ const competition = singleSourceCompetition();
+ const results = linkedSourceResults(
+ competition,
+ [],
+ 0,
+ liveRound([
+ liveRoundResult({
+ person: { id: '1', registrantId: 1, name: 'Alice Solver' },
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+ advancing: false,
+ advancingQuestionable: false,
+ }),
+ ]),
+ );
+ expect(results.find(({ personId }) => personId === 1)).toMatchObject({
+ ranking: null,
+ best: 0,
+ average: 0,
+ attempts: [],
+ advancing: false,
+ advancingQuestionable: false,
+ });
+ });
+
+ it('keeps selected Live flags authoritative over an actual stored target roster', () => {
+ const competition = singleSourceCompetition();
+ competition.events[0].rounds[1].results = [blankResult(1)];
+ const results = linkedSourceResults(
+ competition,
+ [],
+ 0,
+ liveRound([
+ liveRoundResult({
+ person: { id: '1', registrantId: 1, name: 'Alice Solver' },
+ advancing: false,
+ advancingQuestionable: false,
+ }),
+ liveRoundResult({
+ person: { id: '2', registrantId: 2, name: 'Bob Solver' },
+ advancing: true,
+ advancingQuestionable: true,
+ }),
+ ]),
+ );
+ expect(results.find(({ personId }) => personId === 1)).toMatchObject({
+ advancing: false,
+ advancingQuestionable: false,
+ });
+ expect(results.find(({ personId }) => personId === 2)).toMatchObject({
+ advancing: true,
+ advancingQuestionable: true,
+ });
+ });
+});
diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts
index c1bbba6..df5a8a0 100644
--- a/src/containers/CompetitionResults/resultsProvider.ts
+++ b/src/containers/CompetitionResults/resultsProvider.ts
@@ -7,7 +7,11 @@ import { getAdvancementConditionForRound } from '@/lib/wcif';
import { PersonalRoundResult } from '../CompetitionPersonalResults/CompetitionPersonalResultsTable';
import { CompetitionRoundResult } from './CompetitionResultsTable';
import { normalizeResultRecordTag } from './ResultRecordBadge';
-import { getQualifyingResultKeys, getStoredRoundResults } from './advancement';
+import {
+ getAdvancementTargetRoundIds,
+ getFallbackAdvancement,
+ getStoredRoundResults,
+} from './advancement';
import {
findPersonForApiResult,
findUniquePersonByName,
@@ -94,26 +98,25 @@ const findPersonForLiveRoundResult = (
};
const getLiveRoundResults = (wcif: Competition, wcaLiveRound: WcaLiveRound | null | undefined) =>
- wcaLiveRound?.results
- .filter((result) => result.attempts.length > 0)
- .map((result) => {
- const matchedPerson = findPersonForLiveRoundResult(wcif, result);
-
- return {
- id: result.id,
- personId: matchedPerson?.registrantId ?? result.person.registrantId,
- personName: result.person.name,
- wcaId: result.person.wcaId,
- ranking: result.ranking,
- advancing: result.advancing,
- advancingQuestionable: result.advancingQuestionable,
- attempts: result.attempts,
- best: result.best,
- average: result.average,
- singleRecordTag: normalizeResultRecordTag(result.singleRecordTag),
- averageRecordTag: normalizeResultRecordTag(result.averageRecordTag),
- };
- }) ?? [];
+ // Blank Live rows also supersede stale stored scores and advancement flags.
+ wcaLiveRound?.results.map((result) => {
+ const matchedPerson = findPersonForLiveRoundResult(wcif, result);
+
+ return {
+ id: result.id,
+ personId: matchedPerson?.registrantId ?? result.person.registrantId,
+ personName: result.person.name,
+ wcaId: result.person.wcaId,
+ ranking: result.ranking,
+ advancing: result.advancing,
+ advancingQuestionable: result.advancingQuestionable,
+ attempts: result.attempts,
+ best: result.best,
+ average: result.average,
+ singleRecordTag: normalizeResultRecordTag(result.singleRecordTag),
+ averageRecordTag: normalizeResultRecordTag(result.averageRecordTag),
+ };
+ }) ?? [];
export const getRoundResultsFromSources = ({
wcif,
@@ -160,18 +163,51 @@ export const getRoundResultsFromSources = ({
const hasUnresolvedLinkedIdentity =
advancementCondition?.sourceType === 'linkedRounds' &&
sourceResults.some((result) => result.personId == null && !result.wcaId);
- const qualifyingKeys = getQualifyingResultKeys(
+ // A WCIF target roster is a complete membership snapshot, so do not resurrect
+ // an API-only participant that was removed from it. Use published API results
+ // only when WCIF has no target rows. Assignments are not actual participation.
+ const targetRoundIds = getAdvancementTargetRoundIds(
+ selectedRound.event.rounds,
+ advancementCondition,
+ );
+ const targetResults = selectedRound.event.rounds
+ .filter(({ id }) => targetRoundIds.includes(id))
+ .flatMap((round) =>
+ round.results.length > 0
+ ? getStoredRoundResults(round, null)
+ : (apiResultsByRound.get(round.id) ?? []).map((result) =>
+ toCompetitionRoundResult(wcif.persons, result),
+ ),
+ );
+ const { advancingKeys, questionableKeys } = getFallbackAdvancement(
selectedRound.round,
- hasUnresolvedLinkedIdentity ? null : advancementCondition,
- sourceResults.map((result) => ({ ...result, key: getResultKey(result) })),
+ advancementCondition,
+ selectedRound.event.rounds
+ .filter(({ id }) => sourceRoundIds.includes(id))
+ .map((round) => ({
+ round,
+ results: (fallbackResultsByRound.get(round.id) ?? []).map((result) => ({
+ ...result,
+ key: getResultKey(result),
+ })),
+ })),
+ targetResults.map((result) => ({
+ key: getResultKey(result),
+ identityResolved: result.personId != null || Boolean(result.wcaId),
+ })),
+ !hasUnresolvedLinkedIdentity,
);
const fallbackResults = (fallbackResultsByRound.get(selectedRound.round.id) ?? []).map(
- (result) => ({
- ...result,
+ (result) => {
// Keep empty component/roster rows visually empty, even if the competitor
// qualifies through the other component. Live flags remain authoritative.
- advancing: result.ranking != null && qualifyingKeys.has(getResultKey(result)),
- }),
+ const advancing = result.ranking != null && advancingKeys.has(getResultKey(result));
+ return {
+ ...result,
+ advancing,
+ advancingQuestionable: advancing && questionableKeys.has(getResultKey(result)),
+ };
+ },
);
const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound);
diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx
index 0b7f791..81f9b4c 100644
--- a/src/containers/CompetitionRound/CompetitionRound.test.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx
@@ -322,3 +322,19 @@ describe('CompetitionRoundContainer', () => {
}
});
});
+
+it.each(['333-r99', 'not-a-round', '999-r1'])(
+ 'explains missing round %s without implying that it has a schedule or results',
+ (roundId) => {
+ const { rerender } = renderRound(roundId);
+ expect(screen.getByRole('status')).toHaveTextContent('competition.results.roundNotFound');
+ expect(screen.queryByText('See Results')).not.toBeInTheDocument();
+ expect(screen.getByText('Back To Events')).toHaveAttribute(
+ 'href',
+ '/competitions/TestComp2026/events/',
+ );
+ rerender();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(screen.getByText('Group 1')).toBeInTheDocument();
+ },
+);
diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx
index e0837e2..f26d8dd 100644
--- a/src/containers/CompetitionRound/CompetitionRound.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.tsx
@@ -32,17 +32,17 @@ export function CompetitionRoundContainer({
const { wcif, setTitle } = useWCIF();
const { roundActivies } = useWcifUtils();
- useEffect(() => {
- setTitle(activityCodeToName(roundId));
- }, [roundId, setTitle]);
-
const round = useMemo(() => {
const events = wcif && getAllEvents(wcif);
return events?.flatMap((e) => e.rounds).find((r) => r.id === roundId);
}, [roundId, wcif]);
+ useEffect(() => {
+ setTitle(round ? activityCodeToName(roundId) : t('competition.results.roundNotFound'));
+ }, [round, roundId, setTitle, t]);
+
const eventRounds = useMemo(
- () => getEventRoundsForRound(wcif?.events, roundId),
- [roundId, wcif?.events],
+ () => (round ? getEventRoundsForRound(wcif?.events, roundId) : []),
+ [round, roundId, wcif?.events],
);
const linkedRoundNames = useMemo(
() =>
@@ -61,6 +61,21 @@ export function CompetitionRoundContainer({
const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))];
const hasResults = (round?.results.length ?? 0) > 0;
+ if (!wcif) return null;
+
+ if (!round) {
+ return (
+
+ {t('competition.results.roundNotFound')}
+
+
+ );
+ }
+
return (
diff --git a/src/containers/Competitors/Competitors.test.tsx b/src/containers/Competitors/Competitors.test.tsx
index 2d9b92d..eeb33e4 100644
--- a/src/containers/Competitors/Competitors.test.tsx
+++ b/src/containers/Competitors/Competitors.test.tsx
@@ -1,10 +1,13 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Competition, Person } from '@wca/helpers';
import { Link, MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
+import { usePinnedPersons } from '@/hooks/UsePinnedPersons';
import { Competitors } from './Competitors';
jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
-jest.mock('@/hooks/UsePinnedPersons', () => ({ usePinnedPersons: () => ({ pinnedPersons: [] }) }));
+jest.mock('@/hooks/UsePinnedPersons', () => ({
+ usePinnedPersons: jest.fn(() => ({ pinnedPersons: [] })),
+}));
jest.mock('@/hooks/useOngoingActivities', () => ({
useOngoingActivities: () => ({ ongoingActivities: [] }),
}));
@@ -96,3 +99,20 @@ it('renders no unpinned competitors for a search with no match', () => {
renderCompetition('/competitions/Test2026?search=unknown');
expect(screen.queryAllByRole('link')).toHaveLength(0);
});
+
+it('announces no matches and removes the message when the search is cleared', () => {
+ renderCompetition('/competitions/Test2026?search=unknown');
+ expect(screen.getByText('competition.competitors.noMatches')).toHaveAttribute('role', 'status');
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } });
+ expect(screen.queryByText('competition.competitors.noMatches')).not.toBeInTheDocument();
+ expect(screen.getAllByRole('link')).toHaveLength(2);
+});
+
+it('does not claim there are no matches when a matching competitor is pinned above the search', () => {
+ jest
+ .mocked(usePinnedPersons)
+ .mockReturnValueOnce({ pinnedPersons: [2] } as ReturnType
);
+ renderCompetition('/competitions/Test2026?search=Tin');
+ expect(screen.getByRole('link', { name: 'Tin Huang' })).toBeInTheDocument();
+ expect(screen.queryByText('competition.competitors.noMatches')).not.toBeInTheDocument();
+});
diff --git a/src/containers/Competitors/Competitors.tsx b/src/containers/Competitors/Competitors.tsx
index 1dac564..94d2f1f 100644
--- a/src/containers/Competitors/Competitors.tsx
+++ b/src/containers/Competitors/Competitors.tsx
@@ -61,6 +61,10 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => {
!person.pinned && (!input || person.name.toLowerCase().includes(input.toLowerCase().trim())),
);
+ const hasMatchingPerson = acceptedPersons.some((person) =>
+ person.name.toLowerCase().includes(input.toLowerCase().trim()),
+ );
+
return (
{me && (
@@ -90,7 +94,13 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => {
-
+ {input.trim() && !hasMatchingPerson ? (
+
+ {t('competition.competitors.noMatches')}
+
+ ) : (
+
+ )}
);
};
diff --git a/src/containers/PersonalSchedule/Assignments.test.tsx b/src/containers/PersonalSchedule/Assignments.test.tsx
new file mode 100644
index 0000000..f3fffe3
--- /dev/null
+++ b/src/containers/PersonalSchedule/Assignments.test.tsx
@@ -0,0 +1,49 @@
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Person } from '@wca/helpers';
+import { PersonalScheduleContent } from './PersonalSchedule';
+
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+jest.mock('@/components', () => ({ DisclaimerText: () => null }));
+jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ wcif: { id: 'Test' } }) }));
+jest.mock('@/hooks/useNow/useNow', () => ({ useNow: () => 0 }));
+jest.mock('@/lib/events', () => ({ isOfficialEventId: jest.fn() }));
+jest.mock('@/lib/activityCodes', () => ({ parseActivityCodeFlexible: jest.fn() }));
+jest.mock('@/lib/activities', () => ({ hasMultipleScheduleLocations: () => false }));
+jest.mock('./utils', () => ({
+ getGroupedAssignmentsByDate: () => [
+ { date: 'Saturday', dateParts: [], assignments: [] },
+ { date: 'Sunday', dateParts: [], assignments: [] },
+ ],
+}));
+jest.mock('./PersonalExtraAssignment', () => ({ ExtraAssignment: () => null }));
+jest.mock('./PersonalNormalAssignment', () => ({ PersonalNormalAssignment: () => null }));
+jest.mock('./PersonalPageLayout', () => ({ PersonalPageLayout: () => null }));
+
+const person = { registrantId: 1, assignments: [{ activityId: 1 }] } as Person;
+beforeEach(() => localStorage.clear());
+
+it('makes personal schedule days operable by keyboard with a stable focused button', async () => {
+ render();
+ const saturday = screen.getByRole('button', { name: 'Saturday' });
+ expect(saturday).toHaveAttribute('aria-expanded', 'true');
+ await act(() => userEvent.tab());
+ expect(saturday).toHaveFocus();
+ await act(() => userEvent.keyboard('{Enter}'));
+ expect(saturday).toHaveAttribute('aria-expanded', 'false');
+ await act(() => userEvent.keyboard(' '));
+ expect(saturday).toHaveAttribute('aria-expanded', 'true');
+ expect(saturday).toHaveFocus();
+});
+
+it('does not carry a collapsed day into a different person on repeated navigation', async () => {
+ const { rerender } = render();
+ await act(() => userEvent.click(screen.getByText('Saturday')));
+ rerender();
+ expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute('aria-expanded', 'true');
+ rerender();
+ expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+});
diff --git a/src/containers/PersonalSchedule/Assignments.tsx b/src/containers/PersonalSchedule/Assignments.tsx
index 2068e9b..99a36b6 100644
--- a/src/containers/PersonalSchedule/Assignments.tsx
+++ b/src/containers/PersonalSchedule/Assignments.tsx
@@ -95,14 +95,20 @@ export function Assignments({ wcif, person, showStationNumber }: AssignmentsProp
return (
{!isSingleDay && (
- toggleDate(date)} className="table-row-hover-bg">
-
-
+
+ |
+
|
)}
diff --git a/src/containers/PersonalSchedule/PersonalSchedule.tsx b/src/containers/PersonalSchedule/PersonalSchedule.tsx
index 9fdcbf1..d7da249 100644
--- a/src/containers/PersonalSchedule/PersonalSchedule.tsx
+++ b/src/containers/PersonalSchedule/PersonalSchedule.tsx
@@ -24,6 +24,7 @@ export function PersonalScheduleContent({ person }: PersonalScheduleContainerPro
<>
{person.assignments && person.assignments.length > 0 ? (
({
+ getScheduledDays: jest.fn(() => [
+ { date: 'Saturday, 1/1/2030', activities: [{ id: 1, endTime: '2030-01-01T12:00:00Z' }] },
+ ]),
+ hasMultipleScheduleLocations: () => false,
+}));
+jest.mock('./scheduleActivityGroups', () => ({ getScheduleActivityGroups: () => [{ id: '1' }] }));
+jest.mock('./ScheduleActivityRow', () => ({
+ ScheduleActivityRow: () => Day activities ,
+}));
+const wcif = { id: 'One2026' } as Competition;
+beforeEach(() => localStorage.clear());
+
+it('toggles with Enter and Space, preserves focus, and exposes the controlled day', async () => {
+ render();
+ const toggle = screen.getByRole('button', { name: 'Saturday, 1/1/2030' });
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toContainElement(
+ screen.getByText('Day activities'),
+ );
+ await act(() => userEvent.tab());
+ expect(toggle).toHaveFocus();
+ await act(() => userEvent.keyboard('{Enter}'));
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText('Day activities')).not.toBeInTheDocument();
+ expect(toggle).toHaveFocus();
+ await act(() => userEvent.keyboard(' '));
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('Day activities')).toBeInTheDocument();
+ expect(toggle).toHaveFocus();
+});
+
+it('restores a saved collapse state without carrying it into another competition on the same date', async () => {
+ const { rerender } = render();
+ await act(() => userEvent.click(screen.getByText('Saturday, 1/1/2030')));
+ expect(screen.queryByText('Day activities')).not.toBeInTheDocument();
+ rerender();
+ expect(screen.getByText('Day activities')).toBeInTheDocument();
+ rerender();
+ expect(screen.queryByText('Day activities')).not.toBeInTheDocument();
+});
+
+it('persists toggles for multiple days rather than overwriting the previous day', async () => {
+ const mockDays = jest.mocked(getScheduledDays);
+ const days = mockDays(wcif);
+ mockDays.mockReturnValueOnce([...days, { ...days[0], date: 'Sunday, 1/2/2030' }]);
+ const { unmount } = render();
+ await act(() => userEvent.click(screen.getByText('Saturday, 1/1/2030')));
+ await act(() => userEvent.click(screen.getByText('Sunday, 1/2/2030')));
+ const saved = JSON.parse(localStorage.getItem('collapse')!) as Record;
+ expect(saved['One2026-schedule']).toEqual(['Saturday, 1/1/2030', 'Sunday, 1/2/2030']);
+ unmount();
+ mockDays.mockReturnValueOnce([...days, { ...days[0], date: 'Sunday, 1/2/2030' }]);
+ render();
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ screen
+ .getAllByRole('button')
+ .forEach((button) => expect(button).toHaveAttribute('aria-expanded', 'false'));
+});
+
+it('automatically collapses past days, lets the user reopen them, and keeps later days expanded', async () => {
+ const mockDays = jest.mocked(getScheduledDays);
+ const days = mockDays(wcif);
+ mockDays.mockReturnValueOnce([
+ {
+ ...days[0],
+ date: 'Past day',
+ activities: [{ ...days[0].activities[0], endTime: '2020-01-01T12:00:00Z' }],
+ },
+ { ...days[0], date: 'Future day' },
+ ]);
+ try {
+ render();
+ const pastDay = screen.getByRole('button', { name: 'Past day' });
+ expect(pastDay).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.getByRole('button', { name: 'Future day' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ await act(() => userEvent.click(pastDay));
+ expect(pastDay).toHaveAttribute('aria-expanded', 'true');
+ expect(pastDay).toHaveFocus();
+ } finally {
+ jest.restoreAllMocks();
+ }
+});
diff --git a/src/containers/Schedule/Schedule.tsx b/src/containers/Schedule/Schedule.tsx
index 40006ba..54ffc26 100644
--- a/src/containers/Schedule/Schedule.tsx
+++ b/src/containers/Schedule/Schedule.tsx
@@ -1,5 +1,5 @@
import { Competition } from '@wca/helpers';
-import { useCallback, useEffect, useMemo } from 'react';
+import { useEffect, useId, useMemo } from 'react';
import { useCollapse } from '@/hooks/UseCollapse';
import { getScheduledDays, hasMultipleScheduleLocations } from '@/lib/activities';
import { LinkRenderer } from '@/lib/linkRenderer';
@@ -14,34 +14,39 @@ const ScheduleDay = ({
date,
activities,
showRoom,
+ collapsed,
+ onToggle,
LinkComponent,
}: {
- wcif;
+ wcif: Competition;
date: string;
activities: ActivityWithRoomOrParent[];
showRoom: boolean;
+ collapsed: boolean;
+ onToggle: () => void;
LinkComponent?: LinkRenderer;
}) => {
- const { collapsedDates, toggleDate } = useCollapse(key(wcif.id));
const activityGroups = useMemo(
() => getScheduleActivityGroups(wcif, activities),
[activities, wcif],
);
- const collapsed = collapsedDates.includes(date);
- const toggleCollapsed = useCallback(() => {
- toggleDate(date);
- }, [date, toggleDate]);
+ const activitiesId = useId();
return (
- toggleCollapsed()}>
+
-
+
+ {collapsed ? ' â–¼' : ' â–²'}
+
+
+
{(collapsed ? [] : activityGroups).map((group) => (
{
- const { collapsedDates, setCollapsedDates } = useCollapse(key(wcif.id));
+// A competition owns one collapse state for all its days. Remount it on navigation
+// so a different competition cannot inherit the previous competition's dates.
+export const ScheduleContainer = (props: ScheduleContainerProps) => (
+
+);
+
+const CompetitionSchedule = ({ wcif, LinkComponent }: ScheduleContainerProps) => {
+ const { collapsedDates, setCollapsedDates, toggleDate } = useCollapse(key(wcif.id));
const scheduleDays = useMemo(() => getScheduledDays(wcif), [wcif]);
useEffect(() => {
const now = new Date().getTime();
- const collapse = new Set(collapsedDates);
- scheduleDays.forEach(({ date, activities }) => {
- const lastActivityEndTime = Math.max(...activities.map((i) => new Date(i.endTime).getTime()));
-
- // Collapse days that are more than 4 hours old.
- if (now - new Date(lastActivityEndTime).getTime() > 1000 * 60 * 60 * 4) {
- collapse.add(date);
- }
- });
+ const pastDates = scheduleDays
+ .filter(({ activities }) => {
+ const lastActivityEndTime = Math.max(
+ ...activities.map((i) => new Date(i.endTime).getTime()),
+ );
+ // Collapse days that are more than 4 hours old.
+ return now - lastActivityEndTime > 1000 * 60 * 60 * 4;
+ })
+ .map(({ date }) => date);
- setCollapsedDates([...collapse]);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [scheduleDays]);
+ setCollapsedDates((previous) => [...new Set([...previous, ...pastDates])]);
+ }, [scheduleDays, setCollapsedDates]);
const showRoom = useMemo(() => hasMultipleScheduleLocations(wcif), [wcif]);
@@ -92,6 +102,8 @@ export const ScheduleContainer = ({ wcif, LinkComponent }: ScheduleContainerProp
wcif={wcif}
activities={activities}
date={date}
+ collapsed={collapsedDates.includes(date)}
+ onToggle={() => toggleDate(date)}
showRoom={showRoom}
LinkComponent={LinkComponent}
/>
diff --git a/src/containers/UpcomingCompetitions/UpcomingCompetitions.test.tsx b/src/containers/UpcomingCompetitions/UpcomingCompetitions.test.tsx
new file mode 100644
index 0000000..9d992c3
--- /dev/null
+++ b/src/containers/UpcomingCompetitions/UpcomingCompetitions.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from '@testing-library/react';
+import { useInfiniteCompetitions } from '@/hooks/queries/useInfiniteCompetitions';
+import UpcomingCompetitions from './UpcomingCompetitions';
+
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+jest.mock('react-intersection-observer', () => ({ useInView: () => ({ inView: false }) }));
+jest.mock('@/providers/AppProvider', () => ({ useApp: () => ({ online: true }) }));
+jest.mock('@/hooks/queries/useInfiniteCompetitions', () => ({
+ useInfiniteCompetitions: jest.fn(),
+}));
+jest.mock('@/queries', () => ({ useCompetitionsQuery: () => ({}) }));
+jest.mock('@/components', () => ({
+ NoteBox: () => null,
+ LastFetchedAt: ({ lastFetchedAt }: { lastFetchedAt: Date }) => (
+ Fetched at {lastFetchedAt.toISOString()}
+ ),
+ CompetitionListFragment: () => null,
+ Container: ({ children }: { children: React.ReactNode }) => {children} ,
+}));
+it('omits the last-fetched timestamp until data has actually arrived and preserves it during refresh', () => {
+ Object.assign(globalThis, { __GIT_COMMIT__: 'test' });
+ const mockQuery = jest.mocked(useInfiniteCompetitions);
+ mockQuery.mockReturnValue({ dataUpdatedAt: 0, status: 'pending' } as ReturnType<
+ typeof useInfiniteCompetitions
+ >);
+ const { rerender } = render();
+ expect(screen.queryByText(/Fetched at/)).not.toBeInTheDocument();
+ mockQuery.mockReturnValue({ dataUpdatedAt: 1793718000000, status: 'success' } as ReturnType<
+ typeof useInfiniteCompetitions
+ >);
+ rerender();
+ expect(screen.getByText(/Fetched at 2026/)).toBeInTheDocument();
+ mockQuery.mockReturnValue({ dataUpdatedAt: 1793718000000, status: 'error' } as ReturnType<
+ typeof useInfiniteCompetitions
+ >);
+ rerender();
+ expect(screen.getByText(/Fetched at 2026/)).toBeInTheDocument();
+});
diff --git a/src/containers/UpcomingCompetitions/UpcomingCompetitions.tsx b/src/containers/UpcomingCompetitions/UpcomingCompetitions.tsx
index f486edb..99e713a 100644
--- a/src/containers/UpcomingCompetitions/UpcomingCompetitions.tsx
+++ b/src/containers/UpcomingCompetitions/UpcomingCompetitions.tsx
@@ -67,7 +67,7 @@ export default function UpcomingCompetitions() {
{__GIT_COMMIT__}
- { }
+ {!!dataUpdatedAt && }
diff --git a/src/hooks/UseCollapse/UseCollapse.test.tsx b/src/hooks/UseCollapse/UseCollapse.test.tsx
new file mode 100644
index 0000000..c5eb6f4
--- /dev/null
+++ b/src/hooks/UseCollapse/UseCollapse.test.tsx
@@ -0,0 +1,26 @@
+import { act, renderHook } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { useCollapse } from './UseCollapse';
+
+beforeEach(() => localStorage.clear());
+
+it('handles repeated toggles without render-phase storage writes or losing other saved schedules', () => {
+ localStorage.setItem('collapse', JSON.stringify({ other: ['Other date'] }));
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ const { result } = renderHook(() => useCollapse('schedule'), { wrapper: StrictMode });
+ act(() => {
+ result.current.toggleDate('Saturday');
+ result.current.toggleDate('Sunday');
+ result.current.toggleDate('Saturday');
+ });
+ expect(result.current.collapsedDates).toEqual(['Sunday']);
+ expect(JSON.parse(localStorage.getItem('collapse')!)).toEqual({
+ other: ['Other date'],
+ schedule: ['Sunday'],
+ });
+ expect(consoleError).not.toHaveBeenCalled();
+ } finally {
+ consoleError.mockRestore();
+ }
+});
diff --git a/src/hooks/UseCollapse/UseCollapse.ts b/src/hooks/UseCollapse/UseCollapse.ts
index 21410c9..ffc3162 100644
--- a/src/hooks/UseCollapse/UseCollapse.ts
+++ b/src/hooks/UseCollapse/UseCollapse.ts
@@ -1,5 +1,5 @@
import { useLocalStorage } from '@uidotdev/usehooks';
-import { startTransition, useCallback, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
export const useCollapse = (key: string) => {
const [initialCollapsed, saveCollapsed] = useLocalStorage >(
@@ -20,27 +20,22 @@ export const useCollapse = (key: string) => {
return collapsed;
});
+ const collapsedDatesRef = useRef(collapsedDates);
+ collapsedDatesRef.current = collapsedDates;
+
const toggleDate = useCallback(
(date: string) => {
- startTransition(() => {
- setCollapsedDates((prev) => {
- let newState = prev;
- if (prev.includes(date)) {
- newState = prev.filter((d) => d !== date);
- } else {
- newState = [...prev, date];
- }
-
- saveCollapsed({
- ...initialCollapsed,
- [key]: newState,
- });
-
- return newState;
- });
- });
+ // Keep rapid toggles in order without writing to the external storage store
+ // from a React state updater, which may run during rendering or be replayed.
+ const previous = collapsedDatesRef.current;
+ const next = previous.includes(date)
+ ? previous.filter((collapsedDate) => collapsedDate !== date)
+ : [...previous, date];
+ collapsedDatesRef.current = next;
+ setCollapsedDates(next);
+ saveCollapsed((saved) => ({ ...saved, [key]: next }));
},
- [initialCollapsed, key, saveCollapsed],
+ [key, saveCollapsed],
);
return {
diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml
index 5dda002..1a1eb39 100644
--- a/src/i18n/en/translation.yaml
+++ b/src/i18n/en/translation.yaml
@@ -40,9 +40,14 @@ common:
linkedRanking: 'Top {{level}} in dual rounds {{rounds}} advance to {{what}}'
linkedPercent: 'Top {{level}}% in dual rounds {{rounds}} advance to {{what}}'
attemptResult: Result better than {level} advances to next round. Minimum of 25% of competitors must be eliminated.
- resultAchieved: '{{scope}} < {{result}} advance to {{what}}; minimum of 25% of competitors must be eliminated'
- linkedResultAchieved: '{{scope}} < {{result}} in dual rounds {{rounds}} advance to {{what}}; minimum of 25% of competitors must be eliminated'
- resultThresholdUnknown: an unknown result
+ resultAchieved: '{{scope}} better than {{result}} advance to {{what}}; minimum of 25% of competitors must be eliminated'
+ linkedResultAchieved: '{{scope}} better than {{result}} in dual rounds {{rounds}} advance to {{what}}; minimum of 25% of competitors must be eliminated'
+ anyResultAchieved: 'Any {{scope}} advances to {{what}}; minimum of 25% of competitors must be eliminated'
+ linkedAnyResultAchieved: 'Any {{scope}} in dual rounds {{rounds}} advances to {{what}}; minimum of 25% of competitors must be eliminated'
+ resultInMoves: '{{result}} moves'
+ successfulScope:
+ single: successful single
+ average: successful average
nextRound: next round
final: final
unknown: unknown
@@ -152,6 +157,7 @@ appUpdate:
description: 'Refresh to get the latest version of the app.'
action: 'Refresh now'
header:
+ settings: 'Settings'
login: $t(common.login)
logout: 'Logout'
tabs:
@@ -186,6 +192,7 @@ competition:
viewMyAssignments: 'View My Assignments'
viewCompetitionInformation: 'View Competition Information'
searchCompetitors: 'Search Competitors'
+ noMatches: 'No competitors match your search.'
groups:
backToEvents: 'Back to Events'
nextGroup: 'Next Group'
@@ -219,7 +226,10 @@ competition:
viewLiveResults: View live results
dualPersonalNote: 'Dual-round ranks use the better result from either linked round. Each row shows that round’s own results.'
dualRoundNote: 'Dual-round standings: rank is based on the better result from either linked round. Attempts, best and average below belong to the selected round. No competitors are eliminated between the linked rounds.'
- resultsSourceNote: Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.
+ resultsSourceNote: Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.
+ person:
+ notFound: 'Competitor not found.'
+ backToCompetitors: 'Back to competitors'
personalResults:
eventResults: Event results
round:
diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx
index aecc77e..5a9811f 100644
--- a/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx
+++ b/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx
@@ -116,3 +116,29 @@ it('replaces an initial error with content after a successful retry', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByText('Competition content')).toBeInTheDocument();
});
+
+it('announces initial loading and retry loading even when the query clears its previous error', () => {
+ const { rerender } = render(view());
+ expect(screen.getByRole('status')).toHaveTextContent('common.loading');
+ mockUseWcif.mockReturnValue({
+ error: new Error('Missing'),
+ dataUpdatedAt: 0,
+ isFetching: false,
+ refetch,
+ });
+ rerender(view());
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
+ mockUseWcif.mockReturnValue({ error: null, dataUpdatedAt: 0, isFetching: true, refetch });
+ rerender(view());
+ expect(screen.getByRole('status')).toHaveTextContent('common.loading');
+ expect(screen.queryByText('Competition content')).not.toBeInTheDocument();
+ mockUseWcif.mockReturnValue({
+ error: new Error('Missing'),
+ dataUpdatedAt: 0,
+ isFetching: false,
+ refetch,
+ });
+ rerender(view());
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'common.retry' })).toBeEnabled();
+});
diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tsx
index ced849f..635f6a8 100644
--- a/src/layouts/CompetitionLayout/CompetitionLayout.tsx
+++ b/src/layouts/CompetitionLayout/CompetitionLayout.tsx
@@ -74,6 +74,11 @@ export function CompetitionLayout() {
+ {!wcif && isFetching && (
+
+ {t('common.loading')}
+
+ )}
{error && (
diff --git a/src/layouts/RootLayout/Header.test.tsx b/src/layouts/RootLayout/Header.test.tsx
new file mode 100644
index 0000000..b837099
--- /dev/null
+++ b/src/layouts/RootLayout/Header.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import Header from './Header';
+
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+jest.mock('@/providers/AuthProvider', () => ({ useAuth: () => ({ user: null }) }));
+jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({}) }));
+jest.mock('@/lib/featureFlags', () => ({ FEATURE_FLAGS: {} }));
+jest.mock('@/hooks/queries/useCompetition', () => ({ useCompetition: () => ({}) }));
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ getQueryData: () => undefined, getQueriesData: () => [] }),
+}));
+it.each(['/', '/settings'])(
+ 'names the Home and Settings links without a bogus competition link on %s',
+ (path) => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'common.home' })).toHaveAttribute('href', '/');
+ expect(screen.getByRole('link', { name: 'header.settings' })).toHaveAttribute(
+ 'href',
+ '/settings',
+ );
+ expect(screen.getAllByRole('link')).toHaveLength(2);
+ expect(screen.queryByRole('link', { name: '' })).not.toBeInTheDocument();
+ },
+);
+it('uses the competition ID as a nonempty name while the competition details load', () => {
+ render(
+
+
+ } />
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'Loading2026' })).toHaveAttribute(
+ 'href',
+ '/competitions/Loading2026',
+ );
+});
diff --git a/src/layouts/RootLayout/Header.tsx b/src/layouts/RootLayout/Header.tsx
index b0cf4c2..677f9c0 100644
--- a/src/layouts/RootLayout/Header.tsx
+++ b/src/layouts/RootLayout/Header.tsx
@@ -21,7 +21,7 @@ export default function Header() {
const { data: comp } = useCompetition(competitionId);
const { wcif } = useWCIF();
- const competitioName = useMemo(() => {
+ const competitionName = useMemo(() => {
if (comp?.name) {
return comp.name;
}
@@ -55,13 +55,15 @@ export default function Header() {
return (
)}
diff --git a/src/pages/Home/Home.test.tsx b/src/pages/Home/Home.test.tsx
new file mode 100644
index 0000000..5ad61c6
--- /dev/null
+++ b/src/pages/Home/Home.test.tsx
@@ -0,0 +1,69 @@
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
+import { fetchSearchCompetition } from '@/lib/api';
+import Home from './index';
+
+jest.mock('@/lib/api', () => ({ fetchSearchCompetition: jest.fn() }));
+jest.mock('@/providers/UserSettingsProvider', () => ({
+ useUserSettings: () => ({ effectiveTheme: 'light' }),
+}));
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+jest.mock('@/providers/AuthProvider', () => ({ useAuth: () => ({ user: {}, signIn: jest.fn() }) }));
+jest.mock('@/containers/MyCompetitions', () => ({ MyCompetitions: () => null }));
+jest.mock('@/containers/UpcomingCompetitions/UpcomingCompetitions', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('@/components/LoggedOutPromptCard', () => ({ LoggedOutPromptCard: () => null }));
+function Competition() {
+ const navigate = useNavigate();
+ return (
+ <>
+ Opened competition
+
+ >
+ );
+}
+it('opens the selected competition with the keyboard, then supports Back and another selection', async () => {
+ jest.mocked(fetchSearchCompetition).mockResolvedValue({
+ result: [
+ {
+ id: 'Campulung2026',
+ name: 'Campulung Moldovenesc Open 2026',
+ start_date: '2026-10-31',
+ end_date: '2026-10-31',
+ country_iso2: 'RO',
+ city: 'Campulung',
+ } as ApiCompetition,
+ ],
+ });
+ render(
+
+
+ } />
+ } />
+
+ ,
+ );
+ await act(async () => {
+ await userEvent.type(screen.getByRole('combobox'), 'Campulung');
+ });
+ await screen.findByText('Campulung Moldovenesc Open 2026');
+ await act(async () => {
+ await userEvent.keyboard('{ArrowDown}{Enter}');
+ });
+ expect(screen.getByText('Opened competition')).toBeInTheDocument();
+ await act(async () => {
+ await userEvent.click(screen.getByRole('button', { name: 'Back' }));
+ });
+ expect(screen.getByRole('combobox')).toHaveValue('');
+ await act(async () => {
+ await userEvent.type(screen.getByRole('combobox'), 'Campulung');
+ });
+ const option = await screen.findByText('Campulung Moldovenesc Open 2026');
+ await act(async () => {
+ await userEvent.click(option);
+ });
+ expect(screen.getByText('Opened competition')).toBeInTheDocument();
+});
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
index 55e48a3..866766a 100644
--- a/src/pages/Home/index.tsx
+++ b/src/pages/Home/index.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { Link } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
import { CompetitionSelect } from '@/components/CompetitionSelect';
import { Container } from '@/components/Container';
import { LoggedOutPromptCard } from '@/components/LoggedOutPromptCard';
@@ -10,6 +10,7 @@ import { useAuth } from '@/providers/AuthProvider';
export default function Home() {
const { t } = useTranslation();
+ const navigate = useNavigate();
const { user, signIn } = useAuth();
useEffect(() => {
@@ -32,7 +33,7 @@ export default function Home() {
{!user && }
- console.log(e)} />
+ navigate(`/competitions/${id}`)} />
From fb8ac2fcff6a966b4981e06ffed2825f4513b8e0 Mon Sep 17 00:00:00 2001
From: Cailyn Sinclair
Date: Tue, 3 Nov 2026 08:40:22 -0800
Subject: [PATCH 3/4] Fix second-pass UAT findings and restore session scroll
Preserve explicit schedule choices, name and contain result dialogs, keep open result details current, label personal bookmarks, and distinguish condensed loading data from missing content.
Remember each history entry's scroll position with sessionStorage, including nested panes, Back/Forward, reload and delayed content. Cancel deferred restoration after user input or newer navigation.
Verified source tree: 34d1d8a557564c6506641790ad4b6a45c811449f. 413 tests; type/lint/build/Storybook; independent 50 advancement and 18 scroll probes.
---
src/components/Container/Container.tsx | 9 +-
src/containers/About/About.tsx | 2 +-
.../CompetitionPersonalResults.test.tsx | 4 +-
.../CompetitionPersonalResultsTable.tsx | 41 +-
.../CompetitionResultsTable.tsx | 48 +-
.../ResultDetailsDialog.test.tsx | 386 +++++++++++++++
.../ResultDetailsDialog.tsx | 80 +++-
.../PersonalSchedule/Assignments.test.tsx | 92 +++-
.../PersonalSchedule/Assignments.tsx | 42 +-
.../PersonalSchedule/PersonHeader.test.tsx | 110 +++++
.../PersonalSchedule/PersonHeader.tsx | 9 +-
src/containers/Schedule/Schedule.test.tsx | 85 +++-
src/containers/Schedule/Schedule.tsx | 14 +-
src/containers/Support/Support.tsx | 2 +-
src/hooks/UseCollapse/UseCollapse.test.tsx | 36 ++
src/hooks/UseCollapse/UseCollapse.ts | 16 +-
.../restoreScroll.ts | 156 ++++++
.../scrollPositions.test.ts | 77 +++
.../scrollPositions.ts | 65 +++
.../useSessionScrollRestoration.test.tsx | 452 ++++++++++++++++++
.../useSessionScrollRestoration.ts | 51 ++
src/i18n/en/translation.yaml | 2 +
.../CompetitionLayout.test.tsx | 207 +++++++-
.../CompetitionLayout/CompetitionLayout.tsx | 20 +-
src/layouts/RootLayout/index.tsx | 9 +-
src/pages/Home/index.tsx | 4 +-
src/pages/LiveActivities/About.tsx | 4 +-
src/pages/User/index.tsx | 8 +-
28 files changed, 1924 insertions(+), 107 deletions(-)
create mode 100644 src/containers/CompetitionResults/ResultDetailsDialog.test.tsx
create mode 100644 src/containers/PersonalSchedule/PersonHeader.test.tsx
create mode 100644 src/hooks/useSessionScrollRestoration/restoreScroll.ts
create mode 100644 src/hooks/useSessionScrollRestoration/scrollPositions.test.ts
create mode 100644 src/hooks/useSessionScrollRestoration/scrollPositions.ts
create mode 100644 src/hooks/useSessionScrollRestoration/useSessionScrollRestoration.test.tsx
create mode 100644 src/hooks/useSessionScrollRestoration/useSessionScrollRestoration.ts
diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx
index bc032f9..147d7e0 100644
--- a/src/components/Container/Container.tsx
+++ b/src/components/Container/Container.tsx
@@ -4,11 +4,18 @@ import { PropsWithChildren } from 'react';
interface ContainerProps extends PropsWithChildren {
className?: string;
fullWidth?: boolean;
+ scrollRestorationId?: string;
}
-export const Container = ({ children, className, fullWidth = false }: ContainerProps) => {
+export const Container = ({
+ children,
+ className,
+ fullWidth = false,
+ scrollRestorationId,
+}: ContainerProps) => {
return (
+
{t('about.welcome')}
diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx
index 592e19d..d7713f9 100644
--- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx
+++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx
@@ -340,7 +340,9 @@ describe('CompetitionPersonalResultsContainer', () => {
fireEvent.click(screen.getByRole('row', { name: /2 Round 1/ }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: '#2' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('dialog', { name: 'Vikram Haldar · 3x3x3 Cube · Round 1 #2' }),
+ ).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('Event')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('3x3x3 Cube')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('Round')).toBeInTheDocument();
diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx
index 42bfe44..578f8d2 100644
--- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx
+++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx
@@ -1,6 +1,6 @@
import { ResultValue, EventId, Person } from '@wca/helpers';
import classNames from 'classnames';
-import { KeyboardEvent, useState } from 'react';
+import { KeyboardEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CompetitionRoundResult } from '@/containers/CompetitionResults/CompetitionResultsTable';
import { ResultDetailsDialog } from '@/containers/CompetitionResults/ResultDetailsDialog';
@@ -58,7 +58,16 @@ const toCompetitionRoundResult = (result: PersonalRoundResult): CompetitionRound
average: result.average,
});
-export function CompetitionPersonalResultsTable({
+export function CompetitionPersonalResultsTable(props: CompetitionPersonalResultsTableProps) {
+ return (
+
+ );
+}
+
+function PersonalEventResultsTable({
competitionId,
eventId,
eventName,
@@ -67,21 +76,30 @@ export function CompetitionPersonalResultsTable({
LinkComponent,
}: CompetitionPersonalResultsTableProps) {
const { t } = useTranslation();
- const [selectedResult, setSelectedResult] = useState (null);
+ const [selection, setSelection] = useState<{
+ roundId: string;
+ trigger: HTMLTableRowElement;
+ } | null>(null);
+ const selectedResult = selection
+ ? roundResults.find((result) => result.roundId === selection.roundId)
+ : undefined;
+ useEffect(() => {
+ if (selection && !selectedResult) setSelection(null);
+ }, [selection, selectedResult]);
const attemptColumnCount = Math.max(0, ...roundResults.map((result) => result.attempts.length));
const getRoundName = (result: PersonalRoundResult) =>
result.roundName ?? t('common.activityCodeToName.round', { roundNumber: result.roundNumber });
- const openDetailsDialog = (result: PersonalRoundResult) => {
+ const openDetailsDialog = (result: PersonalRoundResult, trigger: HTMLTableRowElement) => {
if (!shouldOpenResultDetailsDialog()) {
return;
}
- setSelectedResult(result);
+ setSelection({ roundId: result.roundId, trigger });
};
const openDetailsDialogFromKeyboard =
(result: PersonalRoundResult) => (event: KeyboardEvent) => {
- if (event.key !== 'Enter' && event.key !== ' ') {
+ if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return;
}
@@ -90,7 +108,7 @@ export function CompetitionPersonalResultsTable({
}
event.preventDefault();
- setSelectedResult(result);
+ setSelection({ roundId: result.roundId, trigger: event.currentTarget });
};
return (
@@ -137,7 +155,7 @@ export function CompetitionPersonalResultsTable({
key={result.roundId}
className="cursor-pointer border-b border-tertiary-weak hover-transition hover:bg-gray-50 last:border-b-0 dark:hover:bg-gray-800 md:cursor-default"
tabIndex={0}
- onClick={() => openDetailsDialog(result)}
+ onClick={(event) => openDetailsDialog(result, event.currentTarget)}
onKeyDown={openDetailsDialogFromKeyboard(result)}>
setSelectedResult(null)}
+ returnFocusTo={selection?.trigger}
+ onClose={() => setSelection(null)}
/>
)}
diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx
index 1e04355..d47739f 100644
--- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx
+++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx
@@ -7,7 +7,7 @@ import {
RoundFormat,
} from '@wca/helpers';
import classNames from 'classnames';
-import { KeyboardEvent, ReactNode, useState } from 'react';
+import { KeyboardEvent, ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer';
import { renderResultByEventId } from '@/lib/results';
@@ -163,6 +163,15 @@ const ResultValue = ({
);
+// Use the same identity as merged results, not a source-specific result ID.
+// A stored result replaced by Live should keep its row, selection and focus.
+const getResultKey = (result: CompetitionRoundResult) =>
+ result.personId != null
+ ? `person-${result.personId}`
+ : result.wcaId
+ ? `wca-${result.wcaId}`
+ : `result-${result.id}`;
+
const isRosterOnlyResult = (result: CompetitionRoundResult) =>
result.ranking == null &&
result.attempts.length === 0 &&
@@ -207,7 +216,16 @@ const toCompetitionRoundResult = (result: Round['results'][number]): Competition
};
};
-export function CompetitionResultsTable({
+export function CompetitionResultsTable(props: CompetitionResultsTableProps) {
+ return (
+
+ );
+}
+
+function RoundResultsTable({
competitionId,
eventId,
round,
@@ -216,23 +234,32 @@ export function CompetitionResultsTable({
LinkComponent = AnchorLink,
}: CompetitionResultsTableProps) {
const { t } = useTranslation();
- const [selectedResult, setSelectedResult] = useState(null);
+ const [selection, setSelection] = useState<{
+ resultKey: string;
+ trigger: HTMLTableRowElement;
+ } | null>(null);
+ const selectedResult = selection
+ ? results.find((result) => getResultKey(result) === selection.resultKey)
+ : undefined;
+ useEffect(() => {
+ if (selection && !selectedResult) setSelection(null);
+ }, [selection, selectedResult]);
const primaryRankingType = getPrimaryRankingType(round.format);
const showSecondaryBest = primaryRankingType === 'average';
const attemptColumnCount = Math.max(0, ...results.map((result) => result.attempts.length));
const sortedResults = sortResultsByRanking(results);
- const openDetailsDialog = (result: CompetitionRoundResult) => {
+ const openDetailsDialog = (result: CompetitionRoundResult, trigger: HTMLTableRowElement) => {
if (!shouldOpenResultDetailsDialog()) {
return;
}
- setSelectedResult(result);
+ setSelection({ resultKey: getResultKey(result), trigger });
};
const openDetailsDialogFromKeyboard =
(result: CompetitionRoundResult) => (event: KeyboardEvent) => {
- if (event.key !== 'Enter' && event.key !== ' ') {
+ if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return;
}
@@ -241,7 +268,7 @@ export function CompetitionResultsTable({
}
event.preventDefault();
- setSelectedResult(result);
+ setSelection({ resultKey: getResultKey(result), trigger: event.currentTarget });
};
if (results.length === 0) {
@@ -308,10 +335,10 @@ export function CompetitionResultsTable({
return (
openDetailsDialog(result)}
+ onClick={(event) => openDetailsDialog(result, event.currentTarget)}
onKeyDown={openDetailsDialogFromKeyboard(result)}>
setSelectedResult(null)}
+ returnFocusTo={selection?.trigger}
+ onClose={() => setSelection(null)}
/>
);
})()}
diff --git a/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx b/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx
new file mode 100644
index 0000000..386820e
--- /dev/null
+++ b/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx
@@ -0,0 +1,386 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Person, Round } from '@wca/helpers';
+import { StrictMode } from 'react';
+import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
+import { CompetitionPersonalResultsTable } from '@/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable';
+import { CompetitionResultsTable, CompetitionRoundResult } from './CompetitionResultsTable';
+import { ResultDetailsDialog } from './ResultDetailsDialog';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) =>
+ ({
+ 'common.close': 'Close',
+ 'competition.results.allResults': 'All results',
+ })[key] ?? key,
+ }),
+}));
+
+const persons = [
+ { registrantId: 1, name: 'Competitor One' },
+ { registrantId: 2, name: 'Competitor Two' },
+] as Person[];
+const results: CompetitionRoundResult[] = persons.map((person, index) => ({
+ id: index + 1,
+ personId: person.registrantId,
+ ranking: index + 1,
+ attempts: [{ result: 1200 }],
+ best: 1200,
+ average: 1300,
+ advancing: false,
+ advancingQuestionable: false,
+}));
+const round = { id: '333-r1', format: 'a', results: [] } as unknown as Round;
+const table = (roundId = '333-r1') => (
+
+);
+
+async function openRow(name = 'Competitor One', key = '{Enter}') {
+ const row = screen.getByRole('row', { name: new RegExp(name) });
+ act(() => row.focus());
+ await act(() => userEvent.keyboard(key));
+ return row;
+}
+
+it('opens a named dialog, moves focus inside and traps forward/backward tabbing', async () => {
+ render({table()});
+ const row = await openRow();
+ const dialog = screen.getByRole('dialog', { name: 'Competitor One #1' });
+ const close = within(dialog).getByRole('button', { name: 'Close' });
+ const link = within(dialog).getByRole('link', { name: 'All results' });
+ expect(close).toHaveFocus();
+ await act(() => userEvent.tab());
+ expect(link).toHaveFocus();
+ await act(() => userEvent.tab({ shift: true }));
+ expect(close).toHaveFocus();
+ await act(() => userEvent.tab({ shift: true }));
+ expect(link).toHaveFocus();
+ await act(() => userEvent.tab());
+ expect(close).toHaveFocus();
+ // A programmatic focus escape is also redirected while the modal is open.
+ act(() => row.focus());
+ expect(close).toHaveFocus();
+});
+
+it.each(['Close', 'Escape', 'backdrop'])(
+ 'restores the invoking row after %s and repeated opening',
+ async (dismissal) => {
+ render(
+
+ {table()}
+ ,
+ );
+ const row = await openRow();
+ const dialog = screen.getByRole('dialog');
+ if (dismissal === 'Close')
+ await act(() => userEvent.click(within(dialog).getByRole('button', { name: 'Close' })));
+ else if (dismissal === 'Escape') await act(() => userEvent.keyboard('{Escape}'));
+ else await act(() => userEvent.click(dialog));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(row).toHaveFocus();
+ const secondRow = await openRow('Competitor Two', ' ');
+ expect(screen.getByRole('dialog', { name: 'Competitor Two #2' })).toBeInTheDocument();
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(secondRow).toHaveFocus();
+ },
+);
+
+it('keeps a single Close control focused and uses the latest close callback without resetting focus', async () => {
+ const firstClose = jest.fn();
+ const nextClose = jest.fn();
+ const props = {
+ competitionId: 'Test',
+ eventId: '333',
+ name: 'One',
+ primaryRankingType: 'average' as const,
+ result: results[0],
+ };
+ const { rerender, unmount } = render();
+ const close = screen.getByRole('button', { name: 'Close' });
+ expect(close).toHaveFocus();
+ await act(() => userEvent.tab());
+ expect(close).toHaveFocus();
+ await act(() => userEvent.tab({ shift: true }));
+ expect(close).toHaveFocus();
+ rerender();
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(nextClose).toHaveBeenCalledTimes(1);
+ expect(firstClose).not.toHaveBeenCalled();
+ unmount();
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
+ document.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+});
+
+it('releases focus and listeners on link navigation rather than restoring a removed row', async () => {
+ render(
+
+
+
+ Personal page action}
+ />
+
+ ,
+ );
+ const row = await openRow();
+ const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' });
+ act(() => link.focus());
+ await act(() => userEvent.keyboard('{Enter}'));
+ expect(screen.getByRole('button', { name: 'Personal page action' })).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(row.isConnected).toBe(false);
+ await act(() => userEvent.tab());
+ expect(screen.getByRole('button', { name: 'Personal page action' })).toHaveFocus();
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
+ document.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+});
+
+it('dismisses a stale selection on a round change and does not reopen it on Back', async () => {
+ const { rerender } = render({table()});
+ await openRow();
+ rerender({table('333-r2')});
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ rerender({table()});
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+it('applies the same focus lifecycle and person isolation to personal result tables', async () => {
+ const personalTable = (person: Person) => (
+
+ );
+ const { rerender } = render({personalTable(persons[0])});
+ const row = screen.getByRole('row', { name: /Round 1/ });
+ act(() => row.focus());
+ await act(() => userEvent.keyboard('{Enter}'));
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAccessibleName();
+ expect(within(dialog).getByRole('button', { name: 'Close' })).toHaveFocus();
+ await act(() => userEvent.tab());
+ expect(within(dialog).getByRole('link', { name: 'All results' })).toHaveFocus();
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(row).toHaveFocus();
+ fireEvent.click(row);
+ rerender({personalTable(persons[1])});
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+it('keeps focus through live prop updates, calls the latest callback and restores the original opener', async () => {
+ const opener = document.createElement('button');
+ opener.textContent = 'Original opener';
+ document.body.appendChild(opener);
+ opener.focus();
+ const firstClose = jest.fn();
+ const nextClose = jest.fn();
+ const props = {
+ competitionId: 'Test',
+ eventId: '333',
+ name: 'Competitor One',
+ person: persons[0],
+ primaryRankingType: 'average' as const,
+ result: results[0],
+ };
+ const { rerender, unmount } = render(
+
+
+ ,
+ );
+ const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' });
+ act(() => link.focus());
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByRole('dialog', { name: 'Updated competitor #2' })).toBeInTheDocument();
+ expect(link).toHaveFocus();
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(nextClose).toHaveBeenCalledTimes(1);
+ expect(firstClose).not.toHaveBeenCalled();
+ await act(async () => unmount());
+ expect(opener).toHaveFocus();
+ opener.remove();
+});
+
+it('restores a pointer-invoked row even when the click did not focus the row', async () => {
+ render({table()});
+ const row = screen.getByRole('row', { name: /Competitor Two/ });
+ fireEvent.click(row);
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
+ await act(() => userEvent.click(screen.getByRole('button', { name: 'Close' })));
+ expect(row).toHaveFocus();
+});
+
+it('does not steal focus from a replacement screen during unmount cleanup', async () => {
+ const opener = document.createElement('button');
+ const replacement = document.createElement('button');
+ document.body.append(opener, replacement);
+ opener.focus();
+ const { unmount } = render(
+ ,
+ );
+ await act(async () => {
+ unmount();
+ replacement.focus();
+ });
+ expect(replacement).toHaveFocus();
+ opener.remove();
+ replacement.remove();
+});
+
+it('refreshes an open competition modal from current results while retaining focus and source-independent row identity', async () => {
+ const liveTable = (currentResults: CompetitionRoundResult[]) => (
+
+
+
+ );
+ const { rerender } = render(liveTable(results));
+ const row = await openRow();
+ const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' });
+ act(() => link.focus());
+ // The result source changes its ID, but this is still the same registered person.
+ const refreshed = [
+ {
+ ...results[0],
+ id: 'live-result-1',
+ ranking: 2,
+ attempts: [{ result: 900 }],
+ best: 900,
+ average: 1000,
+ },
+ results[1],
+ ];
+ rerender(liveTable(refreshed));
+ const dialog = screen.getByRole('dialog', { name: 'Competitor One #2' });
+ expect(within(dialog).getByText('10.00')).toBeInTheDocument();
+ expect(within(dialog).getAllByText('9.00')).toHaveLength(2);
+ expect(within(dialog).queryByText('13.00')).not.toBeInTheDocument();
+ expect(link).toHaveFocus();
+ expect(screen.getByRole('row', { name: /Competitor One/ })).toBe(row);
+ expect(row).toHaveTextContent('10.00');
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(row).toHaveFocus();
+});
+
+it('dismisses a removed competition result without reopening it when results return', async () => {
+ const liveTable = (currentResults: CompetitionRoundResult[]) => (
+
+
+
+ );
+ const { rerender } = render(liveTable(results));
+ const row = await openRow();
+ rerender(liveTable([results[1]]));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(row.isConnected).toBe(false);
+ rerender(liveTable(results));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
+ document.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+});
+
+it('refreshes and removes an open personal round modal from current round results', async () => {
+ const initial = [
+ {
+ roundId: '333-r1',
+ roundName: 'Round 1',
+ roundNumber: 1,
+ ranking: 1,
+ attempts: [{ result: 1200 }],
+ best: 1200,
+ average: 1300,
+ advancing: false,
+ advancingQuestionable: false,
+ },
+ ];
+ const personalTable = (currentResults: typeof initial) => (
+
+
+
+ );
+ const { rerender } = render(personalTable(initial));
+ const row = screen.getByRole('row', { name: /Round 1/ });
+ act(() => row.focus());
+ await act(() => userEvent.keyboard('{Enter}'));
+ const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' });
+ act(() => link.focus());
+ const refreshed = [
+ { ...initial[0], ranking: 2, attempts: [{ result: 900 }], best: 900, average: 1000 },
+ ];
+ rerender(personalTable(refreshed));
+ const dialog = screen.getByRole('dialog', { name: 'Competitor One · 3x3x3 Cube · Round 1 #2' });
+ expect(within(dialog).getByText('10.00')).toBeInTheDocument();
+ expect(within(dialog).getAllByText('9.00')).toHaveLength(2);
+ expect(within(dialog).queryByText('13.00')).not.toBeInTheDocument();
+ expect(link).toHaveFocus();
+ await act(() => userEvent.keyboard('{Escape}'));
+ expect(row).toHaveFocus();
+ await act(() => userEvent.keyboard('{Enter}'));
+ rerender(personalTable([]));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ rerender(personalTable(refreshed));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
diff --git a/src/containers/CompetitionResults/ResultDetailsDialog.tsx b/src/containers/CompetitionResults/ResultDetailsDialog.tsx
index 6562e5f..0ad08e5 100644
--- a/src/containers/CompetitionResults/ResultDetailsDialog.tsx
+++ b/src/containers/CompetitionResults/ResultDetailsDialog.tsx
@@ -1,5 +1,5 @@
import { EventId, Person, RankingType } from '@wca/helpers';
-import { MouseEvent, useEffect } from 'react';
+import { MouseEvent, useId, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer';
import { renderResultByEventId } from '@/lib/results';
@@ -24,6 +24,7 @@ interface ResultDetailsDialogProps {
primaryRankingType: RankingType;
result: CompetitionRoundResult;
onClose: () => void;
+ returnFocusTo?: HTMLElement | null;
LinkComponent?: LinkRenderer;
}
@@ -45,9 +46,15 @@ export function ResultDetailsDialog({
primaryRankingType,
result,
onClose,
+ returnFocusTo,
LinkComponent = AnchorLink,
}: ResultDetailsDialogProps) {
const { t } = useTranslation();
+ const titleId = useId();
+ const dialogRef = useRef(null);
+ const closeButtonRef = useRef(null);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
const attempts = result.attempts
.map((attempt) => renderValue(eventId, 'single', attempt.result))
.filter(Boolean);
@@ -70,29 +77,83 @@ export function ResultDetailsDialog({
event.stopPropagation();
};
- useEffect(() => {
- const closeOnEscape = (event: KeyboardEvent) => {
+ useLayoutEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ const previousFocus =
+ returnFocusTo ??
+ (document.activeElement instanceof HTMLElement ? document.activeElement : null);
+ const focusInside = () => (closeButtonRef.current ?? dialog).focus({ preventScroll: true });
+ const focusableElements = () =>
+ Array.from(
+ dialog.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]',
+ ),
+ ).filter((element) => {
+ const style = window.getComputedStyle(element);
+ return (
+ element.tabIndex >= 0 &&
+ !element.matches(':disabled') &&
+ !element.closest('[hidden], [inert], [aria-hidden="true"]') &&
+ style.display !== 'none' &&
+ style.visibility !== 'hidden'
+ );
+ });
+ const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
- onClose();
+ event.preventDefault();
+ event.stopPropagation();
+ onCloseRef.current();
+ } else if (event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey) {
+ event.preventDefault();
+ const controls = focusableElements();
+ const index = controls.indexOf(document.activeElement as HTMLElement);
+ const nextIndex = event.shiftKey
+ ? index <= 0
+ ? controls.length - 1
+ : index - 1
+ : (index + 1) % controls.length;
+ (controls[nextIndex] ?? closeButtonRef.current ?? dialog).focus();
}
};
+ const containFocus = (event: FocusEvent) => {
+ if (event.target instanceof Node && !dialog.contains(event.target)) focusInside();
+ };
- document.addEventListener('keydown', closeOnEscape);
+ document.addEventListener('keydown', handleKeyDown, true);
+ document.addEventListener('focusin', containFocus);
+ focusInside();
return () => {
- document.removeEventListener('keydown', closeOnEscape);
+ document.removeEventListener('keydown', handleKeyDown, true);
+ document.removeEventListener('focusin', containFocus);
+ // Wait until removed route elements are detached. Do not steal focus from
+ // a new page/dialog, or restore during StrictMode's effect replay.
+ queueMicrotask(() => {
+ const active = document.activeElement;
+ if (
+ !dialog.isConnected &&
+ previousFocus?.isConnected &&
+ (active === document.body || (active instanceof Node && dialog.contains(active)))
+ ) {
+ previousFocus.focus({ preventScroll: true });
+ }
+ });
};
- }, [onClose]);
+ }, [returnFocusTo]);
return (
-
+
{title ?? `${name}${result.ranking ? ` #${result.ranking}` : ''}`}
@@ -137,7 +198,8 @@ export function ResultDetailsDialog({
| | |