mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Draft tournaments (#2826)
This commit is contained in:
parent
7df92b0c9f
commit
3a6dc4ace5
|
|
@ -475,6 +475,7 @@ export interface TournamentSettings {
|
|||
/** Maximum number of team members that can be registered (only applies to 4v4 tournaments) */
|
||||
maxMembersPerTeam?: number;
|
||||
isTest?: boolean;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export interface CastedMatchesInfo {
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ type CreateArgs = Pick<
|
|||
requireInGameNames?: boolean;
|
||||
isRanked?: boolean;
|
||||
isTest?: boolean;
|
||||
isDraft?: boolean;
|
||||
isInvitational?: boolean;
|
||||
enableNoScreenToggle?: boolean;
|
||||
enableSubs?: boolean;
|
||||
|
|
@ -472,6 +473,7 @@ export async function create(args: CreateArgs) {
|
|||
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||
isRanked: args.isRanked,
|
||||
isTest: args.isTest,
|
||||
isDraft: args.isDraft,
|
||||
isInvitational: args.isInvitational,
|
||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||
enableSubs: args.enableSubs,
|
||||
|
|
@ -536,7 +538,7 @@ export async function create(args: CreateArgs) {
|
|||
bracketUrl: args.bracketUrl,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
hidden: args.parentTournamentId || args.isTest ? 1 : 0,
|
||||
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
|
||||
tournamentId,
|
||||
})
|
||||
.returning("id")
|
||||
|
|
@ -616,6 +618,22 @@ export async function update(args: UpdateArgs) {
|
|||
? await updateTournamentTables(args, trx, tournamentId)
|
||||
: null;
|
||||
|
||||
if (tournamentId) {
|
||||
const { parentTournamentId, settings: existingSettings } = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select(["parentTournamentId", "settings"])
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const hidden =
|
||||
existingSettings.isTest || parentTournamentId || args.isDraft ? 1 : 0;
|
||||
await trx
|
||||
.updateTable("CalendarEvent")
|
||||
.set({ hidden })
|
||||
.where("id", "=", args.eventId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.deleteFrom("CalendarEventDate")
|
||||
.where("eventId", "=", args.eventId)
|
||||
|
|
@ -668,6 +686,7 @@ async function updateTournamentTables(
|
|||
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||
isRanked: args.isRanked,
|
||||
isTest: existingSettings.isTest, // this one is not editable after creation
|
||||
isDraft: args.isDraft,
|
||||
isInvitational: args.isInvitational,
|
||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||
enableSubs: args.enableSubs,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
maxMembersPerTeam: data.maxMembersPerTeam ?? undefined,
|
||||
isRanked: data.isRanked ?? undefined,
|
||||
isTest: data.isTest ?? undefined,
|
||||
isDraft: data.isDraft ?? undefined,
|
||||
isInvitational: data.isInvitational ?? false,
|
||||
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
||||
enableSubs: data.enableSubs ?? undefined,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const newCalendarEventActionSchema = z
|
|||
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
|
||||
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isDraft: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
|
||||
enableNoScreenToggle: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function EventForm() {
|
|||
setIsInvitational={setIsInvitational}
|
||||
/>
|
||||
{!eventToEdit ? <TestToggle /> : null}
|
||||
<DraftToggle />
|
||||
</>
|
||||
) : null}
|
||||
{data.isAddingTournament ? (
|
||||
|
|
@ -998,6 +999,31 @@ function TestToggle() {
|
|||
);
|
||||
}
|
||||
|
||||
function DraftToggle() {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const baseEvent = useBaseEvent();
|
||||
const [isDraft, setIsDraft] = React.useState(
|
||||
baseEvent?.tournament?.ctx.settings.isDraft ?? false,
|
||||
);
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="w-max">
|
||||
{t("calendar:forms.draft")}
|
||||
</label>
|
||||
<SendouSwitch
|
||||
name="isDraft"
|
||||
id={id}
|
||||
size="small"
|
||||
isSelected={isDraft}
|
||||
onChange={setIsDraft}
|
||||
/>
|
||||
<FormMessage type="info">{t("calendar:forms.draftInfo")}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegClosesAtSelect() {
|
||||
const baseEvent = useBaseEvent();
|
||||
const [regClosesAt, setRegClosesAt] = React.useState<RegClosesAtOption>(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
switch (data._action) {
|
||||
case "START_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
errorToastIfFalsy(
|
||||
!tournament.isDraft,
|
||||
"Tournament must be opened before starting a bracket",
|
||||
);
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
|
@ -154,7 +158,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (!tournament.isTest) {
|
||||
if (!tournament.isTest && !tournament.isDraft) {
|
||||
notify({
|
||||
userIds: seeding.flatMap((tournamentTeamId) =>
|
||||
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),
|
||||
|
|
|
|||
|
|
@ -593,6 +593,11 @@ export class Tournament {
|
|||
return this.ctx.settings.isTest ?? false;
|
||||
}
|
||||
|
||||
/** Draft tournament that is hidden during preparation, must be opened before bracket start */
|
||||
get isDraft() {
|
||||
return this.ctx.settings.isDraft ?? false;
|
||||
}
|
||||
|
||||
/** What seeding skill rating this tournament counts for */
|
||||
get skillCountsFor() {
|
||||
if (this.ranked) {
|
||||
|
|
|
|||
|
|
@ -199,7 +199,11 @@ export default function TournamentBracketsPage() {
|
|||
{bracket.participantTournamentTeamIds.length}/
|
||||
{totalTeamsAvailableForTheBracket()} teams checked in
|
||||
{bracket.canBeStarted ? (
|
||||
<BracketStarter bracket={bracket} bracketIdx={bracketIdx} />
|
||||
tournament.isDraft ? (
|
||||
<DraftBracketStartPopover />
|
||||
) : (
|
||||
<BracketStarter bracket={bracket} bracketIdx={bracketIdx} />
|
||||
)
|
||||
) : null}
|
||||
</Alert>
|
||||
{!bracket.canBeStarted ? (
|
||||
|
|
@ -305,6 +309,27 @@ function BracketStarter({
|
|||
);
|
||||
}
|
||||
|
||||
function DraftBracketStartPopover() {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
popoverClassName="text-xs"
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
data-testid="finalize-bracket-button"
|
||||
>
|
||||
Start the bracket
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{t("calendar:forms.draftBracketStartBlocked")}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPreparer({
|
||||
bracket,
|
||||
bracketIdx,
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (!tournament.isTest) {
|
||||
if (!tournament.isTest && !tournament.isDraft) {
|
||||
notify({
|
||||
userIds: [data.userId],
|
||||
notification: {
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (!tournament.isTest) {
|
||||
if (!tournament.isTest && !tournament.isDraft) {
|
||||
notify({
|
||||
userIds: [data.userId],
|
||||
notification: {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
tournament.ctx.organization?.members.some(
|
||||
(m) => m.userId === user?.id && m.role === "ORGANIZER",
|
||||
);
|
||||
if (tournament.ctx.settings.isDraft && !isTournamentOrganizer) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const showFriendCodes = tournamentStartedInTheLastMonth && isTournamentAdmin;
|
||||
|
||||
// skip expensive rr7 data serialization (hot path loader)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const NotifyCheckInStartRoutine = new Routine({
|
|||
user: undefined,
|
||||
});
|
||||
|
||||
if (tournament.ctx.settings.isTest) {
|
||||
if (tournament.ctx.settings.isTest || tournament.ctx.settings.isDraft) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,31 @@ Especially for tournaments where verification is important. Players need to have
|
|||
|
||||
All teams added by the tournament organizer manually. No open registration or subs list. In addition for invitational teams can add only 5 members before the tournament starts on their own (and 6 during it if autonomous subs are enabled).
|
||||
|
||||
### Test
|
||||
|
||||
Test tournaments are for dry-run testing. They don't appear on the calendar, don't send notifications to players, and won't show up in players' profiles or results. Test mode cannot be changed after creation and the tournament will never become a real tournament. The toggle is only available when creating a new tournament, not when editing.
|
||||
|
||||
### Draft
|
||||
|
||||
Draft mode hides the tournament from the calendar and front page. Only organizers can see and access it. This is useful for preparing a tournament privately before making it visible to participants.
|
||||
|
||||
The tournament must be opened before any bracket can be started. To open a draft tournament, edit it on /calendar/new and disable the draft toggle.
|
||||
|
||||
Unlike test mode, draft tournaments are fully functional once opened: they appear on the calendar, affect rankings/results, and behave like any other tournament.
|
||||
|
||||
#### Draft vs Test
|
||||
|
||||
| Feature | Draft | Test |
|
||||
|---------|-------|------|
|
||||
| Visible on calendar | No (until opened) | No (never) |
|
||||
| Accessible to organizers | Yes | Yes |
|
||||
| Accessible to anyone with link | No (until opened) | Yes |
|
||||
| Can be opened later | Yes | No |
|
||||
| Affects rankings/results | Yes (once opened) | No (never) |
|
||||
| Bracket can be started | Only after opening | Yes |
|
||||
| Editable after creation | Yes | No |
|
||||
| Purpose | Prepare tournament privately | Dry-run testing |
|
||||
|
||||
## Tournament maps
|
||||
|
||||
With sendou.ink tournaments all maps are decided ahead of time.
|
||||
|
|
|
|||
|
|
@ -76,5 +76,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,5 +76,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,5 +76,8 @@
|
|||
"filter.orgsExcluded": "Hidden organizations",
|
||||
"filter.authorIdsExcluded": "Authors excluded",
|
||||
"filter.apply": "Apply",
|
||||
"filter.applyAndDefault": "Apply & make default"
|
||||
"filter.applyAndDefault": "Apply & make default",
|
||||
"forms.draft": "Draft",
|
||||
"forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.",
|
||||
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "ארגונים מוסתרים",
|
||||
"filter.authorIdsExcluded": "מחברים לא נכללו",
|
||||
"filter.apply": "החל",
|
||||
"filter.applyAndDefault": "החל והפוך לברירת מחדל"
|
||||
"filter.applyAndDefault": "החל והפוך לברירת מחדל",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,5 +76,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,5 +80,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,5 +78,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,5 +80,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,8 @@
|
|||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": ""
|
||||
"filter.applyAndDefault": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user