From ef9d1048db16cd88d7d08b4fa5b36b420971a418 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 18:33:29 -0700 Subject: [PATCH 01/64] fix: show advancement details for participation rounds --- .../CutoffTimeLimitPanel.test.tsx | 139 +++++++++++++- .../CutoffTimeLimitPanel.tsx | 153 +++++++++++---- src/lib/wcif.test.ts | 112 +++++++++++ src/lib/wcif.ts | 181 ++++++++++++++++++ 4 files changed, 543 insertions(+), 42 deletions(-) create mode 100644 src/lib/wcif.test.ts create mode 100644 src/lib/wcif.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 832e75e..21ee6db 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -22,24 +22,122 @@ jest.mock('react-tiny-popover', () => ({ })); jest.mock('react-i18next', () => ({ - Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, + Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record }) => { + if (i18nKey === 'common.wca.advancement.ranking') { + return `Top ${values?.level} to next round`; + } + + if (i18nKey === 'common.wca.advancement.percent') { + return `Top ${values?.level}% to next round`; + } + + if (i18nKey === 'common.wca.cumulativeTimelimit') { + return `Time Limit: ${values?.time} Cumulative`; + } + + if (i18nKey === 'common.wca.cumulativeTimelimitWithrounds') { + return `Time Limit: ${values?.time} Total with:`; + } + + return i18nKey; + }, useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => { + if (key === 'common.help') { + return 'help'; + } + + if (key === 'common.wca.cutoff') { + return 'Cutoff'; + } + + if (key === 'common.wca.timeLimit') { + return 'Time Limit'; + } + + if (key === 'common.activityCodeToName.round') { + return `Round ${options?.roundNumber}`; + } + + if (options?.defaultValue) { + return String(options.defaultValue) + .replace('{{level}}', String(options.level ?? '')) + .replace('{{rounds}}', String(options.rounds ?? '')) + .replace('{{scope}}', String(options.scope ?? '')) + .replace('{{result}}', String(options.result ?? '')); + } + + return key; + }, }), })); +const wcifMock = { + id: 'TestComp2026', + schedule: { venues: [] }, + events: [ + { + id: '333', + rounds: [ + { + id: '333-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: { + type: 'ranking', + level: 16, + }, + results: [], + }, + { + id: '333-r2', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'percent', + value: 75, + }, + }, + }, + results: [], + }, + { + id: '333-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + results: [], + }, + ], + }, + ], +}; + jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ competitionId: 'TestComp2026', - wcif: { - id: 'TestComp2026', - schedule: { venues: [] }, - }, + wcif: wcifMock, setTitle: () => {}, }), })); -const round = { +const cutoffOnlyRound = { id: '333-r1', cutoff: { numberOfAttempts: 2, @@ -49,7 +147,7 @@ const round = { advancementCondition: null, } as unknown as Round; -function renderPanel() { +function renderPanel(round: Round) { return render( @@ -59,7 +157,7 @@ function renderPanel() { describe('CutoffTimeLimitPanel', () => { it('uses theme-aware popover classes for help content', () => { - renderPanel(); + renderPanel(cutoffOnlyRound); fireEvent.click(screen.getByRole('button', { name: /help/i })); @@ -71,4 +169,27 @@ describe('CutoffTimeLimitPanel', () => { expect(popoverContent).toHaveClass('text-default'); expect(popoverContent).not.toHaveClass('bg-white'); }); + + it('shows the legacy advancement text for stable wcif rounds', () => { + renderPanel(wcifMock.events[0].rounds[0] as unknown as Round); + + expect(screen.getByText('Top 16 to next round')).toBeInTheDocument(); + }); + + it('shows advancement text derived from the next round participation ruleset', () => { + renderPanel({ + ...(wcifMock.events[0].rounds[0] as object), + advancementCondition: null, + } as Round); + + expect(screen.getByText('Top 75% to next round')).toBeInTheDocument(); + }); + + it('shows linked-round advancement text when a later round depends on combined results', () => { + renderPanel(wcifMock.events[0].rounds[1] as unknown as Round); + + expect( + screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 615882b..1f15715 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,11 @@ import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; import { renderCentiseconds, renderCutoff } from '@/lib/results'; +import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; export function CutoffTimeLimitPanel({ @@ -20,10 +21,20 @@ export function CutoffTimeLimitPanel({ const cutoff = round.cutoff; const timeLimit = round.timeLimit; const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds); + const eventRounds = useMemo(() => { + const { eventId } = parseActivityCode(round.id); + return ( + wcif?.events + ?.find((event) => event.id === eventId) + ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] + ); + }, [round.id, wcif?.events]); + const advancement = useMemo( + () => getAdvancementConditionForRound(eventRounds, round as CompatibleRound), + [eventRounds, round], + ); - if (!timeLimit && !cutoff && !round.advancementCondition) return null; - - const level = round.advancementCondition?.level; + if (!timeLimit && !cutoff && !advancement) return null; return (
@@ -86,35 +97,9 @@ export function CutoffTimeLimitPanel({
)} - {round.advancementCondition && ( -
- {round.advancementCondition.type === 'ranking' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'percent' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'attemptResult' && ( -
- }} - /> -
- )} + {advancement && ( +
+ {renderAdvancementText(t, advancement.sourceType, advancement)}
)}
@@ -125,6 +110,108 @@ export function CutoffTimeLimitPanel({ ); } +function renderAdvancementText( + t: ReturnType['t'], + sourceType: 'registrations' | 'round' | 'linkedRounds', + advancement: NonNullable>, +) { + const isLinkedRounds = sourceType === 'linkedRounds'; + const { resultCondition } = advancement; + const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => + activityCodeToRoundName(t, roundId), + ); + const sourceRoundsLabel = joinLabels(sourceRoundNames); + + switch (resultCondition.type) { + case 'ranking': + return isLinkedRounds ? ( + <> + {t('common.wca.advancement.linkedRanking', { + defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round', + level: resultCondition.value, + rounds: sourceRoundsLabel, + })} + + ) : ( + }} + /> + ); + case 'percent': + return isLinkedRounds ? ( + <> + {t('common.wca.advancement.linkedPercent', { + defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round', + level: resultCondition.value, + rounds: sourceRoundsLabel, + })} + + ) : ( + }} + /> + ); + case 'resultAchieved': { + const thresholdCondition = resultCondition as Extract< + ResultCondition, + { type: 'resultAchieved' } + >; + const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, { + defaultValue: thresholdCondition.scope, + }).toLowerCase(); + const resultValue = + thresholdCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown', { + defaultValue: 'an unknown result', + }) + : renderCentiseconds(thresholdCondition.value); + + return ( + <> + {t( + isLinkedRounds + ? 'common.wca.advancement.linkedResultAchieved' + : 'common.wca.advancement.resultAchieved', + { + defaultValue: isLinkedRounds + ? 'Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated.' + : 'Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated.', + scope: scopeLabel, + result: resultValue, + rounds: sourceRoundsLabel, + }, + )} + + ); + } + } +} + +function activityCodeToRoundName(t: ReturnType['t'], roundId: string) { + const { roundNumber } = parseActivityCode(roundId); + + return t('common.activityCodeToName.round', { + defaultValue: `Round ${roundNumber}`, + roundNumber, + }); +} + +function joinLabels(labels: string[]) { + if (labels.length <= 1) { + return labels[0] || ''; + } + + if (labels.length === 2) { + return `${labels[0]} and ${labels[1]}`; + } + + return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; +} + function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts new file mode 100644 index 0000000..fb722c4 --- /dev/null +++ b/src/lib/wcif.test.ts @@ -0,0 +1,112 @@ +import { + CompatibleRound, + getAdvancementConditionForRound, + getRoundParticipationRuleset, +} from './wcif'; + +describe('wcif participation helpers', () => { + it('backfills stable advancement conditions into a participation ruleset', () => { + const rounds = [ + { + id: '333-r1', + format: 'a', + results: [], + }, + { + id: '333-r2', + format: 'a', + advancementCondition: { + type: 'ranking', + level: 16, + }, + results: [], + }, + ] as unknown as CompatibleRound[]; + + expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({ + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'ranking', + value: 16, + }, + }, + }); + }); + + it('derives current-round advancement from the next round participation ruleset', () => { + const rounds = [ + { + id: '333-r1', + format: 'a', + results: [], + }, + { + id: '333-r2', + format: 'a', + participationRuleset: { + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'percent', + value: 75, + }, + }, + }, + results: [], + }, + ] as unknown as CompatibleRound[]; + + expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ + sourceType: 'round', + sourceRoundIds: ['333-r1'], + resultCondition: { + type: 'percent', + value: 75, + }, + reservedPlaces: null, + }); + }); + + it('derives linked-round advancement from the next round participation ruleset', () => { + const rounds = [ + { + id: '333-r1', + format: 'a', + results: [], + }, + { + id: '333-r2', + format: 'a', + results: [], + }, + { + id: '333-r3', + format: 'a', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + results: [], + }, + ] as unknown as CompatibleRound[]; + + expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ + sourceType: 'linkedRounds', + sourceRoundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + reservedPlaces: null, + }); + }); +}); diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts new file mode 100644 index 0000000..b36c6b8 --- /dev/null +++ b/src/lib/wcif.ts @@ -0,0 +1,181 @@ +import { Round, parseActivityCode } from '@wca/helpers'; + +type LegacyAdvancementCondition = { + type: 'ranking' | 'percent' | 'attemptResult'; + level: number; +}; + +export type ResultCondition = + | { + type: 'ranking' | 'percent'; + value: number; + } + | { + type: 'resultAchieved'; + scope: 'single' | 'average'; + value: number | null; + }; + +export type ParticipationSource = + | { + type: 'registrations'; + } + | { + type: 'round'; + roundId: string; + resultCondition: ResultCondition; + } + | { + type: 'linkedRounds'; + roundIds: string[]; + resultCondition: ResultCondition; + }; + +export type ReservedPlaces = { + nationalities: string[]; + count?: number; + reservations?: number; +}; + +export type ParticipationRuleset = { + participationSource: ParticipationSource; + reservedPlaces?: ReservedPlaces | null; +}; + +export interface RoundAdvancementCondition { + sourceType: ParticipationSource['type']; + sourceRoundIds: string[]; + resultCondition: ResultCondition; + reservedPlaces?: ReservedPlaces | null; +} + +export type CompatibleRound = Round & { + advancementCondition?: LegacyAdvancementCondition | null; + participationRuleset?: ParticipationRuleset | null; +}; + +const averagedFormats = new Set(['a', 'm', '5', 'h']); + +const getRoundResultType = (round: Pick): 'single' | 'average' => + averagedFormats.has(round.format) ? 'average' : 'single'; + +const getLegacyResultCondition = ( + advancementCondition: LegacyAdvancementCondition, + sourceRound?: CompatibleRound, +): ResultCondition => { + switch (advancementCondition.type) { + case 'ranking': + return { + type: 'ranking', + value: advancementCondition.level, + }; + case 'percent': + return { + type: 'percent', + value: advancementCondition.level, + }; + case 'attemptResult': + return { + type: 'resultAchieved', + scope: sourceRound ? getRoundResultType(sourceRound) : 'single', + value: advancementCondition.level, + }; + } +}; + +const getPreviousRound = ( + eventRounds: CompatibleRound[], + round: CompatibleRound, +): CompatibleRound | undefined => { + const { eventId, roundNumber } = parseActivityCode(round.id); + + if (!roundNumber || roundNumber <= 1) { + return undefined; + } + + return eventRounds.find((candidate) => candidate.id === `${eventId}-r${roundNumber - 1}`); +}; + +export const getRoundParticipationRuleset = ( + eventRounds: CompatibleRound[], + round: CompatibleRound, +): ParticipationRuleset | null => { + if (round.participationRuleset) { + return round.participationRuleset; + } + + if (!round.advancementCondition) { + return null; + } + + const previousRound = getPreviousRound(eventRounds, round); + if (!previousRound) { + return null; + } + + return { + participationSource: { + type: 'round', + roundId: previousRound.id, + resultCondition: getLegacyResultCondition(round.advancementCondition, previousRound), + }, + }; +}; + +const getRoundParticipationSource = ( + eventRounds: CompatibleRound[], + round: CompatibleRound, +): ParticipationSource | null => + getRoundParticipationRuleset(eventRounds, round)?.participationSource ?? null; + +export const getAdvancementConditionForRound = ( + eventRounds: CompatibleRound[], + round: CompatibleRound, +): RoundAdvancementCondition | null => { + if (round.advancementCondition) { + return { + sourceType: 'round', + sourceRoundIds: [round.id], + resultCondition: getLegacyResultCondition(round.advancementCondition, round), + reservedPlaces: null, + }; + } + + const { roundNumber } = parseActivityCode(round.id); + const futureRounds = eventRounds.filter((candidate) => { + const parsedCandidate = parseActivityCode(candidate.id); + return (parsedCandidate.roundNumber ?? 0) > (roundNumber ?? 0); + }); + + const nextEligibleRound = futureRounds.find((candidate) => { + const source = getRoundParticipationSource(eventRounds, candidate); + + if (!source || source.type === 'registrations') { + return false; + } + + if (source.type === 'round') { + return source.roundId === round.id; + } + + return source.roundIds.includes(round.id); + }); + + if (!nextEligibleRound) { + return null; + } + + const ruleset = getRoundParticipationRuleset(eventRounds, nextEligibleRound); + const source = ruleset?.participationSource; + + if (!source || source.type === 'registrations') { + return null; + } + + return { + sourceType: source.type, + sourceRoundIds: source.type === 'round' ? [source.roundId] : source.roundIds, + resultCondition: source.resultCondition, + reservedPlaces: ruleset?.reservedPlaces ?? null, + }; +}; From 57e52132e175f87bc22e34db465466c7aeee7aff Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 14:03:48 -0700 Subject: [PATCH 02/64] add participation condition typing and stories --- .../CutoffTimeLimitPanel.stories.tsx | 20 +++++ .../CompetitionRound.stories.tsx | 22 ++++++ src/lib/wcif.test.ts | 13 ++-- src/lib/wcif.ts | 77 +++++-------------- src/storybook/competitionFixtures.ts | 57 ++++++++++++++ src/types/wca-helpers.d.ts | 44 +++++++++++ 6 files changed, 168 insertions(+), 65 deletions(-) create mode 100644 src/types/wca-helpers.d.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index e12a099..55021f4 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { getStorybookRoundFixture, makeStorybookCompetitionFixtureWithRound, + storybookParticipationConditionLinkedRoundsFixture, + storybookParticipationConditionPercentFixture, } from '@/storybook/competitionFixtures'; import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; @@ -53,6 +55,24 @@ export const RankingAdvancement: Story = { }, }; +export const ParticipationConditionPercent: Story = { + parameters: { + competitionFixture: storybookParticipationConditionPercentFixture, + }, + args: { + round: storybookParticipationConditionPercentFixture.events[0].rounds[0], + }, +}; + +export const ParticipationConditionLinkedRounds: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + round: storybookParticipationConditionLinkedRoundsFixture.events[0].rounds[1], + }, +}; + export const CutoffAndTimeLimit: Story = { args: { round: getStorybookRoundFixture('222-r1'), diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index 92d0868..aec8f31 100644 --- a/src/containers/CompetitionRound/CompetitionRound.stories.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { makeStorybookCompetitionFixtureWithRound, makeStorybookEventCompetitionFixture, + storybookParticipationConditionLinkedRoundsFixture, + storybookParticipationConditionPercentFixture, } from '@/storybook/competitionFixtures'; import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; import { CompetitionRoundContainer } from './CompetitionRound'; @@ -24,6 +26,16 @@ export const RoundOne: Story = { }, }; +export const ParticipationConditionPercent: Story = { + parameters: { + competitionFixture: storybookParticipationConditionPercentFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + }, +}; + export const RoundTwo: Story = { args: { competitionId: 'SeattleSummerOpen2026', @@ -31,6 +43,16 @@ export const RoundTwo: Story = { }, }; +export const ParticipationConditionLinkedRounds: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + export const FinalRound: Story = { parameters: { competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index fb722c4..5b67872 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -1,8 +1,5 @@ -import { - CompatibleRound, - getAdvancementConditionForRound, - getRoundParticipationRuleset, -} from './wcif'; +import { Round } from '@wca/helpers'; +import { getAdvancementConditionForRound, getRoundParticipationRuleset } from './wcif'; describe('wcif participation helpers', () => { it('backfills stable advancement conditions into a participation ruleset', () => { @@ -21,7 +18,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({ participationSource: { @@ -57,7 +54,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ sourceType: 'round', @@ -97,7 +94,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ sourceType: 'linkedRounds', diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts index b36c6b8..af6d1b2 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -1,46 +1,15 @@ -import { Round, parseActivityCode } from '@wca/helpers'; +import { + ParticipationResultCondition, + ParticipationRuleset, + ParticipationSource, + ReservedPlaces, + Round, + parseActivityCode, +} from '@wca/helpers'; -type LegacyAdvancementCondition = { - type: 'ranking' | 'percent' | 'attemptResult'; - level: number; -}; - -export type ResultCondition = - | { - type: 'ranking' | 'percent'; - value: number; - } - | { - type: 'resultAchieved'; - scope: 'single' | 'average'; - value: number | null; - }; +type LegacyAdvancementCondition = NonNullable; -export type ParticipationSource = - | { - type: 'registrations'; - } - | { - type: 'round'; - roundId: string; - resultCondition: ResultCondition; - } - | { - type: 'linkedRounds'; - roundIds: string[]; - resultCondition: ResultCondition; - }; - -export type ReservedPlaces = { - nationalities: string[]; - count?: number; - reservations?: number; -}; - -export type ParticipationRuleset = { - participationSource: ParticipationSource; - reservedPlaces?: ReservedPlaces | null; -}; +export type ResultCondition = ParticipationResultCondition; export interface RoundAdvancementCondition { sourceType: ParticipationSource['type']; @@ -49,19 +18,16 @@ export interface RoundAdvancementCondition { reservedPlaces?: ReservedPlaces | null; } -export type CompatibleRound = Round & { - advancementCondition?: LegacyAdvancementCondition | null; - participationRuleset?: ParticipationRuleset | null; -}; +export type CompatibleRound = Round; const averagedFormats = new Set(['a', 'm', '5', 'h']); -const getRoundResultType = (round: Pick): 'single' | 'average' => +const getRoundResultType = (round: Pick): 'single' | 'average' => averagedFormats.has(round.format) ? 'average' : 'single'; const getLegacyResultCondition = ( advancementCondition: LegacyAdvancementCondition, - sourceRound?: CompatibleRound, + sourceRound?: Round, ): ResultCondition => { switch (advancementCondition.type) { case 'ranking': @@ -83,10 +49,7 @@ const getLegacyResultCondition = ( } }; -const getPreviousRound = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, -): CompatibleRound | undefined => { +const getPreviousRound = (eventRounds: Round[], round: Round): Round | undefined => { const { eventId, roundNumber } = parseActivityCode(round.id); if (!roundNumber || roundNumber <= 1) { @@ -97,8 +60,8 @@ const getPreviousRound = ( }; export const getRoundParticipationRuleset = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): ParticipationRuleset | null => { if (round.participationRuleset) { return round.participationRuleset; @@ -123,14 +86,14 @@ export const getRoundParticipationRuleset = ( }; const getRoundParticipationSource = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): ParticipationSource | null => getRoundParticipationRuleset(eventRounds, round)?.participationSource ?? null; export const getAdvancementConditionForRound = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): RoundAdvancementCondition | null => { if (round.advancementCondition) { return { diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 178d513..2bb83b7 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -730,3 +730,60 @@ export const makeStorybookCompetitionFixtureWithRound = ( return competition; }; + +export const makeStorybookCompetitionFixtureWithRoundUpdates = ( + updates: Record Round>, +): Competition => { + const competition = cloneCompetition(storybookCompetitionFixture); + + competition.events = competition.events.map((event) => ({ + ...event, + rounds: event.rounds.map((round) => updates[round.id]?.(round) ?? round), + })); + + return competition; +}; + +export const storybookParticipationConditionPercentFixture = + makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r1': (round) => ({ + ...round, + advancementCondition: null, + }), + '333-r2': (round) => ({ + ...round, + advancementCondition: null, + participationRuleset: { + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'percent', + value: 75, + }, + }, + }, + }), + }); + +export const storybookParticipationConditionLinkedRoundsFixture = + makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r2': (round) => ({ + ...round, + advancementCondition: null, + }), + '333-r3': (round) => ({ + ...round, + advancementCondition: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + }), + }); diff --git a/src/types/wca-helpers.d.ts b/src/types/wca-helpers.d.ts new file mode 100644 index 0000000..bb76a48 --- /dev/null +++ b/src/types/wca-helpers.d.ts @@ -0,0 +1,44 @@ +import '@wca/helpers'; + +declare module '@wca/helpers' { + export type ParticipationResultCondition = + | { + type: 'ranking' | 'percent'; + value: number; + } + | { + type: 'resultAchieved'; + scope: 'single' | 'average'; + value: number | null; + }; + + export type ParticipationSource = + | { + type: 'registrations'; + } + | { + type: 'round'; + roundId: string; + resultCondition: ParticipationResultCondition; + } + | { + type: 'linkedRounds'; + roundIds: string[]; + resultCondition: ParticipationResultCondition; + }; + + export interface ReservedPlaces { + nationalities: string[]; + count?: number; + reservations?: number; + } + + export interface ParticipationRuleset { + participationSource: ParticipationSource; + reservedPlaces?: ReservedPlaces | null; + } + + export interface Round { + participationRuleset?: ParticipationRuleset | null; + } +} From ee5398b0c3962f1edc64cbe50f85d2b88dc6e866 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 14:33:50 -0700 Subject: [PATCH 03/64] Address CutoffTimeLimitPanel review feedback --- .../CutoffTimeLimitPanel.test.tsx | 8 +++ .../CutoffTimeLimitPanel.tsx | 68 ++++++++++--------- src/i18n/en/translation.yaml | 5 ++ 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 21ee6db..54dcb55 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -31,6 +31,14 @@ jest.mock('react-i18next', () => ({ return `Top ${values?.level}% to next round`; } + if (i18nKey === 'common.wca.advancement.linkedRanking') { + return `Top ${values?.level} combined across ${values?.rounds} advance to next round`; + } + + if (i18nKey === 'common.wca.advancement.linkedPercent') { + return `Top ${values?.level}% combined across ${values?.rounds} advance to next round`; + } + if (i18nKey === 'common.wca.cumulativeTimelimit') { return `Time Limit: ${values?.time} Cumulative`; } diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 1f15715..7df8a21 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,4 +1,4 @@ -import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; +import { Competition, Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; @@ -21,14 +21,11 @@ export function CutoffTimeLimitPanel({ const cutoff = round.cutoff; const timeLimit = round.timeLimit; const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds); - const eventRounds = useMemo(() => { - const { eventId } = parseActivityCode(round.id); - return ( - wcif?.events - ?.find((event) => event.id === eventId) - ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] - ); - }, [round.id, wcif?.events]); + const cumulativeRoundIds = getCumulativeRoundIds(timeLimit, round.id); + const eventRounds = useMemo( + () => getEventRoundsForRound(wcif?.events, round.id), + [round.id, wcif?.events], + ); const advancement = useMemo( () => getAdvancementConditionForRound(eventRounds, round as CompatibleRound), [eventRounds, round], @@ -55,8 +52,7 @@ export function CutoffTimeLimitPanel({ {timeLimit && timeLimit?.cumulativeRoundIds.length > 0 && - timeLimit.cumulativeRoundIds.filter((activityCode) => activityCode !== round.id) - .length === 0 && ( + cumulativeRoundIds.length === 0 && ( 0 && - timeLimit.cumulativeRoundIds.filter((activityCode) => activityCode !== round.id) - .length > 0 && ( + cumulativeRoundIds.length > 0 && (
}} /> - {timeLimit.cumulativeRoundIds - .filter((activityCode) => activityCode !== round.id) - .map((activityCode, i, arry) => { - const { eventId, roundNumber } = parseActivityCode(activityCode); - return ( - - - {t('common.activityCodeToName.round', { roundNumber })} - {i < arry.length - 1 ? ', ' : ''} - - - ); - })} + {cumulativeRoundIds.map((activityCode, i, arry) => { + const { eventId, roundNumber } = parseActivityCode(activityCode); + return ( + + + {t('common.activityCodeToName.round', { roundNumber })} + {i < arry.length - 1 ? ', ' : ''} + + + ); + })}
)} @@ -110,6 +103,20 @@ export function CutoffTimeLimitPanel({ ); } +function getEventRoundsForRound(events: Competition['events'] | undefined, roundId: string) { + const { eventId } = parseActivityCode(roundId); + + return ( + events + ?.find((event) => event.id === eventId) + ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] + ); +} + +function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) { + return timeLimit?.cumulativeRoundIds.filter((activityCode) => activityCode !== roundId) || []; +} + function renderAdvancementText( t: ReturnType['t'], sourceType: 'registrations' | 'round' | 'linkedRounds', @@ -177,9 +184,6 @@ function renderAdvancementText( ? 'common.wca.advancement.linkedResultAchieved' : 'common.wca.advancement.resultAchieved', { - defaultValue: isLinkedRounds - ? 'Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated.' - : 'Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated.', scope: scopeLabel, result: resultValue, rounds: sourceRoundsLabel, diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index 81aad5b..cff3c8e 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -33,7 +33,12 @@ common: advancement: ranking: 'Top {{level}} to next round' percent: 'Top {{level}}% to next round' + linkedRanking: 'Top {{level}} combined across {{rounds}} advance to next round' + linkedPercent: 'Top {{level}}% combined across {{rounds}} advance to next round' attemptResult: Result better than {level} advances to next round. Minimum of 25% of competitors must be eliminated. + resultAchieved: Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated. + linkedResultAchieved: Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated. + resultThresholdUnknown: an unknown result resultType: single: single average: Average From cb09e3e2e0fb7a2dd01b00d2a7ec162333c58885 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 16:10:19 -0700 Subject: [PATCH 04/64] Refine advancement copy and linked round UI --- .../CutoffTimeLimitPanel.stories.tsx | 9 + .../CutoffTimeLimitPanel.test.tsx | 80 +++++++- .../CutoffTimeLimitPanel.tsx | 168 ++++++++-------- .../CompetitionRound.stories.tsx | 20 ++ .../CompetitionRound.test.tsx | 190 ++++++++++++++++++ .../CompetitionRound/CompetitionRound.tsx | 24 +++ src/i18n/en/translation.yaml | 20 +- src/lib/roundLabels.ts | 31 +++ src/lib/wcif.test.ts | 2 + src/lib/wcif.ts | 9 + src/storybook/competitionFixtures.ts | 4 + 11 files changed, 458 insertions(+), 99 deletions(-) create mode 100644 src/containers/CompetitionRound/CompetitionRound.test.tsx create mode 100644 src/lib/roundLabels.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index 55021f4..ff0533c 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -73,6 +73,15 @@ export const ParticipationConditionLinkedRounds: Story = { }, }; +export const ParticipationConditionLinkedRoundsStart: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + round: storybookParticipationConditionLinkedRoundsFixture.events[0].rounds[0], + }, +}; + export const CutoffAndTimeLimit: Story = { args: { round: getStorybookRoundFixture('222-r1'), diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 54dcb55..de058ce 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -67,10 +67,40 @@ jest.mock('react-i18next', () => ({ return `Round ${options?.roundNumber}`; } + if (key === 'common.wca.advancement.ranking') { + return `Top ${options?.level} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.percent') { + return `Top ${options?.level}% advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.linkedRanking') { + return `Top ${options?.level} in dual rounds ${options?.rounds} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.linkedPercent') { + return `Top ${options?.level}% in dual rounds ${options?.rounds} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.nextRound') { + return 'next round'; + } + + if (key === 'common.wca.advancement.final') { + return 'final'; + } + + if (key === 'common.wca.advancement.resultThresholdUnknown') { + return 'an unknown result'; + } + if (options?.defaultValue) { return String(options.defaultValue) .replace('{{level}}', String(options.level ?? '')) .replace('{{rounds}}', String(options.rounds ?? '')) + .replace('{{round}}', String(options.round ?? '')) + .replace('{{what}}', String(options.what ?? '')) .replace('{{scope}}', String(options.scope ?? '')) .replace('{{result}}', String(options.result ?? '')); } @@ -134,6 +164,42 @@ const wcifMock = { }, ], }, + { + id: '222', + rounds: [ + { + id: '222-r1', + format: 'a', + cutoff: null, + timeLimit: null, + results: [], + }, + { + id: '222-r2', + format: 'a', + cutoff: null, + timeLimit: null, + results: [], + }, + { + id: '222-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['222-r1', '222-r2'], + resultCondition: { + type: 'ranking', + value: 8, + }, + }, + }, + results: [], + }, + ], + }, ], }; @@ -181,7 +247,7 @@ describe('CutoffTimeLimitPanel', () => { it('shows the legacy advancement text for stable wcif rounds', () => { renderPanel(wcifMock.events[0].rounds[0] as unknown as Round); - expect(screen.getByText('Top 16 to next round')).toBeInTheDocument(); + expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument(); }); it('shows advancement text derived from the next round participation ruleset', () => { @@ -190,14 +256,18 @@ describe('CutoffTimeLimitPanel', () => { advancementCondition: null, } as Round); - expect(screen.getByText('Top 75% to next round')).toBeInTheDocument(); + expect(screen.getByText('Top 75% advance to next round')).toBeInTheDocument(); }); it('shows linked-round advancement text when a later round depends on combined results', () => { renderPanel(wcifMock.events[0].rounds[1] as unknown as Round); - expect( - screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'), - ).toBeInTheDocument(); + expect(screen.getByText('Top 12 in dual rounds 1 & 2 advance to final')).toBeInTheDocument(); + }); + + it('shows the same dual-round advancement text for the first round in a linked-round set', () => { + renderPanel(wcifMock.events[1].rounds[0] as unknown as Round); + + expect(screen.getByText('Top 8 in dual rounds 1 & 2 advance to final')).toBeInTheDocument(); }); }); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 7df8a21..265db36 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,11 @@ -import { Competition, Cutoff, Round, parseActivityCode } from '@wca/helpers'; +import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; import { renderCentiseconds, renderCutoff } from '@/lib/results'; +import { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -92,7 +93,7 @@ export function CutoffTimeLimitPanel({ {advancement && (
- {renderAdvancementText(t, advancement.sourceType, advancement)} + {renderAdvancementText(t, eventRounds, advancement.sourceType, advancement)}
)} @@ -103,117 +104,108 @@ export function CutoffTimeLimitPanel({ ); } -function getEventRoundsForRound(events: Competition['events'] | undefined, roundId: string) { - const { eventId } = parseActivityCode(roundId); - - return ( - events - ?.find((event) => event.id === eventId) - ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] - ); -} - function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) { return timeLimit?.cumulativeRoundIds.filter((activityCode) => activityCode !== roundId) || []; } function renderAdvancementText( t: ReturnType['t'], + eventRounds: CompatibleRound[], sourceType: 'registrations' | 'round' | 'linkedRounds', advancement: NonNullable>, ) { - const isLinkedRounds = sourceType === 'linkedRounds'; + if (sourceType === 'linkedRounds') { + return renderLinkedRoundsAdvancementText(t, eventRounds, advancement); + } + + return renderSingleRoundAdvancementText(t, eventRounds, advancement); +} + +function renderLinkedRoundsAdvancementText( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + advancement: NonNullable>, +) { const { resultCondition } = advancement; - const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => - activityCodeToRoundName(t, roundId), - ); + const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => { + const roundNumber = parseActivityCode(roundId).roundNumber; + return roundNumber ? roundNumber.toString() : ''; + }); const sourceRoundsLabel = joinLabels(sourceRoundNames); + const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId); switch (resultCondition.type) { case 'ranking': - return isLinkedRounds ? ( - <> - {t('common.wca.advancement.linkedRanking', { - defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round', - level: resultCondition.value, - rounds: sourceRoundsLabel, - })} - - ) : ( - }} - /> - ); + return t('common.wca.advancement.linkedRanking', { + level: resultCondition.value, + rounds: sourceRoundsLabel, + what: targetLabel, + }); case 'percent': - return isLinkedRounds ? ( - <> - {t('common.wca.advancement.linkedPercent', { - defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round', - level: resultCondition.value, - rounds: sourceRoundsLabel, - })} - - ) : ( - }} - /> - ); - case 'resultAchieved': { - const thresholdCondition = resultCondition as Extract< - ResultCondition, - { type: 'resultAchieved' } - >; - const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, { - defaultValue: thresholdCondition.scope, - }).toLowerCase(); - const resultValue = - thresholdCondition.value === null - ? t('common.wca.advancement.resultThresholdUnknown', { - defaultValue: 'an unknown result', - }) - : renderCentiseconds(thresholdCondition.value); - - return ( - <> - {t( - isLinkedRounds - ? 'common.wca.advancement.linkedResultAchieved' - : 'common.wca.advancement.resultAchieved', - { - scope: scopeLabel, - result: resultValue, - rounds: sourceRoundsLabel, - }, - )} - - ); - } + return t('common.wca.advancement.linkedPercent', { + level: resultCondition.value, + rounds: sourceRoundsLabel, + what: targetLabel, + }); + case 'resultAchieved': + return t('common.wca.advancement.linkedResultAchieved', { + scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), + result: + resultCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown') + : renderCentiseconds(resultCondition.value), + rounds: sourceRoundsLabel, + what: targetLabel, + }); } } -function activityCodeToRoundName(t: ReturnType['t'], roundId: string) { - const { roundNumber } = parseActivityCode(roundId); +function renderSingleRoundAdvancementText( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + advancement: NonNullable>, +) { + const { resultCondition } = advancement; + const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId); - return t('common.activityCodeToName.round', { - defaultValue: `Round ${roundNumber}`, - roundNumber, - }); + switch (resultCondition.type) { + case 'ranking': + return t('common.wca.advancement.ranking', { + level: resultCondition.value, + what: targetLabel, + }); + case 'percent': + return t('common.wca.advancement.percent', { + level: resultCondition.value, + what: targetLabel, + }); + case 'resultAchieved': + return t('common.wca.advancement.resultAchieved', { + scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), + result: + resultCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown') + : renderCentiseconds(resultCondition.value), + what: targetLabel, + }); + } } -function joinLabels(labels: string[]) { - if (labels.length <= 1) { - return labels[0] || ''; +function getAdvancementTargetLabel( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + targetRoundId: string | null | undefined, +) { + if (!targetRoundId) { + return t('common.wca.advancement.unknown'); } - if (labels.length === 2) { - return `${labels[0]} and ${labels[1]}`; + const targetRoundIndex = eventRounds.findIndex((candidate) => candidate.id === targetRoundId); + if (targetRoundIndex === eventRounds.length - 1) { + return t('common.wca.advancement.final'); } - return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; + return t('common.wca.advancement.nextRound'); } function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index aec8f31..15435cc 100644 --- a/src/containers/CompetitionRound/CompetitionRound.stories.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -53,6 +53,26 @@ export const ParticipationConditionLinkedRounds: Story = { }, }; +export const DualRoundWithPreviousRound: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + +export const DualRoundWithNextRound: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + }, +}; + export const FinalRound: Story = { parameters: { competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx new file mode 100644 index 0000000..7578c7f --- /dev/null +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -0,0 +1,190 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { AnchorLink } from '@/lib/linkRenderer'; +import { CompetitionRoundContainer } from './CompetitionRound'; + +jest.mock('@/components/Breadcrumbs/Breadcrumbs', () => ({ + Breadcrumbs: () =>
breadcrumbs
, +})); + +jest.mock('@/components/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('@/components/CutoffTimeLimitPanel', () => ({ + CutoffTimeLimitPanel: () =>
cutoff panel
, +})); + +jest.mock('@/lib/activityCodes', () => ({ + activityCodeToName: (activityCode: string) => activityCode, + parseActivityCodeFlexible: (activityCode: string) => { + const groupMatch = activityCode.match(/-g(\d+)$/); + return { + eventId: '333', + roundNumber: 2, + groupNumber: groupMatch ? parseInt(groupMatch[1], 10) : null, + attemptNumber: null, + }; + }, + toRoundAttemptId: (activityCode: string) => activityCode.replace(/-g\d+$/, ''), +})); + +jest.mock('@/lib/events', () => ({ + getAllEvents: (wcif: Competition) => wcif.events, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'competition.groups.allGroups') { + return 'All Groups'; + } + + if (key === 'competition.groups.backToEvents') { + return 'Back To Events'; + } + + if (key === 'competition.round.linkedWith') { + return `Dual round with ${options?.rounds}`; + } + + if (key === 'common.activityCodeToName.round') { + return `Round ${options?.roundNumber}`; + } + + return key; + }, + }), +})); + +const linkedRoundsCompetition = { + formatVersion: '1.0', + id: 'TestComp2026', + name: 'Test Competition 2026', + shortName: 'Test Comp 2026', + persons: [], + competitorLimit: null, + extensions: [], + events: [ + { + id: '333', + extensions: [], + rounds: [ + { + id: '333-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + }, + { + id: '333-r2', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + }, + { + id: '333-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + results: [], + }, + ], + }, + ], + schedule: { + numberOfDays: 1, + startDate: '2026-05-03', + venues: [ + { + id: 1, + name: 'Main Venue', + latitudeMicrodegrees: 0, + longitudeMicrodegrees: 0, + countryIso2: 'US', + timezone: 'America/Los_Angeles', + rooms: [ + { + id: 1, + name: 'Main Room', + color: '#fff', + activities: [ + { + id: 10, + activityCode: '333-r2', + name: '3x3x3 Cube, Round 2', + startTime: '2026-05-03T16:00:00Z', + endTime: '2026-05-03T17:00:00Z', + childActivities: [ + { + id: 11, + activityCode: '333-r2-g1', + name: '3x3x3 Cube, Round 2, Group 1', + startTime: '2026-05-03T16:00:00Z', + endTime: '2026-05-03T16:30:00Z', + childActivities: [], + extensions: [], + scrambleSetId: null, + }, + ], + extensions: [], + scrambleSetId: null, + }, + ], + extensions: [], + }, + ], + }, + ], + }, +} as unknown as Competition; + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ + competitionId: 'TestComp2026', + wcif: linkedRoundsCompetition, + setTitle: () => {}, + }), + useWcifUtils: () => ({ + roundActivies: linkedRoundsCompetition.schedule.venues[0].rooms[0].activities, + }), +})); + +function renderRound(roundId: string) { + return render( + , + ); +} + +describe('CompetitionRoundContainer', () => { + it('shows linked-round context for rounds in a dual-round advancement set', () => { + renderRound('333-r2'); + + expect(screen.getByText('Dual round with Round 1')).toBeInTheDocument(); + }); + + it('does not show linked-round context for rounds outside a dual-round advancement set', () => { + renderRound('333-r3'); + + expect(screen.queryByText(/Dual round with/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index ecd83d4..28b2f72 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -10,7 +10,9 @@ import { } from '@/lib/activityCodes'; import { getAllEvents } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { activityCodeToRoundName, getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; import { formatDateTimeRange } from '@/lib/time'; +import { getAdvancementConditionForRound } from '@/lib/wcif'; import { useWCIF, useWcifUtils } from '@/providers/WCIFProvider'; export interface CompetitionRoundContainerProps { @@ -36,6 +38,23 @@ export function CompetitionRoundContainer({ const events = wcif && getAllEvents(wcif); return events?.flatMap((e) => e.rounds).find((r) => r.id === roundId); }, [roundId, wcif]); + const eventRounds = useMemo( + () => getEventRoundsForRound(wcif?.events, roundId), + [roundId, wcif?.events], + ); + const advancement = useMemo( + () => (round ? getAdvancementConditionForRound(eventRounds, round) : null), + [eventRounds, round], + ); + const linkedRoundNames = useMemo(() => { + if (!advancement || advancement.sourceType !== 'linkedRounds') { + return []; + } + + return advancement.sourceRoundIds + .filter((sourceRoundId) => sourceRoundId !== roundId) + .map((sourceRoundId) => activityCodeToRoundName(t, sourceRoundId)); + }, [advancement, roundId, t]); const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); const groups = rounds.flatMap((r) => r.childActivities); @@ -53,6 +72,11 @@ export function CompetitionRoundContainer({ ]} />
+ {linkedRoundNames.length > 0 && ( +

+ {t('competition.round.linkedWith', { rounds: joinLabels(linkedRoundNames) })} +

+ )} {round && }
diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index cff3c8e..fa01c69 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -31,14 +31,20 @@ common: cumulativeTimelimit: 'Time Limit: {{time}} Cumulative' cumulativeTimelimitWithrounds: 'Time Limit: {{time}} Total with: ' advancement: - ranking: 'Top {{level}} to next round' - percent: 'Top {{level}}% to next round' - linkedRanking: 'Top {{level}} combined across {{rounds}} advance to next round' - linkedPercent: 'Top {{level}}% combined across {{rounds}} advance to next round' + ranking: 'Top {{level}} advance to {{what}}' + percent: 'Top {{level}}% advance to {{what}}' + 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: Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated. - linkedResultAchieved: Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance 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 + nextRound: next round + final: final + unknown: unknown + scope: + single: Singles + average: Averages resultType: single: single average: Average @@ -185,6 +191,8 @@ competition: rankings: title: Rankings name: Name + round: + linkedWith: 'Dual round with {{rounds}}' personalSchedule: registeredEvents: Registered Events viewPersonalRecords: View Personal Records diff --git a/src/lib/roundLabels.ts b/src/lib/roundLabels.ts new file mode 100644 index 0000000..b2b3fab --- /dev/null +++ b/src/lib/roundLabels.ts @@ -0,0 +1,31 @@ +import { Competition, parseActivityCode } from '@wca/helpers'; +import { TFunction } from 'i18next'; +import { CompatibleRound } from './wcif'; + +export function getEventRoundsForRound(events: Competition['events'] | undefined, roundId: string) { + const { eventId } = parseActivityCode(roundId); + + return ( + events + ?.find((event) => event.id === eventId) + ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] + ); +} + +export function activityCodeToRoundName(t: TFunction, roundId: string) { + const { roundNumber } = parseActivityCode(roundId); + + return t('common.activityCodeToName.round', { roundNumber }); +} + +export function joinLabels(labels: string[]) { + if (labels.length <= 1) { + return labels[0] || ''; + } + + if (labels.length === 2) { + return `${labels[0]} & ${labels[1]}`; + } + + return `${labels.slice(0, -1).join(', ')}, & ${labels[labels.length - 1]}`; +} diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index 5b67872..4bb141c 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -59,6 +59,7 @@ describe('wcif participation helpers', () => { expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ sourceType: 'round', sourceRoundIds: ['333-r1'], + targetRoundId: '333-r2', resultCondition: { type: 'percent', value: 75, @@ -99,6 +100,7 @@ describe('wcif participation helpers', () => { expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ sourceType: 'linkedRounds', sourceRoundIds: ['333-r1', '333-r2'], + targetRoundId: '333-r3', resultCondition: { type: 'ranking', value: 12, diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts index af6d1b2..c2b2f59 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -14,6 +14,7 @@ export type ResultCondition = ParticipationResultCondition; export interface RoundAdvancementCondition { sourceType: ParticipationSource['type']; sourceRoundIds: string[]; + targetRoundId?: string | null; resultCondition: ResultCondition; reservedPlaces?: ReservedPlaces | null; } @@ -96,9 +97,16 @@ export const getAdvancementConditionForRound = ( round: Round, ): RoundAdvancementCondition | null => { if (round.advancementCondition) { + const { eventId, roundNumber } = parseActivityCode(round.id); + const targetRoundId = + roundNumber != null + ? eventRounds.find((candidate) => candidate.id === `${eventId}-r${roundNumber + 1}`)?.id + : null; + return { sourceType: 'round', sourceRoundIds: [round.id], + targetRoundId: targetRoundId ?? null, resultCondition: getLegacyResultCondition(round.advancementCondition, round), reservedPlaces: null, }; @@ -138,6 +146,7 @@ export const getAdvancementConditionForRound = ( return { sourceType: source.type, sourceRoundIds: source.type === 'round' ? [source.roundId] : source.roundIds, + targetRoundId: nextEligibleRound.id, resultCondition: source.resultCondition, reservedPlaces: ruleset?.reservedPlaces ?? null, }; diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 2bb83b7..044074e 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -768,6 +768,10 @@ export const storybookParticipationConditionPercentFixture = export const storybookParticipationConditionLinkedRoundsFixture = makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r1': (round) => ({ + ...round, + advancementCondition: null, + }), '333-r2': (round) => ({ ...round, advancementCondition: null, From 23a15ad715cd8e4cd1bf658f2b3c201b01fdd0ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:17:38 +0000 Subject: [PATCH 05/64] fix: show FMC attempt activities for registered competitors without group assignments Agent-Logs-Url: https://github.com/coder13/Competitor-groups/sessions/cebd1b6a-e202-4765-907c-77cadcf9012c --- src/containers/PersonalSchedule/utils.ts | 55 +++++++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/containers/PersonalSchedule/utils.ts b/src/containers/PersonalSchedule/utils.ts index 4b2303e..e4ebdb9 100644 --- a/src/containers/PersonalSchedule/utils.ts +++ b/src/containers/PersonalSchedule/utils.ts @@ -17,13 +17,24 @@ export const getNormalAssignments = (wcif: Competition, person: Person) => { ...assignment, activity: allActivities.find(({ id }) => id === assignment.activityId), })) - .filter( - (assignment) => - !( - assignment.activity?.activityCode === 'other-multi' && - assignment.assignmentCode === 'competitor' - ), - ) + .filter((assignment) => { + const activityCode = assignment.activity?.activityCode || ''; + const parsed = parseActivityCodeFlexible(activityCode); + if ( + assignment.activity?.activityCode === 'other-multi' && + assignment.assignmentCode === 'competitor' + ) { + return false; + } + if ( + parsed.eventId === '333fm' && + parsed.attemptNumber !== null && + assignment.assignmentCode === 'competitor' + ) { + return false; + } + return true; + }) .sort((a, b) => byDate(a.activity, b.activity)) : []; @@ -85,15 +96,45 @@ const getCubeSubmissionAssignments = (wcif: Competition, person: Person) => { ); }; +const getFmcAttemptAssignments = (wcif: Competition, person: Person) => { + const allActivities = getAllActivities(wcif); + + if (!person.registration?.eventIds.includes('333fm')) { + return []; + } + + const fmcAttemptActivities = allActivities.filter((activity) => { + const parsed = parseActivityCodeFlexible(activity.activityCode); + return parsed.eventId === '333fm' && parsed.attemptNumber !== null; + }); + + return fmcAttemptActivities.map( + ( + activity, + ): Assignment & { + type: 'extra'; + activity: Activity; + } => ({ + type: 'extra', + assignmentCode: 'competitor', + activityId: activity.id, + stationNumber: null, + activity, + }), + ); +}; + export const getAllAssignments = (wcif: Competition, person: Person) => { const normalAssignments = getNormalAssignments(wcif, person); const extraAssignments = getExtraAssignments(person); const mbldCubeSubmissionAssignments = getCubeSubmissionAssignments(wcif, person); + const fmcAttemptAssignments = getFmcAttemptAssignments(wcif, person); const allAssignments = [ ...normalAssignments, ...extraAssignments, ...mbldCubeSubmissionAssignments, + ...fmcAttemptAssignments, ].sort((a, b) => byDate(a.activity, b.activity)); return allAssignments; From cc1a434426a73f0d64e8075aac80414e104cabb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:19:14 +0000 Subject: [PATCH 06/64] refactor: parse activityCode only when needed in getNormalAssignments filter Agent-Logs-Url: https://github.com/coder13/Competitor-groups/sessions/cebd1b6a-e202-4765-907c-77cadcf9012c --- src/containers/PersonalSchedule/utils.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/containers/PersonalSchedule/utils.ts b/src/containers/PersonalSchedule/utils.ts index e4ebdb9..c005759 100644 --- a/src/containers/PersonalSchedule/utils.ts +++ b/src/containers/PersonalSchedule/utils.ts @@ -18,20 +18,17 @@ export const getNormalAssignments = (wcif: Competition, person: Person) => { activity: allActivities.find(({ id }) => id === assignment.activityId), })) .filter((assignment) => { - const activityCode = assignment.activity?.activityCode || ''; - const parsed = parseActivityCodeFlexible(activityCode); if ( assignment.activity?.activityCode === 'other-multi' && assignment.assignmentCode === 'competitor' ) { return false; } - if ( - parsed.eventId === '333fm' && - parsed.attemptNumber !== null && - assignment.assignmentCode === 'competitor' - ) { - return false; + if (assignment.assignmentCode === 'competitor') { + const parsed = parseActivityCodeFlexible(assignment.activity?.activityCode || ''); + if (parsed.eventId === '333fm' && parsed.attemptNumber !== null) { + return false; + } } return true; }) From 71a5cee5e8045ab31000c1eafe2614072574dc10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:35:34 +0000 Subject: [PATCH 07/64] fix: show activity name for FMC attempt entries in personal schedule Agent-Logs-Url: https://github.com/coder13/Competitor-groups/sessions/f85b3e5e-9cc5-4f60-b1ac-625893f5c500 --- src/containers/PersonalSchedule/Assignments.tsx | 8 +++++++- .../PersonalExtraAssignment.tsx | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/containers/PersonalSchedule/Assignments.tsx b/src/containers/PersonalSchedule/Assignments.tsx index 63df975..2068e9b 100644 --- a/src/containers/PersonalSchedule/Assignments.tsx +++ b/src/containers/PersonalSchedule/Assignments.tsx @@ -4,12 +4,13 @@ import { useTranslation } from 'react-i18next'; import { useCollapse } from '@/hooks/UseCollapse'; import { useNow } from '@/hooks/useNow/useNow'; import { parseActivityCodeFlexible } from '@/lib/activityCodes'; +import { isOfficialEventId } from '@/lib/events'; import { isActivityWithRoomOrParent } from '@/lib/typeguards'; import { byDate, roundTime } from '@/lib/utils'; import { getRoomData, hasMultipleScheduleLocations } from '../../lib/activities'; import { ExtraAssignment } from './PersonalExtraAssignment'; import { PersonalNormalAssignment } from './PersonalNormalAssignment'; -import { getGroupedAssignmentsByDate } from './utils'; +import { formatBriefActivityName, getGroupedAssignmentsByDate } from './utils'; export interface AssignmentsProps { wcif: Competition; @@ -126,12 +127,17 @@ export function Assignments({ wcif, person, showStationNumber }: AssignmentsProp ? activity.parent?.room : undefined; if (assignment.type === 'extra') { + const parsed = parseActivityCodeFlexible(activity.activityCode); + const activityName = isOfficialEventId(parsed.eventId) + ? formatBriefActivityName(activity) + : undefined; return ( - - {formattedStartTime} - {formattedEndTime} - + {activityName ? ( + <> + {activityName} + + {formattedStartTime} - {formattedEndTime} + + + ) : ( + + {formattedStartTime} - {formattedEndTime} + + )} {worldsAssignmentMap[assignment.assignmentCode] || assignment.assignmentCode} From 47a746ab73527b06d06ff2b6c8a31fbc6410e44d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:44:48 +0000 Subject: [PATCH 08/64] fix: FMC/MBLD extra rows show start time only, Submit Multi Cubes keeps time range Agent-Logs-Url: https://github.com/coder13/Competitor-groups/sessions/53b8493b-45bd-46da-b9b4-d0d29efd555b --- src/containers/PersonalSchedule/PersonalExtraAssignment.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx b/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx index fa34315..712aa9e 100644 --- a/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx +++ b/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx @@ -51,9 +51,7 @@ export const ExtraAssignment = ({ {activityName ? ( <> {activityName} - - {formattedStartTime} - {formattedEndTime} - + {formattedStartTime} ) : ( From 4708944255299715774f5c63d045c3738f245982 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:54:45 +0000 Subject: [PATCH 09/64] feat: use AssignmentLabel pill for competitor code in ExtraAssignment Agent-Logs-Url: https://github.com/coder13/Competitor-groups/sessions/84f8058f-8090-4db3-9870-0b329ff694f9 --- .../PersonalSchedule/PersonalExtraAssignment.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx b/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx index 712aa9e..6bac747 100644 --- a/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx +++ b/src/containers/PersonalSchedule/PersonalExtraAssignment.tsx @@ -1,4 +1,6 @@ +import { AssignmentCode } from '@wca/helpers'; import classNames from 'classnames'; +import { AssignmentLabel } from '@/components'; import { BaseAssignmentPill } from '@/components/Pill'; import { worldsAssignmentMap } from './constants'; @@ -59,7 +61,11 @@ export const ExtraAssignment = ({ )} - {worldsAssignmentMap[assignment.assignmentCode] || assignment.assignmentCode} + {worldsAssignmentMap[assignment.assignmentCode] ? ( + worldsAssignmentMap[assignment.assignmentCode] + ) : ( + + )} {room ? ( From 3896f7e8c39b15b5a6b91cc80e3ccdb3c431d9bf Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 21 Mar 2026 03:45:34 +0000 Subject: [PATCH 10/64] chore: bump Node to 22 and update GitHub Actions --- .github/workflows/lint.yml | 9 +++++---- .github/workflows/test.yml | 9 +++++---- .github/workflows/type-check.yml | 9 +++++---- .nvmrc | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f9dfe8e..162f582 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,17 +2,18 @@ name: Lint on: push: - branches: [ main ] + branches: [main] pull_request: jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc + cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f98042..2e65ce5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,17 +2,18 @@ name: Test on: push: - branches: [ main ] + branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc + cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn test diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index fa83009..27f8d16 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -2,17 +2,18 @@ name: Type Check on: push: - branches: [ main ] + branches: [main] pull_request: jobs: type-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc + cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn run check:type diff --git a/.nvmrc b/.nvmrc index 7ea6a59..517f386 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.11.0 +v22.14.0 From feedaf8afd950847f094355a2933b15bf77e9db9 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 9 May 2026 13:16:34 -0700 Subject: [PATCH 11/64] Polish competition select dropdown --- .../CompetitionListItem.tsx | 6 +- .../CompetitionListItem.test.tsx.snap | 4 +- .../CompetitionSelect/CompetitionSelect.tsx | 58 ++++++++++++++----- .../CompetitionGroup/CompetitionGroup.tsx | 10 ++-- .../CompetitionPsychSheetEvent.tsx | 2 +- .../CompetitionRoom/CompetitionRoom.tsx | 2 +- .../CompetitionRound/CompetitionRound.tsx | 4 +- .../Competitors/CompetitorListItem.tsx | 30 +++++----- .../OngoingActivities/OngoingActivities.tsx | 2 +- src/containers/Schedule/Schedule.tsx | 2 +- .../Competition/Schedule/EventActivity.tsx | 4 +- src/pages/Competition/Schedule/PeopleList.tsx | 4 +- src/styles/_buttons.scss | 2 +- src/styles/_links.scss | 2 +- src/styles/_tables.scss | 2 +- src/styles/_tokens.scss | 29 ++++++++++ 16 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/components/CompetitionListItem/CompetitionListItem.tsx b/src/components/CompetitionListItem/CompetitionListItem.tsx index c2744dc..9edbc19 100644 --- a/src/components/CompetitionListItem/CompetitionListItem.tsx +++ b/src/components/CompetitionListItem/CompetitionListItem.tsx @@ -13,6 +13,7 @@ interface CompetitionListItemProps { city: string; isLive: boolean; isBookmarked?: boolean; + variant?: 'card' | 'dropdown'; } export const CompetitionListItem = ({ @@ -24,6 +25,7 @@ export const CompetitionListItem = ({ city, isLive, isBookmarked, + variant = 'card', }: CompetitionListItemProps) => { const endDate = new Date( new Date(end_date).getTime() + 1000 * 60 * new Date().getTimezoneOffset(), @@ -33,7 +35,9 @@ export const CompetitionListItem = ({
  • {t('common.activityCodeToName.group', { groupNumber })} @@ -105,7 +105,7 @@ export function CompetitionRoundContainer({
    + className="my-1 flex w-full flex-row rounded-md border border-primary bg-primary p-2 px-1 hover-transition hover:bg-primary-strong group dark:text-gray-100"> {t('competition.groups.backToEvents')}
    diff --git a/src/containers/Competitors/CompetitorListItem.tsx b/src/containers/Competitors/CompetitorListItem.tsx index 77b3f17..a74b476 100644 --- a/src/containers/Competitors/CompetitorListItem.tsx +++ b/src/containers/Competitors/CompetitorListItem.tsx @@ -20,7 +20,7 @@ export const CompetitorListItem = ({ return ( -
  • +
  • {highlight && (
    @@ -42,19 +42,21 @@ export const CompetitorListItem = ({ )}
    - {bookmarked && !currentAssignmentCode && ( -
    - -
    - )} - {currentAssignmentCode ? ( - - ) : null} +
    + {currentAssignmentCode ? ( + + ) : null} + {bookmarked && !currentAssignmentCode && ( +
    + +
    + )} +
  • ); diff --git a/src/containers/OngoingActivities/OngoingActivities.tsx b/src/containers/OngoingActivities/OngoingActivities.tsx index 77937fa..2cf5220 100644 --- a/src/containers/OngoingActivities/OngoingActivities.tsx +++ b/src/containers/OngoingActivities/OngoingActivities.tsx @@ -27,7 +27,7 @@ export const OngoingActivities = ({ competitionId }: OngoingActivitiesProps) => return isInChargeOfComp && wcif?.id ? (
    diff --git a/src/containers/Schedule/Schedule.tsx b/src/containers/Schedule/Schedule.tsx index 18f3f0c..7234abc 100644 --- a/src/containers/Schedule/Schedule.tsx +++ b/src/containers/Schedule/Schedule.tsx @@ -40,7 +40,7 @@ const ScheduleDay = ({ return (
    toggleCollapsed()}> {date} {collapsed ? ' â–¼' : ' â–²'} diff --git a/src/pages/Competition/Schedule/EventActivity.tsx b/src/pages/Competition/Schedule/EventActivity.tsx index 77e3066..7c86034 100644 --- a/src/pages/Competition/Schedule/EventActivity.tsx +++ b/src/pages/Competition/Schedule/EventActivity.tsx @@ -291,7 +291,7 @@ export function EventActivity({ Date: Sat, 9 May 2026 13:54:56 -0700 Subject: [PATCH 12/64] Define shared animation curves --- src/styles/_tokens.scss | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/styles/_tokens.scss b/src/styles/_tokens.scss index 5c2b908..b704b11 100644 --- a/src/styles/_tokens.scss +++ b/src/styles/_tokens.scss @@ -129,6 +129,14 @@ $utility-token-map: ( } } +@layer base { + :root { + --cubic-enter: cubic-bezier(0.16, 1, 0.3, 1); + --cubic-move: cubic-bezier(0.4, 0, 0.2, 1); + --cubic-exit: cubic-bezier(0.7, 0, 0.84, 0); + } +} + @layer components { @each $class-name, $settings in $component-token-map { .#{$class-name} { @@ -147,11 +155,11 @@ $utility-token-map: ( .hover-transition { transition-duration: 40ms; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; - transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + transition-timing-function: var(--cubic-move); } .dropdown-open-transition { - animation: dropdown-open 120ms cubic-bezier(0.16, 1, 0.3, 1); + animation: dropdown-open 120ms var(--cubic-enter); transform-origin: top center; } From 842cdc830fcdac29beac87a1b47a727f91857bb3 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 9 May 2026 14:56:24 -0700 Subject: [PATCH 13/64] Scaffold competition results pages --- src/App.tsx | 3 + .../CompetitionResults.test.tsx | 244 ++++++++++++++++++ .../CompetitionResults/CompetitionResults.tsx | 148 +++++++++++ .../CompetitionResultsTable.tsx | 150 +++++++++++ src/containers/CompetitionResults/index.ts | 1 + .../CompetitionRound.test.tsx | 13 + .../CompetitionRound/CompetitionRound.tsx | 9 + src/i18n/en/translation.yaml | 14 + .../CompetitionLayout.tabs.tsx | 4 + src/lib/rounds.ts | 21 ++ .../Competition/Results/index.stories.tsx | 28 ++ src/pages/Competition/Results/index.tsx | 21 ++ 12 files changed, 656 insertions(+) create mode 100644 src/containers/CompetitionResults/CompetitionResults.test.tsx create mode 100644 src/containers/CompetitionResults/CompetitionResults.tsx create mode 100644 src/containers/CompetitionResults/CompetitionResultsTable.tsx create mode 100644 src/containers/CompetitionResults/index.ts create mode 100644 src/lib/rounds.ts create mode 100644 src/pages/Competition/Results/index.stories.tsx create mode 100644 src/pages/Competition/Results/index.tsx diff --git a/src/App.tsx b/src/App.tsx index 882b13e..7a72179 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import CompetitionLive from './pages/Competition/Live'; import CompetitionPerson from './pages/Competition/Person'; import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests'; import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent'; +import CompetitionResults from './pages/Competition/Results'; import { CompetitionActivity, CompetitionRoom, @@ -106,6 +107,8 @@ const Navigation = () => { } /> } /> + } /> + } /> } /> } /> diff --git a/src/containers/CompetitionResults/CompetitionResults.test.tsx b/src/containers/CompetitionResults/CompetitionResults.test.tsx new file mode 100644 index 0000000..522c84e --- /dev/null +++ b/src/containers/CompetitionResults/CompetitionResults.test.tsx @@ -0,0 +1,244 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { AnchorLink } from '@/lib/linkRenderer'; +import { CompetitionResultsContainer } from './CompetitionResults'; + +jest.mock('@/components/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
    {children}
    , +})); + +jest.mock('@/lib/events', () => ({ + getEventName: (eventId: string) => (eventId === '333' ? '3x3x3 Cube' : eventId), + getAllEvents: (wcif: Competition) => wcif.events, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'competition.results.title') return 'Results'; + if (key === 'competition.results.selectRound') return 'Select a round'; + if (key === 'competition.results.roundNotFound') return 'Round not found.'; + if (key === 'competition.results.back') return 'Back'; + if (key === 'competition.results.noResults') return 'No results yet.'; + if (key === 'competition.results.rank') return '#'; + if (key === 'competition.results.competitor') return 'Competitor'; + if (key === 'competition.results.average') return 'Avg'; + if (key === 'competition.results.best') return 'Best'; + if (key === 'competition.results.attempts') return 'Attempts'; + if (key === 'competition.results.unknownCompetitor') { + return `Competitor #${options?.personId}`; + } + if (key === 'common.wca.event') return 'Event'; + if (key === 'common.wca.round') return 'Round'; + if (key === 'common.view') return 'View'; + if (key === 'common.activityCodeToName.round') return `Round ${options?.roundNumber}`; + + return key; + }, + }), +})); + +const wcifMock = { + formatVersion: '1.0', + id: 'TestComp2026', + name: 'Test Competition 2026', + shortName: 'Test Comp 2026', + persons: [ + { + registrantId: 1, + name: 'Blake Thompson', + wcaUserId: 1001, + wcaId: '2010THOM03', + countryIso2: 'US', + registration: null, + extensions: [], + }, + { + registrantId: 2, + name: 'Nick Silvestri', + wcaUserId: 1002, + wcaId: '2016SILV08', + countryIso2: 'US', + registration: null, + extensions: [], + }, + ], + competitorLimit: null, + extensions: [], + events: [ + { + id: '333', + extensions: [], + rounds: [ + { + id: '333-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [ + { + personId: 1, + ranking: 1, + attempts: [ + { result: 1200, reconstruction: null }, + { result: 1300, reconstruction: null }, + { result: 1400, reconstruction: null }, + ], + best: 1200, + average: 1300, + }, + { + personId: 2, + ranking: 2, + attempts: [ + { result: 1400, reconstruction: null }, + { result: 1500, reconstruction: null }, + { result: 1600, reconstruction: null }, + ], + best: 1400, + average: 1500, + }, + ], + extensions: [], + }, + { + id: '333-r2', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + extensions: [], + }, + ], + }, + { + id: '222', + extensions: [], + rounds: [ + { + id: '222-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + extensions: [], + }, + { + id: '222-r2', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + extensions: [], + }, + ], + }, + ], + schedule: { + numberOfDays: 1, + startDate: '2026-05-03', + venues: [], + }, +} as unknown as Competition; + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ + competitionId: 'TestComp2026', + wcif: wcifMock, + setTitle: () => {}, + }), +})); + +function renderResults(selectedRoundId?: string) { + return render( + , + ); +} + +describe('CompetitionResultsContainer', () => { + it('shows the round chooser without selected round content by default', () => { + renderResults(); + + expect(screen.queryByText('Results')).not.toBeInTheDocument(); + expect(screen.queryByText('Select a round')).not.toBeInTheDocument(); + expect(screen.getAllByRole('link')).toHaveLength(2); + expect( + screen.getAllByRole('link', { name: 'Round 1' }).map((link) => link.getAttribute('href')), + ).toEqual([ + '/competitions/TestComp2026/results/333-r1', + '/competitions/TestComp2026/results/222-r1', + ]); + expect(screen.getAllByText('3x3x3 Cube')).toHaveLength(1); + expect(screen.getAllByText('222')).toHaveLength(1); + expect(screen.queryByRole('link', { name: 'Round 2' })).not.toBeInTheDocument(); + expect(screen.queryByRole('table', { name: 'Results' })).not.toBeInTheDocument(); + }); + + it('shows the selected round results table', () => { + renderResults('333-r1'); + + expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/results', + ); + expect(screen.getByText('3x3x3 Cube Round 1')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Results' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: '#' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Competitor' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: '1' })).toHaveClass('hidden', 'md:table-cell'); + expect(screen.getByRole('columnheader', { name: 'Avg' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Best' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Blake Thompson' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/persons/1', + ); + expect(screen.getByRole('link', { name: 'Nick Silvestri' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/persons/2', + ); + expect(screen.queryByText('2010THOM03')).not.toBeInTheDocument(); + expect(screen.getAllByText('13.00')).toHaveLength(2); + expect(screen.getAllByText('12.00')).toHaveLength(2); + expect(screen.queryByLabelText('Attempts')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Round 1' })).not.toBeInTheDocument(); + }); + + it('shows a not-found state for an invalid round id', () => { + renderResults('333-r9'); + + expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/results', + ); + expect(screen.getByText('Round not found.')).toBeInTheDocument(); + expect(screen.getAllByRole('link')).toHaveLength(1); + }); + + it('does not show the selected round scaffold for a round with no result rows', () => { + renderResults('333-r2'); + + expect(screen.getByText('Round not found.')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Round 2' })).not.toBeInTheDocument(); + }); + + it('shows round one for an event even before it has results', () => { + renderResults('222-r1'); + + expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/results', + ); + expect(screen.getByText('222 Round 1')).toBeInTheDocument(); + expect(screen.getByText('No results yet.')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Round 1' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/containers/CompetitionResults/CompetitionResults.tsx b/src/containers/CompetitionResults/CompetitionResults.tsx new file mode 100644 index 0000000..df5b7fe --- /dev/null +++ b/src/containers/CompetitionResults/CompetitionResults.tsx @@ -0,0 +1,148 @@ +import classNames from 'classnames'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; +import { LinkButton } from '@/components/LinkButton'; +import { getEventName } from '@/lib/events'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { findRoundWithEvent, getAllRoundsWithEvents } from '@/lib/rounds'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { CompetitionResultsTable } from './CompetitionResultsTable'; + +export interface CompetitionResultsContainerProps { + competitionId: string; + selectedRoundId?: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionResultsContainer({ + competitionId, + selectedRoundId, + LinkComponent = AnchorLink, +}: CompetitionResultsContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(t('competition.results.title')); + }, [setTitle, t]); + + const roundOptions = useMemo( + () => + wcif + ? getAllRoundsWithEvents(wcif).filter( + ({ round, roundNumber }) => roundNumber === 1 || round.results.length > 0, + ) + : [], + [wcif], + ); + const eventsWithResults = useMemo( + () => + roundOptions.reduce< + { + event: (typeof roundOptions)[number]['event']; + rounds: typeof roundOptions; + }[] + >((groups, roundOption) => { + const existingGroup = groups.find((group) => group.event.id === roundOption.event.id); + + if (existingGroup) { + existingGroup.rounds.push(roundOption); + return groups; + } + + groups.push({ + event: roundOption.event, + rounds: [roundOption], + }); + return groups; + }, []), + [roundOptions], + ); + const selectedRound = useMemo(() => { + if (!wcif || !selectedRoundId) { + return undefined; + } + + const round = findRoundWithEvent(wcif, selectedRoundId); + return round && (round.roundNumber === 1 || round.round.results.length > 0) ? round : undefined; + }, [selectedRoundId, wcif]); + + if (selectedRoundId) { + return ( + +
    +
    + +
    + + {selectedRound ? ( +
    +

    + {getEventName(selectedRound.event.id, selectedRound.event)}{' '} + {t('common.activityCodeToName.round', { + roundNumber: selectedRound.roundNumber, + })} +

    + +
    + ) : ( +
    +

    {t('competition.results.roundNotFound')}

    +
    + )} +
    +
    + ); + } + + return ( + +
    +
    + {eventsWithResults.map(({ event, rounds }) => ( +
    +
    +
    + {rounds.map(({ round, roundNumber }) => { + const isSelected = round.id === selectedRoundId; + + return ( + + + {t('common.activityCodeToName.round', { roundNumber })} + + + ); + })} +
    + ))} +
    +
    +
    + ); +} diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx new file mode 100644 index 0000000..30a613b --- /dev/null +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -0,0 +1,150 @@ +import { + AttemptResult, + EventId, + Person, + RankingType, + Result, + Round, + RoundFormat, +} from '@wca/helpers'; +import { useTranslation } from 'react-i18next'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { renderResultByEventId } from '@/lib/results'; + +interface CompetitionResultsTableProps { + competitionId: string; + eventId: string; + round: Round; + persons: Person[]; + LinkComponent?: LinkRenderer; +} + +const getPrimaryRankingType = (format: RoundFormat): RankingType => + format === 'a' || format === 'm' ? 'average' : 'single'; + +const getResultValue = (result: Result, rankingType: RankingType) => + rankingType === 'average' ? result.average : result.best; + +const renderResultValue = (eventId: string, rankingType: RankingType, result: Result) => { + const value = getResultValue(result, rankingType); + + if (value === 0) { + return '-'; + } + + return renderResultByEventId(eventId as EventId, rankingType, value); +}; + +const renderAttemptValue = (eventId: string, value: AttemptResult) => { + if (value === 0) { + return '-'; + } + + return renderResultByEventId(eventId as EventId, 'single', value); +}; + +export function CompetitionResultsTable({ + competitionId, + eventId, + round, + persons, + LinkComponent = AnchorLink, +}: CompetitionResultsTableProps) { + const { t } = useTranslation(); + const primaryRankingType = getPrimaryRankingType(round.format); + const attemptColumnCount = Math.max(0, ...round.results.map((result) => result.attempts.length)); + + if (round.results.length === 0) { + return ( +
    + {t('competition.results.noResults')} +
    + ); + } + + return ( +
    + + + + + {Array.from({ length: attemptColumnCount }, (_, attemptIndex) => ( + + ))} + + + + + + + + {Array.from({ length: attemptColumnCount }, (_, attemptIndex) => ( + + ))} + + + + + + {round.results.map((result) => { + const person = persons.find((p) => p.registrantId === result.personId); + const name = + person?.name ?? + t('competition.results.unknownCompetitor', { personId: result.personId }); + + return ( + + + + {Array.from({ length: attemptColumnCount }, (_, attemptIndex) => ( + + ))} + + + + ); + })} + +
    + {t('competition.results.rank')} + + {t('competition.results.competitor')} + + {attemptIndex + 1} + + {primaryRankingType === 'average' + ? t('competition.results.average') + : t('competition.results.best')} + + {t('competition.results.best')} +
    + {result.ranking ?? '-'} + + {person ? ( + + {name} + + ) : ( + {name} + )} + + {result.attempts[attemptIndex] + ? renderAttemptValue(eventId, result.attempts[attemptIndex].result) + : '-'} + + {renderResultValue(eventId, primaryRankingType, result)} + + {renderResultValue(eventId, 'single', result)} +
    +
    + ); +} diff --git a/src/containers/CompetitionResults/index.ts b/src/containers/CompetitionResults/index.ts new file mode 100644 index 0000000..c4b6944 --- /dev/null +++ b/src/containers/CompetitionResults/index.ts @@ -0,0 +1 @@ +export * from './CompetitionResults'; diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index 7578c7f..7e627dc 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -49,6 +49,10 @@ jest.mock('react-i18next', () => ({ return `Dual round with ${options?.rounds}`; } + if (key === 'competition.results.seeResults') { + return 'See Results'; + } + if (key === 'common.activityCodeToName.round') { return `Round ${options?.roundNumber}`; } @@ -187,4 +191,13 @@ describe('CompetitionRoundContainer', () => { expect(screen.queryByText(/Dual round with/i)).not.toBeInTheDocument(); }); + + it('links to results for the selected round', () => { + renderRound('333-r2'); + + expect(screen.getByText('See Results')).toHaveAttribute( + 'href', + '/competitions/TestComp2026/results/333-r2', + ); + }); }); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index abdfa87..16e5201 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; import { Container } from '@/components/Container'; import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; +import { LinkButton } from '@/components/LinkButton'; import { activityCodeToName, parseActivityCodeFlexible, @@ -78,6 +79,14 @@ export function CompetitionRoundContainer({

    )} {round && } + {round && ( + + )}
    - {person.wcaId && ( - <> -
    -
    - - {wcaLiveFetchStatus === 'success' && ( - - {t('competition.personalSchedule.viewResults')} - - )} -
    - - )} ); }; diff --git a/src/containers/PersonalSchedule/PersonalPageLayout.tsx b/src/containers/PersonalSchedule/PersonalPageLayout.tsx new file mode 100644 index 0000000..f419d36 --- /dev/null +++ b/src/containers/PersonalSchedule/PersonalPageLayout.tsx @@ -0,0 +1,106 @@ +import { Person } from '@wca/helpers'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { Container } from '@/components/Container'; +import { PersonHeader } from './PersonHeader'; + +export type PersonalPage = 'schedule' | 'results' | 'records'; + +interface PersonalPageLayoutProps { + activePage: PersonalPage; + children: React.ReactNode; + competitionId: string; + person: Person; +} + +interface PersonalPageTab { + id: PersonalPage; + iconClassName: string; + label: string; + to: string; +} + +const usePersonalPageTabs = (competitionId: string, person: Person) => { + const { t } = useTranslation(); + + return [ + { + id: 'schedule', + iconClassName: 'fa-calendar-days', + label: t('competition.personalSchedule.schedule'), + to: `/competitions/${competitionId}/persons/${person.registrantId}`, + }, + { + id: 'results', + iconClassName: 'fa-chart-simple', + label: t('competition.personalSchedule.results'), + to: `/competitions/${competitionId}/persons/${person.registrantId}/results`, + }, + ...(person.wcaId + ? [ + { + id: 'records' as const, + iconClassName: 'fa-trophy', + label: t('competition.personalSchedule.records'), + to: `/competitions/${competitionId}/persons/${person.registrantId}/records`, + }, + ] + : []), + ] satisfies PersonalPageTab[]; +}; + +function PersonalPageTabs({ + activePage, + tabs, +}: { + activePage: PersonalPage; + tabs: PersonalPageTab[]; +}) { + return ( + + ); +} + +export function PersonalPageLayout({ + activePage, + children, + competitionId, + person, +}: PersonalPageLayoutProps) { + const tabs = usePersonalPageTabs(competitionId, person); + + return ( +
    + +
    + + + {children} +
    +
    +
    + ); +} diff --git a/src/containers/PersonalSchedule/PersonalSchedule.tsx b/src/containers/PersonalSchedule/PersonalSchedule.tsx index 5974803..9fdcbf1 100644 --- a/src/containers/PersonalSchedule/PersonalSchedule.tsx +++ b/src/containers/PersonalSchedule/PersonalSchedule.tsx @@ -3,26 +3,22 @@ import { useTranslation } from 'react-i18next'; import { DisclaimerText } from '@/components'; import { useWCIF } from '@/providers/WCIFProvider'; import { Assignments } from './Assignments'; -import { PersonHeader } from './PersonHeader'; +import { PersonalPageLayout } from './PersonalPageLayout'; export interface PersonalScheduleContainerProps { person: Person; } -export function PersonalScheduleContainer({ person }: PersonalScheduleContainerProps) { +export function PersonalScheduleContent({ person }: PersonalScheduleContainerProps) { const { t } = useTranslation(); - const { wcif, competitionId } = useWCIF(); + const { wcif } = useWCIF(); const anyAssignmentsHasStationNumber = !!person.assignments?.some((a) => a.stationNumber); return ( -
    - - -
    + <> -
    {wcif && ( <> @@ -40,6 +36,16 @@ export function PersonalScheduleContainer({ person }: PersonalScheduleContainerP )} )} -
    + + ); +} + +export function PersonalScheduleContainer({ person }: PersonalScheduleContainerProps) { + const { competitionId } = useWCIF(); + + return ( + + + ); } diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index 1b7d245..b75ee4e 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -205,12 +205,18 @@ competition: best: Best attempts: Attempts unknownCompetitor: 'Competitor #{{personId}}' + personalResults: + eventResults: Event results round: linkedWith: 'Dual round with {{rounds}}' personalSchedule: + schedule: Schedule + results: Results + records: Records registeredEvents: Registered Events viewPersonalRecords: View Personal Records viewResults: View Results + viewWcaLiveResults: View WCA Live Results noAssignments: line1: No Assignments line2: Check back later for updates! From c165a9309bd3353389c94b8d7ca54bdba93a52c3 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 9 May 2026 16:09:20 -0700 Subject: [PATCH 15/64] Add WCA Live personal results polling --- .../CompetitionPersonalResults.test.tsx | 77 ++++++++++ .../CompetitionPersonalResults.tsx | 112 +++++++++++++- .../CompetitionPersonalResultsTable.tsx | 43 ++++-- src/hooks/queries/useWcaLive.ts | 141 +++++++++++++++++- src/i18n/en/translation.yaml | 1 + 5 files changed, 348 insertions(+), 26 deletions(-) diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx index f618a05..b077817 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx @@ -5,6 +5,8 @@ import { MemoryRouter } from 'react-router-dom'; import { AnchorLink } from '@/lib/linkRenderer'; import { CompetitionPersonalResultsContainer } from './CompetitionPersonalResults'; +let mockWcaLiveResults: unknown[] = []; + jest.mock('@/components/Container', () => ({ Container: ({ children }: { children: React.ReactNode }) =>
    {children}
    , })); @@ -21,6 +23,17 @@ jest.mock('@/hooks/UsePinnedPersons', () => ({ }), })); +jest.mock('@/hooks/queries/useWcaLive', () => ({ + useWcaLiveCompetitorLink: () => ({ + data: 'https://live.worldcubeassociation.org/competitions/TestComp2026/competitors/1', + status: 'success', + }), + useWcaLiveCompetitorResults: () => ({ + data: mockWcaLiveResults, + status: 'success', + }), +})); + jest.mock('country-flag-icons', () => ({ hasFlag: () => true, })); @@ -38,6 +51,7 @@ jest.mock('react-i18next', () => ({ if (key === 'competition.results.rank') return '#'; if (key === 'competition.results.average') return 'Avg'; if (key === 'competition.results.best') return 'Best'; + if (key === 'competition.results.viewLiveResults') return 'view live results'; if (key === 'competition.personalResults.eventResults') return 'Event results'; if (key === 'competition.personalSchedule.schedule') return 'Schedule'; if (key === 'competition.personalSchedule.results') return 'Results'; @@ -52,6 +66,11 @@ jest.mock('react-i18next', () => ({ const wcifMock = { id: 'TestComp2026', + schedule: { + startDate: '2026-05-03', + numberOfDays: 2, + venues: [], + }, persons: [ { registrantId: 1, @@ -128,10 +147,26 @@ function renderPersonalResults(registrantId = '1') { } describe('CompetitionPersonalResultsContainer', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-03T12:00:00')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + mockWcaLiveResults = []; + }); + it('shows event tables for the selected competitor results', () => { renderPersonalResults(); expect(screen.getByText('Vikram Haldar')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view live results/i })).toHaveAttribute( + 'href', + 'https://live.worldcubeassociation.org/competitions/TestComp2026/competitors/1', + ); expect(screen.getByText('🇺🇸')).toBeInTheDocument(); expect(screen.getByText('3x3x3 Cube')).toBeInTheDocument(); expect(screen.queryByText('4x4x4 Cube')).not.toBeInTheDocument(); @@ -150,4 +185,46 @@ describe('CompetitionPersonalResultsContainer', () => { expect(container).toBeEmptyDOMElement(); }); + + it('uses WCA Live partial results when they are available', () => { + mockWcaLiveResults = [ + { + id: 'live-result-1', + ranking: 4, + advancing: true, + advancingQuestionable: true, + attempts: [{ result: 800 }, { result: 900 }], + best: 800, + average: 0, + round: { + id: 'live-round-1', + name: 'First Round', + number: 1, + competitionEvent: { + id: '333', + event: { + id: '333', + name: '3x3x3 Cube', + rank: 10, + }, + }, + format: { + id: 'a', + numberOfAttempts: 5, + sortBy: 'average', + }, + }, + }, + ]; + + renderPersonalResults(); + + expect(screen.getByRole('link', { name: 'First Round' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/results/333-r1', + ); + expect(screen.getAllByText('8.00')).toHaveLength(2); + expect(screen.getByText('4')).toHaveClass('bg-yellow-200'); + expect(screen.queryByText('7.41')).not.toBeInTheDocument(); + }); }); diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx index b95059d..aea1089 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx @@ -1,7 +1,13 @@ import { Competition, Person } from '@wca/helpers'; import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { ExternalLink } from '@/components/ExternalLink'; import { PersonalPageLayout } from '@/containers/PersonalSchedule/PersonalPageLayout'; +import { + useWcaLiveCompetitorLink, + useWcaLiveCompetitorResults, + WcaLiveCompetitorResult, +} from '@/hooks/queries/useWcaLive'; import { getEventName } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -23,12 +29,13 @@ interface CompetitionPersonalResultsContentProps { interface EventResults { eventId: string; eventName: string; + eventRank: number; rounds: PersonalRoundResult[]; } const getPersonalResults = (wcif: Competition, person: Person): EventResults[] => wcif.events - .map((event) => { + .map((event, eventIndex) => { const rounds = event.rounds .map((round, index) => { const result = round.results.find( @@ -37,9 +44,14 @@ const getPersonalResults = (wcif: Competition, person: Person): EventResults[] = return result ? { - round, + roundId: round.id, roundNumber: index + 1, - result, + ranking: result.ranking, + advancing: false, + advancingQuestionable: false, + attempts: result.attempts.map((attempt) => ({ result: attempt.result })), + best: result.best, + average: result.average, } : undefined; }) @@ -48,22 +60,101 @@ const getPersonalResults = (wcif: Competition, person: Person): EventResults[] = return { eventId: event.id, eventName: getEventName(event.id, event), + eventRank: eventIndex, rounds, }; }) .filter((eventResults) => eventResults.rounds.length > 0); +const getLivePersonalResults = ( + wcif: Competition, + liveResults: WcaLiveCompetitorResult[], +): EventResults[] => { + const liveResultsWithAttempts = liveResults.filter((result) => result.attempts.length > 0); + const eventIds = Array.from( + new Set(liveResultsWithAttempts.map((result) => result.round.competitionEvent.event.id)), + ); + + return eventIds + .map((eventId) => { + const eventResults = liveResultsWithAttempts.filter( + (result) => result.round.competitionEvent.event.id === eventId, + ); + const firstResult = eventResults[0]; + const wcifEvent = wcif.events.find((event) => event.id === eventId); + + return { + eventId, + eventName: firstResult.round.competitionEvent.event.name, + eventRank: firstResult.round.competitionEvent.event.rank, + rounds: eventResults + .sort((a, b) => a.round.number - b.round.number) + .map((result) => ({ + roundId: + wcifEvent?.rounds[result.round.number - 1]?.id ?? + `${eventId}-r${result.round.number}`, + roundName: result.round.name, + roundNumber: result.round.number, + ranking: result.ranking, + advancing: result.advancing, + advancingQuestionable: result.advancingQuestionable, + attempts: result.attempts, + best: result.best, + average: result.average, + })), + }; + }) + .sort((a, b) => a.eventRank - b.eventRank); +}; + +const getDateKey = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}`; + +const getLocalDateFromKey = (dateKey: string) => { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(year, month - 1, day); +}; + +const isCompetitionDay = (wcif: Competition, date = new Date()) => { + const currentDateKey = getDateKey(date); + const startDate = getLocalDateFromKey(wcif.schedule.startDate); + + return Array.from({ length: wcif.schedule.numberOfDays }, (_, dayOffset) => { + const competitionDate = new Date(startDate); + competitionDate.setDate(startDate.getDate() + dayOffset); + return getDateKey(competitionDate); + }).includes(currentDateKey); +}; + export function CompetitionPersonalResultsContent({ person, LinkComponent = AnchorLink, }: CompetitionPersonalResultsContentProps) { const { t } = useTranslation(); const { wcif, competitionId } = useWCIF(); - - const eventResults = useMemo( - () => (wcif && person ? getPersonalResults(wcif, person) : []), - [person, wcif], + const isTodayCompetitionDay = wcif ? isCompetitionDay(wcif) : false; + const { data: wcaLiveLink, status: wcaLiveFetchStatus } = useWcaLiveCompetitorLink( + competitionId, + person.registrantId.toString(), + { enabled: isTodayCompetitionDay }, ); + const { data: wcaLiveResults } = useWcaLiveCompetitorResults(wcaLiveLink, { + enabled: isTodayCompetitionDay && wcaLiveFetchStatus === 'success', + }); + + const eventResults = useMemo(() => { + if (!wcif) { + return []; + } + + const liveEventResults = wcaLiveResults?.length + ? getLivePersonalResults(wcif, wcaLiveResults) + : []; + + return liveEventResults.length > 0 ? liveEventResults : getPersonalResults(wcif, person); + }, [person, wcaLiveResults, wcif]); if (!wcif) { return null; @@ -71,6 +162,13 @@ export function CompetitionPersonalResultsContent({ return (
    + {isTodayCompetitionDay && wcaLiveFetchStatus === 'success' && ( + + {t('competition.results.viewLiveResults')} + + )} {eventResults.length > 0 ? (
    {eventResults.map((eventResult) => ( diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx index 251b719..1623d2b 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx @@ -1,12 +1,21 @@ -import { AttemptResult, EventId, Result, Round } from '@wca/helpers'; +import { AttemptResult, EventId } from '@wca/helpers'; +import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { LinkRenderer } from '@/lib/linkRenderer'; import { renderResultByEventId } from '@/lib/results'; interface PersonalRoundResult { - round: Round; + roundId: string; + roundName?: string; roundNumber: number; - result: Result; + ranking: number | null; + advancing: boolean; + advancingQuestionable: boolean; + attempts: { + result: AttemptResult; + }[]; + best: AttemptResult; + average: AttemptResult; } interface CompetitionPersonalResultsTableProps { @@ -39,10 +48,7 @@ export function CompetitionPersonalResultsTable({ LinkComponent, }: CompetitionPersonalResultsTableProps) { const { t } = useTranslation(); - const attemptColumnCount = Math.max( - 0, - ...roundResults.map(({ result }) => result.attempts.length), - ); + const attemptColumnCount = Math.max(0, ...roundResults.map((result) => result.attempts.length)); return (
    @@ -55,8 +61,8 @@ export function CompetitionPersonalResultsTable({ {Array.from({ length: attemptColumnCount }, (_, attemptIndex) => ( ))} - - + + @@ -83,16 +89,25 @@ export function CompetitionPersonalResultsTable({ - {roundResults.map(({ round, roundNumber, result }) => ( - - + {roundResults.map((result) => ( + + {result.ranking ?? '-'} - {t('common.activityCodeToName.round', { roundNumber })} + {result.roundName ?? + t('common.activityCodeToName.round', { roundNumber: result.roundNumber })} {Array.from({ length: attemptColumnCount }, (_, attemptIndex) => ( diff --git a/src/hooks/queries/useWcaLive.ts b/src/hooks/queries/useWcaLive.ts index a4b6b40..5c2c6ec 100644 --- a/src/hooks/queries/useWcaLive.ts +++ b/src/hooks/queries/useWcaLive.ts @@ -1,13 +1,13 @@ import { useQuery } from '@tanstack/react-query'; +const WCA_LIVE_ORIGIN = 'https://live.worldcubeassociation.org'; + export const useWcaLiveCompetitionLink = (competitionId: string) => { return useQuery({ retry: false, queryKey: ['wca-live/competition-link', competitionId], queryFn: async () => { - const response = await fetch( - `https://live.worldcubeassociation.org/link/competitions/${competitionId}`, - ); + const response = await fetch(`${WCA_LIVE_ORIGIN}/link/competitions/${competitionId}`); if (!response.ok && response.status === 404) { const { errors } = (await response.json()) as { errors: { @@ -21,13 +21,18 @@ export const useWcaLiveCompetitionLink = (competitionId: string) => { }); }; -export const useWcaLiveCompetitorLink = (competitionId: string, competitorId: string) => { +export const useWcaLiveCompetitorLink = ( + competitionId: string, + competitorId: string, + options: { enabled?: boolean } = {}, +) => { return useQuery({ + enabled: options.enabled ?? true, retry: false, queryKey: ['wca-live/competition-competitor-link', competitionId, competitorId], queryFn: async () => { const response = await fetch( - `https://live.worldcubeassociation.org/link/competitions/${competitionId}/competitors/${competitorId}`, + `${WCA_LIVE_ORIGIN}/link/competitions/${competitionId}/competitors/${competitorId}`, ); if (!response.ok && response.status === 404) { const { errors } = (await response.json()) as { @@ -41,3 +46,129 @@ export const useWcaLiveCompetitorLink = (competitionId: string, competitorId: st }, }); }; + +export interface WcaLiveCompetitorResult { + id: string; + ranking: number | null; + advancing: boolean; + advancingQuestionable: boolean; + attempts: { + result: number; + }[]; + best: number; + average: number; + round: { + id: string; + name: string; + number: number; + competitionEvent: { + id: string; + event: { + id: string; + name: string; + rank: number; + }; + }; + format: { + id: string; + numberOfAttempts: number; + sortBy: string; + }; + }; +} + +interface WcaLiveCompetitorResultsResponse { + data?: { + person?: { + id: string; + results: WcaLiveCompetitorResult[]; + } | null; + }; + errors?: { + message: string; + }[]; +} + +const WCA_LIVE_COMPETITOR_RESULTS_QUERY = ` + query Competitor($id: ID!) { + person(id: $id) { + id + results { + id + ranking + advancing + advancingQuestionable + attempts { + result + } + best + average + round { + id + name + number + competitionEvent { + id + event { + id + name + rank + } + } + format { + id + numberOfAttempts + sortBy + } + } + } + } + } +`; + +const getCompetitorIdFromWcaLiveUrl = (url: string) => { + const match = url.match(/\/competitors\/([^/?#]+)/); + return match?.[1]; +}; + +const fetchWcaLiveCompetitorResults = async (competitorUrl: string) => { + const competitorId = getCompetitorIdFromWcaLiveUrl(competitorUrl); + + if (!competitorId) { + throw new Error('Could not resolve WCA Live competitor id.'); + } + + const response = await fetch(`${WCA_LIVE_ORIGIN}/api`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: WCA_LIVE_COMPETITOR_RESULTS_QUERY, + variables: { id: competitorId }, + }), + }); + + if (!response.ok) { + throw new Error(`WCA Live results request failed with status ${response.status}.`); + } + + const payload = (await response.json()) as WcaLiveCompetitorResultsResponse; + + if (payload.errors?.length) { + throw new Error(payload.errors.map((error) => error.message).join('\n')); + } + + return payload.data?.person?.results ?? []; +}; + +export const useWcaLiveCompetitorResults = ( + competitorUrl: string | undefined, + options: { enabled?: boolean } = {}, +) => + useQuery({ + enabled: Boolean(competitorUrl) && (options.enabled ?? true), + queryKey: ['wca-live/competitor-results', competitorUrl], + queryFn: () => fetchWcaLiveCompetitorResults(competitorUrl!), + refetchInterval: 5 * 60 * 1000, + }); diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index b75ee4e..c24cdbe 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -205,6 +205,7 @@ competition: best: Best attempts: Attempts unknownCompetitor: 'Competitor #{{personId}}' + viewLiveResults: View live results personalResults: eventResults: Event results round: From 709afab5d9a92de934b0d872f5a2b25a5514d47c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 9 May 2026 16:21:57 -0700 Subject: [PATCH 16/64] Add live competition results polling --- .../CompetitionPersonalResults.tsx | 22 +-- .../CompetitionResults.test.tsx | 64 ++++++- .../CompetitionResults/CompetitionResults.tsx | 171 ++++++++++++++---- .../CompetitionResultsTable.tsx | 65 +++++-- src/hooks/queries/useWcaLive.ts | 141 +++++++++++++++ src/i18n/en/translation.yaml | 1 - src/lib/competitionDates.ts | 22 +++ 7 files changed, 405 insertions(+), 81 deletions(-) create mode 100644 src/lib/competitionDates.ts diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx index aea1089..6c9d623 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx @@ -8,6 +8,7 @@ import { useWcaLiveCompetitorResults, WcaLiveCompetitorResult, } from '@/hooks/queries/useWcaLive'; +import { isCompetitionDay } from '@/lib/competitionDates'; import { getEventName } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -107,27 +108,6 @@ const getLivePersonalResults = ( .sort((a, b) => a.eventRank - b.eventRank); }; -const getDateKey = (date: Date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( - date.getDate(), - ).padStart(2, '0')}`; - -const getLocalDateFromKey = (dateKey: string) => { - const [year, month, day] = dateKey.split('-').map(Number); - return new Date(year, month - 1, day); -}; - -const isCompetitionDay = (wcif: Competition, date = new Date()) => { - const currentDateKey = getDateKey(date); - const startDate = getLocalDateFromKey(wcif.schedule.startDate); - - return Array.from({ length: wcif.schedule.numberOfDays }, (_, dayOffset) => { - const competitionDate = new Date(startDate); - competitionDate.setDate(startDate.getDate() + dayOffset); - return getDateKey(competitionDate); - }).includes(currentDateKey); -}; - export function CompetitionPersonalResultsContent({ person, LinkComponent = AnchorLink, diff --git a/src/containers/CompetitionResults/CompetitionResults.test.tsx b/src/containers/CompetitionResults/CompetitionResults.test.tsx index 522c84e..a87bd84 100644 --- a/src/containers/CompetitionResults/CompetitionResults.test.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.test.tsx @@ -4,6 +4,8 @@ import { Competition } from '@wca/helpers'; import { AnchorLink } from '@/lib/linkRenderer'; import { CompetitionResultsContainer } from './CompetitionResults'; +let mockWcaLiveRoundResults: unknown[] = []; + jest.mock('@/components/Container', () => ({ Container: ({ children }: { children: React.ReactNode }) =>
    {children}
    , })); @@ -13,11 +15,21 @@ jest.mock('@/lib/events', () => ({ getAllEvents: (wcif: Competition) => wcif.events, })); +jest.mock('@/hooks/queries/useWcaLive', () => ({ + useWcaLiveRoundLink: () => ({ + data: 'https://live.worldcubeassociation.org/competitions/TestComp2026/rounds/1', + status: 'success', + }), + useWcaLiveRoundResults: () => ({ + data: { results: mockWcaLiveRoundResults }, + status: 'success', + }), +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { if (key === 'competition.results.title') return 'Results'; - if (key === 'competition.results.selectRound') return 'Select a round'; if (key === 'competition.results.roundNotFound') return 'Round not found.'; if (key === 'competition.results.back') return 'Back'; if (key === 'competition.results.noResults') return 'No results yet.'; @@ -165,6 +177,14 @@ function renderResults(selectedRoundId?: string) { } describe('CompetitionResultsContainer', () => { + beforeEach(() => { + mockWcaLiveRoundResults = []; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('shows the round chooser without selected round content by default', () => { renderResults(); @@ -205,11 +225,17 @@ describe('CompetitionResultsContainer', () => { 'href', '/competitions/TestComp2026/persons/2', ); + expect(screen.getByRole('navigation', { name: 'Round' })).toBeInTheDocument(); + expect( + screen.getAllByRole('link', { name: 'Round 1' }).map((link) => link.getAttribute('href')), + ).toEqual([ + '/competitions/TestComp2026/results/333-r1', + '/competitions/TestComp2026/results/222-r1', + ]); expect(screen.queryByText('2010THOM03')).not.toBeInTheDocument(); expect(screen.getAllByText('13.00')).toHaveLength(2); expect(screen.getAllByText('12.00')).toHaveLength(2); expect(screen.queryByLabelText('Attempts')).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Round 1' })).not.toBeInTheDocument(); }); it('shows a not-found state for an invalid round id', () => { @@ -220,7 +246,7 @@ describe('CompetitionResultsContainer', () => { '/competitions/TestComp2026/results', ); expect(screen.getByText('Round not found.')).toBeInTheDocument(); - expect(screen.getAllByRole('link')).toHaveLength(1); + expect(screen.getAllByRole('link')).toHaveLength(3); }); it('does not show the selected round scaffold for a round with no result rows', () => { @@ -239,6 +265,36 @@ describe('CompetitionResultsContainer', () => { ); expect(screen.getByText('222 Round 1')).toBeInTheDocument(); expect(screen.getByText('No results yet.')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Round 1' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Round 1' })).toHaveLength(2); + }); + + it('uses WCA Live partial results for the selected round when they are available', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-03T12:00:00')); + mockWcaLiveRoundResults = [ + { + id: 'live-result-1', + ranking: 3, + advancing: true, + advancingQuestionable: true, + attempts: [{ result: 1100 }, { result: 1200 }], + best: 1100, + average: 0, + person: { + id: 'live-person-1', + registrantId: 1, + name: 'Blake Thompson', + }, + }, + ]; + + renderResults('333-r1'); + + expect(screen.getByRole('link', { name: 'Blake Thompson' })).toHaveAttribute( + 'href', + '/competitions/TestComp2026/persons/1', + ); + expect(screen.getByText('3')).toHaveClass('bg-yellow-200'); + expect(screen.getAllByText('11.00')).toHaveLength(2); + expect(screen.queryByText('13.00')).not.toBeInTheDocument(); }); }); diff --git a/src/containers/CompetitionResults/CompetitionResults.tsx b/src/containers/CompetitionResults/CompetitionResults.tsx index df5b7fe..f96c38f 100644 --- a/src/containers/CompetitionResults/CompetitionResults.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.tsx @@ -3,11 +3,13 @@ import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Container } from '@/components/Container'; import { LinkButton } from '@/components/LinkButton'; +import { useWcaLiveRoundLink, useWcaLiveRoundResults } from '@/hooks/queries/useWcaLive'; +import { isCompetitionDay } from '@/lib/competitionDates'; import { getEventName } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { findRoundWithEvent, getAllRoundsWithEvents } from '@/lib/rounds'; import { useWCIF } from '@/providers/WCIFProvider'; -import { CompetitionResultsTable } from './CompetitionResultsTable'; +import { CompetitionResultsTable, CompetitionRoundResult } from './CompetitionResultsTable'; export interface CompetitionResultsContainerProps { competitionId: string; @@ -15,6 +17,65 @@ export interface CompetitionResultsContainerProps { LinkComponent?: LinkRenderer; } +interface RoundOptionGroup { + event: ReturnType[number]['event']; + rounds: ReturnType; +} + +function ResultsRoundNav({ + competitionId, + groups, + selectedRoundId, + LinkComponent, +}: { + competitionId: string; + groups: RoundOptionGroup[]; + selectedRoundId?: string; + LinkComponent: LinkRenderer; +}) { + const { t } = useTranslation(); + + return ( + + ); +} + export function CompetitionResultsContainer({ competitionId, selectedRoundId, @@ -22,6 +83,7 @@ export function CompetitionResultsContainer({ }: CompetitionResultsContainerProps) { const { t } = useTranslation(); const { wcif, setTitle } = useWCIF(); + const isTodayCompetitionDay = wcif ? isCompetitionDay(wcif) : false; useEffect(() => { setTitle(t('competition.results.title')); @@ -38,12 +100,7 @@ export function CompetitionResultsContainer({ ); const eventsWithResults = useMemo( () => - roundOptions.reduce< - { - event: (typeof roundOptions)[number]['event']; - rounds: typeof roundOptions; - }[] - >((groups, roundOption) => { + roundOptions.reduce((groups, roundOption) => { const existingGroup = groups.find((group) => group.event.id === roundOption.event.id); if (existingGroup) { @@ -65,43 +122,81 @@ export function CompetitionResultsContainer({ } const round = findRoundWithEvent(wcif, selectedRoundId); - return round && (round.roundNumber === 1 || round.round.results.length > 0) ? round : undefined; - }, [selectedRoundId, wcif]); + return round && + (round.roundNumber === 1 || round.round.results.length > 0 || isTodayCompetitionDay) + ? round + : undefined; + }, [isTodayCompetitionDay, selectedRoundId, wcif]); + const { data: wcaLiveRoundLink, status: wcaLiveRoundLinkStatus } = useWcaLiveRoundLink( + competitionId, + selectedRound?.event.id ?? '', + selectedRound?.roundNumber ?? 1, + { enabled: Boolean(selectedRound) && isTodayCompetitionDay }, + ); + const { data: wcaLiveRound } = useWcaLiveRoundResults(wcaLiveRoundLink, { + enabled: isTodayCompetitionDay && wcaLiveRoundLinkStatus === 'success', + }); + const liveRoundResults = useMemo( + () => + wcaLiveRound?.results + .filter((result) => result.attempts.length > 0) + .map((result) => ({ + id: result.id, + personId: result.person.registrantId, + personName: result.person.name, + ranking: result.ranking, + advancing: result.advancing, + advancingQuestionable: result.advancingQuestionable, + attempts: result.attempts, + best: result.best, + average: result.average, + })) ?? [], + [wcaLiveRound], + ); if (selectedRoundId) { return ( -
    -
    - -
    - - {selectedRound ? ( -
    -

    - {getEventName(selectedRound.event.id, selectedRound.event)}{' '} - {t('common.activityCodeToName.round', { - roundNumber: selectedRound.roundNumber, - })} -

    - + +
    +
    + -
    - ) : ( -
    -

    {t('competition.results.roundNotFound')}

    -
    - )} +
    + + {selectedRound ? ( +
    +

    + {getEventName(selectedRound.event.id, selectedRound.event)}{' '} + {t('common.activityCodeToName.round', { + roundNumber: selectedRound.roundNumber, + })} +

    + 0 ? liveRoundResults : undefined} + LinkComponent={LinkComponent} + /> +
    + ) : ( +
    +

    {t('competition.results.roundNotFound')}

    +
    + )} +
    ); diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx index 30a613b..6580b4d 100644 --- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -1,31 +1,43 @@ -import { - AttemptResult, - EventId, - Person, - RankingType, - Result, - Round, - RoundFormat, -} from '@wca/helpers'; +import { AttemptResult, EventId, Person, RankingType, Round, RoundFormat } from '@wca/helpers'; +import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { renderResultByEventId } from '@/lib/results'; +export interface CompetitionRoundResult { + id: string | number; + personId: number | null; + personName?: string; + ranking: number | null; + advancing: boolean; + advancingQuestionable: boolean; + attempts: { + result: AttemptResult; + }[]; + best: AttemptResult; + average: AttemptResult; +} + interface CompetitionResultsTableProps { competitionId: string; eventId: string; round: Round; persons: Person[]; + results?: CompetitionRoundResult[]; LinkComponent?: LinkRenderer; } const getPrimaryRankingType = (format: RoundFormat): RankingType => format === 'a' || format === 'm' ? 'average' : 'single'; -const getResultValue = (result: Result, rankingType: RankingType) => +const getResultValue = (result: CompetitionRoundResult, rankingType: RankingType) => rankingType === 'average' ? result.average : result.best; -const renderResultValue = (eventId: string, rankingType: RankingType, result: Result) => { +const renderResultValue = ( + eventId: string, + rankingType: RankingType, + result: CompetitionRoundResult, +) => { const value = getResultValue(result, rankingType); if (value === 0) { @@ -48,13 +60,23 @@ export function CompetitionResultsTable({ eventId, round, persons, + results = round.results.map((result) => ({ + id: result.personId, + personId: result.personId, + ranking: result.ranking, + advancing: false, + advancingQuestionable: false, + attempts: result.attempts.map((attempt) => ({ result: attempt.result })), + best: result.best, + average: result.average, + })), LinkComponent = AnchorLink, }: CompetitionResultsTableProps) { const { t } = useTranslation(); const primaryRankingType = getPrimaryRankingType(round.format); - const attemptColumnCount = Math.max(0, ...round.results.map((result) => result.attempts.length)); + const attemptColumnCount = Math.max(0, ...results.map((result) => result.attempts.length)); - if (round.results.length === 0) { + if (results.length === 0) { return (
    {t('competition.results.noResults')} @@ -103,19 +125,28 @@ export function CompetitionResultsTable({ - {round.results.map((result) => { + {results.map((result) => { const person = persons.find((p) => p.registrantId === result.personId); const name = person?.name ?? + result.personName ?? t('competition.results.unknownCompetitor', { personId: result.personId }); return ( - - + + {result.ranking ?? '-'} - {person ? ( + {person && result.personId ? ( diff --git a/src/hooks/queries/useWcaLive.ts b/src/hooks/queries/useWcaLive.ts index 5c2c6ec..bfa3137 100644 --- a/src/hooks/queries/useWcaLive.ts +++ b/src/hooks/queries/useWcaLive.ts @@ -47,6 +47,33 @@ export const useWcaLiveCompetitorLink = ( }); }; +export const useWcaLiveRoundLink = ( + competitionId: string, + eventId: string, + roundNumber: number, + options: { enabled?: boolean } = {}, +) => { + return useQuery({ + enabled: options.enabled ?? true, + retry: false, + queryKey: ['wca-live/competition-round-link', competitionId, eventId, roundNumber], + queryFn: async () => { + const response = await fetch( + `${WCA_LIVE_ORIGIN}/link/competitions/${competitionId}/rounds/${eventId}/${roundNumber}`, + ); + if (!response.ok && response.status === 404) { + const { errors } = (await response.json()) as { + errors: { + detail: string; + }; + }; + throw new Error(errors.detail); + } + return response.url; + }, + }); +}; + export interface WcaLiveCompetitorResult { id: string; ranking: number | null; @@ -77,6 +104,33 @@ export interface WcaLiveCompetitorResult { }; } +export interface WcaLiveRoundResult { + id: string; + ranking: number | null; + advancing: boolean; + advancingQuestionable: boolean; + attempts: { + result: number; + }[]; + best: number; + average: number; + person: { + id: string; + registrantId: number | null; + name: string; + }; +} + +export interface WcaLiveRound { + id: string; + results: WcaLiveRoundResult[]; + format: { + id: string; + numberOfAttempts: number; + sortBy: string; + }; +} + interface WcaLiveCompetitorResultsResponse { data?: { person?: { @@ -89,6 +143,15 @@ interface WcaLiveCompetitorResultsResponse { }[]; } +interface WcaLiveRoundResultsResponse { + data?: { + round?: WcaLiveRound | null; + }; + errors?: { + message: string; + }[]; +} + const WCA_LIVE_COMPETITOR_RESULTS_QUERY = ` query Competitor($id: ID!) { person(id: $id) { @@ -126,11 +189,45 @@ const WCA_LIVE_COMPETITOR_RESULTS_QUERY = ` } `; +const WCA_LIVE_ROUND_RESULTS_QUERY = ` + query Round($id: ID!) { + round(id: $id) { + id + format { + id + numberOfAttempts + sortBy + } + results { + id + ranking + advancing + advancingQuestionable + attempts { + result + } + best + average + person { + id + registrantId + name + } + } + } + } +`; + const getCompetitorIdFromWcaLiveUrl = (url: string) => { const match = url.match(/\/competitors\/([^/?#]+)/); return match?.[1]; }; +const getRoundIdFromWcaLiveUrl = (url: string) => { + const match = url.match(/\/rounds\/([^/?#]+)/); + return match?.[1]; +}; + const fetchWcaLiveCompetitorResults = async (competitorUrl: string) => { const competitorId = getCompetitorIdFromWcaLiveUrl(competitorUrl); @@ -162,6 +259,37 @@ const fetchWcaLiveCompetitorResults = async (competitorUrl: string) => { return payload.data?.person?.results ?? []; }; +const fetchWcaLiveRoundResults = async (roundUrl: string) => { + const roundId = getRoundIdFromWcaLiveUrl(roundUrl); + + if (!roundId) { + throw new Error('Could not resolve WCA Live round id.'); + } + + const response = await fetch(`${WCA_LIVE_ORIGIN}/api`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: WCA_LIVE_ROUND_RESULTS_QUERY, + variables: { id: roundId }, + }), + }); + + if (!response.ok) { + throw new Error(`WCA Live round results request failed with status ${response.status}.`); + } + + const payload = (await response.json()) as WcaLiveRoundResultsResponse; + + if (payload.errors?.length) { + throw new Error(payload.errors.map((error) => error.message).join('\n')); + } + + return payload.data?.round; +}; + export const useWcaLiveCompetitorResults = ( competitorUrl: string | undefined, options: { enabled?: boolean } = {}, @@ -171,4 +299,17 @@ export const useWcaLiveCompetitorResults = ( queryKey: ['wca-live/competitor-results', competitorUrl], queryFn: () => fetchWcaLiveCompetitorResults(competitorUrl!), refetchInterval: 5 * 60 * 1000, + refetchIntervalInBackground: false, + }); + +export const useWcaLiveRoundResults = ( + roundUrl: string | undefined, + options: { enabled?: boolean } = {}, +) => + useQuery({ + enabled: Boolean(roundUrl) && (options.enabled ?? true), + queryKey: ['wca-live/round-results', roundUrl], + queryFn: () => fetchWcaLiveRoundResults(roundUrl!), + refetchInterval: 5 * 60 * 1000, + refetchIntervalInBackground: false, }); diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index c24cdbe..7bc4790 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -194,7 +194,6 @@ competition: name: Name results: title: Results - selectRound: Select a round to view its results. seeResults: See Results back: Back roundNotFound: Round not found. diff --git a/src/lib/competitionDates.ts b/src/lib/competitionDates.ts new file mode 100644 index 0000000..f9c7fd5 --- /dev/null +++ b/src/lib/competitionDates.ts @@ -0,0 +1,22 @@ +import { Competition } from '@wca/helpers'; + +const getDateKey = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}`; + +const getLocalDateFromKey = (dateKey: string) => { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(year, month - 1, day); +}; + +export const isCompetitionDay = (wcif: Competition, date = new Date()) => { + const currentDateKey = getDateKey(date); + const startDate = getLocalDateFromKey(wcif.schedule.startDate); + + return Array.from({ length: wcif.schedule.numberOfDays }, (_, dayOffset) => { + const competitionDate = new Date(startDate); + competitionDate.setDate(startDate.getDate() + dayOffset); + return getDateKey(competitionDate); + }).includes(currentDateKey); +}; From 35b791de0e28f34d2ed8cadd2e44b109a66ea230 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 9 May 2026 16:36:15 -0700 Subject: [PATCH 17/64] Fix group arrow key navigation --- .../CompetitionGroup.test.tsx | 92 +++++++++++++++++++ .../CompetitionGroup/CompetitionGroup.tsx | 48 ++++++++-- src/pages/Competition/ByGroup/Group.tsx | 4 +- 3 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/containers/CompetitionGroup/CompetitionGroup.test.tsx diff --git a/src/containers/CompetitionGroup/CompetitionGroup.test.tsx b/src/containers/CompetitionGroup/CompetitionGroup.test.tsx new file mode 100644 index 0000000..e6a8aaa --- /dev/null +++ b/src/containers/CompetitionGroup/CompetitionGroup.test.tsx @@ -0,0 +1,92 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { AnchorLink } from '@/lib/linkRenderer'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { CompetitionGroupContainer } from './CompetitionGroup'; + +const mockSetTitle = jest.fn(); +const mockWcif = storybookCompetitionFixture as unknown as Competition; + +jest.mock('@/i18n', () => ({ + __esModule: true, + default: { + t: (key: string, options?: Record) => + key === 'common.activityCodeToName.group' ? `Group ${options?.groupNumber}` : key, + }, +})); + +jest.mock('@/components', () => ({ + ActivityRow: () =>
    , +})); + +jest.mock('@/components/AssignmentCodeCell', () => ({ + AssignmentCodeCell: ({ count }: { count: number }) =>
    {count}
    , +})); + +jest.mock('@/components/Breadcrumbs/Breadcrumbs', () => ({ + Breadcrumbs: () =>
    , +})); + +jest.mock('@/components/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
    {children}
    , +})); + +jest.mock('@/components/CutoffTimeLimitPanel', () => ({ + CutoffTimeLimitPanel: () =>
    , +})); + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ + wcif: mockWcif, + setTitle: mockSetTitle, + }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('CompetitionGroupContainer', () => { + beforeEach(() => { + mockSetTitle.mockClear(); + }); + + it('navigates between groups with arrow keys', () => { + const onNavigate = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + expect(onNavigate).toHaveBeenCalledWith('/competitions/SeattleSummerOpen2026/events/333-r1/2'); + }); + + it('does not navigate past the first group with the left arrow key', () => { + const onNavigate = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + + expect(onNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/containers/CompetitionGroup/CompetitionGroup.tsx b/src/containers/CompetitionGroup/CompetitionGroup.tsx index 8abef73..9d34634 100644 --- a/src/containers/CompetitionGroup/CompetitionGroup.tsx +++ b/src/containers/CompetitionGroup/CompetitionGroup.tsx @@ -1,6 +1,6 @@ import { ActivityCode } from '@wca/helpers'; import classNames from 'classnames'; -import { Fragment, useEffect, useMemo } from 'react'; +import { Fragment, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityRow } from '@/components'; import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; @@ -26,6 +26,7 @@ export interface CompetitionGroupContainerProps { roundId: string; groupNumber: string; LinkComponent?: LinkRenderer; + onNavigate?: (url: string) => void; } export function CompetitionGroupContainer({ @@ -33,6 +34,7 @@ export function CompetitionGroupContainer({ roundId, groupNumber, LinkComponent = AnchorLink, + onNavigate, }: CompetitionGroupContainerProps) { const { t } = useTranslation(); const { wcif, setTitle } = useWCIF(); @@ -95,12 +97,44 @@ export function CompetitionGroupContainer({ const prev = wcif && prevActivityCode(wcif, activityCode); const next = wcif && nextActivityCode(wcif, activityCode); - const prevUrl = `/competitions/${competitionId}/events/${prev?.split?.('-g')?.[0]}/${ - prev?.split?.('-g')?.[1] - }`; - const nextUrl = `/competitions/${competitionId}/events/${next?.split?.('-g')?.[0]}/${ - next?.split?.('-g')?.[1] - }`; + const prevUrl = prev + ? `/competitions/${competitionId}/events/${prev.split('-g')[0]}/${prev.split('-g')[1]}` + : undefined; + const nextUrl = next + ? `/competitions/${competitionId}/events/${next.split('-g')[0]}/${next.split('-g')[1]}` + : undefined; + + const goToPrev = useCallback(() => { + if (prevUrl) { + onNavigate?.(prevUrl); + } + }, [onNavigate, prevUrl]); + + const goToNext = useCallback(() => { + if (nextUrl) { + onNavigate?.(nextUrl); + } + }, [onNavigate, nextUrl]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + goToPrev(); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + goToNext(); + } + }; + + document.addEventListener('keydown', handleKeydown); + + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [goToPrev, goToNext]); return ( <> diff --git a/src/pages/Competition/ByGroup/Group.tsx b/src/pages/Competition/ByGroup/Group.tsx index d8a0cc2..d264ba4 100644 --- a/src/pages/Competition/ByGroup/Group.tsx +++ b/src/pages/Competition/ByGroup/Group.tsx @@ -1,8 +1,9 @@ -import { Link, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import { CompetitionGroupContainer } from '@/containers/CompetitionGroup'; export default function Group() { const { competitionId, roundId, groupNumber } = useParams(); + const navigate = useNavigate(); if (!competitionId || !roundId || !groupNumber) { return null; @@ -14,6 +15,7 @@ export default function Group() { LinkComponent={Link} roundId={roundId} groupNumber={groupNumber} + onNavigate={navigate} /> ); } From ecfe562d2f1f6b7357a0837e0684139e676afa41 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 10 May 2026 07:27:39 -0700 Subject: [PATCH 18/64] Fixed images in non prod envs --- src/hooks/useRegisterSW/useRegisterSW.ts | 4 ++++ vite.config.ts | 3 --- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/useRegisterSW/useRegisterSW.ts b/src/hooks/useRegisterSW/useRegisterSW.ts index a383347..0c7d4f0 100644 --- a/src/hooks/useRegisterSW/useRegisterSW.ts +++ b/src/hooks/useRegisterSW/useRegisterSW.ts @@ -6,6 +6,10 @@ export function usePWAUpdate() { const updateSWRef = useRef<(reloadPage?: boolean) => Promise>(); useEffect(() => { + if (!import.meta.env.PROD) { + return; + } + updateSWRef.current = registerSW({ onNeedRefresh() { setUpdateAvailable(true); diff --git a/vite.config.ts b/vite.config.ts index 6a12f92..db0eb2e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,9 +28,6 @@ export default defineConfig({ ViteYaml(), VitePWA({ registerType: 'autoUpdate', - devOptions: { - enabled: true, - }, }), ], build: { From cb9b4a3138139f67c7598db53717f175d21646ef Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 10 May 2026 08:34:15 -0700 Subject: [PATCH 19/64] Improve competition round results UI --- .../RoundActionPicker.stories.tsx | 68 +++++++ .../RoundActionPicker/RoundActionPicker.tsx | 127 +++++++++++++ src/components/RoundActionPicker/index.ts | 2 + src/components/RoundActionPicker/types.ts | 23 +++ .../CompetitionEvents/CompetitionEvents.tsx | 62 ++----- .../CompetitionPerson/CompetitionPerson.tsx | 4 +- .../CompetitionPersonalResults.test.tsx | 30 +++- .../CompetitionPersonalResults.tsx | 2 + .../CompetitionPersonalResultsTable.tsx | 104 +++++++++-- .../CompetitionResults.test.tsx | 170 +++++++++++++++--- .../CompetitionResults/CompetitionResults.tsx | 152 ++++++++++++---- .../CompetitionResultsTable.tsx | 130 +++++++++++--- .../ResultDetailsDialog.tsx | 150 ++++++++++++++++ .../CompetitionResults/advancement.ts | 74 ++++++++ .../resultDetailsViewport.ts | 4 + .../CompetitionResults/roundRoster.ts | 34 ++++ src/i18n/en/translation.yaml | 3 + src/pages/Competition/ByGroup/Events.tsx | 5 +- 18 files changed, 998 insertions(+), 146 deletions(-) create mode 100644 src/components/RoundActionPicker/RoundActionPicker.stories.tsx create mode 100644 src/components/RoundActionPicker/RoundActionPicker.tsx create mode 100644 src/components/RoundActionPicker/index.ts create mode 100644 src/components/RoundActionPicker/types.ts create mode 100644 src/containers/CompetitionResults/ResultDetailsDialog.tsx create mode 100644 src/containers/CompetitionResults/advancement.ts create mode 100644 src/containers/CompetitionResults/resultDetailsViewport.ts create mode 100644 src/containers/CompetitionResults/roundRoster.ts diff --git a/src/components/RoundActionPicker/RoundActionPicker.stories.tsx b/src/components/RoundActionPicker/RoundActionPicker.stories.tsx new file mode 100644 index 0000000..fed8ab2 --- /dev/null +++ b/src/components/RoundActionPicker/RoundActionPicker.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { RoundActionPicker } from './RoundActionPicker'; +import { RoundActionPickerEvent } from './types'; + +const events: RoundActionPickerEvent[] = [ + { + id: '333', + name: '3x3x3 Cube', + rounds: [ + { id: '333-r1', roundNumber: 1, groupCount: 5, resultStatus: 'now' }, + { id: '333-r2', roundNumber: 2, groupCount: 3 }, + { id: '333-r3', roundNumber: 3, groupCount: 1 }, + ], + }, + { + id: '444', + name: '4x4x4 Cube', + rounds: [ + { id: '444-r1', roundNumber: 1, groupCount: 4, resultStatus: 'done' }, + { id: '444-r2', roundNumber: 2, groupCount: 1 }, + ], + }, + { + id: '555', + name: '5x5x5 Cube', + rounds: [{ id: '555-r1', roundNumber: 1, groupCount: 3, resultStatus: 'done' }], + }, + { + id: '333bf', + name: '3x3x3 Blindfolded', + rounds: [ + { id: '333bf-r1', roundNumber: 1, groupCount: 2, resultStatus: 'done' }, + { id: '333bf-r2', roundNumber: 2, groupCount: 1 }, + ], + }, +]; + +const meta = { + title: 'Components/Competition/RoundActionPicker', + component: RoundActionPicker, + decorators: [ + (Story) => ( +
    + +
    + ), + ], + args: { + events, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Groups: Story = { + args: { + mode: 'groups', + }, +}; + +export const Results: Story = { + args: { + mode: 'results', + }, +}; diff --git a/src/components/RoundActionPicker/RoundActionPicker.tsx b/src/components/RoundActionPicker/RoundActionPicker.tsx new file mode 100644 index 0000000..f96870a --- /dev/null +++ b/src/components/RoundActionPicker/RoundActionPicker.tsx @@ -0,0 +1,127 @@ +import classNames from 'classnames'; +import { AnchorLink } from '@/lib/linkRenderer'; +import { RoundActionPickerEvent, RoundActionPickerMode, RoundActionPickerProps } from './types'; + +export type RoundResultStatus = RoundActionPickerEvent['rounds'][number]['resultStatus']; + +const modeConfig: Record< + RoundActionPickerMode, + { + actionLabel: string; + } +> = { + groups: { + actionLabel: 'Choose group', + }, + results: { + actionLabel: 'View results', + }, +}; + +const resultStatusLabel: Record< + NonNullable, + string +> = { + now: 'Now', + done: 'Done', +}; + +const resultStatusClassName: Record< + NonNullable, + string +> = { + now: 'bg-amber-100 text-amber-900 ring-amber-300 dark:bg-amber-900 dark:text-amber-100 dark:ring-amber-700', + done: 'bg-blue-100 text-blue-900 ring-blue-300 dark:bg-blue-900 dark:text-blue-100 dark:ring-blue-700', +}; + +function EventIcon({ eventId }: { eventId: string }) { + return
    ); diff --git a/src/containers/Schedule/ScheduleActivityRow.tsx b/src/containers/Schedule/ScheduleActivityRow.tsx new file mode 100644 index 0000000..524229f --- /dev/null +++ b/src/containers/Schedule/ScheduleActivityRow.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { RoomPill } from '@/components/Pill'; +import { useNow } from '@/hooks/useNow'; +import { LinkRenderer } from '@/lib/linkRenderer'; +import { formatTimeRange } from '@/lib/time'; +import { ScheduleActivityGroup } from './scheduleActivityGroups'; + +interface ScheduleActivityRowProps { + group: ScheduleActivityGroup; + LinkComponent?: LinkRenderer; + showRoom?: boolean; +} + +export function ScheduleActivityRow({ + group, + LinkComponent = Link, + showRoom = true, +}: ScheduleActivityRowProps) { + const now = useNow(); + const isOver = new Date(group.endTime).getTime() < now.getTime(); + + return ( + + {group.name} + + {showRoom && ( + + {group.rooms.map((room) => ( + + {room.name} + + ))} + + )} + + {formatTimeRange(group.startTime, group.endTime, 5, group.timeZone)} + + + + ); +} diff --git a/src/containers/Schedule/scheduleActivityGroups.test.ts b/src/containers/Schedule/scheduleActivityGroups.test.ts new file mode 100644 index 0000000..b9ffd66 --- /dev/null +++ b/src/containers/Schedule/scheduleActivityGroups.test.ts @@ -0,0 +1,111 @@ +import { getScheduleActivityGroups, getScheduleActivityPath } from './scheduleActivityGroups'; + +jest.mock('@/i18n', () => ({ + __esModule: true, + default: { + t: (key: string, options?: { groupNumber?: number; roundNumber?: number }) => + key === 'common.activityCodeToName.group' + ? `Group ${options?.groupNumber}` + : key === 'common.activityCodeToName.round' + ? `Round ${options?.roundNumber}` + : key, + }, + t: (key: string, options?: { groupNumber?: number; roundNumber?: number }) => + key === 'common.activityCodeToName.group' + ? `Group ${options?.groupNumber}` + : key === 'common.activityCodeToName.round' + ? `Round ${options?.roundNumber}` + : key, +})); + +const makeActivity = ({ + activityCode, + id, + name, + room, +}: { + activityCode: string; + id: number; + name: string; + room: { color: string; id: number; name: string }; +}) => ({ + activityCode, + childActivities: [], + endTime: '2026-06-01T10:10:00Z', + extensions: [], + id, + name, + parent: { + activityCode: '333-r1', + childActivities: [], + endTime: '2026-06-01T11:00:00Z', + extensions: [], + id: id + 1000, + name: '3x3x3 Cube, Round 1', + room, + startTime: '2026-06-01T10:00:00Z', + }, + startTime: '2026-06-01T10:00:00Z', +}); + +describe('scheduleActivityGroups', () => { + it('groups matching activity codes and keeps unique room chips', () => { + const firstRoom = { color: '#ff0000', id: 1, name: 'Red room' }; + const secondRoom = { color: '#0000ff', id: 2, name: 'Blue room' }; + const firstActivity = makeActivity({ + activityCode: '333-r1-g1', + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + room: firstRoom, + }); + const secondActivity = makeActivity({ + activityCode: '333-r1-g1', + id: 102, + name: '3x3x3 Cube, Round 1, Group 1', + room: secondRoom, + }); + const wcif = { + id: 'ExampleComp2026', + schedule: { + venues: [ + { + rooms: [ + { + ...firstRoom, + activities: [{ ...firstActivity.parent, childActivities: [firstActivity] }], + }, + { + ...secondRoom, + activities: [{ ...secondActivity.parent, childActivities: [secondActivity] }], + }, + ], + timezone: 'America/Los_Angeles', + }, + ], + }, + }; + + const groups = getScheduleActivityGroups( + wcif as never, + [firstActivity, secondActivity] as never, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].activityCode).toBe('333-r1-g1'); + expect(groups[0].rooms.map((room) => room.name)).toEqual(['Red room', 'Blue room']); + expect(groups[0].to).toBe('/competitions/ExampleComp2026/events/333-r1/1'); + }); + + it('links grouped activities to the group page', () => { + const activity = makeActivity({ + activityCode: '333-r2-g3', + id: 201, + name: '3x3x3 Cube, Round 2, Group 3', + room: { color: '#ff0000', id: 1, name: 'Red room' }, + }); + + expect(getScheduleActivityPath('ExampleComp2026', activity as never)).toBe( + '/competitions/ExampleComp2026/events/333-r2/3', + ); + }); +}); diff --git a/src/containers/Schedule/scheduleActivityGroups.ts b/src/containers/Schedule/scheduleActivityGroups.ts new file mode 100644 index 0000000..73023b6 --- /dev/null +++ b/src/containers/Schedule/scheduleActivityGroups.ts @@ -0,0 +1,117 @@ +import { Competition, Room, Venue } from '@wca/helpers'; +import { getRoomData, getVenueForActivity } from '@/lib/activities'; +import { activityCodeToName, parseActivityCodeFlexible, toRoundId } from '@/lib/activityCodes'; +import { ActivityWithRoomOrParent } from '@/lib/types'; + +export interface ScheduleActivityLocation { + color: string; + id: string; + name: string; +} + +export interface ScheduleActivityGroup { + activities: ActivityWithRoomOrParent[]; + activityCode: string; + endTime: string; + id: string; + name: string; + rooms: ScheduleActivityLocation[]; + startTime: string; + timeZone?: Venue['timezone']; + to: string; +} + +const getActivityRoom = (activity: ActivityWithRoomOrParent): Room | undefined => + activity?.parent?.parent?.room || activity?.parent?.room || activity?.room; + +const getActivityLocation = ( + activity: ActivityWithRoomOrParent, +): ScheduleActivityLocation | undefined => { + const room = getActivityRoom(activity); + if (!room) { + return undefined; + } + + const roomData = getRoomData(room, activity); + return { + color: roomData.color, + id: `${room.id}-${roomData.name}`, + name: roomData.name, + }; +}; + +const uniqueLocations = (activities: ActivityWithRoomOrParent[]) => + Array.from( + new Map( + activities + .map(getActivityLocation) + .filter((location): location is ScheduleActivityLocation => Boolean(location)) + .map((location) => [location.id, location]), + ).values(), + ); + +const getActivityName = (activity: ActivityWithRoomOrParent) => + activity.activityCode.startsWith('other') + ? activity.name || activity.activityCode + : activityCodeToName(activity.activityCode); + +export const getScheduleActivityPath = ( + competitionId: string, + activity: ActivityWithRoomOrParent, +) => { + try { + const { groupNumber, roundNumber } = parseActivityCodeFlexible(activity.activityCode); + + if (roundNumber && groupNumber) { + return `/competitions/${competitionId}/events/${toRoundId(activity.activityCode)}/${groupNumber}`; + } + + if (roundNumber) { + return `/competitions/${competitionId}/events/${toRoundId(activity.activityCode)}`; + } + } catch { + return `/competitions/${competitionId}/activities/${activity.id}`; + } + + return `/competitions/${competitionId}/activities/${activity.id}`; +}; + +export const getScheduleActivityGroups = ( + wcif: Competition, + activities: ActivityWithRoomOrParent[], +): ScheduleActivityGroup[] => { + const findVenue = getVenueForActivity(wcif); + const groupsByActivityCode = activities.reduce((groups, activity) => { + const group = groups.get(activity.activityCode) || []; + group.push(activity); + groups.set(activity.activityCode, group); + return groups; + }, new Map()); + + return Array.from(groupsByActivityCode.entries()) + .map(([activityCode, groupedActivities]) => { + const sortedActivities = groupedActivities.toSorted((a, b) => + a.startTime.localeCompare(b.startTime), + ); + const firstActivity = sortedActivities[0]; + const startTime = sortedActivities.map((activity) => activity.startTime).toSorted()[0]; + const endTime = + sortedActivities + .map((activity) => activity.endTime) + .toSorted() + .at(-1) || ''; + + return { + activities: sortedActivities, + activityCode, + endTime, + id: activityCode, + name: firstActivity ? getActivityName(firstActivity) : activityCode, + rooms: uniqueLocations(sortedActivities), + startTime, + timeZone: firstActivity ? findVenue(firstActivity)?.timezone : undefined, + to: firstActivity ? getScheduleActivityPath(wcif.id, firstActivity) : '', + }; + }) + .sort((a, b) => a.startTime.localeCompare(b.startTime)); +}; diff --git a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts index 3894b6a..1694987 100644 --- a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react'; import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { useAuth } from '@/providers/AuthProvider'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; import { useCompetitionRemoteControl } from './useCompetitionRemoteControl'; @@ -7,6 +8,9 @@ import { useCompetitionRemoteControl } from './useCompetitionRemoteControl'; jest.mock('@/hooks/useNotifyCompRemoteActivities', () => ({ useNotifyCompRemoteActivities: jest.fn(), })); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ useNotifyCompRemoteAuth: jest.fn(), })); @@ -35,6 +39,16 @@ const activity = ({ }); const wcif = { + persons: [ + { + roles: ['delegate'], + wcaUserId: 1, + }, + { + roles: ['staff-dataentry'], + wcaUserId: 2, + }, + ], schedule: { venues: [ { @@ -63,6 +77,48 @@ const wcif = { }, }; +const wcifWithFourGroups = { + ...wcif, + schedule: { + venues: [ + { + rooms: [ + { + activities: [ + activity({ + activityCode: '333-r1-g1', + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + startTime: '2026-06-01T10:00:00Z', + }), + activity({ + activityCode: '333-r1-g2', + id: 102, + name: '3x3x3 Cube, Round 1, Group 2', + startTime: '2026-06-01T10:20:00Z', + }), + activity({ + activityCode: '333-r1-g3', + id: 103, + name: '3x3x3 Cube, Round 1, Group 3', + startTime: '2026-06-01T10:40:00Z', + }), + activity({ + activityCode: '333-r1-g4', + id: 104, + name: '3x3x3 Cube, Round 1, Group 4', + startTime: '2026-06-01T11:00:00Z', + }), + ], + id: 1, + name: 'Stage 1', + }, + ], + }, + ], + }, +}; + describe('useCompetitionRemoteControl', () => { it('stops and resets the current group before starting the previous group', async () => { const resetActivities = jest.fn().mockResolvedValue(undefined); @@ -70,6 +126,9 @@ describe('useCompetitionRemoteControl', () => { const stopActivities = jest.fn().mockResolvedValue(undefined); jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ isAuthenticatedForCompetition: () => true, } as unknown as ReturnType); @@ -109,4 +168,129 @@ describe('useCompetitionRemoteControl', () => { startActivities.mock.invocationCallOrder[0], ); }); + + it('does not load or perform remote actions for users who are not listed delegates or organizers', async () => { + const startActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 2 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [], + importCompetition: jest.fn(), + resetAllActivities: jest.fn(), + startActivities, + updateAutoAdvance: jest.fn(), + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + expect(useNotifyCompRemoteActivities).toHaveBeenCalledWith({ + competitionId: 'ExampleComp2026', + enabled: false, + roomId: undefined, + }); + expect(result.current.canManageRemote).toBe(false); + + await expect(result.current.startGroup(result.current.activityGroups[0])).rejects.toThrow( + 'Only listed competition delegates and organizers can manage remote control.', + ); + expect(startActivities).not.toHaveBeenCalled(); + }); + + it('finishes every started activity that has no end time', async () => { + const stopActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:01:00Z', + }, + { + activityId: 102, + endTime: '2026-06-01T10:25:00Z', + startTime: '2026-06-01T10:21:00Z', + }, + { + activityId: 103, + endTime: null, + startTime: null, + }, + ], + stopActivities, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + await act(async () => { + await result.current.finishAllActivities(); + }); + + expect(stopActivities).toHaveBeenCalledWith([101]); + }); + + it('starts and stops skipped next groups before starting a later target group', async () => { + const startActivities = jest.fn().mockResolvedValue(undefined); + const stopActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ + wcif: wcifWithFourGroups, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:01:00Z', + }, + ], + startActivities, + stopActivities, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + await act(async () => { + await result.current.startGroup(result.current.activityGroups[3]); + }); + + expect(stopActivities).toHaveBeenNthCalledWith(1, [101]); + expect(startActivities).toHaveBeenNthCalledWith(1, [102, 103]); + expect(stopActivities).toHaveBeenNthCalledWith(2, [102, 103]); + expect(startActivities).toHaveBeenNthCalledWith(2, [104]); + expect(stopActivities.mock.invocationCallOrder[0]).toBeLessThan( + startActivities.mock.invocationCallOrder[0], + ); + expect(startActivities.mock.invocationCallOrder[0]).toBeLessThan( + stopActivities.mock.invocationCallOrder[1], + ); + expect(stopActivities.mock.invocationCallOrder[1]).toBeLessThan( + startActivities.mock.invocationCallOrder[1], + ); + }); }); diff --git a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts index bd0ee66..6d9529e 100644 --- a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; import { getRemoteActiveGroups, getRemoteActivityGroups, @@ -9,6 +10,7 @@ import { getRemoteScheduledActivities, RemoteActivityGroup, } from '@/lib/notifyCompRemoteActivities'; +import { useAuth } from '@/providers/AuthProvider'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -21,15 +23,20 @@ interface UseCompetitionRemoteControlParams { const activityIdsForGroup = (group: RemoteActivityGroup) => group.scheduledActivities.map((activity) => activity.id); +const isActiveGroup = (group: RemoteActivityGroup) => + group.status === 'current' || group.status === 'mixed'; + export function useCompetitionRemoteControl({ competitionId, enabled = true, roomId, }: UseCompetitionRemoteControlParams) { const { wcif } = useWCIF(); + const { user } = useAuth(); const remoteAuth = useNotifyCompRemoteAuth(); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, user); const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId); - const isEnabled = enabled && isAuthenticated; + const isEnabled = enabled && isAuthenticated && canManageRemote; const remote = useNotifyCompRemoteActivities({ competitionId, @@ -56,14 +63,105 @@ export function useCompetitionRemoteControl({ const previousGroup = useMemo(() => getRemotePreviousGroup(activityGroups), [activityGroups]); const nextGroup = useMemo(() => getRemoteNextGroup(activityGroups), [activityGroups]); - const startGroup = (group: RemoteActivityGroup) => - remote.startActivities(activityIdsForGroup(group)); - const stopGroup = (group: RemoteActivityGroup) => - remote.stopActivities(activityIdsForGroup(group)); - const resetGroup = (group: RemoteActivityGroup) => - remote.resetActivities(activityIdsForGroup(group)); + const requireRemoteAccess = () => { + if (!canManageRemote) { + throw new Error( + 'Only listed competition delegates and organizers can manage remote control.', + ); + } + }; + + const finishSkippedGroupsBefore = async (group: RemoteActivityGroup) => { + const targetIndex = activityGroups.findIndex((candidate) => candidate.id === group.id); + + if (targetIndex < 0) { + return; + } + + const skippedActivityIds = activityGroups + .slice(0, targetIndex) + .filter((candidate) => candidate.status === 'next') + .flatMap(activityIdsForGroup); + + if (skippedActivityIds.length === 0) { + return; + } + + await remote.startActivities(skippedActivityIds); + await remote.stopActivities(skippedActivityIds); + }; + + const startGroup = async (group: RemoteActivityGroup) => { + requireRemoteAccess(); + const currentActivityIds = activeGroups.flatMap(activityIdsForGroup); + + if (currentActivityIds.length > 0) { + await remote.stopActivities(currentActivityIds); + } + + await finishSkippedGroupsBefore(group); + return remote.startActivities(activityIdsForGroup(group)); + }; + const stopGroup = (group: RemoteActivityGroup) => { + requireRemoteAccess(); + return remote.stopActivities(activityIdsForGroup(group)); + }; + const resetGroup = (group: RemoteActivityGroup) => { + requireRemoteAccess(); + return remote.resetActivities(activityIdsForGroup(group)); + }; + const startActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.startActivity(activityId); + }; + const stopActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.stopActivity(activityId); + }; + const resetActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.resetActivity(activityId); + }; + const startActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.startActivities(activityIds); + }; + const stopActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.stopActivities(activityIds); + }; + const resetActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.resetActivities(activityIds); + }; + const importCompetition = () => { + requireRemoteAccess(); + return remote.importCompetition(); + }; + const resetAllActivities = () => { + requireRemoteAccess(); + return remote.resetAllActivities(); + }; + const finishAllActivities = () => { + requireRemoteAccess(); + const unfinishedActivityIds = remote.activities + .filter((activity) => activity.startTime && !activity.endTime) + .map((activity) => activity.activityId); + + if (unfinishedActivityIds.length === 0) { + return Promise.resolve(); + } + + return remote.stopActivities(unfinishedActivityIds); + }; + const updateAutoAdvance = (autoAdvance: boolean) => { + requireRemoteAccess(); + return remote.updateAutoAdvance(autoAdvance); + }; const switchToGroup = async (group?: RemoteActivityGroup) => { + requireRemoteAccess(); + if (!group) { return; } @@ -74,10 +172,16 @@ export function useCompetitionRemoteControl({ await remote.stopActivities(currentActivityIds); } + if (!isActiveGroup(group)) { + await finishSkippedGroupsBefore(group); + } + await remote.startActivities(activityIdsForGroup(group)); }; const switchToPreviousGroup = async () => { + requireRemoteAccess(); + if (!previousGroup) { return; } @@ -97,14 +201,26 @@ export function useCompetitionRemoteControl({ activeGroups, activityGroups, activityStates, + canManageRemote, + finishAllActivities, + importCompetition, isAuthenticated, + isEnabled, nextGroup, previousGroup, + resetActivities, + resetActivity, + resetAllActivities, resetGroup, scheduledActivities, + startActivities, + startActivity, startGroup, + stopActivities, + stopActivity, stopGroup, switchToPreviousGroup, switchToGroup, + updateAutoAdvance, }; } diff --git a/src/hooks/useNotifyCompWebSocketStatus.ts b/src/hooks/useNotifyCompWebSocketStatus.ts new file mode 100644 index 0000000..d434225 --- /dev/null +++ b/src/hooks/useNotifyCompWebSocketStatus.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from 'react'; +import { + getNotifyCompWebSocketStatus, + subscribeToNotifyCompWebSocketStatus, +} from '@/lib/notifyCompWebSocketStatus'; + +export const useNotifyCompWebSocketStatus = () => + useSyncExternalStore( + subscribeToNotifyCompWebSocketStatus, + getNotifyCompWebSocketStatus, + getNotifyCompWebSocketStatus, + ); diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index 8a09064..effe2b7 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -2,6 +2,7 @@ import { Competition } from '@wca/helpers'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { streamActivities } from '@/lib/activities'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; import { isStaff } from '@/lib/person'; import { useAuth } from '@/providers/AuthProvider'; @@ -13,11 +14,13 @@ interface CompetitionLayoutTabsProps { export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLayoutTabsProps) => { const { t } = useTranslation(); const { user } = useAuth(); + const userId = user?.id; return useMemo(() => { const hasStream = wcif && streamActivities(wcif).length > 0; - const person = wcif?.persons.find((p) => p.wcaUserId === user?.id); + const person = wcif?.persons.find((p) => p.wcaUserId === userId); const isPersonStaff = person && isStaff(person); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, userId ? { id: userId } : null); const _tabs: { href: string; @@ -55,7 +58,9 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay href: `/competitions/${competitionId}/scramblers`, text: t('header.tabs.scramblers'), }); + } + if (canManageRemote) { _tabs.push({ href: `/competitions/${competitionId}/remote`, text: t('header.tabs.remote', { @@ -72,5 +77,5 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay } return _tabs; - }, [wcif, competitionId, user?.id, t]); + }, [wcif, competitionId, userId, t]); }; diff --git a/src/lib/competitionAuthorization.test.ts b/src/lib/competitionAuthorization.test.ts new file mode 100644 index 0000000..e4837ba --- /dev/null +++ b/src/lib/competitionAuthorization.test.ts @@ -0,0 +1,31 @@ +import { isCompetitionDelegateOrOrganizer } from './competitionAuthorization'; + +const wcif = { + persons: [ + { + roles: ['organizer'], + wcaUserId: 1, + }, + { + roles: ['trainee-delegate'], + wcaUserId: 2, + }, + { + roles: ['staff-dataentry'], + wcaUserId: 3, + }, + ], +}; + +describe('isCompetitionDelegateOrOrganizer', () => { + it('allows listed organizers and delegates', () => { + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 1 })).toBe(true); + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 2 })).toBe(true); + }); + + it('rejects non-manager staff and missing users', () => { + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 3 })).toBe(false); + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 4 })).toBe(false); + expect(isCompetitionDelegateOrOrganizer(wcif as never, null)).toBe(false); + }); +}); diff --git a/src/lib/competitionAuthorization.ts b/src/lib/competitionAuthorization.ts new file mode 100644 index 0000000..30e0a36 --- /dev/null +++ b/src/lib/competitionAuthorization.ts @@ -0,0 +1,18 @@ +import { Competition } from '@wca/helpers'; +import { isDelegate, isOrganizer } from './person'; + +interface CompetitionManager { + id?: number; +} + +export const isCompetitionDelegateOrOrganizer = ( + wcif: Competition | null | undefined, + user: CompetitionManager | null | undefined, +) => { + if (!wcif || !user?.id) { + return false; + } + + const person = wcif.persons.find((candidate) => candidate.wcaUserId === user.id); + return Boolean(person && (isDelegate(person) || isOrganizer(person))); +}; diff --git a/src/lib/competitionDates.test.ts b/src/lib/competitionDates.test.ts new file mode 100644 index 0000000..f98859d --- /dev/null +++ b/src/lib/competitionDates.test.ts @@ -0,0 +1,16 @@ +import { isCompetitionDayOrAfter } from './competitionDates'; + +const wcif = { + schedule: { + numberOfDays: 2, + startDate: '2026-06-01', + }, +}; + +describe('competitionDates', () => { + it('detects dates on or after the competition start date', () => { + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 4, 31))).toBe(false); + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 5, 1))).toBe(true); + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 5, 3))).toBe(true); + }); +}); diff --git a/src/lib/competitionDates.ts b/src/lib/competitionDates.ts index f9c7fd5..629dc41 100644 --- a/src/lib/competitionDates.ts +++ b/src/lib/competitionDates.ts @@ -20,3 +20,10 @@ export const isCompetitionDay = (wcif: Competition, date = new Date()) => { return getDateKey(competitionDate); }).includes(currentDateKey); }; + +export const isCompetitionDayOrAfter = (wcif: Competition, date = new Date()) => { + const currentDate = getLocalDateFromKey(getDateKey(date)); + const startDate = getLocalDateFromKey(wcif.schedule.startDate); + + return currentDate >= startDate; +}; diff --git a/src/lib/notifyCompRemoteAuth.test.ts b/src/lib/notifyCompRemoteAuth.test.ts index a27ff52..5bc153f 100644 --- a/src/lib/notifyCompRemoteAuth.test.ts +++ b/src/lib/notifyCompRemoteAuth.test.ts @@ -1,7 +1,7 @@ import { clearNotifyCompRemoteToken, getNotifyCompRemoteClaims, - hasNotifyCompRemoteTokenForCompetition, + hasNotifyCompRemoteToken, setNotifyCompRemoteToken, } from './notifyCompRemoteAuth'; @@ -26,7 +26,7 @@ describe('notifyCompRemoteAuth', () => { clearNotifyCompRemoteToken(); }); - it('reads competitionIds from an unpadded remote JWT payload', () => { + it('reads an unpadded remote JWT payload as an authenticated session', () => { setNotifyCompRemoteToken( jwtWithClaims({ competitionIds: ['KentSpring2026'], @@ -36,18 +36,16 @@ describe('notifyCompRemoteAuth', () => { ); expect(getNotifyCompRemoteClaims()?.name).toBe('Test Delegate'); - expect(hasNotifyCompRemoteTokenForCompetition('KentSpring2026')).toBe(true); - expect(hasNotifyCompRemoteTokenForCompetition('OtherComp2026')).toBe(false); + expect(hasNotifyCompRemoteToken()).toBe(true); }); - it('accepts snake_case competition ids from remote JWT payloads', () => { + it('clears expired remote JWT payloads', () => { setNotifyCompRemoteToken( jwtWithClaims({ - competition_ids: ['KentSpring2026'], - exp: Math.floor(Date.now() / 1000) + 60, + exp: Math.floor(Date.now() / 1000) - 60, }), ); - expect(hasNotifyCompRemoteTokenForCompetition('KentSpring2026')).toBe(true); + expect(hasNotifyCompRemoteToken()).toBe(false); }); }); diff --git a/src/lib/notifyCompRemoteAuth.ts b/src/lib/notifyCompRemoteAuth.ts index c889896..d8d4f1a 100644 --- a/src/lib/notifyCompRemoteAuth.ts +++ b/src/lib/notifyCompRemoteAuth.ts @@ -48,15 +48,7 @@ export const getNotifyCompRemoteClaims = () => { return token ? decodeJwtPayload(token) : null; }; -export const hasNotifyCompRemoteTokenForCompetition = (competitionId: string) => { - const claims = getNotifyCompRemoteClaims(); - if (!claims) { - return false; - } - - const competitionIds = claims.competitionIds ?? claims.competition_ids ?? []; - return competitionIds.includes(competitionId); -}; +export const hasNotifyCompRemoteToken = () => Boolean(getNotifyCompRemoteClaims()); export const setNotifyCompRemoteToken = (token: string) => { setLocalStorage(REMOTE_JWT_KEY, token); diff --git a/src/lib/notifyCompWebSocketStatus.ts b/src/lib/notifyCompWebSocketStatus.ts new file mode 100644 index 0000000..310756c --- /dev/null +++ b/src/lib/notifyCompWebSocketStatus.ts @@ -0,0 +1,31 @@ +export type NotifyCompWebSocketStatus = 'idle' | 'connecting' | 'connected' | 'disconnected'; + +export interface NotifyCompWebSocketStatusState { + message?: string; + status: NotifyCompWebSocketStatus; +} + +type Listener = () => void; + +let state: NotifyCompWebSocketStatusState = { + status: 'idle', +}; +const listeners = new Set(); + +export const getNotifyCompWebSocketStatus = () => state; + +export const subscribeToNotifyCompWebSocketStatus = (listener: Listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +export const setNotifyCompWebSocketStatus = (nextState: NotifyCompWebSocketStatusState) => { + state = nextState; + listeners.forEach((listener) => listener()); +}; + +export const canUseNotifyCompRemoteControls = (status: NotifyCompWebSocketStatus) => + status === 'connected'; diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index eb927eb..77afa57 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -8,4 +8,4 @@ export const NOTIFYCOMP_GRAPHQL_ORIGIN = rawNotifyCompApiOrigin.endsWith('/graph export const NOTIFYCOMP_API_ORIGIN = NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/\/graphql$/, ''); export const NOTIFYCOMP_WS_ORIGIN = - import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql'; + import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/^http/, 'ws'); diff --git a/src/pages/Competition/Remote/RemoteActionDialog.tsx b/src/pages/Competition/Remote/RemoteActionDialog.tsx deleted file mode 100644 index 183b9b5..0000000 --- a/src/pages/Competition/Remote/RemoteActionDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Button } from '@/components'; - -export type RemoteAction = 'reset' | 'start' | 'stop'; - -interface RemoteActionDialogProps { - action: RemoteAction; - activityName: string; - disabled?: boolean; - onCancel: () => void; - onConfirm: () => void; - roomNames: string[]; -} - -export function RemoteActionDialog({ - action, - activityName, - disabled, - onCancel, - onConfirm, - roomNames, -}: RemoteActionDialogProps) { - const actionLabel = { - reset: 'Reset', - start: 'Start', - stop: 'Stop', - }[action]; - const buttonVariant = action === 'start' ? 'green' : 'gray'; - - return ( -
    -
    -

    Are you sure?

    - -

    - This will {action} activity: {activityName} in rooms:{' '} - {roomNames.join(', ')} -

    - - {action === 'reset' && ( -

    - The start and stop times will reset as if the activity never happened. -

    - )} - -
    - - -
    -
    -
    - ); -} diff --git a/src/pages/Competition/Remote/RemoteActivityList.stories.tsx b/src/pages/Competition/Remote/RemoteActivityList.stories.tsx new file mode 100644 index 0000000..f06812e --- /dev/null +++ b/src/pages/Competition/Remote/RemoteActivityList.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { RemoteActivityGroup, RemoteScheduledActivity } from '@/lib/notifyCompRemoteActivities'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { RemoteGroupList } from './RemoteActivityList'; + +const venue = storybookCompetitionFixture.schedule.venues[0]; +const mainRoom = venue.rooms[0]; +const sideRoom = venue.rooms[1]; + +const scheduledActivity = ( + room: typeof mainRoom, + parentIndex: number, + childIndex: number, +): RemoteScheduledActivity => { + const parent = room.activities[parentIndex]; + const child = parent.childActivities[childIndex] || parent; + + return { + ...child, + parent, + room: { + ...room, + venue: { + timezone: venue.timezone, + }, + }, + } as RemoteScheduledActivity; +}; + +const group = ( + id: string, + status: RemoteActivityGroup['status'], + activities: RemoteScheduledActivity[], +): RemoteActivityGroup => ({ + id, + name: activities[0]?.name || id, + scheduledActivities: activities, + liveActivities: activities.map((activity) => ({ + activityId: activity.id, + startTime: + status === 'current' || status === 'mixed' || status === 'done' ? activity.startTime : null, + endTime: status === 'done' ? activity.endTime : null, + })), + status, +}); + +const currentGroup = group('333-r1-g1', 'current', [ + scheduledActivity(mainRoom, 0, 0), + scheduledActivity(sideRoom, 0, 0), +]); +const nextGroup = group('333-r1-g2', 'next', [ + scheduledActivity(mainRoom, 0, 1), + scheduledActivity(sideRoom, 0, 1), +]); +const doneGroup = group('222-r1-g1', 'done', [scheduledActivity(mainRoom, 3, 0)]); + +const meta = { + title: 'Pages/Competition/Remote/Activity List', + component: RemoteGroupList, + decorators: [ + (Story) => ( +
    + +
    + ), + ], + args: { + disabled: false, + onSelectGroup: () => {}, + }, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const MixedStates: Story = { + args: { + groups: [currentGroup, nextGroup, doneGroup], + }, +}; + +export const NoUpcomingActivities: Story = { + args: { + groups: [doneGroup], + }, +}; + +export const Saving: Story = { + args: { + disabled: true, + groups: [currentGroup, nextGroup, doneGroup], + }, +}; diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx index 9544cf3..7a0590d 100644 --- a/src/pages/Competition/Remote/RemoteActivityList.tsx +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -24,6 +24,19 @@ const formatStartedDuration = (startTime: string, now: Date) => { return formatDuration({ hours, minutes }) || 'now'; }; +const formatRemoteElapsedDuration = (startTime?: string | null, endTime?: string | null) => { + if (!startTime || !endTime) { + return null; + } + + const { hours, minutes, seconds } = intervalToDuration({ + start: new Date(startTime), + end: new Date(endTime), + }); + + return formatDuration({ hours, minutes, seconds }) || '0 seconds'; +}; + const stateDescription = (state: RemoteActivityState, now: Date) => { const timeZone = groupTimeZone(state.scheduledActivity); @@ -32,11 +45,16 @@ const stateDescription = (state: RemoteActivityState, now: Date) => { } if (state.liveActivity?.endTime) { + const elapsed = formatRemoteElapsedDuration( + state.liveActivity.startTime, + state.liveActivity.endTime, + ); + return `Ended at ${formatTime( state.liveActivity.endTime, REMOTE_TIME_ROUNDING_MINUTES, timeZone, - )}`; + )}${elapsed ? ` - Duration ${elapsed}` : ''}`; } return `Should start at ${formatTime( @@ -62,6 +80,28 @@ const groupTimeZone = (activity: RemoteScheduledActivity) => (activity.room as RemoteScheduledActivity['room'] & { venue?: { timezone?: string } }).venue ?.timezone; +const groupElapsedDuration = (group: RemoteActivityGroup) => { + const startedActivities = group.liveActivities.filter( + (activity) => activity.startTime && activity.endTime, + ); + + if (startedActivities.length === 0) { + return null; + } + + const startTime = startedActivities + .map((activity) => activity.startTime) + .filter(Boolean) + .sort()[0]; + const endTime = startedActivities + .map((activity) => activity.endTime) + .filter(Boolean) + .sort() + .at(-1); + + return formatRemoteElapsedDuration(startTime, endTime); +}; + interface RemoteActivityListProps { disabled?: boolean; onSelectActivity: (state: RemoteActivityState) => void; @@ -168,6 +208,7 @@ export function RemoteGroupList({ disabled, groups, onSelectGroup }: RemoteGroup const activityIds = group.scheduledActivities.map((activity) => activity.id); const firstActivity = group.scheduledActivities[0]; const rooms = groupRooms(group); + const elapsed = group.status === 'done' ? groupElapsedDuration(group) : null; return (
    ) : ( <> {remote.isLoading && } {remote.error && } + {remote.competition && } {!remote.isLoading && !remote.competition ? ( - +
    + + +
    + ) : hasImportedEmptySchedule ? ( + ) : (

    All rooms

    -
    - Auto-advance - { - if ( - confirmAction( - `${remote.autoAdvance ? 'Disable' : 'Enable'} auto-advance for this competition?`, - ) - ) { - void remote.updateAutoAdvance(!remote.autoAdvance); - } - }} - /> +
    +
    +
    Auto-advance
    +
    + Automatically moves Live Activities to the next scheduled group when the + current group ends. +
    +
    +
    + { + void (async () => { + const autoAdvance = !remote.autoAdvance; + const confirmed = await confirm({ + confirmLabel: autoAdvance ? 'Enable' : 'Disable', + message: autoAdvance + ? 'Enable auto-advance for this competition? Live Activities Remote will move to the next scheduled group when the current group ends.' + : 'Disable auto-advance for this competition? Staff will need to advance Live Activities manually.', + }); + + if (confirmed) { + await remote.updateAutoAdvance(autoAdvance); + } + })(); + }} + /> +
    @@ -140,50 +229,76 @@ export default function CompetitionRemote() { 'opacity-60': remote.isSaving, })}> { + void selectGroup(group); + }} />
    - {canResetAllActivities && ( + {(canResetAllActivities || canFinishAllActivities) && (
    - + {canFinishAllActivities ? ( + + ) : ( + + )}
    )} + + {hasFinishedAllStartedActivities && ( + + )}
    )} )}
    - - {pendingAction && ( - setPendingAction(null)} - onConfirm={() => { - pendingAction.onConfirm(); - setPendingAction(null); - }} - /> - )} ); } diff --git a/src/pages/LiveActivities/About.tsx b/src/pages/LiveActivities/About.tsx new file mode 100644 index 0000000..4f47469 --- /dev/null +++ b/src/pages/LiveActivities/About.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react'; +import { Container } from '@/components'; + +const steps = [ + { + title: 'Sign in to Remote', + text: 'Live updates use a separate session to make sure only listed delegates and organizers can control them. Even if you are already signed into the site, you may need to sign in once more with your WCA account.', + }, + { + title: 'Import the competition', + text: 'A listed delegate or organizer opens Remote and imports the published competition schedule.', + }, + { + title: 'Start the active group', + text: 'Remote shows one row per activity code with room chips, so multi-room groups can be started together.', + }, + { + title: 'Keep the room display current', + text: 'Start, stop, reset, and auto-advance controls update what competitors and staff see as currently running.', + }, +]; + +export default function LiveActivitiesAbout() { + useEffect(() => { + document.title = 'Live Activities - Competition Groups'; + }, []); + + return ( + +
    +
    +
    +

    Live Activities

    +

    + Live Activities let delegates and organizers show what is happening right now at a + competition. The Remote tab controls the live state from the schedule you already + publish, with NotifyComp quietly handling the backend updates that keep displays in + sync. +

    +
    +
    + +
    +

    How it works

    +
    + {steps.map((step, index) => ( +
    + + {index + 1} + +
    +

    {step.title}

    +

    {step.text}

    +
    +
    + ))} +
    +
    + +
    +

    What it looks like

    +
    +
    +
    + + Live Activities + Powered by NotifyComp +
    + Live view +
    +
    +
    + Clock, Round 1, Group 1 + + Main stage + Started 4 min ago + +
    +
    + 3x3x3 Cube, Round 1, Group 2 + + Side stage + Up next + +
    +
    +
    +

    + Competitors can quickly see what is happening now and what is coming next, without + needing to interpret the full schedule. +

    +
    + +
    +

    Who can use it

    +

    + Remote controls are available only to listed delegates and organizers for the + competition. Sign in once with your WCA account, then use the Remote tab for any + competition where you are listed. +

    +
    +
    +
    + ); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index bd6517f..87fef82 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -115,9 +115,9 @@ export default function Settings() {
    -

    NotifyComp Remote

    +

    Live Activities Remote

    - Manage the NotifyComp Remote session used by competition remote controls. + Manage the separate session used by Live Activities remote controls.

    {notifyCompRemoteAuth.isAuthenticated ? 'Signed in' : 'Not signed in'} @@ -126,7 +126,7 @@ export default function Settings() { {notifyCompRemoteAuth.isAuthenticated && ( )}

    diff --git a/src/providers/ConfirmProvider/ConfirmContext.tsx b/src/providers/ConfirmProvider/ConfirmContext.tsx new file mode 100644 index 0000000..ce05c69 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmContext.tsx @@ -0,0 +1,16 @@ +import { createContext, ReactNode, useContext } from 'react'; +import { ButtonVariant } from '@/components/Button'; + +export interface ConfirmOptions { + cancelLabel?: string; + confirmLabel?: string; + confirmVariant?: ButtonVariant; + message: ReactNode; + title?: string; +} + +export type ConfirmFn = (options: ConfirmOptions | string) => Promise; + +export const ConfirmContext = createContext(async () => false); + +export const useConfirm = () => useContext(ConfirmContext); diff --git a/src/providers/ConfirmProvider/ConfirmDialog.tsx b/src/providers/ConfirmProvider/ConfirmDialog.tsx new file mode 100644 index 0000000..d826de4 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useEffect } from 'react'; +import { Button } from '@/components/Button'; +import { ButtonVariant } from '@/components/Button'; + +export interface ConfirmDialogOptions { + cancelLabel: string; + confirmLabel: string; + confirmVariant: ButtonVariant; + message: ReactNode; + title: string; +} + +interface ConfirmDialogProps { + onCancel: () => void; + onConfirm: () => void; + options: ConfirmDialogOptions; +} + +export function ConfirmDialog({ onCancel, onConfirm, options }: ConfirmDialogProps) { + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeydown); + + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [onCancel]); + + return ( +
    +
    +

    {options.title}

    + +
    {options.message}
    + +
    + + +
    +
    +
    + ); +} diff --git a/src/providers/ConfirmProvider/ConfirmProvider.test.tsx b/src/providers/ConfirmProvider/ConfirmProvider.test.tsx new file mode 100644 index 0000000..09688e6 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmProvider.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { useConfirm } from './ConfirmContext'; +import { ConfirmProvider } from './ConfirmProvider'; + +function ConfirmConsumer() { + const confirm = useConfirm(); + const [result, setResult] = useState('pending'); + + return ( + <> + + {result} + + ); +} + +describe('ConfirmProvider', () => { + it('resolves true when the user confirms', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + expect(await screen.findByText('confirmed')).toBeInTheDocument(); + }); + + it('resolves false when the user cancels', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(await screen.findByText('cancelled')).toBeInTheDocument(); + }); + + it('resolves false when the user presses Escape', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(await screen.findByText('cancelled')).toBeInTheDocument(); + }); +}); diff --git a/src/providers/ConfirmProvider/ConfirmProvider.tsx b/src/providers/ConfirmProvider/ConfirmProvider.tsx new file mode 100644 index 0000000..f5067bc --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmProvider.tsx @@ -0,0 +1,66 @@ +import { PropsWithChildren, useCallback, useRef, useState } from 'react'; +import { ConfirmContext, ConfirmOptions } from './ConfirmContext'; +import { ConfirmDialog, ConfirmDialogOptions } from './ConfirmDialog'; + +interface PendingConfirm extends ConfirmDialogOptions { + resolve: (confirmed: boolean) => void; +} + +const normalizeOptions = (options: ConfirmOptions | string): ConfirmOptions => + typeof options === 'string' ? { message: options } : options; + +export function ConfirmProvider({ children }: PropsWithChildren) { + const [pendingConfirm, setPendingConfirm] = useState(null); + const pendingConfirmRef = useRef(null); + const confirmQueueRef = useRef([]); + + const confirm = useCallback((rawOptions: ConfirmOptions | string) => { + const options = normalizeOptions(rawOptions); + + return new Promise((resolve) => { + const nextConfirm = { + cancelLabel: options.cancelLabel || 'Cancel', + confirmLabel: options.confirmLabel || 'Confirm', + confirmVariant: options.confirmVariant || 'blue', + message: options.message, + resolve, + title: options.title || 'Are you sure?', + }; + + if (pendingConfirmRef.current) { + confirmQueueRef.current.push(nextConfirm); + return; + } + + pendingConfirmRef.current = nextConfirm; + setPendingConfirm(nextConfirm); + }); + }, []); + + const close = (confirmed: boolean) => { + const currentConfirm = pendingConfirmRef.current; + if (!currentConfirm) { + return; + } + + currentConfirm.resolve(confirmed); + + const nextConfirm = confirmQueueRef.current.shift() || null; + pendingConfirmRef.current = nextConfirm; + setPendingConfirm(nextConfirm); + }; + + return ( + + {children} + + {pendingConfirm && ( + close(false)} + onConfirm={() => close(true)} + /> + )} + + ); +} diff --git a/src/providers/ConfirmProvider/index.ts b/src/providers/ConfirmProvider/index.ts new file mode 100644 index 0000000..a743384 --- /dev/null +++ b/src/providers/ConfirmProvider/index.ts @@ -0,0 +1,2 @@ +export * from './ConfirmContext'; +export * from './ConfirmProvider'; diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx index dcac8be..0040c23 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx @@ -3,6 +3,10 @@ import { createContext, useContext } from 'react'; export interface NotifyCompRemoteAuthContextValue { authenticating: boolean; error: string | null; + /** + * Remote sign-in is app-wide; the competition-specific access check lives in + * useCompetitionRemoteControl. + */ isAuthenticatedForCompetition: (competitionId: string) => boolean; isAuthenticated: boolean; signIn: (competitionId: string) => Promise; diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx index 2b47a6a..b58f6a4 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx @@ -4,7 +4,7 @@ import { clearNotifyCompRemoteToken, getNotifyCompRemoteClaims, getNotifyCompRemoteToken, - hasNotifyCompRemoteTokenForCompetition, + hasNotifyCompRemoteToken, setNotifyCompRemoteToken, } from '@/lib/notifyCompRemoteAuth'; import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; @@ -71,7 +71,9 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { setNotifyCompRemoteToken(payload.token); setToken(payload.token); } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to authorize NotifyComp Remote.'); + setError( + err instanceof Error ? err.message : 'Unable to authorize Live Activities Remote.', + ); clearNotifyCompRemoteToken(); setToken(null); } finally { @@ -87,7 +89,7 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { return; } - if (hasNotifyCompRemoteTokenForCompetition(pendingCompetitionId)) { + if (hasNotifyCompRemoteToken()) { deleteLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY); return; } @@ -110,7 +112,7 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { () => ({ authenticating, error, - isAuthenticatedForCompetition: hasNotifyCompRemoteTokenForCompetition, + isAuthenticatedForCompetition: hasNotifyCompRemoteToken, isAuthenticated: Boolean(token), signIn, signOut, From 385e07e2b499aec0af69ebd5dc5853fd20abec25 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Tue, 12 May 2026 19:53:21 -0700 Subject: [PATCH 58/64] Normalize results container class order --- .../CompetitionResults/CompetitionResults.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/containers/CompetitionResults/CompetitionResults.tsx b/src/containers/CompetitionResults/CompetitionResults.tsx index e57189c..b243a67 100644 --- a/src/containers/CompetitionResults/CompetitionResults.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.tsx @@ -241,7 +241,7 @@ export function CompetitionResultsContainer({ now={now} apiResultsByRoundId={apiResultsByRoundId} /> -
    +
    {isWcaApiResultsLoading && roundResults.length === 0 ? ( -
    +

    {t('common.loading')}

    ) : ( @@ -276,11 +276,11 @@ export function CompetitionResultsContainer({ )}
    ) : isWcaApiResultsLoading ? ( -
    +

    {t('common.loading')}

    ) : ( -
    +

    {t('competition.results.roundNotFound')}

    )} @@ -292,7 +292,7 @@ export function CompetitionResultsContainer({ return ( -
    +
    From 596361ecf034cd2340ccc8a5869261a82cda85cb Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Tue, 12 May 2026 20:39:23 -0700 Subject: [PATCH 59/64] Add competition admin section --- src/App.tsx | 20 +++++-- .../StyledNavLink/StyledNavLink.tsx | 3 +- .../CompetitionStats/CompetitionStats.tsx | 37 ++++++------ .../CompetitionSumOfRanks.tsx | 6 +- src/i18n/en/translation.yaml | 3 + src/i18n/fr/translation.yaml | 3 + .../CompetitionLayout.tabs.tsx | 59 +++++++++++++++---- .../CompetitionLayout/CompetitionLayout.tsx | 3 +- src/pages/Competition/Admin/index.tsx | 55 +++++++++++++++++ 9 files changed, 150 insertions(+), 39 deletions(-) create mode 100644 src/pages/Competition/Admin/index.tsx diff --git a/src/App.tsx b/src/App.tsx index abaafe2..c9f70eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { usePageTracking } from './hooks/usePageTracking'; import { CompetitionLayout } from './layouts/CompetitionLayout'; import { RootLayout } from './layouts/RootLayout'; import About from './pages/About'; +import CompetitionAdmin from './pages/Competition/Admin'; import CompetitionEvents from './pages/Competition/ByGroup/Events'; import CompetitionGroup from './pages/Competition/ByGroup/Group'; import CompetitionGroupList from './pages/Competition/ByGroup/GroupList'; @@ -85,6 +86,12 @@ const PsychSheet = () => { return null; }; +const CompetitionRedirect = ({ to }: { to: string }) => { + const { competitionId } = useParams() as { competitionId: string }; + + return ; +}; + const Navigation = () => { usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID); @@ -112,11 +119,16 @@ const Navigation = () => { } /> } /> - } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -125,8 +137,8 @@ const Navigation = () => { } /> } /> } /> - } /> - } /> + } /> + } /> Path not resolved

    } />
    } /> diff --git a/src/components/StyledNavLink/StyledNavLink.tsx b/src/components/StyledNavLink/StyledNavLink.tsx index a110d85..cce056f 100644 --- a/src/components/StyledNavLink/StyledNavLink.tsx +++ b/src/components/StyledNavLink/StyledNavLink.tsx @@ -11,11 +11,12 @@ export const StyledNavLink: React.FC = ({ to, text, className, + end = true, ...props }: StyledNavLinkProps) => ( classNames('link-nav', className, { diff --git a/src/containers/CompetitionStats/CompetitionStats.tsx b/src/containers/CompetitionStats/CompetitionStats.tsx index 4928009..56e9a42 100644 --- a/src/containers/CompetitionStats/CompetitionStats.tsx +++ b/src/containers/CompetitionStats/CompetitionStats.tsx @@ -21,29 +21,26 @@ export function CompetitionStatsContainer() { const acceptedRegistrationsCount = acceptedRegistrations?.length || 0; return ( - -
    + +
    -
    -
    - {wcif?.events?.map(({ id }) => ( - - ))} - {wcif?.events?.map(({ id }) => ( - - { - acceptedRegistrations?.filter(({ registration }) => - registration?.eventIds.includes(id), - ).length - } - - ))} +
    + {wcif?.events?.map(({ id }) => { + const registrationCount = + acceptedRegistrations?.filter(({ registration }) => registration?.eventIds.includes(id)) + .length ?? 0; + + return ( +
    + + {registrationCount} +
    + ); + })}
    ); diff --git a/src/containers/CompetitionSumOfRanks/CompetitionSumOfRanks.tsx b/src/containers/CompetitionSumOfRanks/CompetitionSumOfRanks.tsx index a0c5842..3352cec 100644 --- a/src/containers/CompetitionSumOfRanks/CompetitionSumOfRanks.tsx +++ b/src/containers/CompetitionSumOfRanks/CompetitionSumOfRanks.tsx @@ -101,7 +101,7 @@ export function CompetitionSumOfRanksContainer({
    - Medals + Medals @@ -117,7 +117,9 @@ export function CompetitionSumOfRanksContainer({ {ranking.sumOfRanks} {formatKinch(ranking.kinch)} - {formatMedals(ranking)} + + {formatMedals(ranking)} + ))} diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index 3ddbcd1..f656d83 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -158,6 +158,9 @@ header: results: 'Results' scramblers: 'Scramblers' stream: 'Stream' + admin: 'Admin' + sumOfRanks: 'Sum of Rankings' + stats: 'Stats' home: subtitle: 'Learn all you need about your WCA competition assignments!' explanation: 'Note: This site is a convenience tool for organizers, delegates, and competitors. It uses scheduled data, so check with organizers for the latest details. Start and end times may change.' diff --git a/src/i18n/fr/translation.yaml b/src/i18n/fr/translation.yaml index f80680f..4ef61ae 100644 --- a/src/i18n/fr/translation.yaml +++ b/src/i18n/fr/translation.yaml @@ -118,6 +118,9 @@ header: rankings: 'Classements' scramblers: 'Mélangeurs' stream: 'Stream' + admin: 'Admin' + sumOfRanks: 'Somme des classements' + stats: 'Stats' home: subtitle: 'Apprenez tout ce que vous devez savoir sur les tâches qui vous ont été attribuées aux compétitions WCA !' explanation: 'Remarque : Ce site web est un outil pratique pour les organisateurs, délégués et compétiteurs. Les informations fournies sont basées sur le planning prévisionnel. Pensez à consulter régulièrement la page de la compétition pour obtenir les informations les plus récentes. Les horaires de début et de fin peuvent varier.' diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index effe2b7..9b37daf 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -11,22 +11,31 @@ interface CompetitionLayoutTabsProps { wcif?: Competition; } +export interface CompetitionLayoutTab { + href: string; + text: string; + end?: boolean; + hiddenOnMobile?: boolean; +} + +interface CompetitionLayoutTabs { + tabs: CompetitionLayoutTab[]; + adminTabs: CompetitionLayoutTab[]; +} + export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLayoutTabsProps) => { const { t } = useTranslation(); const { user } = useAuth(); const userId = user?.id; - return useMemo(() => { + return useMemo(() => { const hasStream = wcif && streamActivities(wcif).length > 0; const person = wcif?.persons.find((p) => p.wcaUserId === userId); const isPersonStaff = person && isStaff(person); const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, userId ? { id: userId } : null); - const _tabs: { - href: string; - text: string; - hiddenOnMobile?: boolean; - }[] = []; + const _tabs: CompetitionLayoutTab[] = []; + const _adminTabs: CompetitionLayoutTab[] = []; _tabs.push({ href: `/competitions/${competitionId}`, @@ -54,15 +63,15 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay ); if (isPersonStaff) { - _tabs.push({ - href: `/competitions/${competitionId}/scramblers`, + _adminTabs.push({ + href: `/competitions/${competitionId}/admin/scramblers`, text: t('header.tabs.scramblers'), }); } if (canManageRemote) { - _tabs.push({ - href: `/competitions/${competitionId}/remote`, + _adminTabs.push({ + href: `/competitions/${competitionId}/admin/remote`, text: t('header.tabs.remote', { defaultValue: 'Remote', }), @@ -76,6 +85,34 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay }); } - return _tabs; + if (isPersonStaff || canManageRemote) { + _adminTabs.push( + { + href: `/competitions/${competitionId}/admin/sum-of-ranks`, + text: t('header.tabs.sumOfRanks', { + defaultValue: 'Sum of Rankings', + }), + }, + { + href: `/competitions/${competitionId}/admin/stats`, + text: t('header.tabs.stats', { + defaultValue: 'Stats', + }), + }, + ); + } + + if (_adminTabs.length > 0) { + _tabs.push({ + href: `/competitions/${competitionId}/admin`, + text: t('header.tabs.admin'), + end: false, + }); + } + + return { + tabs: _tabs, + adminTabs: _adminTabs, + }; }, [wcif, competitionId, userId, t]); }; diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tsx index cd496bb..a8f40b1 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tsx @@ -20,7 +20,7 @@ export function CompetitionLayout() { const { data: wcif, dataUpdatedAt, isFetching } = useWcif(competitionId!); - const tabs = useCompetitionLayoutTabs({ + const { tabs } = useCompetitionLayoutTabs({ competitionId: competitionId!, wcif: wcif, }); @@ -39,6 +39,7 @@ export function CompetitionLayout() { className={classNames({ 'hidden md:block': i.hiddenOnMobile, })} + end={i.end} to={i.href} text={i.text} /> diff --git a/src/pages/Competition/Admin/index.tsx b/src/pages/Competition/Admin/index.tsx new file mode 100644 index 0000000..32bcb24 --- /dev/null +++ b/src/pages/Competition/Admin/index.tsx @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Container, NoteBox } from '@/components'; +import { useCompetitionLayoutTabs } from '@/layouts/CompetitionLayout/CompetitionLayout.tabs'; +import { useWCIF } from '@/providers/WCIFProvider'; + +const adminDescriptions: Record = { + scramblers: 'View scrambler assignments by event and round.', + remote: 'Control Live Activities updates for the competition.', + 'sum-of-ranks': 'Review competitor rankings across completed rounds.', + stats: 'View competition registration and event counts.', +}; + +const adminItemId = (href: string) => href.split('/').pop() ?? href; + +export default function CompetitionAdmin() { + const { competitionId, wcif, setTitle } = useWCIF(); + const { adminTabs } = useCompetitionLayoutTabs({ competitionId, wcif }); + + useEffect(() => { + setTitle('Admin'); + }, [setTitle]); + + return ( + +

    Admin

    + {adminTabs.length === 0 ? ( + + ) : ( +
    + {adminTabs.map((tab) => { + const itemId = adminItemId(tab.href); + + return ( + +
    +
    +
    {tab.text}
    + {adminDescriptions[itemId] && ( +
    {adminDescriptions[itemId]}
    + )} +
    +