diff --git a/.gitignore b/.gitignore index 112ed65..fb7a4a0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* storybook-static + +.cache diff --git a/.storybook/main.ts b/.storybook/main.ts index 2ae2022..af2e565 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,5 +1,6 @@ import ViteYaml from '@modyfi/vite-plugin-yaml'; import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'node:path'; import { mergeConfig } from 'vite'; import viteTsconfigPaths from 'vite-tsconfig-paths'; @@ -20,6 +21,11 @@ const config: StorybookConfig = { viteFinal: async (config) => mergeConfig(config, { plugins: [viteTsconfigPaths(), ViteYaml()], + resolve: { + alias: { + 'virtual:pwa-register': path.resolve(__dirname, './mocks/pwa-register.ts'), + }, + }, define: { __GIT_COMMIT__: JSON.stringify('storybook'), __GIT_TAG__: JSON.stringify(''), diff --git a/.storybook/mocks/pwa-register.ts b/.storybook/mocks/pwa-register.ts new file mode 100644 index 0000000..723e491 --- /dev/null +++ b/.storybook/mocks/pwa-register.ts @@ -0,0 +1,8 @@ +type RegisterSWOptions = { + onNeedRefresh?: () => void; + onOfflineReady?: () => void; +}; + +export function registerSW(_options?: RegisterSWOptions) { + return async (_reloadPage?: boolean) => {}; +} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 631cde6..b1f67b3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,33 @@ import { MemoryRouter } from 'react-router-dom'; import '../src/i18n'; import '../src/styles/index.scss'; +const mobileViewports = { + androidTypical: { + name: 'Android Typical', + styles: { + width: '360px', + height: '800px', + }, + type: 'mobile', + }, + iphoneModern: { + name: 'iPhone Modern', + styles: { + width: '393px', + height: '852px', + }, + type: 'mobile', + }, + phoneLegacy: { + name: 'Phone 2016', + styles: { + width: '375px', + height: '667px', + }, + type: 'mobile', + }, +}; + const preview: Preview = { decorators: [ (Story) => ( @@ -23,7 +50,8 @@ const preview: Preview = { }, layout: 'centered', viewport: { - defaultViewport: 'mobile1', + defaultViewport: 'androidTypical', + options: mobileViewports, }, }, }; diff --git a/src/components/ActivityRow/ActivityRow.stories.tsx b/src/components/ActivityRow/ActivityRow.stories.tsx new file mode 100644 index 0000000..570f110 --- /dev/null +++ b/src/components/ActivityRow/ActivityRow.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { ActivityRow } from './ActivityRow'; + +const mainRoom = storybookCompetitionFixture.schedule.venues[0].rooms[0]; +const stageActivity = mainRoom.activities[0].childActivities[0]; +const otherActivity = storybookCompetitionFixture.schedule.venues[0].rooms[1].activities[1]; + +const meta = { + title: 'Components/Competition/ActivityRow', + component: ActivityRow, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithStage: Story = { + args: { + activity: stageActivity, + competitionId: storybookCompetitionFixture.id, + stage: { + name: 'Blue Stage', + color: '#3b82f6', + }, + timeZone: 'America/Los_Angeles', + }, +}; + +export const WithoutStage: Story = { + args: { + activity: otherActivity, + competitionId: storybookCompetitionFixture.id, + timeZone: 'America/Los_Angeles', + showRoom: false, + }, +}; diff --git a/src/components/ActivityRow/ActivityRow.tsx b/src/components/ActivityRow/ActivityRow.tsx index 008525e..b80b58b 100644 --- a/src/components/ActivityRow/ActivityRow.tsx +++ b/src/components/ActivityRow/ActivityRow.tsx @@ -1,22 +1,31 @@ import { Activity, Room, Venue } from '@wca/helpers'; import classNames from 'classnames'; import { useMemo } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { Stage } from '@/extensions/org.cubingusa.natshelper.v1/types'; import { useNow } from '@/hooks/useNow'; import { activityCodeToName } from '@/lib/activityCodes'; +import { LinkRenderer } from '@/lib/linkRenderer'; import { formatTimeRange } from '@/lib/time'; import { RoomPill } from '../Pill'; interface ActivityRowProps { activity: Activity; + competitionId: string; stage?: Pick; timeZone: Venue['timezone']; showRoom?: boolean; + LinkComponent?: LinkRenderer; } -export function ActivityRow({ activity, stage, timeZone, showRoom = true }: ActivityRowProps) { - const { competitionId } = useParams(); +export function ActivityRow({ + activity, + competitionId, + stage, + timeZone, + showRoom = true, + LinkComponent = Link, +}: ActivityRowProps) { const now = useNow(); const isOver = useMemo( @@ -28,7 +37,7 @@ export function ActivityRow({ activity, stage, timeZone, showRoom = true }: Acti : activityCodeToName(activity.activityCode); return ( - {formatTimeRange(activity.startTime, activity.endTime, 5, timeZone)} - + ); } diff --git a/src/components/AppUpdatePrompt/AppUpdatePrompt.stories.tsx b/src/components/AppUpdatePrompt/AppUpdatePrompt.stories.tsx index 8bfb93d..f9a531f 100644 --- a/src/components/AppUpdatePrompt/AppUpdatePrompt.stories.tsx +++ b/src/components/AppUpdatePrompt/AppUpdatePrompt.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { AppUpdatePrompt } from './AppUpdatePrompt'; const meta = { - title: 'Components/AppUpdatePrompt', + title: 'Components/App/AppUpdatePrompt', component: AppUpdatePrompt, args: { onUpdate: () => {}, diff --git a/src/components/AssignmentCodeCell/AssignmentCodeCell.stories.tsx b/src/components/AssignmentCodeCell/AssignmentCodeCell.stories.tsx new file mode 100644 index 0000000..6991f68 --- /dev/null +++ b/src/components/AssignmentCodeCell/AssignmentCodeCell.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AssignmentCodeCell } from './AssignmentCodeCell'; + +const meta = { + title: 'Components/Competition/AssignmentCodeCell', + component: AssignmentCodeCell, + decorators: [ + (Story) => ( +
+ + + + + + +
+
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Competitor: Story = { + args: { + assignmentCode: 'competitor', + }, +}; + +export const Verb: Story = { + args: { + assignmentCode: 'staff-judge', + grammar: 'verb', + }, +}; + +export const Letter: Story = { + args: { + assignmentCode: 'staff-scrambler', + letter: true, + }, +}; + +export const BorderedCount: Story = { + args: { + as: 'div', + assignmentCode: 'staff-runner', + border: true, + count: 4, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/components/AssignmentLabel/AssignmentLabel.stories.tsx b/src/components/AssignmentLabel/AssignmentLabel.stories.tsx new file mode 100644 index 0000000..845d9f4 --- /dev/null +++ b/src/components/AssignmentLabel/AssignmentLabel.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AssignmentLabel } from './AssignmentLabel'; + +const meta = { + title: 'Components/Competition/AssignmentLabel', + component: AssignmentLabel, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Competitor: Story = { + args: { + assignmentCode: 'competitor', + }, +}; + +export const Judge: Story = { + args: { + assignmentCode: 'staff-judge', + }, +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 0000000..6c1a221 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Breadcrumbs } from './Breadcrumbs'; + +const meta = { + title: 'Components/Competition/Breadcrumbs', + component: Breadcrumbs, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const LinkedTrail: Story = { + args: { + breadcrumbs: [ + { + label: '3x3x3 Cube, Round 1', + href: '/competitions/SeattleSummerOpen2026/events/333-r1', + }, + { + label: 'Group 1', + }, + ], + }, +}; + +export const MultipleLinks: Story = { + args: { + breadcrumbs: [ + { + label: 'Schedule', + href: '/competitions/SeattleSummerOpen2026/activities', + }, + { + label: 'Main Stage', + href: '/competitions/SeattleSummerOpen2026/rooms/10', + }, + { + label: '3x3x3 Cube, Round 1', + }, + ], + }, +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 3545198..dc2124c 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { Fragment } from 'react'; import { Link } from 'react-router-dom'; +import { LinkRenderer } from '@/lib/linkRenderer'; import { BreadcrumbPill, PillProps } from '../Pill'; export type Breadcrumb = @@ -15,16 +16,17 @@ export type Breadcrumb = export interface BreadcrumbsProps { breadcrumbs: Breadcrumb[]; + LinkComponent?: LinkRenderer; } -export const Breadcrumbs = ({ breadcrumbs }: BreadcrumbsProps) => { +export const Breadcrumbs = ({ breadcrumbs, LinkComponent = Link }: BreadcrumbsProps) => { return (
{breadcrumbs.map(({ label, ...breadcrumb }, index) => ( {index > 0 && ยท} {'href' in breadcrumb ? ( - + { )}> {label} - + ) : ( {label} )} diff --git a/src/components/BreakableActivityName/BreakableActivityName.stories.tsx b/src/components/BreakableActivityName/BreakableActivityName.stories.tsx new file mode 100644 index 0000000..fca180c --- /dev/null +++ b/src/components/BreakableActivityName/BreakableActivityName.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BreakableActivityName } from './BreakableActivityName'; + +const meta = { + title: 'Components/Competition/BreakableActivityName', + component: BreakableActivityName, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const StandardRound: Story = { + args: { + activityCode: '333-r2-g1', + }, +}; + +export const OtherActivity: Story = { + args: { + activityCode: 'other-lunch', + activityName: 'Lunch Break', + }, +}; diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx index 4581517..6d6a0e3 100644 --- a/src/components/Button/Button.stories.tsx +++ b/src/components/Button/Button.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Button } from './Button'; const meta = { - title: 'Components/Button', + title: 'Components/App/Button', component: Button, args: { children: 'Update app', diff --git a/src/components/CompetitionList/CompetitionList.stories.tsx b/src/components/CompetitionList/CompetitionList.stories.tsx new file mode 100644 index 0000000..bd2f68c --- /dev/null +++ b/src/components/CompetitionList/CompetitionList.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeAppContainerDecorator, storybookPinnedCompetitions } from '@/storybook/appStorybook'; +import { CompetitionListFragment } from './CompetitionList'; + +const meta = { + title: 'Components/App/CompetitionList', + component: CompetitionListFragment, + decorators: [makeAppContainerDecorator()], + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Bookmarked Competitions', + competitions: storybookPinnedCompetitions, + loading: false, + liveCompetitionIds: ['SeattleSummerOpen2026'], + }, +}; + +export const Loading: Story = { + args: { + title: 'Upcoming Competitions', + competitions: [], + loading: true, + liveCompetitionIds: [], + }, +}; diff --git a/src/components/CompetitionListItem/CompetitionListItem.stories.tsx b/src/components/CompetitionListItem/CompetitionListItem.stories.tsx index 7154588..21388f2 100644 --- a/src/components/CompetitionListItem/CompetitionListItem.stories.tsx +++ b/src/components/CompetitionListItem/CompetitionListItem.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CompetitionListItem } from './CompetitionListItem'; const meta = { - title: 'Components/CompetitionListItem', + title: 'Components/Competition/CompetitionListItem', component: CompetitionListItem, decorators: [ (Story) => ( diff --git a/src/components/CompetitionSelect/CompetitionSelect.stories.tsx b/src/components/CompetitionSelect/CompetitionSelect.stories.tsx new file mode 100644 index 0000000..6a58860 --- /dev/null +++ b/src/components/CompetitionSelect/CompetitionSelect.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/test'; +import { useEffect } from 'react'; +import { makeAppContainerDecorator } from '@/storybook/appStorybook'; +import { CompetitionSelect } from './CompetitionSelect'; + +function FetchMockDecorator({ children }: { children: React.ReactNode }) { + useEffect(() => { + const originalFetch = window.fetch; + + window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/search/competitions')) { + return new Response( + JSON.stringify({ + result: [ + { + id: 'SeattleSummerOpen2026', + name: 'Seattle Summer Open 2026', + short_name: 'Seattle Summer Open 2026', + city: 'Seattle, Washington', + country_iso2: 'US', + start_date: '2026-05-03', + end_date: '2026-05-04', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + return originalFetch(input, init); + }; + + return () => { + window.fetch = originalFetch; + }; + }, []); + + return <>{children}; +} + +const meta = { + title: 'Components/App/CompetitionSelect', + component: CompetitionSelect, + decorators: [ + makeAppContainerDecorator(), + (Story) => ( + +
+ +
+
+ ), + ], + args: { + onSelect: () => {}, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSearchResults: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await userEvent.click(input); + await userEvent.type(input, 'Seattle'); + }, +}; diff --git a/src/components/Container/Container.stories.tsx b/src/components/Container/Container.stories.tsx new file mode 100644 index 0000000..c83370f --- /dev/null +++ b/src/components/Container/Container.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Container } from './Container'; + +const meta = { + title: 'Components/App/Container', + component: Container, + args: { + children: ( +
+
Container content
+
This demonstrates the shared page width wrapper.
+
+ ), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const FullWidth: Story = { + args: { + fullWidth: true, + }, +}; diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx new file mode 100644 index 0000000..e12a099 --- /dev/null +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + getStorybookRoundFixture, + makeStorybookCompetitionFixtureWithRound, +} from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; + +const cumulativeTimeLimitCompetitionFixture = makeStorybookCompetitionFixtureWithRound( + '333-r2', + (round) => ({ + ...round, + timeLimit: { + centiseconds: 15000, + cumulativeRoundIds: ['333-r2', '333-r3'], + }, + advancementCondition: { + type: 'attemptResult', + level: 950, + }, + }), +); + +const finalRoundCompetitionFixture = makeStorybookCompetitionFixtureWithRound( + '333-r3', + (round) => ({ + ...round, + advancementCondition: null, + }), +); + +const meta = { + title: 'Components/Competition/CutoffTimeLimitPanel', + component: CutoffTimeLimitPanel, + decorators: [ + makeCompetitionContainerDecorator(), + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const RankingAdvancement: Story = { + args: { + round: getStorybookRoundFixture('333-r1'), + }, +}; + +export const CutoffAndTimeLimit: Story = { + args: { + round: getStorybookRoundFixture('222-r1'), + }, +}; + +export const CumulativeTimeLimitWithRoundLinks: Story = { + parameters: { + competitionFixture: cumulativeTimeLimitCompetitionFixture, + }, + args: { + round: cumulativeTimeLimitCompetitionFixture.events[0].rounds[1], + }, +}; + +export const FinalRoundWithoutAdvancement: Story = { + parameters: { + competitionFixture: finalRoundCompetitionFixture, + }, + args: { + round: finalRoundCompetitionFixture.events[0].rounds[2], + }, +}; diff --git a/src/components/DisclaimerText/DisclaimerText.stories.tsx b/src/components/DisclaimerText/DisclaimerText.stories.tsx index 0fb196b..95d4680 100644 --- a/src/components/DisclaimerText/DisclaimerText.stories.tsx +++ b/src/components/DisclaimerText/DisclaimerText.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { DisclaimerText } from './DisclaimerText'; const meta = { - title: 'Components/DisclaimerText', + title: 'Components/App/DisclaimerText', component: DisclaimerText, args: { className: 'max-w-xl', diff --git a/src/components/ErrorFallback/ErrorFallback.stories.tsx b/src/components/ErrorFallback/ErrorFallback.stories.tsx new file mode 100644 index 0000000..a24df2b --- /dev/null +++ b/src/components/ErrorFallback/ErrorFallback.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ErrorFallback } from './ErrorFallback'; + +const meta = { + title: 'Components/App/ErrorFallback', + component: ErrorFallback, + args: { + error: new Error('Failed to load competition data'), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/components/ExternalLink/ExternalLink.stories.tsx b/src/components/ExternalLink/ExternalLink.stories.tsx index afadbcc..fc9443a 100644 --- a/src/components/ExternalLink/ExternalLink.stories.tsx +++ b/src/components/ExternalLink/ExternalLink.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ExternalLink } from './ExternalLink'; const meta = { - title: 'Components/ExternalLink', + title: 'Components/App/ExternalLink', component: ExternalLink, args: { href: 'https://www.worldcubeassociation.org/', diff --git a/src/components/Grid/Grid.stories.tsx b/src/components/Grid/Grid.stories.tsx new file mode 100644 index 0000000..b90487c --- /dev/null +++ b/src/components/Grid/Grid.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from './Grid'; + +const cellClassName = 'rounded-md bg-panel p-3 text-center type-body'; + +const meta = { + title: 'Components/App/Grid', + component: Grid, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ThreeColumns: Story = { + render: (args) => ( + +
One
+
Two
+
Three
+
+ ), + args: { + columnWidths: ['1fr', '2fr', '1fr'], + className: 'gap-2', + }, +}; + +export const RowsAndColumns: Story = { + render: (args) => ( + +
A
+
B
+
C
+
D
+
+ ), + args: { + columnWidths: ['1fr', '1fr'], + rowHeights: ['auto', 'auto'], + className: 'gap-2', + }, +}; diff --git a/src/components/LastFetchedAt/LastFetchedAt.stories.tsx b/src/components/LastFetchedAt/LastFetchedAt.stories.tsx new file mode 100644 index 0000000..e5a8718 --- /dev/null +++ b/src/components/LastFetchedAt/LastFetchedAt.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LastFetchedAt } from './LastFetchedAt'; + +const meta = { + title: 'Components/App/LastFetchedAt', + component: LastFetchedAt, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const JustNow: Story = { + args: { + lastFetchedAt: new Date(Date.now() - 30 * 1000), + }, +}; + +export const EarlierToday: Story = { + args: { + lastFetchedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }, +}; diff --git a/src/components/LinkButton/LinkButton.stories.tsx b/src/components/LinkButton/LinkButton.stories.tsx index 673f1be..f0c3440 100644 --- a/src/components/LinkButton/LinkButton.stories.tsx +++ b/src/components/LinkButton/LinkButton.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { LinkButton } from './LinkButton'; const meta = { - title: 'Components/LinkButton', + title: 'Components/App/LinkButton', component: LinkButton, args: { to: '/support', diff --git a/src/components/LinkButton/LinkButton.tsx b/src/components/LinkButton/LinkButton.tsx index 25e7f11..454beee 100644 --- a/src/components/LinkButton/LinkButton.tsx +++ b/src/components/LinkButton/LinkButton.tsx @@ -1,14 +1,22 @@ import classNames from 'classnames'; import { Link, LinkProps } from 'react-router-dom'; +import { LinkRenderer } from '@/lib/linkRenderer'; export interface LinkButtonProps { to: LinkProps['to']; title: string; variant?: 'blue' | 'green' | 'gray' | 'light'; className?: string; + LinkComponent?: LinkRenderer; } -export const LinkButton = ({ to, title, variant = 'blue', className }: LinkButtonProps) => { +export const LinkButton = ({ + to, + title, + variant = 'blue', + className, + LinkComponent = Link, +}: LinkButtonProps) => { const variantClasses = { blue: 'btn-blue', green: 'btn-green', @@ -17,10 +25,10 @@ export const LinkButton = ({ to, title, variant = 'blue', className }: LinkButto } satisfies Record, string>; return ( - {title} - + ); }; diff --git a/src/components/LoggedOutPromptCard/LoggedOutPromptCard.stories.tsx b/src/components/LoggedOutPromptCard/LoggedOutPromptCard.stories.tsx index 1ee2eb7..9da904b 100644 --- a/src/components/LoggedOutPromptCard/LoggedOutPromptCard.stories.tsx +++ b/src/components/LoggedOutPromptCard/LoggedOutPromptCard.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { LoggedOutPromptCard } from './LoggedOutPromptCard'; const meta = { - title: 'Components/LoggedOutPromptCard', + title: 'Components/App/LoggedOutPromptCard', component: LoggedOutPromptCard, args: { onLogin: () => {}, @@ -20,7 +20,7 @@ type Story = StoryObj; export const Mobile: Story = { parameters: { viewport: { - defaultViewport: 'mobile1', + defaultViewport: 'androidTypical', }, }, }; diff --git a/src/components/Notebox/Notebox.stories.tsx b/src/components/Notebox/Notebox.stories.tsx new file mode 100644 index 0000000..628f237 --- /dev/null +++ b/src/components/Notebox/Notebox.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { NoteBox } from './Notebox'; + +const meta = { + title: 'Components/App/Notebox', + component: NoteBox, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + text: 'This app is operating in offline mode. Some data may be outdated.', + }, +}; + +export const WithoutPrefix: Story = { + args: { + text: 'Live activity support is currently unavailable for this competition.', + prefix: '', + }, +}; diff --git a/src/components/Pill/Pill.stories.tsx b/src/components/Pill/Pill.stories.tsx new file mode 100644 index 0000000..06269c8 --- /dev/null +++ b/src/components/Pill/Pill.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BaseAssignmentPill, BreadcrumbPill, Pill, RoomPill } from './Pill'; + +const meta = { + title: 'Components/App/Pill', + component: Pill, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'General pill', + }, +}; + +export const Breadcrumb: Story = { + render: () => Round 1, +}; + +export const Assignment: Story = { + render: () => ( + Judge + ), +}; + +export const Room: Story = { + render: () => Main Stage, +}; diff --git a/src/components/PinCompetitionButton/PinCompetitionButton.stories.tsx b/src/components/PinCompetitionButton/PinCompetitionButton.stories.tsx new file mode 100644 index 0000000..0778c3e --- /dev/null +++ b/src/components/PinCompetitionButton/PinCompetitionButton.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeAppContainerDecorator, storybookPinnedCompetitions } from '@/storybook/appStorybook'; +import { PinCompetitionButton } from './PinCompetitionButton'; + +const meta = { + title: 'Components/App/PinCompetitionButton', + component: PinCompetitionButton, + decorators: [ + makeAppContainerDecorator(), + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Unpinned: Story = { + args: { + competitionId: 'PortlandAutumn2026', + }, + parameters: { + competitionDetails: [ + { + ...storybookPinnedCompetitions[0], + id: 'PortlandAutumn2026', + name: 'Portland Autumn 2026', + short_name: 'Portland Autumn 2026', + city: 'Portland, Oregon', + start_date: '2026-10-10', + end_date: '2026-10-11', + website: 'https://www.worldcubeassociation.org/competitions/PortlandAutumn2026', + }, + ], + pinnedCompetitions: [], + }, +}; + +export const Pinned: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, + parameters: { + competitionDetails: storybookPinnedCompetitions, + pinnedCompetitions: storybookPinnedCompetitions, + }, +}; diff --git a/src/components/RoomSelector/RoomSelector.stories.tsx b/src/components/RoomSelector/RoomSelector.stories.tsx new file mode 100644 index 0000000..d7c8696 --- /dev/null +++ b/src/components/RoomSelector/RoomSelector.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import RoomSelector from './RoomSelector'; + +const venues = [ + { + id: 1, + name: 'Seattle Center', + rooms: [ + { id: 10, name: 'Main Stage' }, + { id: 11, name: 'Side Stage' }, + ], + }, + { + id: 2, + name: 'Annex Hall', + rooms: [ + { id: 20, name: 'Practice Room' }, + { id: 21, name: 'Finals Stage' }, + ], + }, +]; + +function RoomSelectorStory() { + const [currentVenue, setCurrentVenue] = useState(1); + const [currentRoom, setCurrentRoom] = useState(10); + + const handleVenueChange = (venue: (typeof venues)[number]) => { + setCurrentVenue(venue.id); + setCurrentRoom(venue.rooms[0].id); + }; + + const handleRoomChange = (room: (typeof venues)[number]['rooms'][number]) => { + setCurrentRoom(room.id); + }; + + return ( + + ); +} + +const meta = { + title: 'Components/Competition/RoomSelector', + component: RoomSelector, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + venues, + currentVenue: 1, + currentRoom: 10, + onVenueChange: () => {}, + onRoomChange: () => {}, + }, + render: () => , +}; diff --git a/src/components/StyledNavLink/StyledNavLink.stories.tsx b/src/components/StyledNavLink/StyledNavLink.stories.tsx new file mode 100644 index 0000000..5450b92 --- /dev/null +++ b/src/components/StyledNavLink/StyledNavLink.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeRouteDecorator } from '@/storybook/competitionStorybook'; +import { StyledNavLink } from './StyledNavLink'; + +const meta = { + title: 'Components/App/StyledNavLink', + component: StyledNavLink, + decorators: [ + makeRouteDecorator({ + initialPath: '/competitions/SeattleSummerOpen2026/live', + routePath: '/competitions/:competitionId/*', + }), + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Active: Story = { + args: { + to: '/competitions/SeattleSummerOpen2026/live', + text: 'Live', + }, +}; + +export const Inactive: Story = { + args: { + to: '/competitions/SeattleSummerOpen2026/information', + text: 'Information', + }, +}; diff --git a/src/containers/StyledNavLink/StyledNavLink.tsx b/src/components/StyledNavLink/StyledNavLink.tsx similarity index 100% rename from src/containers/StyledNavLink/StyledNavLink.tsx rename to src/components/StyledNavLink/StyledNavLink.tsx diff --git a/src/containers/StyledNavLink/index.ts b/src/components/StyledNavLink/index.ts similarity index 100% rename from src/containers/StyledNavLink/index.ts rename to src/components/StyledNavLink/index.ts diff --git a/src/containers/About/About.stories.tsx b/src/containers/About/About.stories.tsx new file mode 100644 index 0000000..796e990 --- /dev/null +++ b/src/containers/About/About.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AboutContainer } from './About'; + +const meta = { + title: 'Containers/App/About', + component: AboutContainer, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/About/About.tsx b/src/containers/About/About.tsx new file mode 100644 index 0000000..152c468 --- /dev/null +++ b/src/containers/About/About.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; + +const Link = ({ to, children }: { to: string; children: React.ReactNode }) => ( + + {children} + +); + +export function AboutContainer() { + const { t } = useTranslation(); + + useEffect(() => { + document.title = `${t('about.title')} - ${t('common.appName')}`; + }, [t]); + + return ( + +
+
+

{t('about.welcome')}

+

{t('about.purpose')}

+

{t('about.startTimes')}

+ +

+ + To get your competition{"'"}s assignments to show here, you must generate them with a + tool like Groupifier,{' '} + DelegateDashboard, or{' '} + AGE. + +

+ +

+ + If you are a developer and you want to learn more about the data that is shown here, + check out the{' '} + + WCIF specification + + . + +

+
+

+ + People can be assigned to groups, rounds, or any arbitrary activity. If you want to + get creative with the website, feel free to{' '} + reach out to me! + +

+
+

+ If you find this website useful, you can support me by donating{' '} + here. +

+
+
+
+ ); +} diff --git a/src/containers/About/index.ts b/src/containers/About/index.ts new file mode 100644 index 0000000..da0f79e --- /dev/null +++ b/src/containers/About/index.ts @@ -0,0 +1 @@ +export * from './About'; diff --git a/src/containers/CompetitionActivity/CompetitionActivity.stories.tsx b/src/containers/CompetitionActivity/CompetitionActivity.stories.tsx new file mode 100644 index 0000000..9e35916 --- /dev/null +++ b/src/containers/CompetitionActivity/CompetitionActivity.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionActivityContainer } from './CompetitionActivity'; + +const meta = { + title: 'Containers/Competition/Activity', + component: CompetitionActivityContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + activityId: 111, + }, +}; + +export const MissingActivity: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + activityId: 999999, + }, +}; diff --git a/src/containers/CompetitionActivity/CompetitionActivity.tsx b/src/containers/CompetitionActivity/CompetitionActivity.tsx new file mode 100644 index 0000000..d1a402d --- /dev/null +++ b/src/containers/CompetitionActivity/CompetitionActivity.tsx @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import { Container } from '@/components/Container'; +import { getAllActivities } from '@/lib/activities'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { EventActivity } from '@/pages/Competition/Schedule/EventActivity'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionActivityContainerProps { + competitionId: string; + activityId: number; + LinkComponent?: LinkRenderer; + onNavigate?: (to: string) => void; +} + +export function CompetitionActivityContainer({ + competitionId, + activityId, + LinkComponent = AnchorLink, + onNavigate = (to) => window.location.assign(to), +}: CompetitionActivityContainerProps) { + const { wcif } = useWCIF(); + + const activity = useMemo( + () => wcif && getAllActivities(wcif).find((a) => a.id === activityId), + [wcif, activityId], + ); + + const everyoneInActivity = useMemo( + () => + wcif + ? wcif.persons + .map((person) => ({ + ...person, + assignments: person.assignments?.filter((a) => a.activityId === activityId), + })) + .filter(({ assignments }) => assignments && assignments.length > 0) + : [], + [wcif, activityId], + ); + + if (!wcif) { + return ; + } + + if (!activity) { + return ( + +

Activity not found

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/containers/CompetitionActivity/index.ts b/src/containers/CompetitionActivity/index.ts new file mode 100644 index 0000000..65b7dc8 --- /dev/null +++ b/src/containers/CompetitionActivity/index.ts @@ -0,0 +1 @@ +export * from './CompetitionActivity'; diff --git a/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.stories.tsx b/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.stories.tsx new file mode 100644 index 0000000..44d8630 --- /dev/null +++ b/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionCompareSchedulesContainer } from './CompetitionCompareSchedules'; + +const meta = { + title: 'Containers/Competition/Compare Schedules', + component: CompetitionCompareSchedulesContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.tsx b/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.tsx new file mode 100644 index 0000000..deaed63 --- /dev/null +++ b/src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.tsx @@ -0,0 +1,132 @@ +import { AssignmentCode, Person } from '@wca/helpers'; +import { Fragment, useMemo, useRef } from 'react'; +import { Grid } from '@/components/Grid/Grid'; +import { usePinnedPersons } from '@/hooks/UsePinnedPersons'; +import { + doesActivityOverlapInterval, + getScheduledDays, + getUniqueActivityTimes, +} from '@/lib/activities'; +import Assignments from '@/lib/assignments'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { formatTime } from '@/lib/time'; +import { useAuth } from '@/providers/AuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionCompareSchedulesContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionCompareSchedulesContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionCompareSchedulesContainerProps) { + const headerRef = useRef(null); + const { user } = useAuth(); + const { wcif } = useWCIF(); + + const me = wcif?.persons.find((i) => i.wcaUserId === user?.id); + const { pinnedPersons: pinnedRegistrantIds } = usePinnedPersons(competitionId); + + const pinnedPersons = pinnedRegistrantIds.map((id) => + wcif?.persons.find((p) => p.registrantId === id), + ); + const persons = [me, ...pinnedPersons].filter(Boolean) as Person[]; + + const scheduleDays = useMemo(() => wcif && getScheduledDays(wcif), [wcif]); + const columnWidths = `repeat(${persons.length + 1}, minmax(5em, 1fr))`; + const headerHeight = useMemo( + () => (headerRef.current ? headerRef.current.clientHeight : 0), + [headerRef], + ); + + return ( +
+ +
+ Time +
+ {persons.map((person) => ( +
+ + {person.name} + +
+ ))} +
+ + {scheduleDays?.map((day) => { + const startTimes = getUniqueActivityTimes(day.activities); + + return ( + +
+ {day.date} +
+ {startTimes.map((startTime, index) => { + const endTime = startTimes[index + 1]; + + if (!endTime) { + return null; + } + + const activitiesHappeningDuringStartTime = day.activities.filter((activity) => + doesActivityOverlapInterval(activity, startTime.startTime, endTime.startTime), + ); + + return ( + +
{formatTime(startTime.startTime)}
+ {persons.map((person) => { + const assignment = person.assignments?.find((candidate) => + activitiesHappeningDuringStartTime.some( + (activity) => activity.id === candidate.activityId, + ), + ); + const assignmentCode = assignment?.assignmentCode as AssignmentCode; + + if (!assignmentCode) { + return ( +
+ - +
+ ); + } + + const config = Assignments.find((item) => item.id === assignmentCode); + + return ( +
+ {config ? config.key.toUpperCase() : assignmentCode[0].toUpperCase()} +
+ ); + })} +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/containers/CompetitionCompareSchedules/index.ts b/src/containers/CompetitionCompareSchedules/index.ts new file mode 100644 index 0000000..4a75f50 --- /dev/null +++ b/src/containers/CompetitionCompareSchedules/index.ts @@ -0,0 +1 @@ +export * from './CompetitionCompareSchedules'; diff --git a/src/containers/CompetitionEvents/CompetitionEvents.stories.tsx b/src/containers/CompetitionEvents/CompetitionEvents.stories.tsx new file mode 100644 index 0000000..1d3638c --- /dev/null +++ b/src/containers/CompetitionEvents/CompetitionEvents.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionEventsContainer } from './CompetitionEvents'; + +const meta = { + title: 'Containers/Competition/Events', + component: CompetitionEventsContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionEvents/CompetitionEvents.tsx b/src/containers/CompetitionEvents/CompetitionEvents.tsx new file mode 100644 index 0000000..455a7f4 --- /dev/null +++ b/src/containers/CompetitionEvents/CompetitionEvents.tsx @@ -0,0 +1,82 @@ +import { parseActivityCode } from '@wca/helpers'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; +import { groupActivitiesByRound } from '@/lib/activities'; +import { getAllEvents, getEventName } from '@/lib/events'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionEventsContainerProps { + competitionId: string; + onNavigate?: (to: string) => void; +} + +function onlyUnique(value, index, self) { + return self.indexOf(value) === index; +} + +export function CompetitionEventsContainer({ + competitionId, + onNavigate = (to) => window.location.assign(to), +}: CompetitionEventsContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + const uniqueGroupCountForRound = useCallback( + (roundId: string) => + wcif + ? groupActivitiesByRound(wcif, roundId) + .map(({ activityCode }) => activityCode) + .filter(onlyUnique).length + : 0, + [wcif], + ); + + const events = useMemo(() => (wcif ? getAllEvents(wcif) : []), [wcif]); + + useEffect(() => { + setTitle('Events'); + }, [setTitle]); + + return ( + +
+
+ + + + + + + + + + + {events.map((event) => + event.rounds?.map((round, index) => { + const url = `/competitions/${competitionId}/events/${round.id}`; + + return ( + onNavigate(url)}> + + + + + + ); + }), + )} + +
{t('common.wca.event')}{t('common.wca.round')}{t('common.wca.group_other')} + {t('common.view')} +
{index === 0 ? getEventName(event.id) : ''} + {parseActivityCode(round.id).roundNumber} + {uniqueGroupCountForRound(round.id)}{t('common.view')}
+
+
+
+ ); +} diff --git a/src/containers/CompetitionEvents/index.ts b/src/containers/CompetitionEvents/index.ts new file mode 100644 index 0000000..497bba0 --- /dev/null +++ b/src/containers/CompetitionEvents/index.ts @@ -0,0 +1 @@ +export * from './CompetitionEvents'; diff --git a/src/containers/CompetitionGroup/CompetitionGroup.stories.tsx b/src/containers/CompetitionGroup/CompetitionGroup.stories.tsx new file mode 100644 index 0000000..5edeb53 --- /dev/null +++ b/src/containers/CompetitionGroup/CompetitionGroup.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeStorybookEventCompetitionFixture } from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionGroupContainer } from './CompetitionGroup'; + +const meta = { + title: 'Containers/Competition/Group', + component: CompetitionGroupContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + groupNumber: '1', + }, +}; + +export const GroupTwo: Story = { + parameters: { + competitionFixture: makeStorybookEventCompetitionFixture('333'), + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + groupNumber: '2', + }, +}; diff --git a/src/containers/CompetitionGroup/CompetitionGroup.tsx b/src/containers/CompetitionGroup/CompetitionGroup.tsx new file mode 100644 index 0000000..5f6ee56 --- /dev/null +++ b/src/containers/CompetitionGroup/CompetitionGroup.tsx @@ -0,0 +1,353 @@ +import { ActivityCode } from '@wca/helpers'; +import classNames from 'classnames'; +import { Fragment, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityRow } from '@/components'; +import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; +import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; +import { Container } from '@/components/Container'; +import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; +import { getAllRoundActivities, getRoomData, getRooms, hasActivities } from '@/lib/activities'; +import { + activityCodeToName, + matchesActivityCode, + nextActivityCode, + prevActivityCode, + toRoundAttemptId, +} from '@/lib/activityCodes'; +import { GroupAssignmentCodeRank } from '@/lib/constants'; +import { getAllEvents } from '@/lib/events'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { byName } from '@/lib/utils'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionGroupContainerProps { + competitionId: string; + roundId: string; + groupNumber: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionGroupContainer({ + competitionId, + roundId, + groupNumber, + LinkComponent = AnchorLink, +}: CompetitionGroupContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + const activityCode = `${roundId}-g${groupNumber}` as ActivityCode; + + const events = wcif ? getAllEvents(wcif) : []; + const rounds = events.flatMap((e) => e.rounds); + const round = rounds.find((r) => r.id === roundId); + + useEffect(() => { + setTitle(activityCodeToName(activityCode)); + }, [activityCode, setTitle]); + + const allRoundActivities = wcif + ? getAllRoundActivities(wcif).filter((a) => + rounds.some((r) => r.id === toRoundAttemptId(a.activityCode)), + ) + : []; + + const rooms = wcif + ? getRooms(wcif).filter((room) => + room.activities.some((a) => allRoundActivities.some((b) => a.id === b.id)), + ) + : []; + + const multistage = rooms.length > 1; + const childActivities = allRoundActivities + .flatMap((activity) => activity.childActivities) + .filter((ca) => matchesActivityCode(activityCode)(ca.activityCode)); + const childActivityIds = childActivities.map((ca) => ca.id); + + const personsInActivity = wcif?.persons + ?.filter((person) => + person.assignments?.some((assignment) => childActivityIds.includes(assignment.activityId)), + ) + .map((person) => { + const assignment = person.assignments?.find((a) => childActivityIds.includes(a.activityId)); + const activity = childActivities.find((ca) => ca.id === assignment?.activityId); + const room = rooms.find((candidateRoom) => + candidateRoom.activities.some((a) => + a.childActivities.some((ca) => ca.id === activity?.id), + ), + ); + const stage = room && activity && getRoomData(room, activity); + + return { + wcif, + ...person, + assignment, + activity, + room, + stage, + }; + }); + + const activityName = activityCodeToName(activityCode); + const activityNameSplit = activityName.split(', '); + const roundName = activityNameSplit.slice(0, 2).join(', '); + const groupName = activityNameSplit.slice(-1).join('') || undefined; + + 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] + }`; + + return ( + <> + +
+
+ +
+
+ + + {t('competition.groups.previousGroup')} + + + {t('competition.groups.nextGroup')} + + +
+
+ {round && } +
+
+ {rooms.filter(hasActivities(activityCode)).map((room) => { + const activity = room.activities + .flatMap((ra) => ra.childActivities) + .find((a) => a.activityCode === activityCode); + + if (!activity) { + return null; + } + + return ( + + + + ); + })} +
+
+
+ {GroupAssignmentCodeRank.filter((assignmentCode) => + personsInActivity?.some( + (person) => person.assignment?.assignmentCode === assignmentCode, + ), + ).map((assignmentCode) => { + const personsInActivityWithAssignment = + personsInActivity?.filter( + (person) => person.assignment?.assignmentCode === assignmentCode, + ) || []; + + return ( + +
+
+ +
+ {personsInActivityWithAssignment + .sort((a, b) => { + const stageSort = (a.stage?.name || '').localeCompare( + b.stage?.name || '', + ); + return stageSort !== 0 ? stageSort : byName(a, b); + }) + .map((person) => ( + +
{person.name}
+ {multistage && ( +
+ {person.stage && person.stage.name} +
+ )} +
+ ))} +
+
+
+
+ ); + })} +
+
+ +
+
+ +
+
+ + + {t('competition.groups.previousGroup')} + + + {t('competition.groups.nextGroup')} + + +
+
+ {rooms.filter(hasActivities(activityCode)).map((room) => { + const activity = room.activities + .flatMap((ra) => ra.childActivities) + .find((a) => a.activityCode === activityCode); + + if (!activity) { + return null; + } + + return ( + + + + ); + })} +
+
+ {round && } +
+ {rooms.map((stage) => ( +
+ {stage.name} +
+ ))} + {GroupAssignmentCodeRank.filter((assignmentCode) => + personsInActivity?.some( + (person) => person.assignment?.assignmentCode === assignmentCode, + ), + ).map((assignmentCode) => { + const personsInActivityWithAssignment = + personsInActivity?.filter( + (person) => person.assignment?.assignmentCode === assignmentCode, + ) || []; + return ( + + + {rooms.map((room) => ( +
+ {personsInActivityWithAssignment + .filter((person) => person.room?.id === room.id) + .sort(byName) + .map((person) => ( + + {person.name} + + ))} +
+ ))} +
+ ); + })} +
+
+ + ); +} diff --git a/src/containers/CompetitionGroup/index.ts b/src/containers/CompetitionGroup/index.ts new file mode 100644 index 0000000..a6cc6a1 --- /dev/null +++ b/src/containers/CompetitionGroup/index.ts @@ -0,0 +1 @@ +export * from './CompetitionGroup'; diff --git a/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.stories.tsx b/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.stories.tsx new file mode 100644 index 0000000..5047749 --- /dev/null +++ b/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionGroupsOverviewContainer } from './CompetitionGroupsOverview'; + +const meta = { + title: 'Containers/Competition/Groups Overview', + component: CompetitionGroupsOverviewContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.tsx b/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.tsx new file mode 100644 index 0000000..c757c5f --- /dev/null +++ b/src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.tsx @@ -0,0 +1,188 @@ +import { Activity, EventId, parseActivityCode } from '@wca/helpers'; +import classNames from 'classnames'; +import { Fragment, useCallback, useMemo } from 'react'; +import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; +import { Container } from '@/components/Container'; +import { groupActivitiesByRound } from '@/lib/activities'; +import { acceptedRegistration, hasAssignmentInStage } from '@/lib/person'; +import { useWCIF } from '@/providers/WCIFProvider'; + +const groupNumber = ({ activityCode }: Activity) => parseActivityCode(activityCode)?.groupNumber; + +const staffingAssignmentToText = ({ assignmentCode, activity }) => + `${assignmentCode.split('-')[1][0].toUpperCase()}${groupNumber(activity)}`; + +const competingAssignmentToText = (activity) => + `${activity.parent.room.name[0]}${groupNumber(activity)}`; + +export function CompetitionGroupsOverviewContainer() { + const { wcif } = useWCIF(); + + const memoizedGroupActivitiesForRound = useCallback( + (activityCode) => (wcif ? groupActivitiesByRound(wcif, activityCode) : []), + [wcif], + ); + + const assignmentsToObj = useCallback( + (person) => { + const obj = { + competing: {}, + staffing: {}, + }; + + wcif?.events.forEach((event) => { + const activitiesForEvent = memoizedGroupActivitiesForRound(`${event.id}-r1`); + const assignmentsForEvent = person.assignments + .filter((assignment) => + activitiesForEvent.some((activity) => activity.id === assignment.activityId), + ) + .map((assignment) => ({ + ...assignment, + activity: activitiesForEvent.find((activity) => assignment.activityId === activity.id), + })); + + const competingAssignment = assignmentsForEvent.find( + ({ assignmentCode }) => assignmentCode === 'competitor', + ); + const staffingAssignments = assignmentsForEvent.filter( + ({ assignmentCode }) => assignmentCode.indexOf('staff') > -1, + ); + + obj.competing[event.id.toString()] = + competingAssignment && competingAssignmentToText(competingAssignment.activity); + obj.staffing[event.id.toString() + '_staff'] = staffingAssignments + .map(staffingAssignmentToText) + .join(','); + }); + + return obj; + }, + [memoizedGroupActivitiesForRound, wcif?.events], + ); + + const assignments = useMemo( + () => + wcif?.persons + .filter(acceptedRegistration) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((person) => ({ + ...person, + assignmentsData: assignmentsToObj(person), + })), + [assignmentsToObj, wcif?.persons], + ); + + const stages = wcif?.schedule?.venues?.flatMap((venue) => venue.rooms); + const columns = (wcif?.events?.length || 0) * 2 + 2; + + return ( + + + + + + + {wcif?.events.map((event) => ( + + ))} + {wcif?.events.map((event) => ( + + ))} + + + + {stages?.map((stage) => { + const childActivities = stage.activities.flatMap( + (roundActivity) => roundActivity.childActivities, + ); + + return ( + + + + + {assignments + ?.filter((person) => hasAssignmentInStage(stage, person)) + .map((person) => { + const personAssignments = + person.assignments?.map((assignment) => { + const activity = childActivities.find( + (childActivity) => childActivity.id === assignment.activityId, + ); + + return { + ...assignment, + activity, + ...(activity?.activityCode && { + ...(parseActivityCode(activity.activityCode) as { + eventId: EventId; + roundNumber: number; + groupNumber: number; + }), + }), + }; + }) || []; + + const competingAssignments = personAssignments.filter( + ({ assignmentCode }) => assignmentCode === 'competitor', + ); + const staffingAssignments = personAssignments.filter( + ({ assignmentCode }) => assignmentCode.indexOf('staff') > -1, + ); + + return ( + + + + {wcif?.events.map((event) => { + const competingAssignment = competingAssignments.find( + (assignment) => + assignment.eventId === event.id && assignment.roundNumber === 1, + ); + + return ( + + ); + })} + {wcif?.events.map((event) => { + const staffingAssignment = staffingAssignments.find( + (assignment) => + assignment.eventId === event.id && assignment.roundNumber === 1, + ); + + return ( + + {staffingAssignment && staffingAssignmentToText(staffingAssignment)} + + ); + })} + + ); + })} + + ); + })} + +
NameWCA ID + {event.id} + + {event.id} +
+ {stage.name} +
{person.name}{person.wcaId} + {competingAssignment?.groupNumber} +
+
+ ); +} diff --git a/src/containers/CompetitionGroupsOverview/index.ts b/src/containers/CompetitionGroupsOverview/index.ts new file mode 100644 index 0000000..1a24619 --- /dev/null +++ b/src/containers/CompetitionGroupsOverview/index.ts @@ -0,0 +1 @@ +export * from './CompetitionGroupsOverview'; diff --git a/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.stories.tsx b/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.stories.tsx new file mode 100644 index 0000000..26e2ba3 --- /dev/null +++ b/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionGroupsScheduleContainer } from './CompetitionGroupsSchedule'; + +const meta = { + title: 'Containers/Competition/Groups Schedule', + component: CompetitionGroupsScheduleContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Placeholder: Story = {}; diff --git a/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.tsx b/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.tsx new file mode 100644 index 0000000..9838571 --- /dev/null +++ b/src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.tsx @@ -0,0 +1,22 @@ +import { Container } from '@/components/Container'; + +export function CompetitionGroupsScheduleContainer() { + return ( + +
+
+
+ + + + + + + + +
StageActivity
+
+
+
+ ); +} diff --git a/src/containers/CompetitionGroupsSchedule/index.ts b/src/containers/CompetitionGroupsSchedule/index.ts new file mode 100644 index 0000000..0909992 --- /dev/null +++ b/src/containers/CompetitionGroupsSchedule/index.ts @@ -0,0 +1 @@ +export * from './CompetitionGroupsSchedule'; diff --git a/src/containers/CompetitionHome/CompetitionHome.stories.tsx b/src/containers/CompetitionHome/CompetitionHome.stories.tsx new file mode 100644 index 0000000..4023b14 --- /dev/null +++ b/src/containers/CompetitionHome/CompetitionHome.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionHomeContainer } from './CompetitionHome'; + +const meta = { + title: 'Containers/Competition/Home', + component: CompetitionHomeContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionHome/CompetitionHome.tsx b/src/containers/CompetitionHome/CompetitionHome.tsx new file mode 100644 index 0000000..06689bb --- /dev/null +++ b/src/containers/CompetitionHome/CompetitionHome.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; +import { LinkButton } from '@/components/LinkButton'; +import { PinCompetitionButton } from '@/components/PinCompetitionButton'; +import { Competitors } from '@/containers/Competitors'; +import { OngoingActivities } from '@/containers/OngoingActivities'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionHomeContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionHomeContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionHomeContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(''); + }, [setTitle]); + + return ( + +
+ + +
+ + {wcif && } +
+ ); +} diff --git a/src/containers/CompetitionHome/index.ts b/src/containers/CompetitionHome/index.ts new file mode 100644 index 0000000..24bc726 --- /dev/null +++ b/src/containers/CompetitionHome/index.ts @@ -0,0 +1 @@ +export * from './CompetitionHome'; diff --git a/src/containers/CompetitionInformation/CompetitionInformation.stories.tsx b/src/containers/CompetitionInformation/CompetitionInformation.stories.tsx new file mode 100644 index 0000000..9ca9f5a --- /dev/null +++ b/src/containers/CompetitionInformation/CompetitionInformation.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionInformationContainer } from './CompetitionInformation'; + +const meta = { + title: 'Containers/Competition/Information', + component: CompetitionInformationContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionInformation/CompetitionInformation.tsx b/src/containers/CompetitionInformation/CompetitionInformation.tsx new file mode 100644 index 0000000..11dfcc2 --- /dev/null +++ b/src/containers/CompetitionInformation/CompetitionInformation.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { Container, ExternalLink } from '@/components'; +import { useCompetition } from '@/hooks/queries/useCompetition'; +import { UserRow } from '@/pages/Competition/Information/UserRow'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionInformationContainerProps { + competitionId: string; +} + +export function CompetitionInformationContainer({ + competitionId, +}: CompetitionInformationContainerProps) { + const { setTitle, wcif } = useWCIF(); + const { data, error } = useCompetition(competitionId); + + useEffect(() => { + setTitle('Information'); + }, [setTitle]); + + if (error) { + return null; + } + + return ( + +
+ View WCA competition webpage + +
+

Organizers

+
    + {data?.organizers?.map((user) => )} +
+
+
+

Delegates

+
    + {data?.delegates?.map((user) => )} +
+
+
+
+ ); +} diff --git a/src/containers/CompetitionInformation/index.ts b/src/containers/CompetitionInformation/index.ts new file mode 100644 index 0000000..0c25fe9 --- /dev/null +++ b/src/containers/CompetitionInformation/index.ts @@ -0,0 +1 @@ +export * from './CompetitionInformation'; diff --git a/src/containers/CompetitionLive/CompetitionLive.stories.tsx b/src/containers/CompetitionLive/CompetitionLive.stories.tsx new file mode 100644 index 0000000..0f21a02 --- /dev/null +++ b/src/containers/CompetitionLive/CompetitionLive.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionLiveContainer } from './CompetitionLive'; + +const meta = { + title: 'Containers/Competition/Live', + component: CompetitionLiveContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionLive/CompetitionLive.tsx b/src/containers/CompetitionLive/CompetitionLive.tsx new file mode 100644 index 0000000..1fb8ce2 --- /dev/null +++ b/src/containers/CompetitionLive/CompetitionLive.tsx @@ -0,0 +1,18 @@ +import { Container } from '@/components/Container'; +import { LiveActivities } from '@/containers/LiveActivities'; + +export interface CompetitionLiveContainerProps { + competitionId: string; +} + +export function CompetitionLiveContainer({ competitionId }: CompetitionLiveContainerProps) { + return ( + +
+ + Live Activities +
+ +
+ ); +} diff --git a/src/containers/CompetitionLive/index.ts b/src/containers/CompetitionLive/index.ts new file mode 100644 index 0000000..f361364 --- /dev/null +++ b/src/containers/CompetitionLive/index.ts @@ -0,0 +1 @@ +export * from './CompetitionLive'; diff --git a/src/containers/CompetitionPerson/CompetitionPerson.stories.tsx b/src/containers/CompetitionPerson/CompetitionPerson.stories.tsx new file mode 100644 index 0000000..427f265 --- /dev/null +++ b/src/containers/CompetitionPerson/CompetitionPerson.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionPersonContainer } from './CompetitionPerson'; + +const meta = { + title: 'Containers/Competition/Person', + component: CompetitionPersonContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Competitor: Story = { + args: { + registrantId: '1', + }, +}; + +export const Delegate: Story = { + args: { + registrantId: '6', + }, +}; diff --git a/src/containers/CompetitionPerson/CompetitionPerson.tsx b/src/containers/CompetitionPerson/CompetitionPerson.tsx new file mode 100644 index 0000000..e56f64a --- /dev/null +++ b/src/containers/CompetitionPerson/CompetitionPerson.tsx @@ -0,0 +1,36 @@ +import { Person } from '@wca/helpers'; +import { Extension } from '@wca/helpers/lib/models/extension'; +import { useEffect } from 'react'; +import { Container } from '@/components/Container'; +import { PersonalScheduleContainer } from '@/containers/PersonalSchedule'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionPersonContainerProps { + registrantId: string; +} + +export function CompetitionPersonContainer({ registrantId }: CompetitionPersonContainerProps) { + const { wcif, setTitle } = useWCIF(); + + const person = wcif?.persons?.find((p) => p.registrantId.toString() === registrantId) as + | (Person & { + extensions: Extension[]; + }) + | undefined; + + useEffect(() => { + if (person) { + setTitle(person.name); + } + }, [person, setTitle]); + + if (!wcif || !person) { + return null; + } + + return ( + + + + ); +} diff --git a/src/containers/CompetitionPerson/index.ts b/src/containers/CompetitionPerson/index.ts new file mode 100644 index 0000000..dbc09c0 --- /dev/null +++ b/src/containers/CompetitionPerson/index.ts @@ -0,0 +1 @@ +export * from './CompetitionPerson'; diff --git a/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.stories.tsx b/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.stories.tsx new file mode 100644 index 0000000..f0c14b1 --- /dev/null +++ b/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionPersonalBestsContainer } from './CompetitionPersonalBests'; + +const meta = { + title: 'Containers/Competition/Personal Bests', + component: CompetitionPersonalBestsContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BlakeThompson: Story = { + args: { + wcaId: '2010THOM03', + }, +}; diff --git a/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.tsx b/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.tsx new file mode 100644 index 0000000..d5ea777 --- /dev/null +++ b/src/containers/CompetitionPersonalBests/CompetitionPersonalBests.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { Container } from '@/components/Container'; +import { PersonalBestsContainer } from '@/containers/PersonalBests'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionPersonalBestsContainerProps { + wcaId: string; +} + +export function CompetitionPersonalBestsContainer({ + wcaId, +}: CompetitionPersonalBestsContainerProps) { + const { wcif, setTitle } = useWCIF(); + const person = wcif?.persons?.find((p) => p?.wcaId === wcaId); + + useEffect(() => { + setTitle(`Personal Bests: ${person?.name}`); + }, [person?.name, setTitle]); + + if (!wcif) { + return null; + } + + if (!person) { + return
Person not found
; + } + + return ( + + + + ); +} diff --git a/src/containers/CompetitionPersonalBests/index.ts b/src/containers/CompetitionPersonalBests/index.ts new file mode 100644 index 0000000..bb5213d --- /dev/null +++ b/src/containers/CompetitionPersonalBests/index.ts @@ -0,0 +1 @@ +export * from './CompetitionPersonalBests'; diff --git a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.stories.tsx b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.stories.tsx new file mode 100644 index 0000000..7d8f356 --- /dev/null +++ b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionPsychSheetEventContainer } from './CompetitionPsychSheetEvent'; + +const meta = { + title: 'Containers/Competition/Psych Sheet Event', + component: CompetitionPsychSheetEventContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Average: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + eventId: '333', + resultType: 'average', + onEventChange: () => {}, + onResultTypeChange: () => {}, + }, +}; + +export const Single: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + eventId: '333', + resultType: 'single', + onEventChange: () => {}, + onResultTypeChange: () => {}, + }, +}; diff --git a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx new file mode 100644 index 0000000..f1af5fd --- /dev/null +++ b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx @@ -0,0 +1,181 @@ +import { Event, EventId, Person, PersonalBest } from '@wca/helpers'; +import classNames from 'classnames'; +import getUnicodeFlagIcon from 'country-flag-icons/unicode'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; +import { findPR } from '@/lib/activities'; +import { activityCodeToName } from '@/lib/activityCodes'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { acceptedRegistration, isRegisteredForEvent } from '@/lib/person'; +import { renderResultByEventId } from '@/lib/results'; +import { byWorldRanking } from '@/lib/sort'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionPsychSheetEventContainerProps { + competitionId: string; + eventId: EventId; + resultType: 'average' | 'single'; + onEventChange: (eventId: EventId) => void; + onResultTypeChange: (resultType: 'average' | 'single') => void; + LinkComponent?: LinkRenderer; +} + +export function CompetitionPsychSheetEventContainer({ + competitionId, + eventId, + resultType, + onEventChange, + onResultTypeChange, + LinkComponent = AnchorLink, +}: CompetitionPsychSheetEventContainerProps) { + const { t } = useTranslation(); + const { wcif } = useWCIF(); + + const persons = useMemo( + () => + wcif?.persons.filter( + (person) => acceptedRegistration(person) && isRegisteredForEvent(eventId)(person), + ) || [], + [wcif, eventId], + ); + + const sortedPersons = useMemo(() => { + if (!persons.length) { + return []; + } + + const findPersonalRecord = findPR(eventId); + + return persons.sort(byWorldRanking(eventId, resultType)).reduce( + ( + entries: (Person & { + rank: number; + mainRank: number; + subRank: number; + pr?: PersonalBest; + })[], + person, + index, + ) => { + const lastPerson = index > 0 ? entries[index - 1] : undefined; + const averagePr = findPersonalRecord(person.personalBests || [], 'average'); + const singlePr = findPersonalRecord(person.personalBests || [], 'single'); + const mainRank = + (resultType === 'average' ? averagePr?.worldRanking : singlePr?.worldRanking) ?? 0; + const subRank = singlePr?.worldRanking ?? 0; + const rank = + lastPerson && mainRank === lastPerson.mainRank && subRank === lastPerson.subRank + ? lastPerson.rank + : index + 1; + + return [ + ...entries, + { + ...person, + mainRank, + subRank, + rank, + pr: resultType === 'average' ? averagePr : singlePr, + }, + ]; + }, + [], + ); + }, [eventId, persons, resultType]); + + if (!eventId) { + return null; + } + + return ( + +
+
+ + +
+ +
+
+ # + + {t('competition.rankings.name')} + + {resultType === 'single' + ? t('common.wca.resultType.single') + : t('common.wca.resultType.average')}{' '} + + {t('common.wca.recordType.WR')} +
+
+ {sortedPersons.map((person, index) => { + const prAverage = person.personalBests?.find( + (personalBest) => + personalBest.eventId === eventId && personalBest.type === resultType, + ); + const isOdd = index % 2 === 1; + + return ( + span]:transition-colors', + isOdd ? '[&>span]:table-bg-row-alt-secondary' : '[&>span]:table-bg-row', + '[&:hover>span]:table-bg-row-hover-secondary', + )} + to={`/competitions/${competitionId}/personal-bests/${person.wcaId}`}> + + {person.rank} + + + {getUnicodeFlagIcon(person.countryIso2)} + + {person.name} + + {prAverage ? renderResultByEventId(eventId, resultType, prAverage.best) : ''} + + + {prAverage + ? `${prAverage.worldRanking.toLocaleString([...navigator.languages])}` + : ''} + + + ); + })} +
+
+
+
+ ); +} + +const EventSelector = ({ + value, + events, + onChange, +}: { + value: EventId; + events: Event[]; + onChange: (eventId: EventId) => void; +}) => ( + +); diff --git a/src/containers/CompetitionPsychSheetEvent/index.ts b/src/containers/CompetitionPsychSheetEvent/index.ts new file mode 100644 index 0000000..19e2173 --- /dev/null +++ b/src/containers/CompetitionPsychSheetEvent/index.ts @@ -0,0 +1 @@ +export * from './CompetitionPsychSheetEvent'; diff --git a/src/containers/CompetitionRoom/CompetitionRoom.stories.tsx b/src/containers/CompetitionRoom/CompetitionRoom.stories.tsx new file mode 100644 index 0000000..e5fda5c --- /dev/null +++ b/src/containers/CompetitionRoom/CompetitionRoom.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionRoomContainer } from './CompetitionRoom'; + +const meta = { + title: 'Containers/Competition/Room', + component: CompetitionRoomContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MainStage: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + roomId: '10', + }, +}; + +export const SideStage: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + roomId: '11', + }, +}; diff --git a/src/containers/CompetitionRoom/CompetitionRoom.tsx b/src/containers/CompetitionRoom/CompetitionRoom.tsx new file mode 100644 index 0000000..b52406f --- /dev/null +++ b/src/containers/CompetitionRoom/CompetitionRoom.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityRow, Container } from '@/components'; +import { getAllChildActivities, getRoomData } from '@/lib/activities'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { formatToParts } from '@/lib/time'; +import { byDate } from '@/lib/utils'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionRoomContainerProps { + competitionId: string; + roomId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionRoomContainer({ + competitionId, + roomId, + LinkComponent = AnchorLink, +}: CompetitionRoomContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + const venue = useMemo( + () => + wcif?.schedule?.venues?.find((venueCandidate) => + venueCandidate.rooms.some((room) => room.id.toString() === roomId), + ), + [roomId, wcif?.schedule?.venues], + ); + + const room = useMemo( + () => venue?.rooms?.find((roomCandidate) => roomCandidate.id.toString() === roomId), + [roomId, venue?.rooms], + ); + + useEffect(() => { + setTitle(room?.name || ''); + }, [room, setTitle]); + + const timeZone = venue?.timezone ?? wcif?.schedule.venues?.[0]?.timezone ?? ''; + const activities = useMemo( + () => + room?.activities + .flatMap((activity) => + activity?.childActivities?.length ? getAllChildActivities(activity) : activity, + ) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) || [], + [room?.activities], + ); + + const scheduleDays = activities + .map((activity) => { + const activityVenue = + wcif?.schedule.venues?.find((venueCandidate) => + venueCandidate.rooms.some((roomCandidate) => + roomCandidate.activities.some( + (roomActivity) => + roomActivity.id === activity.id || + roomActivity.childActivities?.some( + (childActivity) => childActivity.id === activity.id, + ), + ), + ), + ) || wcif?.schedule.venues?.[0]; + + const dateTime = new Date(activity.startTime); + + return { + approxDateTime: dateTime.getTime(), + date: dateTime.toLocaleDateString(navigator.language, { + weekday: 'long', + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZone: activityVenue?.timezone, + }), + dateParts: formatToParts(dateTime), + }; + }) + .filter((value, index, array) => array.findIndex(({ date }) => date === value.date) === index) + .sort((a, b) => a.approxDateTime - b.approxDateTime); + + const activitiesWithParsedDate = activities + .map((activity) => { + const activityVenue = + wcif?.schedule.venues?.find((venueCandidate) => + venueCandidate.rooms.some((roomCandidate) => + roomCandidate.activities.some( + (roomActivity) => + roomActivity.id === activity.id || + roomActivity.childActivities?.some( + (childActivity) => childActivity.id === activity.id, + ), + ), + ), + ) || wcif?.schedule.venues?.[0]; + const dateTime = new Date(activity.startTime); + + return { + ...activity, + date: dateTime.toLocaleDateString(navigator.language, { + weekday: 'long', + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZone: activityVenue?.timezone, + }), + }; + }) + .sort((a, b) => byDate(a, b)); + + const getActivitiesByDate = useCallback( + (date: string) => activitiesWithParsedDate.filter((activity) => activity.date === date), + [activitiesWithParsedDate], + ); + + return ( + +
+
+

{room?.name}

+ {venue?.name} +
+ + {scheduleDays.map((day) => ( +
+

+ {day.date} +

+
+ {getActivitiesByDate(day.date).map((activity) => { + const stage = room && getRoomData(room, activity); + return ( + + ); + })} +
+
+ ))} +
+
+ + {t('competition.room.back')} + +
+
+
+ ); +} diff --git a/src/containers/CompetitionRoom/index.ts b/src/containers/CompetitionRoom/index.ts new file mode 100644 index 0000000..b9d7413 --- /dev/null +++ b/src/containers/CompetitionRoom/index.ts @@ -0,0 +1 @@ +export * from './CompetitionRoom'; diff --git a/src/containers/CompetitionRooms/CompetitionRooms.stories.tsx b/src/containers/CompetitionRooms/CompetitionRooms.stories.tsx new file mode 100644 index 0000000..18c5c86 --- /dev/null +++ b/src/containers/CompetitionRooms/CompetitionRooms.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionRoomsContainer } from './CompetitionRooms'; + +const meta = { + title: 'Containers/Competition/Rooms', + component: CompetitionRoomsContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionRooms/CompetitionRooms.tsx b/src/containers/CompetitionRooms/CompetitionRooms.tsx new file mode 100644 index 0000000..7e25845 --- /dev/null +++ b/src/containers/CompetitionRooms/CompetitionRooms.tsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container } from '@/components/Container'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { getLocalizedTimeZoneName } from '@/lib/time'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionRoomsContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionRoomsContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionRoomsContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(t('competition.rooms.title')); + }, [setTitle, t]); + + return ( + +
+ {wcif?.schedule?.venues?.map((venue) => ( +
+
+ {venue.name} + {getLocalizedTimeZoneName(venue.timezone)} +
+
+ {venue.rooms.map((room) => ( + +
+

{room.name}

+
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/containers/CompetitionRooms/index.ts b/src/containers/CompetitionRooms/index.ts new file mode 100644 index 0000000..b53703d --- /dev/null +++ b/src/containers/CompetitionRooms/index.ts @@ -0,0 +1 @@ +export * from './CompetitionRooms'; diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx new file mode 100644 index 0000000..92d0868 --- /dev/null +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + makeStorybookCompetitionFixtureWithRound, + makeStorybookEventCompetitionFixture, +} from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionRoundContainer } from './CompetitionRound'; + +const meta = { + title: 'Containers/Competition/Round', + component: CompetitionRoundContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const RoundOne: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + }, +}; + +export const RoundTwo: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + +export const FinalRound: Story = { + parameters: { + competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ + ...round, + advancementCondition: null, + })), + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r3', + }, +}; + +export const CutoffAndTimeLimit: Story = { + parameters: { + competitionFixture: makeStorybookEventCompetitionFixture('222'), + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '222-r1', + }, +}; + +export const CumulativeTimeLimit: Story = { + parameters: { + competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r2', (round) => ({ + ...round, + timeLimit: { + centiseconds: 15000, + cumulativeRoundIds: ['333-r2', '333-r3'], + }, + advancementCondition: { + type: 'attemptResult', + level: 950, + }, + })), + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx new file mode 100644 index 0000000..ecd83d4 --- /dev/null +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; +import { Container } from '@/components/Container'; +import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; +import { + activityCodeToName, + parseActivityCodeFlexible, + toRoundAttemptId, +} from '@/lib/activityCodes'; +import { getAllEvents } from '@/lib/events'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { formatDateTimeRange } from '@/lib/time'; +import { useWCIF, useWcifUtils } from '@/providers/WCIFProvider'; + +export interface CompetitionRoundContainerProps { + competitionId: string; + roundId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionRoundContainer({ + competitionId, + roundId, + LinkComponent = AnchorLink, +}: CompetitionRoundContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + const { roundActivies } = useWcifUtils(); + + useEffect(() => { + setTitle(activityCodeToName(roundId)); + }, [roundId, setTitle]); + + const round = useMemo(() => { + const events = wcif && getAllEvents(wcif); + return events?.flatMap((e) => e.rounds).find((r) => r.id === roundId); + }, [roundId, wcif]); + + const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); + const groups = rounds.flatMap((r) => r.childActivities); + const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))]; + + return ( + +
+ +
+ {round && } +
+
+
    + {uniqueGroupCodes.map((value) => { + const { groupNumber } = parseActivityCodeFlexible(value); + const activities = groups.filter((g) => g.activityCode === value); + const minStartTime = activities.map((a) => a.startTime).sort()[0]; + const maxEndTime = activities.map((a) => a.endTime).sort()[activities.length - 1]; + + return ( + +
  • + + {t('common.activityCodeToName.group', { groupNumber })} + + {formatDateTimeRange(minStartTime, maxEndTime)} +
  • +
    + ); + })} +
+
+ + {t('competition.groups.backToEvents')} + +
+
+ ); +} diff --git a/src/containers/CompetitionRound/index.ts b/src/containers/CompetitionRound/index.ts new file mode 100644 index 0000000..a7d7521 --- /dev/null +++ b/src/containers/CompetitionRound/index.ts @@ -0,0 +1 @@ +export * from './CompetitionRound'; diff --git a/src/containers/CompetitionSchedule/CompetitionSchedule.stories.tsx b/src/containers/CompetitionSchedule/CompetitionSchedule.stories.tsx new file mode 100644 index 0000000..feec885 --- /dev/null +++ b/src/containers/CompetitionSchedule/CompetitionSchedule.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionScheduleContainer } from './CompetitionSchedule'; + +const meta = { + title: 'Containers/Competition/Schedule', + component: CompetitionScheduleContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionSchedule/CompetitionSchedule.tsx b/src/containers/CompetitionSchedule/CompetitionSchedule.tsx new file mode 100644 index 0000000..aed7cc4 --- /dev/null +++ b/src/containers/CompetitionSchedule/CompetitionSchedule.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Container, DisclaimerText, LinkButton } from '@/components'; +import { ScheduleContainer } from '@/containers/Schedule'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionScheduleContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionScheduleContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionScheduleContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(t('competition.schedule.title')); + }, [setTitle, t]); + + const resolvedCompetitionId = wcif?.id || competitionId; + + return ( + +
+ +
+
+ +
+
+ {wcif && } +
+
+ ); +} diff --git a/src/containers/CompetitionSchedule/index.ts b/src/containers/CompetitionSchedule/index.ts new file mode 100644 index 0000000..dd25616 --- /dev/null +++ b/src/containers/CompetitionSchedule/index.ts @@ -0,0 +1 @@ +export * from './CompetitionSchedule'; diff --git a/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.stories.tsx b/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.stories.tsx new file mode 100644 index 0000000..08c166d --- /dev/null +++ b/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionScramblerScheduleContainer } from './CompetitionScramblerSchedule'; + +const meta = { + title: 'Containers/Competition/Scrambler Schedule', + component: CompetitionScramblerScheduleContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.tsx b/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.tsx new file mode 100644 index 0000000..9150bc2 --- /dev/null +++ b/src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.tsx @@ -0,0 +1,167 @@ +import flatMap from 'lodash.flatmap'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; +import { BreakableActivityName, Container, ErrorFallback } from '@/components'; +import { getAllActivities, getAllRoundActivities, getRooms } from '@/lib/activities'; +import { parseActivityCodeFlexible } from '@/lib/activityCodes'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { formatToWeekDay } from '@/lib/time'; +import { groupBy } from '@/lib/utils'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionScramblerScheduleContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export function CompetitionScramblerScheduleContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionScramblerScheduleContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(t('competition.scramblers.title')); + }, [setTitle, t]); + + const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]); + const [roomSelector, setRoomSelector] = useState(rooms?.[0]?.name); + + useEffect(() => { + if (!rooms.some((room) => room.name === roomSelector)) { + setRoomSelector(rooms?.[0]?.name); + } + }, [rooms, roomSelector]); + + const allRoundActivities = useMemo( + () => + wcif + ? getAllRoundActivities(wcif) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + .filter((activity) => activity.childActivities.length !== 0) + .filter((activity) => activity.room?.name === roomSelector) + : [], + [roomSelector, wcif], + ); + const allActivities = useMemo(() => (wcif ? getAllActivities(wcif) : []), [wcif]); + const getActivity = useCallback( + (assignment) => allActivities.find(({ id }) => id === assignment.activityId), + [allActivities], + ); + + const assignments = useMemo( + () => + flatMap(wcif?.persons, (person) => + person.assignments + .filter((assignment) => assignment.assignmentCode === 'staff-scrambler') + .map((assignment) => ({ + ...assignment, + personName: person.name, + activity: getActivity(assignment), + })), + ), + [getActivity, wcif?.persons], + ); + + const activitiesSplitAcrossDates = groupBy( + allRoundActivities.map((activity) => ({ + ...activity, + date: formatToWeekDay(new Date(activity.startTime)) || '???', + })), + (activity) => activity.date, + ); + + return ( + +
+
+

{t('competition.scramblers.rooms')}

+
+ {rooms.map((room) => ( +
setRoomSelector(room.name)}> + {}} + /> + +
+ ))} +
+
+
+
+ {t('competition.scramblers.stage')}: {roomSelector} +
+
+ + + + + + + + + + {Object.entries(activitiesSplitAcrossDates).map(([date, activities]) => ( + + + + + {activities.map((activity) => ( + + + + + + {activity.childActivities.map((childActivity) => { + const { groupNumber } = parseActivityCodeFlexible( + childActivity.activityCode, + ); + + return ( + + + + + + ); + })} + + + ))} + + ))} + +
Event{t('competition.scramblers.scramblers')}
+ {date} +
+ +
{`Group ${groupNumber}`} + {assignments + .filter((assignment) => assignment.activityId === childActivity.id) + .sort((a, b) => a.personName.localeCompare(b.personName)) + .map(({ personName }) => personName) + .join(', ')} + + +
+
+
+ ); +} diff --git a/src/containers/CompetitionScramblerSchedule/index.ts b/src/containers/CompetitionScramblerSchedule/index.ts new file mode 100644 index 0000000..3f72340 --- /dev/null +++ b/src/containers/CompetitionScramblerSchedule/index.ts @@ -0,0 +1 @@ +export * from './CompetitionScramblerSchedule'; diff --git a/src/containers/CompetitionStats/CompetitionStats.stories.tsx b/src/containers/CompetitionStats/CompetitionStats.stories.tsx new file mode 100644 index 0000000..d2f8315 --- /dev/null +++ b/src/containers/CompetitionStats/CompetitionStats.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionStatsContainer } from './CompetitionStats'; + +const meta = { + title: 'Containers/Competition/Stats', + component: CompetitionStatsContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/CompetitionStats/CompetitionStats.tsx b/src/containers/CompetitionStats/CompetitionStats.tsx new file mode 100644 index 0000000..4928009 --- /dev/null +++ b/src/containers/CompetitionStats/CompetitionStats.tsx @@ -0,0 +1,50 @@ +import { useEffect, useMemo } from 'react'; +import { Container } from '@/components/Container'; +import { StatsBox } from '@/pages/Competition/Stats/StatsBox'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export function CompetitionStatsContainer() { + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + setTitle(wcif?.name + ' Stats'); + }, [wcif, setTitle]); + + const eventCount = wcif?.events?.length || 0; + const acceptedRegistrations = useMemo( + () => + wcif?.persons?.filter( + ({ registration }) => registration?.isCompeting && registration?.status === 'accepted', + ), + [wcif], + ); + const acceptedRegistrationsCount = acceptedRegistrations?.length || 0; + + return ( + +
+ + +
+
+
+ {wcif?.events?.map(({ id }) => ( + + ))} + {wcif?.events?.map(({ id }) => ( + + { + acceptedRegistrations?.filter(({ registration }) => + registration?.eventIds.includes(id), + ).length + } + + ))} +
+
+ ); +} diff --git a/src/containers/CompetitionStats/index.ts b/src/containers/CompetitionStats/index.ts new file mode 100644 index 0000000..9da7bcc --- /dev/null +++ b/src/containers/CompetitionStats/index.ts @@ -0,0 +1 @@ +export * from './CompetitionStats'; diff --git a/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.stories.tsx b/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.stories.tsx new file mode 100644 index 0000000..187267b --- /dev/null +++ b/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionStreamScheduleContainer } from './CompetitionStreamSchedule'; + +const meta = { + title: 'Containers/Competition/Stream Schedule', + component: CompetitionStreamScheduleContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + competitionId: 'SeattleSummerOpen2026', + }, +}; diff --git a/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.tsx b/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.tsx new file mode 100644 index 0000000..a3b7343 --- /dev/null +++ b/src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.tsx @@ -0,0 +1,166 @@ +import { parseActivityCode } from '@wca/helpers'; +import { Fragment, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DisclaimerText } from '@/components'; +import { getRoomData, streamActivities, streamPersonIds } from '@/lib/activities'; +import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { formatDate, formatToParts } from '@/lib/time'; +import { roundTime } from '@/lib/utils'; +import { useWCIF } from '@/providers/WCIFProvider'; + +export interface CompetitionStreamScheduleContainerProps { + competitionId: string; + LinkComponent?: LinkRenderer; +} + +export const byDate = ( + a: { startTime: string } | undefined, + b: { startTime: string } | undefined, +) => { + const aDate = a ? new Date(a.startTime).getTime() : Number.MAX_SAFE_INTEGER; + const bDate = b ? new Date(b.startTime).getTime() : Number.MAX_SAFE_INTEGER; + return aDate - bDate; +}; + +export function CompetitionStreamScheduleContainer({ + competitionId, + LinkComponent = AnchorLink, +}: CompetitionStreamScheduleContainerProps) { + const { t } = useTranslation(); + const { wcif, setTitle } = useWCIF(); + + useEffect(() => { + if (wcif) { + setTitle(t('competition.streamSchedule.title')); + } + }, [wcif, setTitle, t]); + + const activities = wcif ? streamActivities(wcif) : []; + const getPersonById = useCallback( + (personId) => wcif?.persons.find(({ wcaUserId }) => wcaUserId === personId), + [wcif], + ); + const activitiesWithParsedDate = activities + .map((activity) => ({ + ...activity, + date: formatDate(new Date(activity.startTime)), + })) + .sort(byDate); + const getActivitiesByDate = useCallback( + (date: string) => activitiesWithParsedDate.filter((activity) => activity.date === date), + [activitiesWithParsedDate], + ); + const scheduleDays = activities + .map((activity) => { + const dateTime = new Date(activity.startTime); + return { + approxDateTime: dateTime.getTime(), + date: formatDate(dateTime) || 'foo', + dateParts: formatToParts(dateTime), + }; + }) + .filter((value, index, array) => array.findIndex(({ date }) => date === value.date) === index) + .sort((a, b) => a.approxDateTime - b.approxDateTime); + + if (!wcif) { + return

Loading...

; + } + + return ( +
+ +
+ + {activities.length > 0 ? ( + <> +

+ {t('competition.streamSchedule.subtitle')} +

+
+ + + + + + + + + + + + + {scheduleDays.map(({ date, dateParts }) => ( + + + + + {getActivitiesByDate(date) + .sort(byDate) + .map((activity) => { + const { eventId, roundNumber, groupNumber } = parseActivityCode( + activity.activityCode, + ); + const venue = wcif.schedule.venues?.find((venueCandidate) => + venueCandidate.rooms.some( + (room) => room.id === activity?.parent?.room?.id, + ), + ); + const timeZone = venue?.timezone; + const room = activity?.room || activity?.parent?.room; + const roomData = room && getRoomData(room, activity); + const startTime = roundTime( + new Date(activity?.startTime || 0), + 5, + ).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + timeZone, + }); + + return ( + + + + + + + + + ); + })} + + ))} + +
{t('competition.streamSchedule.time')}{t('competition.streamSchedule.event')}{t('competition.streamSchedule.round')}{t('competition.streamSchedule.group')}{t('competition.streamSchedule.stage')} + {t('competition.streamSchedule.featuredCompetitors')} +
+ {dateParts.find((part) => part.type === 'weekday')?.value || date} +
{startTime} + + {roundNumber}{groupNumber || '*'} + + {roomData?.name} + + + {streamPersonIds(activity) + .map(getPersonById) + .filter(Boolean) + .map((person) => ( +

{person!.name}

+ ))} +
+
+ + ) : ( +
No Live Stream information
+ )} +
+ ); +} diff --git a/src/containers/CompetitionStreamSchedule/index.ts b/src/containers/CompetitionStreamSchedule/index.ts new file mode 100644 index 0000000..c6d4c99 --- /dev/null +++ b/src/containers/CompetitionStreamSchedule/index.ts @@ -0,0 +1 @@ +export * from './CompetitionStreamSchedule'; diff --git a/src/containers/Competitors/Competitors.stories.tsx b/src/containers/Competitors/Competitors.stories.tsx new file mode 100644 index 0000000..833ba73 --- /dev/null +++ b/src/containers/Competitors/Competitors.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import { Competitors } from './Competitors'; + +const meta = { + title: 'Containers/Competition/Competitors', + component: Competitors, + decorators: [ + makeCompetitionContainerDecorator(), + makeRouteDecorator({ + initialPath: '/competitions/SeattleSummerOpen2026', + routePath: '/competitions/:competitionId/*', + }), + ], + parameters: { layout: 'fullscreen' }, + args: { + wcif: storybookCompetitionFixture, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithoutPinnedPeople: Story = { + parameters: { + pinnedPersons: [], + }, +}; diff --git a/src/containers/LiveActivities/LiveActivities.stories.tsx b/src/containers/LiveActivities/LiveActivities.stories.tsx new file mode 100644 index 0000000..51083b9 --- /dev/null +++ b/src/containers/LiveActivities/LiveActivities.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { LiveActivities } from './LiveActivities'; + +const meta = { + title: 'Containers/Competition/Live Activities', + component: LiveActivities, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + args: { + competitionId: 'SeattleSummerOpen2026', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/MyCompetitions/MyCompetitions.stories.tsx b/src/containers/MyCompetitions/MyCompetitions.stories.tsx new file mode 100644 index 0000000..25c5361 --- /dev/null +++ b/src/containers/MyCompetitions/MyCompetitions.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + makeAppContainerDecorator, + storybookPinnedCompetitions, + storybookUserCompetitions, +} from '@/storybook/appStorybook'; +import { MyCompetitions } from './MyCompetitions'; + +const meta = { + title: 'Containers/App/My Competitions', + component: MyCompetitions, + decorators: [makeAppContainerDecorator()], + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const OnlyPinned: Story = { + parameters: { + currentUser: undefined, + userCompetitions: { + upcoming_competitions: [], + ongoing_competitions: [], + }, + pinnedCompetitions: storybookPinnedCompetitions, + }, +}; + +export const UpcomingAndOngoing: Story = { + parameters: { + pinnedCompetitions: [], + userCompetitions: storybookUserCompetitions, + }, +}; diff --git a/src/containers/OngoingActivities/OngoingActivities.stories.tsx b/src/containers/OngoingActivities/OngoingActivities.stories.tsx new file mode 100644 index 0000000..47d99bf --- /dev/null +++ b/src/containers/OngoingActivities/OngoingActivities.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import { OngoingActivities } from './OngoingActivities'; + +const meta = { + title: 'Containers/Competition/Ongoing Activities', + component: OngoingActivities, + decorators: [ + makeCompetitionContainerDecorator(), + makeRouteDecorator({ + initialPath: '/competitions/SeattleSummerOpen2026', + routePath: '/competitions/:competitionId/*', + }), + ], + parameters: { layout: 'fullscreen' }, + args: { + competitionId: 'SeattleSummerOpen2026', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithLiveActivities: Story = {}; + +export const OrganizerPrompt: Story = { + parameters: { + ongoingActivities: [], + }, +}; diff --git a/src/containers/OngoingActivities/OngoingActivities.tsx b/src/containers/OngoingActivities/OngoingActivities.tsx index 23c710d..77937fa 100644 --- a/src/containers/OngoingActivities/OngoingActivities.tsx +++ b/src/containers/OngoingActivities/OngoingActivities.tsx @@ -60,6 +60,8 @@ export const OngoingActivities = ({ competitionId }: OngoingActivitiesProps) => person.registrantId === 1); +const singleOnlyCompetitor = storybookCompetitionFixture.persons.find( + (person) => person.registrantId === 3, +); + +if (!competitor || !singleOnlyCompetitor) { + throw new Error('Missing storybook person fixtures for PersonalBests stories'); +} + +const meta = { + title: 'Containers/Competition/PersonalBests', + component: PersonalBestsContainer, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + wcif: storybookCompetitionFixture, + person: competitor, + }, +}; + +export const SingleOnly: Story = { + args: { + wcif: storybookCompetitionFixture, + person: singleOnlyCompetitor, + }, +}; diff --git a/src/containers/PersonalSchedule/PersonalSchedule.stories.tsx b/src/containers/PersonalSchedule/PersonalSchedule.stories.tsx new file mode 100644 index 0000000..cba0ede --- /dev/null +++ b/src/containers/PersonalSchedule/PersonalSchedule.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { PersonalScheduleContainer } from './PersonalSchedule'; + +const competitor = storybookCompetitionFixture.persons.find((person) => person.registrantId === 1); +const delegate = storybookCompetitionFixture.persons.find((person) => person.registrantId === 6); + +if (!competitor || !delegate) { + throw new Error('Missing storybook person fixtures for PersonalSchedule stories'); +} + +const meta = { + title: 'Containers/Competition/Personal Schedule', + component: PersonalScheduleContainer, + decorators: [makeCompetitionContainerDecorator()], + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const CompetitorAssignments: Story = { + args: { + person: competitor, + }, +}; + +export const NoAssignments: Story = { + args: { + person: delegate, + }, +}; diff --git a/src/containers/PinnedCompetitions/PinnedCompetitions.stories.tsx b/src/containers/PinnedCompetitions/PinnedCompetitions.stories.tsx new file mode 100644 index 0000000..7017b3c --- /dev/null +++ b/src/containers/PinnedCompetitions/PinnedCompetitions.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { makeAppContainerDecorator, storybookPinnedCompetitions } from '@/storybook/appStorybook'; +import { PinnedCompetitions } from './PinnedCompetitions'; + +const meta = { + title: 'Containers/App/Pinned Competitions', + component: PinnedCompetitions, + decorators: [makeAppContainerDecorator()], + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + parameters: { + pinnedCompetitions: [], + }, +}; + +export const MultipleBookmarks: Story = { + parameters: { + pinnedCompetitions: [ + ...storybookPinnedCompetitions, + { + ...storybookPinnedCompetitions[0], + id: 'TacomaSpring2026', + name: 'Tacoma Spring 2026', + short_name: 'Tacoma Spring 2026', + city: 'Tacoma, Washington', + start_date: '2026-05-20', + end_date: '2026-05-21', + website: 'https://www.worldcubeassociation.org/competitions/TacomaSpring2026', + }, + ], + }, +}; diff --git a/src/containers/Schedule/Schedule.stories.tsx b/src/containers/Schedule/Schedule.stories.tsx new file mode 100644 index 0000000..2b7d0c4 --- /dev/null +++ b/src/containers/Schedule/Schedule.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { ScheduleContainer } from './Schedule'; + +const meta = { + title: 'Containers/Competition/Schedule List', + component: ScheduleContainer, + parameters: { layout: 'fullscreen' }, + args: { + wcif: storybookCompetitionFixture, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/Schedule/Schedule.tsx b/src/containers/Schedule/Schedule.tsx index 542f95c..18f3f0c 100644 --- a/src/containers/Schedule/Schedule.tsx +++ b/src/containers/Schedule/Schedule.tsx @@ -8,6 +8,7 @@ import { getVenueForActivity, hasMultipleScheduleLocations, } from '@/lib/activities'; +import { LinkRenderer } from '@/lib/linkRenderer'; import { ActivityWithRoomOrParent } from '@/lib/types'; const key = (compId: string) => `${compId}-schedule`; @@ -17,11 +18,13 @@ const ScheduleDay = ({ date, activities, showRoom, + LinkComponent, }: { wcif; date: string; activities: ActivityWithRoomOrParent[]; showRoom: boolean; + LinkComponent?: LinkRenderer; }) => { const { collapsedDates, toggleDate } = useCollapse(key(wcif.id)); @@ -53,6 +56,8 @@ const ScheduleDay = ({ { +export const ScheduleContainer = ({ wcif, LinkComponent }: ScheduleContainerProps) => { const { collapsedDates, setCollapsedDates } = useCollapse(key(wcif.id)); const scheduleDays = useMemo(() => getScheduledDays(wcif), [wcif]); @@ -101,6 +107,7 @@ export const ScheduleContainer = ({ wcif }: ScheduleContainerProps) => { activities={activities} date={date} showRoom={showRoom} + LinkComponent={LinkComponent} /> ))}
diff --git a/src/containers/Support/Support.stories.tsx b/src/containers/Support/Support.stories.tsx new file mode 100644 index 0000000..1782afc --- /dev/null +++ b/src/containers/Support/Support.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SupportContainer } from './Support'; + +const meta = { + title: 'Containers/App/Support', + component: SupportContainer, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/containers/Support/Support.tsx b/src/containers/Support/Support.tsx new file mode 100644 index 0000000..d9b72fa --- /dev/null +++ b/src/containers/Support/Support.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import { Container } from '@/components/Container'; + +export function SupportContainer() { + useEffect(() => { + document.title = 'Support - Competition Groups'; + }, []); + + return ( + +
+
+

Thanks for being a user of Competition Groups!

+

+ This website is a passion project by Cailyn Sinclair. +
I do not receive any compensation for developing this platform, and if you find + this website useful, please consider supporting me by buying me a coffee +

+