sendou.ink/app/features/calendar/components/BracketProgressionSelector.tsx

601 lines
16 KiB
TypeScript

import { Plus } from "lucide-react";
import { nanoid } from "nanoid";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DateInput } from "~/components/DateInput";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import { defaultBracketSettings } from "../../tournament/tournament-utils";
import styles from "./BracketProgressionSelector.module.css";
const defaultBracket = (): Progression.InputBracket => ({
id: nanoid(),
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
});
export function BracketProgressionSelector({
initialBrackets,
isInvitationalTournament,
setErrored,
isTournamentInProgress,
}: {
initialBrackets?: Progression.InputBracket[];
isInvitationalTournament: boolean;
setErrored: (errored: boolean) => void;
isTournamentInProgress: boolean;
}) {
const [brackets, setBrackets] = React.useState<Progression.InputBracket[]>(
initialBrackets ?? [defaultBracket()],
);
const handleAddBracket = () => {
setBrackets([
...brackets,
{
...defaultBracket(),
id: nanoid(),
name: "",
sources: [
{
bracketId: brackets[0].id,
placements: "",
},
],
},
]);
};
const handleDeleteBracket = (idx: number) => {
const newBrackets = brackets.filter((_, i) => i !== idx);
const newBracketIds = new Set(newBrackets.map((b) => b.id));
const updatedBrackets = newBrackets.map((b) => ({
...b,
sources:
newBrackets.length === 1
? undefined
: b.sources?.map((source) => ({
...source,
bracketId: newBracketIds.has(source.bracketId)
? source.bracketId
: newBrackets[0].id,
})),
}));
setBrackets(updatedBrackets);
};
const validated = Progression.validatedBrackets(brackets);
React.useEffect(() => {
if (Progression.isError(validated)) {
setErrored(true);
} else {
setErrored(false);
}
}, [validated, setErrored]);
return (
<div className="stack lg items-start">
{Progression.isBrackets(validated) ? (
<input
type="hidden"
name="bracketProgression"
value={JSON.stringify(validated)}
/>
) : null}
<div className="stack lg">
{brackets.map((bracket, i) => (
<TournamentFormatBracketSelector
key={bracket.id}
bracket={bracket}
brackets={brackets}
onChange={(newBracket) => {
const newBrackets = structuredClone(brackets);
newBrackets[i] = newBracket;
if (newBracket.settings.advanceThreshold) {
const destinationIdx = newBrackets.findIndex((b) =>
b.sources?.some(
(source) => source.bracketId === newBracket.id,
),
);
if (destinationIdx !== -1) {
newBrackets[destinationIdx].sources = newBrackets[
destinationIdx
].sources?.map((source) => ({
...source,
placements: "",
}));
}
}
setBrackets(newBrackets);
}}
onDelete={
i !== 0 && !bracket.disabled
? () => handleDeleteBracket(i)
: undefined
}
count={i + 1}
isInvitationalTournament={isInvitationalTournament}
isTournamentInProgress={isTournamentInProgress}
/>
))}
</div>
<SendouButton
icon={<Plus />}
size="small"
variant="outlined"
onPress={handleAddBracket}
isDisabled={brackets.length >= TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT}
data-testid="add-bracket-button"
>
Add bracket
</SendouButton>
{Progression.isError(validated) ? (
<ErrorMessage error={validated} />
) : null}
</div>
);
}
function TournamentFormatBracketSelector({
bracket,
brackets,
onChange,
onDelete,
count,
isInvitationalTournament,
isTournamentInProgress,
}: {
bracket: Progression.InputBracket;
brackets: Progression.InputBracket[];
onChange: (newBracket: Progression.InputBracket) => void;
onDelete?: () => void;
count: number;
isInvitationalTournament: boolean;
isTournamentInProgress: boolean;
}) {
const id = React.useId();
const createId = (name: string) => {
return `${id}-${name}`;
};
const isFirstBracket = count === 1;
const updateBracket = (newProps: Partial<Progression.InputBracket>) => {
const defaultSettings = newProps.type
? defaultBracketSettings(newProps.type)
: undefined;
onChange({
...bracket,
...newProps,
settings: newProps.settings ?? defaultSettings ?? bracket.settings,
});
};
return (
<div className="stack horizontal md items-center">
<div>
<div className={styles.count}>Bracket #{count}</div>
{onDelete ? (
<SendouButton
size="small"
variant="minimal-destructive"
onPress={onDelete}
className="mx-auto"
data-testid="delete-bracket-button"
>
Delete
</SendouButton>
) : null}
</div>
<div className={styles.divider} />
<div className="stack md items-start">
<div>
<Label htmlFor={createId("name")}>Bracket's name</Label>
<Input
id={createId("name")}
value={bracket.name}
onChange={(e) => updateBracket({ name: e.target.value })}
maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH}
readOnly={bracket.disabled}
/>
</div>
{bracket.sources ? (
<div>
<Label htmlFor={createId("startTime")}>Start time</Label>
<DateInput
id={createId("startTime")}
defaultValue={bracket.startTime ?? undefined}
onChange={(newDate) =>
updateBracket({ startTime: newDate ?? undefined })
}
readOnly={bracket.disabled}
/>
<FormMessage type="info">
If missing, bracket can be started when the previous brackets have
finished
</FormMessage>
</div>
) : null}
{bracket.sources ? (
<div>
<Label htmlFor={createId("checkIn")}>Check-in required</Label>
<SendouSwitch
id={createId("checkIn")}
isSelected={bracket.requiresCheckIn}
onChange={(isSelected) =>
updateBracket({ requiresCheckIn: isSelected })
}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Check-in starts 1 hour before start time or right after the
previous bracket finishes if no start time is set
</FormMessage>
</div>
) : null}
<div>
<Label htmlFor={createId("format")}>Format</Label>
<select
value={bracket.type}
onChange={(e) =>
updateBracket({
type: e.target.value as Progression.InputBracket["type"],
})
}
className="w-max"
name="format"
id={createId("format")}
disabled={bracket.disabled}
>
<option value="single_elimination">Single-elimination</option>
<option value="double_elimination">Double-elimination</option>
<option value="round_robin">Round robin</option>
<option value="swiss">Swiss</option>
</select>
</div>
{bracket.type === "single_elimination" ? (
<div>
<Label htmlFor={createId("thirdPlaceMatch")}>
Third place match
</Label>
<SendouSwitch
id={createId("thirdPlaceMatch")}
isSelected={Boolean(
bracket.settings.thirdPlaceMatch ??
TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH,
)}
onChange={(isSelected) =>
updateBracket({
settings: {
...bracket.settings,
thirdPlaceMatch: isSelected,
},
})
}
isDisabled={bracket.disabled}
/>
</div>
) : null}
{bracket.type === "round_robin" ? (
<div>
<Label htmlFor="teamsPerGroup">Max participants per group</Label>
<select
value={
bracket.settings.teamsPerGroup ??
TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP
}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
teamsPerGroup: Number(e.target.value),
},
})
}
className="w-max"
name="teamsPerGroup"
id="teamsPerGroup"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
<FormMessage type="info">
Participants are distributed equally, so groups may have fewer
than selected
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissGroupCount">Groups count</Label>
<select
value={
bracket.settings.groupCount ??
TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT
}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
groupCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
disabled={bracket.disabled}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissRoundCount">Round count</Label>
<select
value={
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT
}
onChange={(e) => {
const newRoundCount = Number(e.target.value);
const currentAdvanceThreshold =
bracket.settings.advanceThreshold;
updateBracket({
settings: {
...bracket.settings,
roundCount: newRoundCount,
advanceThreshold:
currentAdvanceThreshold &&
!Swiss.isValidAdvanceThreshold({
roundCount: newRoundCount,
advanceThreshold: currentAdvanceThreshold,
})
? 3
: currentAdvanceThreshold,
},
});
}}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor={createId("earlyAdvance")}>
Early advance/elimination
</Label>
<SendouSwitch
id={createId("earlyAdvance")}
isSelected={Boolean(bracket.settings.advanceThreshold)}
onChange={(isSelected) =>
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: isSelected ? 3 : undefined,
},
})
}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Teams stop playing once they reach required wins or exceed maximum
losses
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" && bracket.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("advanceThreshold")}>
Wins needed to advance
</Label>
<select
value={bracket.settings.advanceThreshold}
onChange={(e) => {
const newThreshold = Number(e.target.value);
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: newThreshold,
},
});
}}
className="w-max"
name="advanceThreshold"
id={createId("advanceThreshold")}
disabled={bracket.disabled}
>
{Swiss.validAdvanceThresholdOptions({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}).map((threshold) => (
<option key={threshold} value={threshold}>
{threshold}
</option>
))}
</select>
<FormMessage type="info">
Maximum losses allowed:{" "}
{Swiss.eliminationThreshold({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
advanceThreshold: bracket.settings.advanceThreshold,
}) - 1}
</FormMessage>
</div>
) : null}
<div>
<div className="stack horizontal sm">
<Label htmlFor={createId("source")}>Source</Label>{" "}
</div>
{!isFirstBracket ? (
<div className="stack sm horizontal mt-1 mb-2">
<SendouSwitch
id={createId("follow-up-bracket")}
isSelected={Boolean(bracket.sources)}
onChange={(isSelected) =>
updateBracket({
sources: isSelected ? [] : undefined,
requiresCheckIn: false,
startTime: undefined,
})
}
isDisabled={bracket.disabled || isTournamentInProgress}
data-testid="follow-up-bracket-switch"
/>
<Label htmlFor={createId("follow-up-bracket")} spaced={false}>
Is follow-up bracket
</Label>
</div>
) : null}
{!bracket.sources ? (
<FormMessage type="info">
{isInvitationalTournament
? "Participants added by the organizer"
: "Participants join from sign-up"}
</FormMessage>
) : (
<SourcesSelector
brackets={brackets.filter(
(bracket2) => bracket.id !== bracket2.id && bracket2.name,
)}
source={bracket.sources?.[0] ?? null}
onChange={(source) => updateBracket({ sources: [source] })}
/>
)}
</div>
</div>
</div>
);
}
function SourcesSelector({
brackets,
source,
onChange,
}: {
brackets: Progression.InputBracket[];
source: Progression.EditableSource | null;
onChange: (sources: Progression.EditableSource) => void;
}) {
const id = React.useId();
const createId = (label: string) => {
return `${id}-${label}`;
};
const inputBracket = brackets.find((b) => b.id === source?.bracketId);
return (
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
</div>
{!inputBracket?.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
) : null}
</div>
);
}
function ErrorMessage({ error }: { error: Progression.ValidationError }) {
const { t } = useTranslation(["tournament"]);
const bracketIdxsArr = (() => {
if (typeof (error as { bracketIdx: number }).bracketIdx === "number") {
return [(error as { bracketIdx: number }).bracketIdx];
}
if ((error as { bracketIdxs: number[] }).bracketIdxs) {
return (error as { bracketIdxs: number[] }).bracketIdxs;
}
return null;
})();
return (
<FormMessage type="error">
Problems with the bracket progression
{bracketIdxsArr ? (
<> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")})</>
) : null}
: {t(`tournament:progression.error.${error.type}`)}
</FormMessage>
);
}