diff --git a/app/db/tables.ts b/app/db/tables.ts index d1d219844..afccd29c6 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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 { diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 91e7255b9..fff96afe1 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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, diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 0a64016f8..b6367eaa7 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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, diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index a1810ad12..f644b808f 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -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, diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index f3f0a5789..fee337501 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -259,6 +259,7 @@ function EventForm() { setIsInvitational={setIsInvitational} /> {!eventToEdit ? : null} + ) : 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 ( +
+ + + {t("calendar:forms.draftInfo")} +
+ ); +} + function RegClosesAtSelect() { const baseEvent = useBaseEvent(); const [regClosesAt, setRegClosesAt] = React.useState( diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 113150adb..05fcb4113 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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), diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 84c892476..59c1c5de4 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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) { diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 5d0906de9..a758e68e4 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -199,7 +199,11 @@ export default function TournamentBracketsPage() { {bracket.participantTournamentTeamIds.length}/ {totalTeamsAvailableForTheBracket()} teams checked in {bracket.canBeStarted ? ( - + tournament.isDraft ? ( + + ) : ( + + ) ) : null} {!bracket.canBeStarted ? ( @@ -305,6 +309,27 @@ function BracketStarter({ ); } +function DraftBracketStartPopover() { + const { t } = useTranslation(["calendar"]); + + return ( + + Start the bracket + + } + > + {t("calendar:forms.draftBracketStartBlocked")} + + ); +} + function MapPreparer({ bracket, bracketIdx, diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 84722f5e3..0cb2c3a3b 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -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: { diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index eb136eb83..181f316db 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -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: { diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index 2c1000670..86bfcbf8d 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -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) diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts index 14dee54c1..6164e7935 100644 --- a/app/routines/notifyCheckInStart.ts +++ b/app/routines/notifyCheckInStart.ts @@ -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; } diff --git a/docs/tournament-creation.md b/docs/tournament-creation.md index d1d6f9d73..7a88ee78f 100644 --- a/docs/tournament-creation.md +++ b/docs/tournament-creation.md @@ -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. diff --git a/locales/da/calendar.json b/locales/da/calendar.json index bc62a6906..a7baefcb6 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -76,5 +76,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/de/calendar.json b/locales/de/calendar.json index 172063666..00f8f418c 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -76,5 +76,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/en/calendar.json b/locales/en/calendar.json index a05558ac4..d6a15d3dd 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -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." } diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index 6ca8f30d1..b005cae62 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 6ca8f30d1..b005cae62 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index d05065c1c..4f84120c7 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index d05065c1c..4f84120c7 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/he/calendar.json b/locales/he/calendar.json index 74e17bd22..2a16dd62e 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "ארגונים מוסתרים", "filter.authorIdsExcluded": "מחברים לא נכללו", "filter.apply": "החל", - "filter.applyAndDefault": "החל והפוך לברירת מחדל" + "filter.applyAndDefault": "החל והפוך לברירת מחדל", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/it/calendar.json b/locales/it/calendar.json index 672bbde71..3dbf60a04 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index b7be5651b..a654dd3cf 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -72,5 +72,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index 0a4f35062..f6c4d3aea 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -72,5 +72,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index 0b327bb73..4d196d1c6 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -76,5 +76,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 19d4cc23d..40c933639 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -80,5 +80,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 598e77914..cfefc88a9 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -78,5 +78,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index fc554a0b7..b882eae0f 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -80,5 +80,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" } diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index feb2c8e9e..639d367cf 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -72,5 +72,8 @@ "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", "filter.apply": "", - "filter.applyAndDefault": "" + "filter.applyAndDefault": "", + "forms.draft": "", + "forms.draftInfo": "", + "forms.draftBracketStartBlocked": "" }