Show round names and default best of on /start

This commit is contained in:
Kalle (Sendou)
2021-12-13 11:36:17 +02:00
parent a4dec62b61
commit c493f0082a
5 changed files with 385 additions and 3 deletions

View File

@@ -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;

View File

@@ -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 (
<>
<div className="tournament__admin__buttons-container">
{/* Don't show when check in is not concluded + secure route */}
<Button>Start tournament</Button>
{/* TODO: */}
<Button variant="outlined">Edit tournament</Button>
</div>
<AdminTeamControls />
<Outlet />
</>
);
}

View File

@@ -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 (
<>
<SeedAlert
fetcher={seedsFetcher}
tournamentId={id}
teamOrder={teamOrder}
/>
<ul>
<li className="tournament__admin__teams-list-row">
<div className="tournament__admin__teams-container__header">Seed</div>
<div className="tournament__admin__teams-container__header">Name</div>
<div className="tournament__admin__teams-container__header">
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
</div>
<div className="tournament__admin__teams-container__header">
Roster size
</div>
</li>
<DndContext
id="team-seed-sorter"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={() => 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);
});
}
}}
>
<SortableContext
items={teamOrder}
strategy={verticalListSortingStrategy}
>
{teamsSorted.map((team, i) => (
<SortableRow
key={team.id}
team={team}
seed={i + 1}
disabled={seedsFetcher.state !== "idle"}
/>
))}
</SortableContext>
</DndContext>
</ul>
</>
);
}
function SeedAlert({
tournamentId,
teamOrder,
fetcher,
}: {
tournamentId: string;
teamOrder: string[];
fetcher: ReturnType<typeof useFetcher>;
}) {
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 (
<fetcher.Form
action={`/api/tournament/${tournamentId}/seeds`}
method="post"
className="tournament__admin__form"
>
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
<Alert
type={teamOrderChanged ? "warning" : showSuccess ? "success" : "info"}
className="tournament__admin__alert"
rightAction={
<Button
className={classNames("tournament__admin__alert__button", {
hidden: !teamOrderChanged,
})}
type="submit"
loadingText="Saving..."
loading={fetcher.state !== "idle"}
>
Save seeds
</Button>
}
>
{teamOrderChanged ? (
<>You have unchanged changes to seeding</>
) : showSuccess ? (
<>Seeds saved successfully!</>
) : (
<>Drag teams to adjust their seeding</>
)}
</Alert>
</fetcher.Form>
);
}
function SortableRow({
team,
seed,
disabled,
}: {
team: Unpacked<FindTournamentByNameForUrlI["teams"]>;
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 (
<li
className={classNames("tournament__admin__teams-list-row", "sortable", {
disabled,
})}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
<div>{seed}</div>
<div>{team.name}</div>
<div>
{!checkInHasStarted(checkInStartTime) ? (
<>
{new Date(team.createdAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</>
) : team.checkedInTime ? (
<CheckOutButton teamId={team.id} />
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
<CheckInButton teamId={team.id} />
) : null}
</div>
<div
className={classNames({
tournament__admin__ok:
team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
tournament__admin__problem:
team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
})}
>
{team.members.length}
</div>
</li>
);
}
function CheckOutButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<fetcher.Form
method="post"
action={`/api/tournamentTeam/${teamId}/check-out`}
className="tournament__action-section__button-container"
>
<Button
tiny
variant="minimal-destructive"
loading={fetcher.state !== "idle"}
loadingText="Checking out"
type="submit"
>
Check-out
</Button>
</fetcher.Form>
);
}
function CheckInButton({ teamId }: { teamId: string }) {
const fetcher = useFetcher();
return (
<fetcher.Form
method="post"
action={`/api/tournamentTeam/${teamId}/check-in`}
className="tournament__action-section__button-container"
>
<Button
tiny
variant="minimal"
loading={fetcher.state !== "idle"}
loadingText="Checking in"
type="submit"
>
Check-in
</Button>
</fetcher.Form>
);
}

View File

@@ -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 (
<pre>
{JSON.stringify(
roundNamesWithDefaultBestOf(eliminationBracket(teamCount, "DE")),
null,
2
)}
</pre>
);
}

View File

@@ -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: {