From 788fcaef90fec071a007eed0aaa07e3456d31c9b Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 18:36:44 -0700 Subject: [PATCH 01/12] refactor: move competition pages into containers --- src/components/ActivityRow/ActivityRow.tsx | 19 +- src/components/Breadcrumbs/Breadcrumbs.tsx | 8 +- src/components/LinkButton/LinkButton.tsx | 14 +- .../CompetitionActivity.tsx | 64 +++ src/containers/CompetitionActivity/index.ts | 1 + .../CompetitionGroup/CompetitionGroup.tsx | 353 +++++++++++++++++ src/containers/CompetitionGroup/index.ts | 1 + .../CompetitionRound/CompetitionRound.tsx | 90 +++++ src/containers/CompetitionRound/index.ts | 1 + .../CompetitionSchedule.tsx | 44 +++ src/containers/CompetitionSchedule/index.ts | 1 + .../OngoingActivities/OngoingActivities.tsx | 2 + src/containers/Schedule/Schedule.tsx | 9 +- src/lib/linkRenderer.tsx | 16 + src/pages/Competition/ByGroup/Group.tsx | 364 +----------------- src/pages/Competition/ByGroup/GroupList.tsx | 85 +--- src/pages/Competition/Schedule/Activity.tsx | 56 +-- .../Competition/Schedule/EventActivity.tsx | 38 +- src/pages/Competition/Schedule/PeopleList.tsx | 11 +- src/pages/Competition/Schedule/Room.tsx | 2 + src/pages/Competition/Schedule/Schedule.tsx | 38 +- 21 files changed, 684 insertions(+), 533 deletions(-) create mode 100644 src/containers/CompetitionActivity/CompetitionActivity.tsx create mode 100644 src/containers/CompetitionActivity/index.ts create mode 100644 src/containers/CompetitionGroup/CompetitionGroup.tsx create mode 100644 src/containers/CompetitionGroup/index.ts create mode 100644 src/containers/CompetitionRound/CompetitionRound.tsx create mode 100644 src/containers/CompetitionRound/index.ts create mode 100644 src/containers/CompetitionSchedule/CompetitionSchedule.tsx create mode 100644 src/containers/CompetitionSchedule/index.ts create mode 100644 src/lib/linkRenderer.tsx 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/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/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/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/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/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.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/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) => `${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/lib/linkRenderer.tsx b/src/lib/linkRenderer.tsx new file mode 100644 index 0000000..8dc6e67 --- /dev/null +++ b/src/lib/linkRenderer.tsx @@ -0,0 +1,16 @@ +import { ComponentType, ReactNode } from 'react'; +import type { To } from 'react-router-dom'; + +export interface LinkRendererProps { + to: To; + className?: string; + children: ReactNode; +} + +export type LinkRenderer = ComponentType; + +export const AnchorLink: LinkRenderer = ({ to, className, children }) => ( + + {children} + +); diff --git a/src/pages/Competition/ByGroup/Group.tsx b/src/pages/Competition/ByGroup/Group.tsx index 600ec7a..d8a0cc2 100644 --- a/src/pages/Competition/ByGroup/Group.tsx +++ b/src/pages/Competition/ByGroup/Group.tsx @@ -1,357 +1,19 @@ -import { ActivityCode } from '@wca/helpers'; -import classNames from 'classnames'; -import { Fragment, useCallback, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link, useNavigate, useParams } from 'react-router-dom'; -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 { byName } from '@/lib/utils'; -import { useWCIF } from '@/providers/WCIFProvider'; - -const useCommon = () => { - const { wcif } = useWCIF(); - const { roundId, groupNumber } = useParams(); - 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); - - const AllRoundActivities = wcif - ? getAllRoundActivities(wcif).filter((a) => { - return !!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; - - // All activities that relate to the activityCode - 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) => { - return 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((room) => - room.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, - }; - }); - - return { - wcif, - round, - roundId, - groupNumber, - activityCode, - rooms, - multistage, - childActivities, - personsInActivity, - }; -}; +import { Link, useParams } from 'react-router-dom'; +import { CompetitionGroupContainer } from '@/containers/CompetitionGroup'; export default function Group() { - return ( - <> - - - - ); -} - -export const GroupHeader = () => { - const { competitionId } = useWCIF(); - const { round, activityCode, rooms } = useCommon(); - - const activityName = activityCodeToName(activityCode); - const activityNameSplit = activityName.split(', '); - - const roundName = activityNameSplit.slice(0, 2).join(', '); - const groupName = activityNameSplit ? activityNameSplit.slice(-1).join('') : undefined; - - return ( -
-
- -
- - -
- {round && } -
-
- {rooms?.filter(hasActivities(activityCode)).map((room) => { - const activity = room.activities - .flatMap((ra) => ra.childActivities) - .find((a) => a.activityCode === activityCode); - - if (!activity) { - return null; - } - const venue = room.venue; - const timeZone = venue.timezone; - - return ( - - {/* {multistage &&
{stage.name}:
} -
- {activity && formatDateTimeRange(minStartTime, maxEndTime)} -
*/} - -
- ); - })} -
-
- ); -}; - -export const MobileGroupView = () => { - const { wcif, personsInActivity, multistage } = useCommon(); - - 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} -
- )} - - ))} -
-
-
-
- ); - })} -
-
- ); -}; - -const DesktopGroupView = () => { - const { rooms, personsInActivity } = useCommon(); - - return ( - - -
- {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} - - ))} -
- ))} -
- ); - })} -
-
- ); -}; - -export const GroupButtonMenu = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { competitionId } = useParams(); - const { wcif, activityCode } = useCommon(); - - const prev = wcif && prevActivityCode(wcif, activityCode); - const next = wcif && nextActivityCode(wcif, activityCode); - - const prevUrl = `/competitions/${competitionId}/events/${prev?.split?.('-g')?.[0]}/${ - prev?.split?.('-g')?.[1] - }`; - const nextUrl = `/competitions/${competitionId}/events/${next?.split?.('-g')?.[0]}/${ - next?.split?.('-g')?.[1] - }`; - - const goToPrev = useCallback(() => { - if (prev) { - navigate(prevUrl); - } - }, [prev, navigate, prevUrl]); + const { competitionId, roundId, groupNumber } = useParams(); - const goToNext = useCallback(() => { - if (next) { - navigate(nextUrl); - } - }, [next, navigate, nextUrl]); - - useEffect(() => { - const handleKeydown = (event: KeyboardEvent) => { - if (event.key === 'ArrowLeft') { - goToPrev(); - } - - if (event.key === 'ArrowRight') { - goToNext(); - } - }; - - document.addEventListener('keydown', handleKeydown); - - return () => { - document.removeEventListener('keydown', handleKeydown); - }; - }, [wcif, activityCode, goToPrev, goToNext]); + if (!competitionId || !roundId || !groupNumber) { + return null; + } return ( -
- - - {t('competition.groups.previousGroup')} - - - {t('competition.groups.nextGroup')} - - -
+ ); -}; +} diff --git a/src/pages/Competition/ByGroup/GroupList.tsx b/src/pages/Competition/ByGroup/GroupList.tsx index 0c22416..23d7d56 100644 --- a/src/pages/Competition/ByGroup/GroupList.tsx +++ b/src/pages/Competition/ByGroup/GroupList.tsx @@ -1,85 +1,18 @@ -import { useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; -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 { formatDateTimeRange } from '@/lib/time'; -import { useWCIF, useWcifUtils } from '@/providers/WCIFProvider'; +import { CompetitionRoundContainer } from '@/containers/CompetitionRound'; export default function GroupList() { - const { t } = useTranslation(); - - const { wcif, setTitle } = useWCIF(); const { competitionId, roundId } = useParams(); - const { roundActivies } = useWcifUtils(); - - useEffect(() => { - if (!roundId) { - return; - } - - 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))]; + if (!competitionId || !roundId) { + return null; + } return ( - -
- -
- {round && } -
-
-
    - {[...uniqueGroupCodes.values()].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/pages/Competition/Schedule/Activity.tsx b/src/pages/Competition/Schedule/Activity.tsx index 7ec9ae0..e8bf5d6 100644 --- a/src/pages/Competition/Schedule/Activity.tsx +++ b/src/pages/Competition/Schedule/Activity.tsx @@ -1,55 +1,23 @@ import { Activity } from '@wca/helpers'; -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { getAllActivities } from '@/lib/activities'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { CompetitionActivityContainer } from '@/containers/CompetitionActivity'; import { activityCodeToName } from '@/lib/activityCodes'; -import { useWCIF } from '@/providers/WCIFProvider'; -import { EventActivity } from './EventActivity'; export function CompetitionActivity() { - const { wcif } = useWCIF(); - const { activityId } = useParams(); + const { competitionId, activityId } = useParams(); + const navigate = useNavigate(); - const activity = useMemo( - () => - wcif && getAllActivities(wcif).find((a) => activityId && a.id === parseInt(activityId, 10)), - [wcif, activityId], - ); - - const everyoneInActivity = useMemo( - () => - wcif - ? wcif.persons - .map((person) => ({ - ...person, - assignments: person.assignments?.filter( - (a) => activityId && a.activityId === parseInt(activityId, 10), // TODO this is a hack because types aren't fixed yet for @wca/helpers - ), - })) - .filter(({ assignments }) => assignments && assignments.length > 0) - : [], - [wcif, activityId], - ); - - if (!wcif) { - ; - } - - if (wcif && !activity) { - return ( - -

Activity not found

-
- ); + if (!competitionId || !activityId) { + return null; } return ( - - {wcif?.id && activity && everyoneInActivity && ( - - )} - + ); } diff --git a/src/pages/Competition/Schedule/EventActivity.tsx b/src/pages/Competition/Schedule/EventActivity.tsx index fffda71..77e3066 100644 --- a/src/pages/Competition/Schedule/EventActivity.tsx +++ b/src/pages/Competition/Schedule/EventActivity.tsx @@ -2,12 +2,13 @@ import { Activity, AssignmentCode, Person } from '@wca/helpers'; import classNames from 'classnames'; import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; import { getRoomData, getRooms } from '@/lib/activities'; import { parseActivityCodeFlexible } from '@/lib/activityCodes'; import { getAllEvents, isOfficialEventId, isRankedBySingle } from '@/lib/events'; +import { LinkRenderer } from '@/lib/linkRenderer'; import { renderResultByEventId } from '@/lib/results'; import { formatDateTimeRange } from '@/lib/time'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -21,12 +22,19 @@ interface EventGroupProps { competitionId: string; activity: Activity; persons: Person[]; + LinkComponent?: LinkRenderer; + onNavigate?: (to: string) => void; } -export function EventActivity({ competitionId, activity, persons }: EventGroupProps) { +export function EventActivity({ + competitionId, + activity, + persons, + LinkComponent = Link, + onNavigate, +}: EventGroupProps) { const { t } = useTranslation(); - const navigate = useNavigate(); const { setTitle, wcif } = useWCIF(); const { eventId, roundNumber } = parseActivityCodeFlexible(activity?.activityCode || ''); const event = useMemo( @@ -225,15 +233,15 @@ export function EventActivity({ competitionId, activity, persons }: EventGroupPr const goToPrev = useCallback(() => { if (prevUrl) { - navigate(prevUrl); + onNavigate?.(prevUrl); } - }, [navigate, prevUrl]); + }, [onNavigate, prevUrl]); const goToNext = useCallback(() => { if (nextUrl) { - navigate(nextUrl); + onNavigate?.(nextUrl); } - }, [navigate, nextUrl]); + }, [onNavigate, nextUrl]); useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { @@ -253,13 +261,12 @@ export function EventActivity({ competitionId, activity, persons }: EventGroupPr }; }, [wcif, activity, goToPrev, goToNext]); - console.log({ prev, next }); - return ( <> {wcif && (
- {t('competition.groups.previousGroup')} - - + {t('competition.groups.nextGroup')} - +
@@ -345,7 +352,7 @@ export function EventActivity({ competitionId, activity, persons }: EventGroupPr return (a.seedRank || 999999999) - (b.seedRank || 999999999); }) .map((person) => ( - @@ -356,7 +363,7 @@ export function EventActivity({ competitionId, activity, persons }: EventGroupPr {stationNumber('competitor')(person)} )} - + ))} @@ -365,6 +372,7 @@ export function EventActivity({ competitionId, activity, persons }: EventGroupPr diff --git a/src/pages/Competition/Schedule/PeopleList.tsx b/src/pages/Competition/Schedule/PeopleList.tsx index c763ab1..d939e81 100644 --- a/src/pages/Competition/Schedule/PeopleList.tsx +++ b/src/pages/Competition/Schedule/PeopleList.tsx @@ -6,18 +6,21 @@ import { Link } from 'react-router-dom'; import { getStationNumber } from '@/lib/activities'; import { AssignmentCodeRank } from '@/lib/assignments'; import { getAssignmentColorClasses } from '@/lib/colors'; +import { LinkRenderer } from '@/lib/linkRenderer'; import { byName } from '@/lib/utils'; export interface PeopleListProps { competitionId: string; activity: Activity; peopleByAssignmentCode: Record; + LinkComponent?: LinkRenderer; } export const PeopleList = ({ competitionId, activity, peopleByAssignmentCode, + LinkComponent = Link, }: PeopleListProps) => { const { t } = useTranslation(); @@ -89,7 +92,7 @@ export const PeopleList = ({ return byName(a, b); }) .map((person, index) => ( - {person.name} {person.stationNumber} - + ))} ) : (
{people.sort(byName).map((person, index) => ( - {person.name} - + ))}
)} diff --git a/src/pages/Competition/Schedule/Room.tsx b/src/pages/Competition/Schedule/Room.tsx index 3ab3c32..4b4c9a5 100644 --- a/src/pages/Competition/Schedule/Room.tsx +++ b/src/pages/Competition/Schedule/Room.tsx @@ -123,6 +123,8 @@ export function CompetitionRoom() { { - setTitle(t('competition.schedule.title')); - }, [setTitle, t]); + if (!competitionId) { + return null; + } - const compId = wcif?.id || competitionId; - - return ( - -
- -
-
- -
-
- {wcif && } -
-
- ); + return ; } From 198c4c9b6a294d5c178ca7a9ec99a91bc4b05719 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 18:48:59 -0700 Subject: [PATCH 02/12] refactor: containerize remaining competition pages --- .../CompetitionCompareSchedules.tsx | 132 ++++++++++++ .../CompetitionCompareSchedules/index.ts | 1 + .../CompetitionEvents/CompetitionEvents.tsx | 82 ++++++++ src/containers/CompetitionEvents/index.ts | 1 + .../CompetitionGroupsOverview.tsx | 188 ++++++++++++++++++ .../CompetitionGroupsOverview/index.ts | 1 + .../CompetitionGroupsSchedule.tsx | 22 ++ .../CompetitionGroupsSchedule/index.ts | 1 + .../CompetitionHome/CompetitionHome.tsx | 42 ++++ src/containers/CompetitionHome/index.ts | 1 + .../CompetitionInformation.tsx | 62 ++++++ .../CompetitionInformation/index.ts | 1 + .../CompetitionLive/CompetitionLive.tsx | 18 ++ src/containers/CompetitionLive/index.ts | 1 + .../CompetitionPerson/CompetitionPerson.tsx | 36 ++++ src/containers/CompetitionPerson/index.ts | 1 + .../CompetitionPersonalBests.tsx | 33 +++ .../CompetitionPersonalBests/index.ts | 1 + .../CompetitionPsychSheetEvent.tsx | 181 +++++++++++++++++ .../CompetitionPsychSheetEvent/index.ts | 1 + .../CompetitionRoom/CompetitionRoom.tsx | 160 +++++++++++++++ src/containers/CompetitionRoom/index.ts | 1 + .../CompetitionRooms/CompetitionRooms.tsx | 51 +++++ src/containers/CompetitionRooms/index.ts | 1 + .../CompetitionScramblerSchedule.tsx | 167 ++++++++++++++++ .../CompetitionScramblerSchedule/index.ts | 1 + .../CompetitionStats/CompetitionStats.tsx | 50 +++++ src/containers/CompetitionStats/index.ts | 1 + .../CompetitionStreamSchedule.tsx | 166 ++++++++++++++++ .../CompetitionStreamSchedule/index.ts | 1 + src/pages/Competition/ByGroup/Events.tsx | 78 +------- .../Competition/CompareSchedules/index.tsx | 121 +---------- src/pages/Competition/GroupsOverview.tsx | 181 +---------------- src/pages/Competition/GroupsSchedule.tsx | 28 +-- src/pages/Competition/Home/index.tsx | 36 +--- src/pages/Competition/Information/index.tsx | 53 +---- src/pages/Competition/Live/index.tsx | 18 +- .../Competition/Person/PersonalBests.tsx | 24 +-- src/pages/Competition/Person/index.tsx | 37 +--- .../PsychSheet/PsychSheetEvent.tsx | 179 ++--------------- src/pages/Competition/Schedule/Room.tsx | 146 +------------- src/pages/Competition/Schedule/Rooms.tsx | 46 +---- .../Competition/ScramblerSchedule/index.tsx | 157 +-------------- src/pages/Competition/Stats/index.tsx | 51 +---- .../Competition/StreamSchedule/index.tsx | 163 +-------------- 45 files changed, 1477 insertions(+), 1246 deletions(-) create mode 100644 src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.tsx create mode 100644 src/containers/CompetitionCompareSchedules/index.ts create mode 100644 src/containers/CompetitionEvents/CompetitionEvents.tsx create mode 100644 src/containers/CompetitionEvents/index.ts create mode 100644 src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.tsx create mode 100644 src/containers/CompetitionGroupsOverview/index.ts create mode 100644 src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.tsx create mode 100644 src/containers/CompetitionGroupsSchedule/index.ts create mode 100644 src/containers/CompetitionHome/CompetitionHome.tsx create mode 100644 src/containers/CompetitionHome/index.ts create mode 100644 src/containers/CompetitionInformation/CompetitionInformation.tsx create mode 100644 src/containers/CompetitionInformation/index.ts create mode 100644 src/containers/CompetitionLive/CompetitionLive.tsx create mode 100644 src/containers/CompetitionLive/index.ts create mode 100644 src/containers/CompetitionPerson/CompetitionPerson.tsx create mode 100644 src/containers/CompetitionPerson/index.ts create mode 100644 src/containers/CompetitionPersonalBests/CompetitionPersonalBests.tsx create mode 100644 src/containers/CompetitionPersonalBests/index.ts create mode 100644 src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx create mode 100644 src/containers/CompetitionPsychSheetEvent/index.ts create mode 100644 src/containers/CompetitionRoom/CompetitionRoom.tsx create mode 100644 src/containers/CompetitionRoom/index.ts create mode 100644 src/containers/CompetitionRooms/CompetitionRooms.tsx create mode 100644 src/containers/CompetitionRooms/index.ts create mode 100644 src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.tsx create mode 100644 src/containers/CompetitionScramblerSchedule/index.ts create mode 100644 src/containers/CompetitionStats/CompetitionStats.tsx create mode 100644 src/containers/CompetitionStats/index.ts create mode 100644 src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.tsx create mode 100644 src/containers/CompetitionStreamSchedule/index.ts 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.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/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.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.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.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.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.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.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.tsx b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx new file mode 100644 index 0000000..a0546d3 --- /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 { useCallback, 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.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.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/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.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.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/pages/Competition/ByGroup/Events.tsx b/src/pages/Competition/ByGroup/Events.tsx index b1406ea..292ef50 100644 --- a/src/pages/Competition/ByGroup/Events.tsx +++ b/src/pages/Competition/ByGroup/Events.tsx @@ -1,79 +1,15 @@ -import { parseActivityCode } from '@wca/helpers'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { groupActivitiesByRound } from '@/lib/activities'; -import { getAllEvents, getEventName } from '@/lib/events'; -import { useWCIF } from '@/providers/WCIFProvider'; - -function onlyUnique(value, index, self) { - return self.indexOf(value) === index; -} +import { useNavigate, useParams } from 'react-router-dom'; +import { CompetitionEventsContainer } from '@/containers/CompetitionEvents'; const Events = () => { - const { t } = useTranslation(); - - const { wcif, setTitle, competitionId } = useWCIF(); const navigate = useNavigate(); + const { competitionId } = useParams<{ competitionId: string }>(); - 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}`; + if (!competitionId) { + return null; + } - return ( - navigate(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')}
-
-
-
- ); + return ; }; export default Events; diff --git a/src/pages/Competition/CompareSchedules/index.tsx b/src/pages/Competition/CompareSchedules/index.tsx index 1b732ba..96d43bf 100644 --- a/src/pages/Competition/CompareSchedules/index.tsx +++ b/src/pages/Competition/CompareSchedules/index.tsx @@ -1,126 +1,15 @@ -import { AssignmentCode, Person } from '@wca/helpers'; -import { Fragment, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { Grid } from '@/components/Grid/Grid'; -import { usePinnedPersons } from '@/hooks/UsePinnedPersons'; -import { - doesActivityOverlapInterval, - getScheduledDays, - getUniqueActivityTimes, -} from '@/lib/activities'; -import Assignments from '@/lib/assignments'; -import { formatTime } from '@/lib/time'; -import { useAuth } from '@/providers/AuthProvider'; +import { CompetitionCompareSchedulesContainer } from '@/containers/CompetitionCompareSchedules'; import { useWCIF } from '@/providers/WCIFProvider'; export default function CompareSchedules() { - const headerRef = useRef(null); - const { user } = useAuth(); const { wcif, competitionId } = 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], - ); + if (!wcif) { + return null; + } return ( -
- -
- Time -
- {persons.map((p) => ( -
- - {p.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((p) => { - const assignment = p.assignments?.find((a) => - activitiesHappeningDuringStartTime.some( - (activity) => activity.id === a.activityId, - ), - ); - const assignmentCode = assignment?.assignmentCode as AssignmentCode; - - if (!assignmentCode) { - return ( -
- - -
- ); - } - - const config = Assignments.find((i) => i.id === assignmentCode); - - return ( -
- {config ? config.key.toUpperCase() : assignmentCode[0].toUpperCase()} -
- ); - })} -
- ); - })} -
- ); - })} -
-
+ ); } diff --git a/src/pages/Competition/GroupsOverview.tsx b/src/pages/Competition/GroupsOverview.tsx index f1aa334..a372709 100644 --- a/src/pages/Competition/GroupsOverview.tsx +++ b/src/pages/Competition/GroupsOverview.tsx @@ -1,182 +1,5 @@ -import { Activity, EventId, parseActivityCode } from '@wca/helpers'; -import classNames from 'classnames'; -import { useCallback, useMemo } from 'react'; -import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; -import { Container } from '@/components/Container'; -import { groupActivitiesByRound } from '@/lib/activities'; -import { acceptedRegistration } from '@/lib/person'; -import { hasAssignmentInStage } from '@/lib/person'; -import { useWCIF } from '@/providers/WCIFProvider'; +import { CompetitionGroupsOverviewContainer } from '@/containers/CompetitionGroupsOverview'; -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)}`; - -const GroupsOverview = () => { - const { wcif } = useWCIF(); - - const memodGroupActivitiesForRound = useCallback( - (activityCode) => (wcif ? groupActivitiesByRound(wcif, activityCode) : []), - [wcif], - ); - - const assignmentsToObj = useCallback( - (person) => { - const obj = { - competing: {}, - staffing: {}, - }; - wcif?.events.forEach((event) => { - // get first round activities - const activitiesForEvent = memodGroupActivitiesForRound(`${event.id}-r1`); - const assignmentsForEvent = person.assignments - .filter((assignment) => activitiesForEvent.some((a) => a.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; - }, - [memodGroupActivitiesForRound, wcif?.events], - ); - - const assignments = useMemo(() => { - return wcif?.persons - .filter(acceptedRegistration) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((person) => { - const a = assignmentsToObj(person); - return { ...person, assignmentsData: a }; - }); - }, [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((ra) => ra.childActivities); - - return ( - <> - - - - {assignments - ?.filter((person) => hasAssignmentInStage(stage, person)) - .map((person) => { - const assignments = - person.assignments?.map((assignment) => { - const activity = childActivities.find( - (ca) => ca.id === assignment.activityId, - ); - - return { - ...assignment, - activity, - ...(activity?.activityCode && { - ...(parseActivityCode(activity?.activityCode) as { - eventId: EventId; - roundNumber: number; - groupNumber: number; - }), - }), - }; - }) || []; - const competingAssignments = assignments.filter( - ({ assignmentCode }) => assignmentCode === 'competitor', - ); - const staffingAssignments = assignments.filter( - ({ assignmentCode }) => assignmentCode.indexOf('staff') > -1, - ); - - return ( - - - - {wcif?.events.map((event) => { - const competingAssignment = competingAssignments.find( - (a) => a.eventId === event.id && a.roundNumber === 1, - ); - - return ( - - ); - })} - {wcif?.events.map((event) => { - const staffingAssignment = staffingAssignments.find( - (a) => a.eventId === event.id && a.roundNumber === 1, - ); - return ( - - {staffingAssignment && staffingAssignmentToText(staffingAssignment)} - - ); - })} - - ); - })} - - ); - })} - -
NameWCA ID - {event.id} - - {event.id} -
- {stage.name} -
{person.name}{person.wcaId} - {competingAssignment?.groupNumber} -
-
- ); -}; +const GroupsOverview = () => ; export default GroupsOverview; diff --git a/src/pages/Competition/GroupsSchedule.tsx b/src/pages/Competition/GroupsSchedule.tsx index 24f28bd..548bde7 100644 --- a/src/pages/Competition/GroupsSchedule.tsx +++ b/src/pages/Competition/GroupsSchedule.tsx @@ -1,29 +1,5 @@ -// import { useWCIF } from './WCIFProvider'; -import { Container } from '@/components/Container'; +import { CompetitionGroupsScheduleContainer } from '@/containers/CompetitionGroupsSchedule'; -const Events = () => { - // const { wcif } = useWCIF(); - - // allChildActivities - - return ( - -
-
-
- - - - - - - - -
StageActivity
-
-
-
- ); -}; +const Events = () => ; export default Events; diff --git a/src/pages/Competition/Home/index.tsx b/src/pages/Competition/Home/index.tsx index 86f33e3..8af5c88 100644 --- a/src/pages/Competition/Home/index.tsx +++ b/src/pages/Competition/Home/index.tsx @@ -1,34 +1,12 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; -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 { useWCIF } from '@/providers/WCIFProvider'; +import { Link, useParams } from 'react-router-dom'; +import { CompetitionHomeContainer } from '@/containers/CompetitionHome'; export default function CompetitionHome() { - const { t } = useTranslation(); - const { competitionId } = useParams() as { competitionId: string }; - const { wcif, setTitle } = useWCIF(); + const { competitionId } = useParams() as { competitionId?: string }; - useEffect(() => { - setTitle(''); - }, [setTitle]); + if (!competitionId) { + return null; + } - return ( - -
- - -
- - {wcif && } -
- ); + return ; } diff --git a/src/pages/Competition/Information/index.tsx b/src/pages/Competition/Information/index.tsx index e0fffdb..3ae0faf 100644 --- a/src/pages/Competition/Information/index.tsx +++ b/src/pages/Competition/Information/index.tsx @@ -1,59 +1,12 @@ -import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Container, ExternalLink } from '@/components'; -import { useCompetition } from '@/hooks/queries/useCompetition'; -import { useWCIF } from '@/providers/WCIFProvider'; -import { UserRow } from './UserRow'; +import { CompetitionInformationContainer } from '@/containers/CompetitionInformation'; export default function Information() { - const { setTitle, wcif } = useWCIF(); const { competitionId = '' } = useParams<{ competitionId: string }>(); - const { data, error } = useCompetition(competitionId); - - useEffect(() => { - setTitle('Information'); - }, [setTitle]); - - if (error) { + if (!competitionId) { return null; } - return ( - -
- View WCA competition webpage - -
-

Organizers

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

Delegates

-
    - {data?.delegates?.map((user) => )} -
-
-
-
- ); + return ; } diff --git a/src/pages/Competition/Live/index.tsx b/src/pages/Competition/Live/index.tsx index 9c305cc..144ce30 100644 --- a/src/pages/Competition/Live/index.tsx +++ b/src/pages/Competition/Live/index.tsx @@ -1,16 +1,12 @@ import { useParams } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { LiveActivities } from '@/containers/LiveActivities'; +import { CompetitionLiveContainer } from '@/containers/CompetitionLive'; export default function LivePage() { const { competitionId } = useParams(); - return ( - -
- - Live Activities -
- -
- ); + + if (!competitionId) { + return null; + } + + return ; } diff --git a/src/pages/Competition/Person/PersonalBests.tsx b/src/pages/Competition/Person/PersonalBests.tsx index 6691d0e..76afcf1 100644 --- a/src/pages/Competition/Person/PersonalBests.tsx +++ b/src/pages/Competition/Person/PersonalBests.tsx @@ -1,30 +1,12 @@ -import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { PersonalBestsContainer } from '@/containers/PersonalBests/PersonalBests'; -import { useWCIF } from '@/providers/WCIFProvider'; +import { CompetitionPersonalBestsContainer } from '@/containers/CompetitionPersonalBests'; export default function PersonalBests() { - const { wcif, setTitle } = useWCIF(); const { wcaId } = useParams<{ wcaId: string }>(); - const person = wcif?.persons?.find((p) => p?.wcaId === wcaId); - - useEffect(() => { - setTitle(`Personal Bests: ${person?.name}`); - }); - - if (!wcif) { + if (!wcaId) { return null; } - if (!person) { - return
Person not found
; - } - - return ( - - - - ); + return ; } diff --git a/src/pages/Competition/Person/index.tsx b/src/pages/Competition/Person/index.tsx index ac15892..664ae27 100644 --- a/src/pages/Competition/Person/index.tsx +++ b/src/pages/Competition/Person/index.tsx @@ -1,43 +1,12 @@ -import { Person } from '@wca/helpers'; -import { Extension } from '@wca/helpers/lib/models/extension'; -import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { PersonalScheduleContainer } from '@/containers/PersonalSchedule'; -import { useWCIF } from '@/providers/WCIFProvider'; - -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; -}; +import { CompetitionPersonContainer } from '@/containers/CompetitionPerson'; export default function PersonPage() { - const { wcif, setTitle } = useWCIF(); const { registrantId } = useParams(); - const person = wcif?.persons?.find( - (p) => p.registrantId.toString() === registrantId, - ) as Person & { - extensions: Extension[]; - }; - - useEffect(() => { - if (person) { - setTitle(person.name); - } - }, [person, setTitle]); - - if (!wcif) { + if (!registrantId) { return null; } - return ( - - - - ); + return ; } diff --git a/src/pages/Competition/PsychSheet/PsychSheetEvent.tsx b/src/pages/Competition/PsychSheet/PsychSheetEvent.tsx index a585cad..a4b8cb1 100644 --- a/src/pages/Competition/PsychSheet/PsychSheetEvent.tsx +++ b/src/pages/Competition/PsychSheet/PsychSheetEvent.tsx @@ -1,96 +1,23 @@ -import { Event, EventId, Person, PersonalBest } from '@wca/helpers'; -import classNames from 'classnames'; -import getUnicodeFlagIcon from 'country-flag-icons/unicode'; +import { EventId } from '@wca/helpers'; import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { findPR } from '@/lib/activities'; -import { activityCodeToName } from '@/lib/activityCodes'; -import { acceptedRegistration, isRegisteredForEvent } from '@/lib/person'; -import { renderResultByEventId } from '@/lib/results'; -import { byWorldRanking } from '@/lib/sort'; -import { useWCIF } from '@/providers/WCIFProvider'; +import { CompetitionPsychSheetEventContainer } from '@/containers/CompetitionPsychSheetEvent'; export const PsychSheetEvent = () => { - const { t } = useTranslation(); const { competitionId, eventId } = useParams<{ competitionId: string; eventId: EventId; }>(); - - const { wcif } = useWCIF(); const navigate = useNavigate(); - - const psychSheetBaseUrl = `/competitions/${competitionId}/psych-sheet`; - const [urlSearchParams, setUrlSearchParams] = useSearchParams({ resultType: 'average', }); - + const psychSheetBaseUrl = `/competitions/${competitionId}/psych-sheet`; const resultType = useMemo( () => urlSearchParams.get('resultType') as 'average' | 'single', [urlSearchParams], ); - const persons = useMemo( - () => - wcif?.persons.filter( - (person) => - eventId && acceptedRegistration(person) && isRegisteredForEvent(eventId)(person), - ) || [], - [wcif, eventId], - ); - - // Creates a proper psychsheet with support for tied rankings - const sortedPersons = useMemo(() => { - if (!eventId || !persons.length) { - return []; - } - - const _findPr = findPR(eventId); - return persons.sort(byWorldRanking(eventId, resultType)).reduce( - ( - persons: (Person & { - rank: number; - mainRank: number; - subRank: number; - pr?: PersonalBest; - })[], - person, - index, - ) => { - const lastPerson = index > 0 ? persons[index - 1] : undefined; - - const avgPr = _findPr(person.personalBests || [], 'average'); - const singlePr = _findPr(person.personalBests || [], 'single'); - - const mainRank = - (resultType === 'average' ? avgPr?.worldRanking : singlePr?.worldRanking) ?? 0; - const subRank = singlePr?.worldRanking ?? 0; - - const rank = - lastPerson && mainRank === lastPerson.mainRank && subRank === lastPerson.subRank - ? lastPerson.rank - : index + 1; - - return [ - ...persons, - { - ...person, - mainRank, - subRank, - rank: rank, - pr: resultType === 'average' ? avgPr : singlePr, - }, - ]; - }, - [], - ); - }, [eventId, persons, resultType]); - - const gridCss = 'grid grid-cols-[3.5em_2em_1fr_min-content_7em]'; - const handleEventChange = useCallback( (newEventId: EventId) => { navigate( @@ -111,100 +38,18 @@ export const PsychSheetEvent = () => { [setUrlSearchParams], ); - if (!eventId) { + if (!competitionId || !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( - (pr) => pr.eventId === eventId && pr.type === resultType, - ); - - const isOdd = index % 2 === 1; // 0-indexed, so even index = odd row visually - - return ( - span]:transition-colors', - isOdd ? '[&>span]:table-bg-row-alt-secondary' : '[&>span]:table-bg-row', - '[&:hover>span]:table-bg-row-hover-secondary', - )} - to={`/competitions/${wcif?.id}/personal-bests/${person.wcaId}`}> - - {person.rank} - - - {getUnicodeFlagIcon(person.countryIso2)} - - {person.name} - - {prAverage ? renderResultByEventId(eventId, resultType, prAverage.best) : ''} - - - {prAverage - ? `${prAverage.worldRanking.toLocaleString([...navigator.languages])}` - : ''} - - - ); - })} -
-
-
-
- ); -}; - -export const EventSelector = ({ - value, - events, - onChange, -}: { - value: EventId; - events: Event[]; - onChange: (eventId: EventId) => void; -}) => { - return ( - + ); }; diff --git a/src/pages/Competition/Schedule/Room.tsx b/src/pages/Competition/Schedule/Room.tsx index 4b4c9a5..eefd1a9 100644 --- a/src/pages/Competition/Schedule/Room.tsx +++ b/src/pages/Competition/Schedule/Room.tsx @@ -1,148 +1,14 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; -import { ActivityRow, Container } from '@/components'; -import { getAllChildActivities, getRoomData } from '@/lib/activities'; -import { formatToParts } from '@/lib/time'; -import { byDate } from '@/lib/utils'; -import { useWCIF } from '@/providers/WCIFProvider'; +import { CompetitionRoomContainer } from '@/containers/CompetitionRoom'; export function CompetitionRoom() { - const { t } = useTranslation(); + const { competitionId, roomId } = useParams<{ competitionId: string; roomId: string }>(); - const { wcif, setTitle } = useWCIF(); - const { roomId } = useParams(); - - const venue = useMemo( - () => - wcif?.schedule?.venues?.find((venue) => - venue.rooms.some((room) => room.id.toString() === roomId), - ), - [roomId, wcif?.schedule?.venues], - ); - - const room = useMemo( - () => venue?.rooms?.find((room) => room.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 venue = - wcif?.schedule.venues?.find((v) => - v.rooms.some((r) => - r.activities.some( - (a) => a.id === activity.id || a.childActivities?.some((ca) => ca.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: venue?.timezone, - }), - dateParts: formatToParts(dateTime), - }; - }) - .filter((v, i, arr) => arr.findIndex(({ date }) => date === v.date) === i) - .sort((a, b) => a.approxDateTime - b.approxDateTime); - - const activitiesWithParsedDate = activities - .map((activity) => { - const venue = - wcif?.schedule.venues?.find((v) => - v.rooms.some((r) => - r.activities.some( - (a) => a.id === activity.id || a.childActivities?.some((ca) => ca.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: venue?.timezone, - }), - }; - }) - .sort((a, b) => byDate(a, b)); - - const getActivitiesByDate = useCallback( - (date) => { - return activitiesWithParsedDate.filter((a) => a.date === date); - }, - [activitiesWithParsedDate], - ); + if (!competitionId || !roomId) { + return null; + } 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/pages/Competition/Schedule/Rooms.tsx b/src/pages/Competition/Schedule/Rooms.tsx index 5ad79f7..44aec3e 100644 --- a/src/pages/Competition/Schedule/Rooms.tsx +++ b/src/pages/Competition/Schedule/Rooms.tsx @@ -1,44 +1,12 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { Container } from '@/components/Container'; -import { getLocalizedTimeZoneName } from '@/lib/time'; -import { useWCIF } from '@/providers/WCIFProvider'; +import { Link, useParams } from 'react-router-dom'; +import { CompetitionRoomsContainer } from '@/containers/CompetitionRooms'; export function CompetitionRooms() { - const { t } = useTranslation(); + const { competitionId } = useParams<{ competitionId: string }>(); - const { wcif, setTitle } = useWCIF(); + if (!competitionId) { + return null; + } - 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}

-
- - ))} -
-
- ))} -
-
- ); + return ; } diff --git a/src/pages/Competition/ScramblerSchedule/index.tsx b/src/pages/Competition/ScramblerSchedule/index.tsx index 4ff7890..b665bdf 100644 --- a/src/pages/Competition/ScramblerSchedule/index.tsx +++ b/src/pages/Competition/ScramblerSchedule/index.tsx @@ -1,162 +1,11 @@ -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 { Link } from 'react-router-dom'; -import { BreakableActivityName, Container, ErrorFallback } from '@/components'; -import { getAllActivities, getAllRoundActivities, getRooms } from '@/lib/activities'; -import { parseActivityCodeFlexible } from '@/lib/activityCodes'; -import { formatToWeekDay } from '@/lib/time'; -import { groupBy } from '@/lib/utils'; +import { CompetitionScramblerScheduleContainer } from '@/containers/CompetitionScramblerSchedule'; import { useWCIF } from '@/providers/WCIFProvider'; export default function ScramblerSchedule() { - 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)) || '???', - })), - (x) => x.date, - ); + const { competitionId } = useWCIF(); 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((a) => a.activityId === childActivity.id) - ?.sort((a, b) => a.personName.localeCompare(b.personName)) - .map(({ personName }) => personName) - .join(', ')} - - -
-
-
+ ); } diff --git a/src/pages/Competition/Stats/index.tsx b/src/pages/Competition/Stats/index.tsx index 008defa..6b763c9 100644 --- a/src/pages/Competition/Stats/index.tsx +++ b/src/pages/Competition/Stats/index.tsx @@ -1,52 +1,5 @@ -import { useEffect, useMemo } from 'react'; -import { Container } from '@/components/Container'; -import { useWCIF } from '@/providers/WCIFProvider'; -import { StatsBox } from './StatsBox'; +import { CompetitionStatsContainer } from '@/containers/CompetitionStats'; export default function Round() { - 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 - } - - ))} -
-
- ); + return ; } diff --git a/src/pages/Competition/StreamSchedule/index.tsx b/src/pages/Competition/StreamSchedule/index.tsx index c98039e..dfc4fb2 100644 --- a/src/pages/Competition/StreamSchedule/index.tsx +++ b/src/pages/Competition/StreamSchedule/index.tsx @@ -1,166 +1,9 @@ -import { parseActivityCode } from '@wca/helpers'; -import { useCallback, useEffect, Fragment } from 'react'; -import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { DisclaimerText } from '@/components'; -import { getRoomData, streamActivities, streamPersonIds } from '@/lib/activities'; -import { formatDate, formatToParts } from '@/lib/time'; -import { roundTime } from '@/lib/utils'; +import { CompetitionStreamScheduleContainer } from '@/containers/CompetitionStreamSchedule'; import { useWCIF } from '@/providers/WCIFProvider'; -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 default function CompetitionStreamSchedule() { - 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) => { - return wcif?.persons.find(({ wcaUserId }) => wcaUserId === personId); - }, - [wcif], - ); - - const activitiesWithParsedDate = activities - .map((a) => ({ - ...a, - date: formatDate(new Date(a.startTime)), - })) - .sort(byDate); - - const getActivitiesByDate = useCallback( - (date) => { - return activitiesWithParsedDate.filter((a) => a.date === date); - }, - [activitiesWithParsedDate], - ); - - const scheduleDays = activities - .map((a) => { - const dateTime = new Date(a.startTime); - return { - approxDateTime: dateTime.getTime(), - date: formatDate(dateTime) || 'foo', - dateParts: formatToParts(dateTime), - }; - }) - .filter((v, i, arr) => arr.findIndex(({ date }) => date === v.date) === i) - .sort((a, b) => a.approxDateTime - b.approxDateTime); - - if (!wcif) { - return

Loading...

; - } - - const renderActivities = () => ( - <> -

{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((v) => - v.rooms.some((r) => r.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((i) => i.type === 'weekday')?.value || date} -
{startTime} - - {roundNumber}{groupNumber || '*'} - - {roomData?.name} - - - {streamPersonIds(activity) - .map(getPersonById) - .filter((person) => !!person) - .map((person) => ( -

{person!.name}

- ))} -
-
- - ); - - return ( -
- -
+ const { competitionId } = useWCIF(); - {activities.length > 0 ? renderActivities() :
No Live Stream information
} -
- ); + return ; } From 40166ba98d04e8cf28d2d7795632ff64cecbdbbf Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 18:49:23 -0700 Subject: [PATCH 03/12] chore: remove unused psych sheet import --- .../CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx index a0546d3..f1af5fd 100644 --- a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx +++ b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx @@ -1,7 +1,7 @@ import { Event, EventId, Person, PersonalBest } from '@wca/helpers'; import classNames from 'classnames'; import getUnicodeFlagIcon from 'country-flag-icons/unicode'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Container } from '@/components/Container'; import { findPR } from '@/lib/activities'; From 2f4ca72f4ee6cfa44c95288ad6cc1660a86ad754 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 19:33:54 -0700 Subject: [PATCH 04/12] feat: add storybook coverage for competition containers --- .storybook/preview.tsx | 30 +- .../CutoffTimeLimitPanel.stories.tsx | 78 ++ .../About/About.stories.tsx | 8 +- src/containers/About/About.tsx | 62 ++ src/containers/About/index.ts | 1 + .../CompetitionActivity.stories.tsx | 28 + .../CompetitionCompareSchedules.stories.tsx | 20 + .../CompetitionEvents.stories.tsx | 20 + .../CompetitionGroup.stories.tsx | 34 + .../CompetitionGroupsOverview.stories.tsx | 16 + .../CompetitionGroupsSchedule.stories.tsx | 16 + .../CompetitionHome.stories.tsx | 20 + .../CompetitionInformation.stories.tsx | 20 + .../CompetitionLive.stories.tsx | 20 + .../CompetitionPerson.stories.tsx | 26 + .../CompetitionPersonalBests.stories.tsx | 20 + .../CompetitionPsychSheetEvent.stories.tsx | 34 + .../CompetitionRoom.stories.tsx | 28 + .../CompetitionRooms.stories.tsx | 20 + .../CompetitionRound.stories.tsx | 75 ++ .../CompetitionSchedule.stories.tsx | 20 + .../CompetitionScramblerSchedule.stories.tsx | 20 + .../CompetitionStats.stories.tsx | 16 + .../CompetitionStreamSchedule.stories.tsx | 20 + .../Support/Support.stories.tsx | 8 +- src/containers/Support/Support.tsx | 30 + src/containers/Support/index.ts | 1 + src/pages/About/index.tsx | 61 +- src/pages/Support/index.tsx | 29 +- src/storybook/competitionFixtures.ts | 732 ++++++++++++++++++ src/storybook/competitionStorybook.tsx | 154 ++++ 31 files changed, 1572 insertions(+), 95 deletions(-) create mode 100644 src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx rename src/{pages => containers}/About/About.stories.tsx (61%) create mode 100644 src/containers/About/About.tsx create mode 100644 src/containers/About/index.ts create mode 100644 src/containers/CompetitionActivity/CompetitionActivity.stories.tsx create mode 100644 src/containers/CompetitionCompareSchedules/CompetitionCompareSchedules.stories.tsx create mode 100644 src/containers/CompetitionEvents/CompetitionEvents.stories.tsx create mode 100644 src/containers/CompetitionGroup/CompetitionGroup.stories.tsx create mode 100644 src/containers/CompetitionGroupsOverview/CompetitionGroupsOverview.stories.tsx create mode 100644 src/containers/CompetitionGroupsSchedule/CompetitionGroupsSchedule.stories.tsx create mode 100644 src/containers/CompetitionHome/CompetitionHome.stories.tsx create mode 100644 src/containers/CompetitionInformation/CompetitionInformation.stories.tsx create mode 100644 src/containers/CompetitionLive/CompetitionLive.stories.tsx create mode 100644 src/containers/CompetitionPerson/CompetitionPerson.stories.tsx create mode 100644 src/containers/CompetitionPersonalBests/CompetitionPersonalBests.stories.tsx create mode 100644 src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.stories.tsx create mode 100644 src/containers/CompetitionRoom/CompetitionRoom.stories.tsx create mode 100644 src/containers/CompetitionRooms/CompetitionRooms.stories.tsx create mode 100644 src/containers/CompetitionRound/CompetitionRound.stories.tsx create mode 100644 src/containers/CompetitionSchedule/CompetitionSchedule.stories.tsx create mode 100644 src/containers/CompetitionScramblerSchedule/CompetitionScramblerSchedule.stories.tsx create mode 100644 src/containers/CompetitionStats/CompetitionStats.stories.tsx create mode 100644 src/containers/CompetitionStreamSchedule/CompetitionStreamSchedule.stories.tsx rename src/{pages => containers}/Support/Support.stories.tsx (60%) create mode 100644 src/containers/Support/Support.tsx create mode 100644 src/containers/Support/index.ts create mode 100644 src/storybook/competitionFixtures.ts create mode 100644 src/storybook/competitionStorybook.tsx 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/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx new file mode 100644 index 0000000..5a84d29 --- /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 Data/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/pages/About/About.stories.tsx b/src/containers/About/About.stories.tsx similarity index 61% rename from src/pages/About/About.stories.tsx rename to src/containers/About/About.stories.tsx index 3d1252e..796e990 100644 --- a/src/pages/About/About.stories.tsx +++ b/src/containers/About/About.stories.tsx @@ -1,14 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; -import About from './index'; +import { AboutContainer } from './About'; const meta = { - title: 'Pages/About', - component: About, + title: 'Containers/App/About', + component: AboutContainer, parameters: { layout: 'fullscreen', }, tags: ['autodocs'], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/containers/About/About.tsx b/src/containers/About/About.tsx new file mode 100644 index 0000000..94af36e --- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/pages/Support/Support.stories.tsx b/src/containers/Support/Support.stories.tsx similarity index 60% rename from src/pages/Support/Support.stories.tsx rename to src/containers/Support/Support.stories.tsx index aeb8ead..1782afc 100644 --- a/src/pages/Support/Support.stories.tsx +++ b/src/containers/Support/Support.stories.tsx @@ -1,14 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; -import Support from './index'; +import { SupportContainer } from './Support'; const meta = { - title: 'Pages/Support', - component: Support, + title: 'Containers/App/Support', + component: SupportContainer, parameters: { layout: 'fullscreen', }, tags: ['autodocs'], -} satisfies Meta; +} satisfies Meta; export default meta; 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 +

+