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 (
+ <>
+
+
+ >
+ );
+}
+
+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: {