diff --git a/app/core/tournament/utils.ts b/app/core/tournament/utils.ts index 53dc21493..f67b4e345 100644 --- a/app/core/tournament/utils.ts +++ b/app/core/tournament/utils.ts @@ -1,3 +1,4 @@ +import invariant from "tiny-invariant"; import { Bracket } from "./bracket"; export function checkInHasStarted(checkInStartTime: string) { @@ -28,6 +29,98 @@ export function sortTeamsBySeed(seeds: string[]) { }; } +export function roundNamesWithDefaultBestOf(bracket: Bracket): { + winners: { + name: string; + bestOf: number; + }[]; + losers: { + name: string; + bestOf: number; + }[]; +} { + const { winners: winnersRoundNames, losers: losersRoundNames } = + roundNames(bracket); + const { winners: winnersDefaultBestOf, losers: losersDefaultBestOf } = + roundDefaultBestOf(bracket); + + invariant( + winnersRoundNames.length === winnersDefaultBestOf.length, + "Unexpected different length with winnersRoundNames and winnersDefaultBestOf" + ); + invariant( + losersRoundNames.length === losersDefaultBestOf.length, + "Unexpected different length with losersRoundNames and losersDefaultBestOf" + ); + + return { + winners: winnersRoundNames.map((roundName, i) => { + const bestOf = winnersDefaultBestOf[i]; + return { + name: roundName, + bestOf, + }; + }), + losers: losersRoundNames.map((roundName, i) => { + const bestOf = losersDefaultBestOf[i]; + return { + name: roundName, + bestOf, + }; + }), + }; +} + +const WINNERS_DEFAULT = 5; +const WINNERS_FIRST_TWO_DEFAULT = 3; +const GRAND_FINALS_DEFAULT = 7; +const LOSERS_DEFAULT = 3; +const LOSERS_FINALS_DEFAULT = 5; + +function roundDefaultBestOf(bracket: Bracket): { + winners: number[]; + losers: number[]; +} { + const { winners: winnersRoundCount, losers: losersRoundCount } = + countRounds(bracket); + + return { + winners: new Array(winnersRoundCount).fill(null).map((_, i) => { + if (i === 0) return WINNERS_FIRST_TWO_DEFAULT; + if (i === 1) return WINNERS_FIRST_TWO_DEFAULT; + if (i === winnersRoundCount - 1) return GRAND_FINALS_DEFAULT; + return WINNERS_DEFAULT; + }), + losers: new Array(losersRoundCount) + .fill(null) + .map((_, i) => + i === losersRoundCount - 1 ? LOSERS_FINALS_DEFAULT : LOSERS_DEFAULT + ), + }; +} + +function roundNames(bracket: Bracket): { + winners: string[]; + losers: string[]; +} { + const { winners: winnersRoundCount, losers: losersRoundCount } = + countRounds(bracket); + + return { + winners: new Array(winnersRoundCount).fill(null).map((_, i) => { + if (i === winnersRoundCount - 3) return "Winners' Semifinals"; + if (i === winnersRoundCount - 2) return "Winners' Finals"; + if (i === winnersRoundCount - 1) return "Grand Finals"; + return `Winners' Round ${i + 1}`; + }), + losers: new Array(losersRoundCount) + .fill(null) + .map((_, i) => + i === losersRoundCount - 1 ? "Losers' Finals" : `Losers' Round ${i + 1}` + ), + }; +} + export function countRounds(bracket: Bracket): { winners: number; losers: number; diff --git a/app/routes/to/$organization.$tournament/admin.tsx b/app/routes/to/$organization.$tournament/admin.tsx index c80b43728..5e46d24b2 100644 --- a/app/routes/to/$organization.$tournament/admin.tsx +++ b/app/routes/to/$organization.$tournament/admin.tsx @@ -1,6 +1,6 @@ import type { LinksFunction } from "remix"; +import { Outlet } from "remix"; import { Button } from "~/components/Button"; -import { AdminTeamControls } from "~/components/tournament/AdminTeamControls"; import styles from "~/styles/tournament-admin.css"; export const links: LinksFunction = () => { @@ -11,10 +11,12 @@ export default function AdminPage() { return ( <>
+ {/* Don't show when check in is not concluded + secure route */} + {/* TODO: */}
- + ); } diff --git a/app/routes/to/$organization.$tournament/admin/index.tsx b/app/routes/to/$organization.$tournament/admin/index.tsx new file mode 100644 index 000000000..2382ac0b3 --- /dev/null +++ b/app/routes/to/$organization.$tournament/admin/index.tsx @@ -0,0 +1,262 @@ +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import classNames from "classnames"; +import * as React from "react"; +import { useFetcher, useMatches } from "remix"; +import { Alert } from "~/components/Alert"; +import { Button } from "~/components/Button"; +import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants"; +import { checkInHasStarted } from "~/core/tournament/utils"; +import type { FindTournamentByNameForUrlI } from "~/services/tournament"; +import type { Unpacked } from "~/utils"; +import { useTimeoutState } from "~/utils/hooks"; + +// TODO: https://docs.dndkit.com/presets/sortable#drag-overlay +// TODO: what if returns error? check other APIs too -> add Cypress test +export default function AdminDefaultTab() { + const seedsFetcher = useFetcher(); + const [, parentRoute] = useMatches(); + const { id, teams, checkInStartTime } = + parentRoute.data as FindTournamentByNameForUrlI; + const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id)); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const teamsSorted = teams.sort( + (a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id) + ); + + return ( + <> + +
    +
  • +
    Seed
    +
    Name
    +
    + {checkInHasStarted(checkInStartTime) ? "" : "Registered at"} +
    +
    + Roster size +
    +
  • + null} + onDragEnd={(event) => { + const { active, over } = event; + + if (!over) return; + if (active.id !== over.id) { + setTeamOrder((teamIds) => { + const oldIndex = teamIds.indexOf(active.id); + const newIndex = teamIds.indexOf(over.id); + + return arrayMove(teamIds, oldIndex, newIndex); + }); + } + }} + > + + {teamsSorted.map((team, i) => ( + + ))} + + +
+ + ); +} + +function SeedAlert({ + tournamentId, + teamOrder, + fetcher, +}: { + tournamentId: string; + teamOrder: string[]; + fetcher: ReturnType; +}) { + const [teamOrderInDb, setTeamOrderInDb] = React.useState(teamOrder); + const [showSuccess, setShowSuccess] = useTimeoutState(false); + + React.useEffect(() => { + // TODO: what if error? + if (fetcher.state !== "loading") return; + + setTeamOrderInDb(teamOrder); + setShowSuccess(true, { timeout: 3000 }); + }, [fetcher.state]); + + const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]); + + return ( + + + + Save seeds + + } + > + {teamOrderChanged ? ( + <>You have unchanged changes to seeding + ) : showSuccess ? ( + <>Seeds saved successfully! + ) : ( + <>Drag teams to adjust their seeding + )} + + + ); +} + +function SortableRow({ + team, + seed, + disabled, +}: { + team: Unpacked; + seed: number; + disabled: boolean; +}) { + const [, parentRoute] = useMatches(); + const { checkInStartTime } = parentRoute.data as FindTournamentByNameForUrlI; + + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: team.id, disabled }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
  • +
    {seed}
    +
    {team.name}
    +
    + {!checkInHasStarted(checkInStartTime) ? ( + <> + {new Date(team.createdAt).toLocaleString("en-US", { + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} + + ) : team.checkedInTime ? ( + + ) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? ( + + ) : null} +
    +
    = TOURNAMENT_TEAM_ROSTER_MIN_SIZE, + tournament__admin__problem: + team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE, + })} + > + {team.members.length} +
    +
  • + ); +} + +function CheckOutButton({ teamId }: { teamId: string }) { + const fetcher = useFetcher(); + return ( + + + + ); +} + +function CheckInButton({ teamId }: { teamId: string }) { + const fetcher = useFetcher(); + return ( + + + + ); +} diff --git a/app/routes/to/$organization.$tournament/admin/start.tsx b/app/routes/to/$organization.$tournament/admin/start.tsx new file mode 100644 index 000000000..bcec6a0b6 --- /dev/null +++ b/app/routes/to/$organization.$tournament/admin/start.tsx @@ -0,0 +1,23 @@ +import { useMatches } from "remix"; +import { eliminationBracket } from "~/core/tournament/bracket"; +import { roundNamesWithDefaultBestOf } from "~/core/tournament/utils"; +import type { FindTournamentByNameForUrlI } from "~/services/tournament"; + +export default function StartTournamentPage() { + const [, parentRoute] = useMatches(); + const { teams } = parentRoute.data as FindTournamentByNameForUrlI; + + const teamCount = teams.reduce( + (acc, cur) => acc + (cur.checkedInTime ? 1 : 0), + 0 + ); + return ( +
    +      {JSON.stringify(
    +        roundNamesWithDefaultBestOf(eliminationBracket(teamCount, "DE")),
    +        null,
    +        2
    +      )}
    +    
    + ); +} diff --git a/prisma/seed/script.ts b/prisma/seed/script.ts index 7b5e039f0..7092c446a 100644 --- a/prisma/seed/script.ts +++ b/prisma/seed/script.ts @@ -165,14 +165,16 @@ export async function seed(variation?: "check-in") { } for (const [mockTeamI, mockTeam] of mockTeams.entries()) { + const memberCount = (mockTeamI % 5) + 2; const team = await prisma.tournamentTeam.create({ data: { name: mockTeam, tournamentId, + checkedInTime: memberCount >= 4 ? new Date() : undefined, }, }); - for (let memberI = 0; memberI < (mockTeamI % 5) + 2; memberI++) { + for (let memberI = 0; memberI < memberCount; memberI++) { const memberId = userIdsCopy.shift()!; await prisma.tournamentTeamMember.create({ data: {