Draft tournaments (#2826)

This commit is contained in:
Kalle 2026-02-21 08:16:03 +02:00 committed by GitHub
parent 7df92b0c9f
commit 3a6dc4ace5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 181 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,5 +76,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -76,5 +76,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -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."
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "ארגונים מוסתרים",
"filter.authorIdsExcluded": "מחברים לא נכללו",
"filter.apply": "החל",
"filter.applyAndDefault": "החל והפוך לברירת מחדל"
"filter.applyAndDefault": "החל והפוך לברירת מחדל",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -72,5 +72,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -72,5 +72,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -76,5 +76,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -80,5 +80,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -78,5 +78,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -80,5 +80,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}

View File

@ -72,5 +72,8 @@
"filter.orgsExcluded": "",
"filter.authorIdsExcluded": "",
"filter.apply": "",
"filter.applyAndDefault": ""
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
}