mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Show round names and default best of on /start
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
262
app/routes/to/$organization.$tournament/admin/index.tsx
Normal file
262
app/routes/to/$organization.$tournament/admin/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
app/routes/to/$organization.$tournament/admin/start.tsx
Normal file
23
app/routes/to/$organization.$tournament/admin/start.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user