Add badges on tournament finalize (#2459)

This commit is contained in:
Kalle 2025-07-19 13:33:58 +03:00 committed by GitHub
parent b04d91d8f2
commit fa60d0d03f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1022 additions and 224 deletions

View File

@ -11,6 +11,8 @@ export type PlacementProps = {
textClassName?: string;
size?: number;
textOnly?: boolean;
/** Render plain text, no icon or wrapping html elements */
plain?: boolean;
showAsSuperscript?: boolean;
};
@ -34,11 +36,11 @@ export function Placement({
size = 20,
textOnly = false,
showAsSuperscript = true,
plain = false,
}: PlacementProps) {
const { t } = useTranslation(undefined, {});
// Remove assertion if types stop claiming result is "never".
const ordinalSuffix: string = t("results.placeSuffix", {
const ordinalSuffix = t("results.placeSuffix", {
count: placement,
ordinal: true,
// no suffix is a better default than english
@ -51,6 +53,10 @@ export function Placement({
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
if (plain) {
return `${placement}${ordinalSuffixText}`;
}
if (!iconPath) {
return (
<span className={textClassName}>

View File

@ -816,6 +816,12 @@ function calendarEvents() {
}
}
const addCalendarEventBadgeStm = sql.prepare(
/*sql */ `insert into "CalendarEventBadge"
("eventId", "badgeId")
values ($eventId, $badgeId)`,
);
function calendarEventBadges() {
for (let eventId = 1; eventId <= AMOUNT_OF_CALENDAR_EVENTS; eventId++) {
if (faker.number.float(1) > 0.25) continue;
@ -827,13 +833,10 @@ function calendarEventBadges() {
i < faker.helpers.arrayElement([1, 1, 1, 1, 2, 2, 3]);
i++
) {
sql
.prepare(
`insert into "CalendarEventBadge"
("eventId", "badgeId")
values ($eventId, $badgeId)`,
)
.run({ eventId, badgeId: availableBadgeIds.pop() });
addCalendarEventBadgeStm.run({
eventId,
badgeId: availableBadgeIds.pop(),
});
}
}
}
@ -913,6 +916,14 @@ function calendarEventWithToTools(
DEPTHS: "The Depths 5",
LUTI: "Leagues Under The Ink Season 15",
}[event];
const badges = {
PICNIC: [1, 2],
ITZ: [3, 4],
PP: [5, 6],
SOS: [7, 8],
DEPTHS: [9, 10],
LUTI: [],
}[event];
const settings: Tables["Tournament"]["settings"] =
event === "DEPTHS"
@ -1139,6 +1150,13 @@ function calendarEventWithToTools(
: new Date(Date.now() - 1000 * 60 * 60),
),
});
for (const badgeId of badges) {
addCalendarEventBadgeStm.run({
eventId,
badgeId,
});
}
}
const tiebreakerPicks = new MapPool([

View File

@ -98,8 +98,10 @@ export interface BadgeManager {
}
export type BadgeOwner = {
badgeId: number | null;
userId: number | null;
badgeId: number;
userId: number;
/** Which tournament the badge is from, if null was added manually by a badge manager as opposed to once a tournament was finalized. */
tournamentId: number | null;
};
export interface Build {

View File

@ -11,6 +11,7 @@ import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { Catcher } from "~/components/Catcher";
import { SendouButton } from "~/components/elements/Button";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import {
SendouTab,
SendouTabList,
@ -23,6 +24,7 @@ import { Input } from "~/components/Input";
import { SearchIcon } from "~/components/icons/Search";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SEED_VARIATIONS } from "~/features/api-private/constants";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@ -423,8 +425,16 @@ function Seed() {
method="post"
action={SEED_URL}
>
<h2>Seed</h2>
<SubmitButton state={fetcher.state}>Seed</SubmitButton>
<div className="stack horizontal md items-end">
<SubmitButton state={fetcher.state}>Seed</SubmitButton>
<SendouSelect label="Variation" name="variation">
{SEED_VARIATIONS.map((variation) => (
<SendouSelectItem key={variation} id={variation}>
{variation}
</SendouSelectItem>
))}
</SendouSelect>
</div>
</fetcher.Form>
);
}

View File

@ -0,0 +1,7 @@
export const SEED_VARIATIONS = [
"NO_TOURNAMENT_TEAMS",
"DEFAULT",
"REG_OPEN",
"SMALL_SOS",
"NZAP_IN_TEAM",
] as const;

View File

@ -1,18 +1,11 @@
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod/v4";
import { seed } from "~/db/seed";
import { SEED_VARIATIONS } from "~/features/api-private/constants";
import { parseRequestPayload } from "~/utils/remix.server";
const seedSchema = z.object({
variation: z
.enum([
"NO_TOURNAMENT_TEAMS",
"DEFAULT",
"REG_OPEN",
"SMALL_SOS",
"NZAP_IN_TEAM",
])
.nullish(),
variation: z.enum(SEED_VARIATIONS).nullish(),
});
export type SeedVariation = NonNullable<

View File

@ -337,17 +337,18 @@ function databaseTimestampWeekAgo() {
return dateToDatabaseTimestamp(now);
}
export async function findById({
id,
includeMapPool = false,
includeTieBreakerMapPool = false,
includeBadgePrizes = false,
}: {
id: number;
includeMapPool?: boolean;
includeTieBreakerMapPool?: boolean;
includeBadgePrizes?: boolean;
}) {
export async function findById(
id: number,
{
includeMapPool = false,
includeTieBreakerMapPool = false,
includeBadgePrizes = false,
}: {
includeMapPool?: boolean;
includeTieBreakerMapPool?: boolean;
includeBadgePrizes?: boolean;
} = {},
) {
const [firstRow, ...rest] = await db
.selectFrom("CalendarEvent")
.$if(includeMapPool, (qb) => qb.select(withMapPool))

View File

@ -30,9 +30,7 @@ export const action: ActionFunction = async (args) => {
};
}
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: params.id }),
);
const event = notFoundIfFalsy(await CalendarRepository.findById(params.id));
errorToastIfFalsy(
canReportCalendarEventWinners({
user,

View File

@ -20,7 +20,7 @@ export const action: ActionFunction = async ({ params, request }) => {
.object({ id: z.preprocess(actualNumber, id) })
.parse(params);
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: parsedParams.id }),
await CalendarRepository.findById(parsedParams.id),
);
if (event.tournamentId) {

View File

@ -110,7 +110,7 @@ export const action: ActionFunction = async ({ request }) => {
if (data.eventToEditId) {
const eventToEdit = badRequestIfFalsy(
await CalendarRepository.findById({ id: data.eventToEditId }),
await CalendarRepository.findById(data.eventToEditId),
);
if (eventToEdit.tournamentId) {
const tournament = await tournamentFromDB({

View File

@ -92,9 +92,9 @@ export const newCalendarEventActionSchema = z
.refine(
async (schema) => {
if (schema.eventToEditId) {
const eventToEdit = await CalendarRepository.findById({
id: schema.eventToEditId,
});
const eventToEdit = await CalendarRepository.findById(
schema.eventToEditId,
);
return schema.date.length === 1 || !eventToEdit?.tournamentId;
}
return schema.date.length === 1 || !schema.toToolsEnabled;

View File

@ -15,9 +15,7 @@ export const loader = async (args: LoaderFunctionArgs) => {
schema: idObject,
});
const user = await requireUserId(args.request);
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: params.id }),
);
const event = notFoundIfFalsy(await CalendarRepository.findById(params.id));
unauthorizedIfFalsy(
canReportCalendarEventWinners({

View File

@ -11,8 +11,7 @@ export const loader = async (args: LoaderFunctionArgs) => {
schema: idObject,
});
const event = notFoundIfFalsy(
await CalendarRepository.findById({
id: params.id,
await CalendarRepository.findById(params.id, {
includeBadgePrizes: true,
includeMapPool: true,
}),

View File

@ -19,8 +19,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const eventId = Number(url.searchParams.get(key));
const event = Number.isNaN(eventId)
? undefined
: await CalendarRepository.findById({
id: eventId,
: await CalendarRepository.findById(eventId, {
includeMapPool: true,
includeTieBreakerMapPool: true,
includeBadgePrizes: true,

View File

@ -24,7 +24,11 @@ export function NotificationItem({
const { t, i18n } = useTranslation(["common"]);
return (
<Link to={notificationLink(notification)} className={styles.item}>
<Link
to={notificationLink(notification)}
className={styles.item}
data-testid="notification-item"
>
<NotificationImage notification={notification}>
{!notification.seen ? <div className={styles.unseenDot} /> : null}
</NotificationImage>

View File

@ -187,8 +187,6 @@ export function groupMembersNoScreenSettings(groups: GroupForMatch[]) {
.execute();
}
// xxx: this new implementation does not show in progress and canceled matches
/**
* Retrieves the pages count of results for a specific user and season. Counting both SendouQ matches and ranked tournaments.
*/

View File

@ -0,0 +1,176 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
queryCurrentTeamRating,
queryCurrentUserRating,
queryCurrentUserSeedingRating,
queryTeamPlayerRatingAverage,
} from "~/features/mmr/mmr-utils.server";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as Standings from "~/features/tournament/core/Standings";
import { tournamentSummary } from "~/features/tournament-bracket/core/summarizer.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
addSummary,
finalizeTournament,
} from "~/features/tournament-bracket/queries/addSummary.server";
import { allMatchResultsByTournamentId } from "~/features/tournament-bracket/queries/allMatchResultsByTournamentId.server";
import {
finalizeTournamentActionSchema,
type TournamentBadgeReceivers,
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
errorToast,
errorToastIfFalsy,
parseParams,
parseRequestPayload,
successToastWithRedirect,
} from "~/utils/remix.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUserId(request);
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
const data = await parseRequestPayload({
request,
schema: finalizeTournamentActionSchema,
});
errorToastIfFalsy(tournament.canFinalize(user), "Can't finalize tournament");
const badgeOwnersValid = data.badgeReceivers
? await requireValidBadgeReceivers(data.badgeReceivers, tournament)
: true;
if (!badgeOwnersValid) errorToast("New badge owners invalid");
const results = allMatchResultsByTournamentId(tournamentId);
invariant(results.length > 0, "No results found");
const season = Seasons.current(tournament.ctx.startTime)?.nth;
const seedingSkillCountsFor = tournament.skillCountsFor;
const summary = tournamentSummary({
teams: tournament.ctx.teams,
finalStandings: Standings.tournamentStandings(tournament),
results,
calculateSeasonalStats: tournament.ranked,
queryCurrentTeamRating: (identifier) =>
queryCurrentTeamRating({ identifier, season: season! }).rating,
queryCurrentUserRating: (userId) =>
queryCurrentUserRating({ userId, season: season! }),
queryTeamPlayerRatingAverage: (identifier) =>
queryTeamPlayerRatingAverage({
identifier,
season: season!,
}),
queryCurrentSeedingRating: (userId) =>
queryCurrentUserSeedingRating({
userId,
type: seedingSkillCountsFor!,
}),
seedingSkillCountsFor,
});
const tournamentSummaryString = `Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`;
if (!tournament.isTest) {
logger.info(`Inserting tournament summary. ${tournamentSummaryString}`);
addSummary({
tournamentId,
summary,
season,
badgeReceivers: data.badgeReceivers ?? undefined,
});
} else {
logger.info(
`Did not insert tournament summary. ${tournamentSummaryString}`,
);
finalizeTournament(tournamentId);
}
if (tournament.ranked) {
try {
refreshUserSkills(season!);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
}
if (data.badgeReceivers) {
logger.info(
`Badge receivers for tournament id ${tournamentId}: ${JSON.stringify(data.badgeReceivers)}`,
);
notifyBadgeReceivers(data.badgeReceivers);
}
clearTournamentDataCache(tournamentId);
return successToastWithRedirect({
url: tournamentBracketsPage({ tournamentId }),
message: "Tournament finalized",
});
};
async function requireValidBadgeReceivers(
badgeReceivers: TournamentBadgeReceivers,
tournament: Tournament,
) {
const badges = (
await CalendarRepository.findById(tournament.ctx.eventId, {
includeBadgePrizes: true,
})
)?.badgePrizes;
invariant(badges, "validateBadgeOwners: Event with badge prizes not found");
const error = validateBadgeReceivers({
badgeReceivers,
badges,
});
if (error) {
logger.warn(
`validateBadgeOwners: Invalid badge receivers for tournament ${tournament.ctx.id}: ${error}`,
);
return false;
}
return true;
}
async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) {
try {
for (const receiver of badgeReceivers) {
const badge = await BadgeRepository.findById(receiver.badgeId);
invariant(badge, `Badge with id ${receiver.badgeId} not found`);
notify({
userIds: receiver.userIds,
notification: {
type: "BADGE_ADDED",
meta: {
badgeName: badge.displayName,
badgeId: receiver.badgeId,
},
},
});
}
} catch (error) {
logger.error("Error notifying badge receivers", error);
}
}

View File

@ -1,22 +1,12 @@
import type { ActionFunction } from "@remix-run/node";
import { sql } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
queryCurrentTeamRating,
queryCurrentUserRating,
queryCurrentUserSeedingRating,
queryTeamPlayerRatingAverage,
} from "~/features/mmr/mmr-utils.server";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as Standings from "~/features/tournament/core/Standings";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
parseParams,
@ -29,14 +19,11 @@ import { updateTeamSeeds } from "../../tournament/queries/updateTeamSeeds.server
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
import { roundMapsFromInput } from "../core/mapList.server";
import * as Swiss from "../core/Swiss";
import { tournamentSummary } from "../core/summarizer.server";
import type { Tournament } from "../core/Tournament";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "../core/Tournament.server";
import { addSummary, finalizeTournament } from "../queries/addSummary.server";
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
import { bracketSchema } from "../tournament-bracket-schemas.server";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
@ -225,67 +212,6 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "FINALIZE_TOURNAMENT": {
errorToastIfFalsy(
tournament.canFinalize(user),
"Can't finalize tournament",
);
const _finalStandings = Standings.tournamentStandings(tournament);
const results = allMatchResultsByTournamentId(tournamentId);
invariant(results.length > 0, "No results found");
const season = Seasons.current(tournament.ctx.startTime)?.nth;
const seedingSkillCountsFor = tournament.skillCountsFor;
const summary = tournamentSummary({
teams: tournament.ctx.teams,
finalStandings: _finalStandings,
results,
calculateSeasonalStats: tournament.ranked,
queryCurrentTeamRating: (identifier) =>
queryCurrentTeamRating({ identifier, season: season! }).rating,
queryCurrentUserRating: (userId) =>
queryCurrentUserRating({ userId, season: season! }),
queryTeamPlayerRatingAverage: (identifier) =>
queryTeamPlayerRatingAverage({
identifier,
season: season!,
}),
queryCurrentSeedingRating: (userId) =>
queryCurrentUserSeedingRating({
userId,
type: seedingSkillCountsFor!,
}),
seedingSkillCountsFor,
});
const tournamentSummaryString = `Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`;
if (!tournament.isTest) {
logger.info(`Inserting tournament summary. ${tournamentSummaryString}`);
addSummary({
tournamentId,
summary,
season,
});
} else {
logger.info(
`Did not insert tournament summary. ${tournamentSummaryString}`,
);
finalizeTournament(tournamentId);
}
if (tournament.ranked) {
try {
refreshUserSkills(season!);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
}
break;
}
case "BRACKET_CHECK_IN": {
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");

View File

@ -0,0 +1,100 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
queryCurrentTeamRating,
queryCurrentUserRating,
queryCurrentUserSeedingRating,
queryTeamPlayerRatingAverage,
} from "~/features/mmr/mmr-utils.server";
import * as Standings from "~/features/tournament/core/Standings";
import { tournamentSummary } from "~/features/tournament-bracket/core/summarizer.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { allMatchResultsByTournamentId } from "~/features/tournament-bracket/queries/allMatchResultsByTournamentId.server";
import invariant from "~/utils/invariant";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
export type FinalizeTournamentLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUserId(request);
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
if (!tournament.canFinalize(user)) {
return redirect(
tournamentBracketsPage({ tournamentId: tournament.ctx.id }),
);
}
const badges = (
await CalendarRepository.findById(tournament.ctx.eventId, {
includeBadgePrizes: true,
})
)?.badgePrizes?.sort((a, b) => a.id - b.id);
invariant(
badges,
`Tournament ${tournament.ctx.id} event not found for badges`,
);
return {
badges,
standings: await standingsWithSetParticipation(tournament),
};
};
async function standingsWithSetParticipation(tournament: Tournament) {
const finalStandings = Standings.tournamentStandings(tournament);
const results = allMatchResultsByTournamentId(tournament.ctx.id);
invariant(results.length > 0, "No results found");
const season = Seasons.current(tournament.ctx.startTime)?.nth;
const seedingSkillCountsFor = tournament.skillCountsFor;
const { setResults } = tournamentSummary({
teams: tournament.ctx.teams,
finalStandings,
results,
calculateSeasonalStats: tournament.ranked,
queryCurrentTeamRating: (identifier) =>
queryCurrentTeamRating({ identifier, season: season! }).rating,
queryCurrentUserRating: (userId) =>
queryCurrentUserRating({ userId, season: season! }),
queryTeamPlayerRatingAverage: (identifier) =>
queryTeamPlayerRatingAverage({
identifier,
season: season!,
}),
queryCurrentSeedingRating: (userId) =>
queryCurrentUserSeedingRating({
userId,
type: seedingSkillCountsFor!,
}),
seedingSkillCountsFor,
});
return finalStandings.map((standing) => {
standing.team.members;
return {
placement: standing.placement,
tournamentTeamId: standing.team.id,
name: standing.team.name,
members: standing.team.members.map((member) => ({
...member,
setResults: setResults.get(member.userId) ?? [],
})),
};
});
}

View File

@ -2,6 +2,7 @@ import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { databaseTimestampNow } from "~/utils/dates";
import type { TournamentSummary } from "../core/summarizer.server";
@ -127,6 +128,18 @@ const addTournamentResultStm = sql.prepare(/* sql */ `
)
`);
const addTournamentBadgeOwnersStm = sql.prepare(/* sql */ `
insert into "TournamentBadgeOwner" (
"tournamentId",
"badgeId",
"userId"
) values (
@tournamentId,
@badgeId,
@userId
)
`);
const finalizeTournamentStm = sql.prepare(/* sql */ `
update "Tournament" set
"isFinalized" = 1
@ -138,10 +151,12 @@ export const addSummary = sql.transaction(
tournamentId,
summary,
season,
badgeReceivers = [],
}: {
tournamentId: number;
summary: TournamentSummary;
season?: number;
badgeReceivers?: TournamentBadgeReceivers;
}) => {
for (const skill of summary.skills) {
const insertedSkill = addSkillStm.get({
@ -200,6 +215,16 @@ export const addSummary = sql.transaction(
});
}
for (const badgeReceiver of badgeReceivers) {
for (const userId of badgeReceiver.userIds) {
addTournamentBadgeOwnersStm.run({
tournamentId,
badgeId: badgeReceiver.badgeId,
userId,
});
}
}
for (const tournamentResult of summary.tournamentResults) {
const setResults = summary.setResults.get(tournamentResult.userId);

View File

@ -0,0 +1,251 @@
import { useFetcher, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-use";
import { Avatar } from "~/components/Avatar";
import { Badge } from "~/components/Badge";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { Placement } from "~/components/Placement";
import { SubmitButton } from "~/components/SubmitButton";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
import invariant from "~/utils/invariant";
import { action } from "../actions/to.$id.brackets.finalize.server";
import {
type FinalizeTournamentLoaderData,
loader,
} from "../loaders/to.$id.brackets.finalize.server";
export { action, loader };
export default function TournamentFinalizePage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["tournament"]);
const location = useLocation();
const [isAssignLaterSelected, setIsAssignLaterSelected] =
React.useState(false);
const [badgeReceivers, setBadgeReceivers] =
React.useState<TournamentBadgeReceivers>([]);
const bracketUrl = location.pathname?.replace(/\/finalize$/, "");
const tournamentHasBadges = data.badges.length > 0;
const badgesError = !isAssignLaterSelected
? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
: null;
return (
<SendouDialog
isOpen
onCloseTo={bracketUrl}
heading={t("tournament:actions.finalize")}
>
<FinalizeForm
error={badgesError}
isAssigningBadges={!isAssignLaterSelected && tournamentHasBadges}
>
{tournamentHasBadges ? (
<>
<SendouSwitch
isSelected={isAssignLaterSelected}
onChange={setIsAssignLaterSelected}
data-testid="assign-badges-later-switch"
>
{t("tournament:actions.finalize.assignBadgesLater")}
</SendouSwitch>
{!isAssignLaterSelected ? (
<>
<input
type="hidden"
name="badgeReceivers"
value={JSON.stringify(badgeReceivers)}
/>
<NewBadgeReceiversSelector
badges={data.badges}
standings={data.standings}
badgeReceivers={badgeReceivers}
setBadgeReceivers={setBadgeReceivers}
/>
</>
) : null}
</>
) : null}
</FinalizeForm>
</SendouDialog>
);
}
function FinalizeForm({
children,
error,
isAssigningBadges,
}: {
children: React.ReactNode;
error: ReturnType<typeof validateBadgeReceivers>;
isAssigningBadges: boolean;
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["tournament"]);
return (
<fetcher.Form method="post" className="stack md">
<input type="hidden" name="_action" value="FINALIZE_TOURNAMENT" />
<div className="stack md">{children}</div>
<div className="stack horizontal md justify-center mt-2">
<SubmitButton testId="confirm-button" isDisabled={Boolean(error)}>
{t(
isAssigningBadges
? "tournament:actions.finalize.action.withBadges"
: "tournament:actions.finalize.action",
)}
</SubmitButton>
</div>
{error ? (
<FormMessage type="error" className="text-center">
{t(`tournament:actions.finalize.error.${error}`)}
</FormMessage>
) : (
<FormMessage type="info" className="text-center">
{t("tournament:actions.finalize.info")}
</FormMessage>
)}
</fetcher.Form>
);
}
function NewBadgeReceiversSelector({
badges,
standings,
badgeReceivers,
setBadgeReceivers,
}: {
badges: FinalizeTournamentLoaderData["badges"];
standings: FinalizeTournamentLoaderData["standings"];
badgeReceivers: TournamentBadgeReceivers;
setBadgeReceivers: (owners: TournamentBadgeReceivers) => void;
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const id = React.useId();
const handleTeamSelected =
(badgeId: number) => (e: React.ChangeEvent<HTMLSelectElement>) => {
const newReceivers = badgeReceivers.filter(
(receiver) => receiver.badgeId !== badgeId,
);
if (e.target.value !== "") {
const newOwnerTournamentTeamId = Number(e.target.value);
const newOwnerStanding = standings.find(
(standing) => standing.tournamentTeamId === newOwnerTournamentTeamId,
);
invariant(newOwnerStanding);
const defaultSelected =
tournament.minMembersPerTeam === newOwnerStanding.members.length;
newReceivers.push({
badgeId: badgeId,
tournamentTeamId: newOwnerTournamentTeamId,
userIds: defaultSelected
? newOwnerStanding.members.map((m) => m.userId)
: [],
});
}
setBadgeReceivers(newReceivers);
};
const handleReceiverSelected =
({ badgeId, userId }: { badgeId: number; userId: number }) =>
(isSelected: boolean) => {
const newReceivers = badgeReceivers.map((receiver) => {
if (receiver.badgeId !== badgeId) return receiver;
const newUserIds = isSelected
? [...receiver.userIds, userId]
: receiver.userIds.filter((id) => id !== userId);
return {
...receiver,
userIds: newUserIds,
};
});
setBadgeReceivers(newReceivers);
};
return (
<div className="stack lg">
{badges.map((badge) => {
const receiver = badgeReceivers.find(
(owner) => owner.badgeId === badge.id,
);
const standingToReceive = standings.find(
(standing) =>
standing.tournamentTeamId === receiver?.tournamentTeamId,
);
return (
<div key={badge.id} className="stack md">
<h2 className="stack sm horizontal items-center text-sm">
<div className="finalize__badge-container">
<Badge badge={badge} size={32} isAnimated />
</div>{" "}
{badge.displayName}
</h2>
<div>
<label htmlFor={`${id}-${badge.id}`}>
{t("tournament:finalize.receivingTeam.label")}
</label>
<select
id={`${id}-${badge.id}`}
value={receiver?.tournamentTeamId ?? ""}
onChange={handleTeamSelected(badge.id)}
>
<option value="">
{t("tournament:finalize.receivingTeam.placeholder")}
</option>
{standings.map((standing) => (
<option key={standing.name} value={standing.tournamentTeamId}>
<Placement placement={standing.placement} plain />){" "}
{standing.name}
</option>
))}
</select>
</div>
{standingToReceive?.members.map((member, i) => {
return (
<div key={member.userId} className="stack sm">
<div className="stack horizontal items-center">
<SendouSwitch
isSelected={receiver?.userIds.includes(member.userId)}
onChange={handleReceiverSelected({
badgeId: badge.id,
userId: member.userId,
})}
size="small"
/>
<Avatar user={member} size="xxs" className="mr-2" />
{member.username}
</div>
<div className="stack horizontal sm items-end">
<ParticipationPill setResults={member.setResults} />
</div>
{i !== standingToReceive?.members.length - 1 && (
<Divider className="mt-3" />
)}
</div>
);
})}
</div>
);
})}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useRevalidator } from "@remix-run/react";
import { Outlet, useOutletContext, useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import { sub } from "date-fns";
import * as React from "react";
@ -8,10 +8,9 @@ import { useCopyToClipboard } from "react-use";
import { useEventSource } from "remix-utils/sse/react";
import { Alert } from "~/components/Alert";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { EyeIcon } from "~/components/icons/Eye";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
@ -50,6 +49,7 @@ export default function TournamentBracketsPage() {
const user = useUser();
const tournament = useTournament();
const isMounted = useIsMounted();
const ctx = useOutletContext();
const defaultBracketIdx = () => {
if (
@ -159,24 +159,19 @@ export default function TournamentBracketsPage() {
return (
<div>
<Outlet context={ctx} />
{visibility !== "hidden" && !tournament.everyBracketOver ? (
<AutoRefresher />
) : null}
{tournament.canFinalize(user) ? (
<div className="tournament-bracket__finalize">
<FormWithConfirm
dialogHeading={t("tournament:actions.finalize.confirm")}
fields={[["_action", "FINALIZE_TOURNAMENT"]]}
submitButtonText={t("tournament:actions.finalize.action")}
submitButtonVariant="outlined"
<LinkButton
variant="minimal"
testId="finalize-tournament-button"
to="finalize"
>
<SendouButton
variant="minimal"
data-testid="finalize-tournament-button"
>
{t("tournament:actions.finalize.question")}
</SendouButton>
</FormWithConfirm>
{t("tournament:actions.finalize.question")}
</LinkButton>
</div>
) : null}
{bracket.preview &&

View File

@ -114,7 +114,6 @@ const tournamentRoundMaps = z.object({
type: z.enum(["BEST_OF", "PLAY_ALL"]),
pickBan: z.enum(PickBan.types).nullish(),
});
export const bracketSchema = z.union([
z.object({
_action: _action("START_BRACKET"),
@ -145,9 +144,6 @@ export const bracketSchema = z.union([
roundId: id,
bracketIdx,
}),
z.object({
_action: _action("FINALIZE_TOURNAMENT"),
}),
z.object({
_action: _action("BRACKET_CHECK_IN"),
bracketIdx,
@ -166,3 +162,17 @@ export const tournamentTeamPageParamsSchema = z.object({
id,
tid: id,
});
export type TournamentBadgeReceivers = z.infer<typeof badgeReceivers>;
const badgeReceivers = z.array(
z.object({
badgeId: id,
tournamentTeamId: id,
userIds: z.array(id).min(1).max(50),
}),
);
export const finalizeTournamentActionSchema = z.object({
badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()),
});

View File

@ -3,6 +3,7 @@ import {
fillWithNullTillPowerOfTwo,
groupNumberToLetters,
mapCountPlayedInSetWithCertainty,
validateBadgeReceivers,
} from "./tournament-bracket-utils";
const mapCountParamsToResult: {
@ -71,4 +72,54 @@ describe("groupNumberToLetters()", () => {
expect(groupNumberToLetters(groupNumber)).toBe(expected);
});
}
describe("validateNewBadgeOwners", () => {
const badges = [{ id: 1 }, { id: 2 }];
test("returns BADGE_NOT_ASSIGNED if a badge has no owner", () => {
const badgeReceivers = [
{ badgeId: 1, userIds: [10], tournamentTeamId: 100 },
];
expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe(
"BADGE_NOT_ASSIGNED",
);
});
test("returns BADGE_NOT_ASSIGNED if a badge owner has empty userIds", () => {
const badgeReceivers = [
{ badgeId: 1, userIds: [], tournamentTeamId: 100 },
{ badgeId: 2, userIds: [20], tournamentTeamId: 101 },
];
expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe(
"BADGE_NOT_ASSIGNED",
);
});
test("returns DUPLICATE_TOURNAMENT_TEAM_ID if tournamentTeamId is duplicated", () => {
const badgeReceivers = [
{ badgeId: 1, userIds: [10], tournamentTeamId: 100 },
{ badgeId: 2, userIds: [20], tournamentTeamId: 100 },
];
expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe(
"DUPLICATE_TOURNAMENT_TEAM_ID",
);
});
test("returns BADGE_NOT_FOUND if some receiver has a badge not from the tournament", () => {
const badgeReceivers = [
{ badgeId: 1, userIds: [10], tournamentTeamId: 100 },
];
expect(
validateBadgeReceivers({ badgeReceivers, badges: [{ id: 2 }] }),
).toBe("BADGE_NOT_FOUND");
});
test("returns null if all badges are assigned and tournamentTeamIds are unique", () => {
const badgeReceivers = [
{ badgeId: 1, userIds: [10], tournamentTeamId: 100 },
{ badgeId: 2, userIds: [20], tournamentTeamId: 101 },
];
expect(validateBadgeReceivers({ badgeReceivers, badges })).toBeNull();
});
});
});

View File

@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import * as R from "remeda";
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
@ -304,3 +305,48 @@ export function ensureOneStandingPerUser(standings: Standing[]) {
};
});
}
/**
* Validates the assignment of badges to receivers in a tournament finalization context.
*
* Checks the following conditions:
* - Each badge receiver references a valid badge from the provided list.
* - Every badge has at least one assigned receiver (both team and at least one user).
* - No duplicate tournament team IDs exist among the badge receivers.
*
* Returns `null` if all validations pass.
*/
export function validateBadgeReceivers({
badgeReceivers,
badges,
}: {
badgeReceivers: TournamentBadgeReceivers;
badges: ReadonlyArray<{ id: number }>;
}) {
if (
badgeReceivers.some(
(receiver) => !badges.some((badge) => badge.id === receiver.badgeId),
)
) {
return "BADGE_NOT_FOUND";
}
for (const badge of badges) {
const owner = badgeReceivers.find(
(receiver) => receiver.badgeId === badge.id,
);
if (!owner || owner.userIds.length === 0) {
return "BADGE_NOT_ASSIGNED";
}
}
const tournamentTeamIds = badgeReceivers.map(
(receiver) => receiver.tournamentTeamId,
);
const uniqueTournamentTeamIds = new Set(tournamentTeamIds);
if (tournamentTeamIds.length !== uniqueTournamentTeamIds.size) {
return "DUPLICATE_TOURNAMENT_TEAM_ID";
}
return null;
}

View File

@ -561,3 +561,9 @@
min-width: 275px;
text-align: start;
}
.finalize__badge-container {
padding: var(--s-2);
background-color: black;
border-radius: var(--rounded);
}

View File

@ -0,0 +1,29 @@
.container {
width: 60px;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.text {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
}
.pill {
display: flex;
gap: var(--s-1);
height: 10px;
}
.pillLine {
width: 100%;
height: 100%;
background-color: var(--bg-lightest);
border-radius: var(--rounded);
}
.participating {
background-color: var(--theme);
}

View File

@ -0,0 +1,32 @@
import clsx from "clsx";
import styles from "./ParticipationPill.module.css";
import type { UserResultsTableProps } from "./UserResultsTable";
export function ParticipationPill({
setResults,
}: {
setResults: UserResultsTableProps["results"][number]["setResults"];
}) {
if (!setResults) {
return null;
}
const playedCount = setResults.filter(Boolean).length;
const playedPercentage = Math.round((playedCount / setResults.length) * 100);
return (
<div className={styles.container}>
<div className={styles.text}>{playedPercentage}%</div>
<div className={styles.pill}>
{setResults.map((result, i) => (
<div
key={i}
className={clsx(styles.pillLine, {
[styles.participating]: result,
})}
/>
))}
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
@ -18,6 +17,7 @@ import {
userSubmittedImage,
} from "~/utils/urls";
import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server";
import { ParticipationPill } from "./ParticipationPill";
export type UserResultsTableProps = {
results: UserResultsLoaderData["results"];
@ -189,32 +189,3 @@ export function UserResultsTable({
</Table>
);
}
function ParticipationPill({
setResults,
}: {
setResults: UserResultsTableProps["results"][number]["setResults"];
}) {
if (!setResults) {
return null;
}
const playedCount = setResults.filter(Boolean).length;
const playedPercentage = Math.round((playedCount / setResults.length) * 100);
return (
<div className="u__results__pill__container">
<div className="u__results__pill__text">{playedPercentage}%</div>
<div className="u__results__pill">
{setResults.map((result, i) => (
<div
key={i}
className={clsx("u__results__pill-line", {
"u__results__pill-line__participating": result,
})}
/>
))}
</div>
</div>
);
}

View File

@ -99,11 +99,20 @@ export default [
route("subs", "features/tournament-subs/routes/to.$id.subs.tsx"),
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"),
route(
"divisions",
"features/tournament-bracket/routes/to.$id.divisions.tsx",
),
route(
"brackets",
"features/tournament-bracket/routes/to.$id.brackets.tsx",
[
route(
"finalize",
"features/tournament-bracket/routes/to.$id.brackets.finalize.tsx",
),
],
),
route(
"brackets/subscribe",
"features/tournament-bracket/routes/to.$id.brackets.subscribe.ts",

View File

@ -145,36 +145,6 @@
list-style: none;
}
.u__results__pill__container {
width: 60px;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.u__results__pill__text {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
}
.u__results__pill {
display: flex;
gap: var(--s-1);
height: 10px;
}
.u__results__pill-line {
width: 100%;
height: 100%;
background-color: var(--bg-lightest);
border-radius: var(--rounded);
}
.u__results__pill-line__participating {
background-color: var(--theme);
}
.u-search__container {
display: flex;
flex-direction: column;

View File

@ -217,6 +217,16 @@ export function successToast(message: string) {
return redirect(`?__success=${message}`);
}
export function successToastWithRedirect({
message,
url,
}: {
message: string;
url: string;
}) {
return redirect(`${url}?__success=${message}`);
}
export type ActionError = { field: string; msg: string; isError: true };
export function actionError<T extends z.ZodTypeAny>({

View File

@ -142,8 +142,7 @@ export function processMany(
export function safeJSONParse(value: unknown): unknown {
try {
if (typeof value !== "string") return value;
const parsedValue = z.string().parse(value);
return JSON.parse(parsedValue);
return JSON.parse(value);
} catch {
return undefined;
}

View File

@ -11,6 +11,7 @@ import {
submit,
} from "~/utils/playwright";
import {
NOTIFICATIONS_URL,
tournamentAdminPage,
tournamentBracketsPage,
tournamentMatchPage,
@ -285,7 +286,9 @@ test.describe("Tournament bracket", () => {
await expect(page).toHaveURL(/brackets/);
});
test("completes and finalizes a small tournament", async ({ page }) => {
test("completes and finalizes a small tournament with badge assigning", async ({
page,
}) => {
const tournamentId = 2;
await seed(page);
@ -322,6 +325,10 @@ test.describe("Tournament bracket", () => {
await backToBracket(page);
await page.getByTestId("finalize-tournament-button").click();
await page.getByLabel("Receiving team").first().selectOption("101");
await page.getByLabel("Receiving team").last().selectOption("102");
await page.getByTestId("confirm-button").click();
await page.getByTestId("results-tab").click();
@ -334,6 +341,15 @@ test.describe("Tournament bracket", () => {
});
await expect(page.getByText("In The Zone 22")).toBeVisible();
await navigate({
page,
url: NOTIFICATIONS_URL,
});
await expect(page.getByTestId("notification-item").first()).toContainText(
"New badge",
);
});
test("completes and finalizes a small tournament (RR->SE w/ underground bracket)", async ({
@ -429,6 +445,7 @@ test.describe("Tournament bracket", () => {
await backToBracket(page);
}
await page.getByTestId("finalize-tournament-button").click();
await page.getByTestId("assign-badges-later-switch").click();
await page.getByTestId("confirm-button").click();
// not possible to reopen finals match anymore

View File

@ -72,9 +72,17 @@
"actions.sub.prompt_one": "Du kan stadigvæk tilføje {{count}} Suppleant til din holdliste",
"actions.sub.prompt_other": "Du kan stadigvæk tilføje {{count}} Suppleanter til din holdliste",
"actions.sub.prompt_zero": "Din holdliste er fuld, så du kan ikke tilføje flere Suppleanter",
"actions.finalize": "",
"actions.finalize.question": "Når du har undersøgt, om de indrapporterede resultater er korrekte, så tryk her for at afslutte turneringen og bearbejd resultaterne.",
"actions.finalize.action": "Afslut",
"actions.finalize.confirm": "Er du sikker på, at du vil afslutte turneringen? (Dette kan ikke annulleres)",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Der p.t. er ingen direkte udsendelser af denne turnering",
"team.setWins": "Vundne sæt",
"team.mapWins": "Sejr på baner",

View File

@ -72,9 +72,17 @@
"actions.sub.prompt_one": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_other": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_zero": "Dein Roster ist voll und keine weiteren Ersatzspieler können hinzugefügt werden",
"actions.finalize": "",
"actions.finalize.question": "Sobald du überprüft hast, dass die gemeldeten Ergebnisse korrekt sind, klicke hier, um das Turnier zu finalisieren und die Endergebnisse zu verarbeiten",
"actions.finalize.action": "Finalisieren",
"actions.finalize.confirm": "Bist du sicher, dass du das Turnier finalisieren möchtest (kann nicht widerrufen werden)?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aktuell sind keine Livestreams von diesem Turnier verfügbar",
"team.setWins": "Set-Siege",
"team.mapWins": "Arenen-Siege",

View File

@ -72,9 +72,17 @@
"actions.sub.prompt_other": "You can still add {{count}} subs to your roster",
"actions.sub.prompt_one": "You can still add {{count}} sub to your roster",
"actions.sub.prompt_zero": "Your roster is full and more subs can't be added",
"actions.finalize": "Finalizing tournament",
"actions.finalize.question": "When you have checked that reported scores are correct click here to finalize the tournament and process results",
"actions.finalize.action": "Finalize",
"actions.finalize.confirm": "Are you sure you want to finalize the tournament (can't be undone)?",
"actions.finalize.action.withBadges": "Finalize & assign badges",
"actions.finalize.info": "This action can't be undone. Please ensure that all reported matches are correct before proceeding.",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "Not all badges have been assigned to teams",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "Same badge assigned to multiple teams",
"actions.finalize.error.BADGE_NOT_FOUND": "Unexpected badge not found",
"actions.finalize.assignBadgesLater": "Assign badges later manually",
"finalize.receivingTeam.label": "Receiving team",
"finalize.receivingTeam.placeholder": "Select team…",
"streams.none": "No live streams of this tournament available currently",
"team.setWins": "Set wins",
"team.mapWins": "Map wins",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
"actions.sub.prompt_zero": "Tu equipo está lleno y no puedes añadir más subs",
"actions.finalize": "",
"actions.finalize.question": "Al verificar que los resultados presentados son correctos, haz click aquí para finalizar el torneo y procesar los resultados",
"actions.finalize.action": "Finalizar",
"actions.finalize.confirm": "¿Seguro que quieres finalizar el torneo? (No se puede anular)",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "No hay streams disponibles para este torneo al momento",
"team.setWins": "Victorias de sets",
"team.mapWins": "Victorias de mapas",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
"actions.sub.prompt_zero": "Tu equipo está lleno y no puedes añadir más subs",
"actions.finalize": "",
"actions.finalize.question": "Al verificar que los resultados presentados son correctos, haz click aquí para finalizar el torneo y procesar los resultados",
"actions.finalize.action": "Finalizar",
"actions.finalize.confirm": "¿Seguro que quieres finalizar el torneo? (No se puede anular)",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "No hay streams disponibles para este torneo al momento",
"team.setWins": "Victorias de sets",
"team.mapWins": "Victorias de juegos",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
"actions.sub.prompt_zero": "Votre liste est pleine, des remplaçant supplémentaires ne peuvent être ajoutés",
"actions.finalize": "",
"actions.finalize.question": "Lorsque vous avez vérifié que les scores indiqués sont corrects, cliquez ici pour finaliser le tournoi et traiter les résultats",
"actions.finalize.action": "Finaliser",
"actions.finalize.confirm": "Êtes-vous sûr de vouloir finaliser le tournoi (irréversible) ?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
"team.setWins": "Set wins",
"team.mapWins": "Map wins",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
"actions.sub.prompt_zero": "Votre liste est pleine, des remplaçant supplémentaires ne peuvent être ajoutés",
"actions.finalize": "",
"actions.finalize.question": "Lorsque vous avez vérifié que les scores indiqués sont corrects, cliquez ici pour finaliser le tournoi et traiter les résultats",
"actions.finalize.action": "Finaliser",
"actions.finalize.confirm": "Êtes-vous sûr de vouloir finaliser le tournoi (irréversible) ?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
"team.setWins": "Set gagné",
"team.mapWins": "Map gagnée",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_two": "",
"actions.sub.prompt_other": "אתם עדיין יכולים להוסיף {{count}} ממלאי מקום לצוות שלכם",
"actions.sub.prompt_zero": "הצוות שלכם מלא ולא ניתן להוסיף עוד ממלאי מקום",
"actions.finalize": "",
"actions.finalize.question": "לאחר שבדקתם שהתוצאות נכונות, לחצו כאן כדי לסיים את הטורניר ולנתח את התוצאות",
"actions.finalize.action": "סיימו",
"actions.finalize.confirm": "האם אתם בטוחים שאתם רוצים לסיים את הטורניר? (לא ניתן לביטול)?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "אין שידורים חיים לטורניר זה כרגע",
"team.setWins": "נצחונות סטים",
"team.mapWins": "נצחונות במפות",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Puoi ancora aggiungere {{count}} sub al tuo roster",
"actions.sub.prompt_zero": "Il tuo roster è completo e non è possibile aggiungere altri sub",
"actions.finalize": "",
"actions.finalize.question": "Quando hai verificato che i punteggi riportati sono corretti, clicca qui per finalizzare il torneo e processare i risultati",
"actions.finalize.action": "Finalizza",
"actions.finalize.confirm": "Sei sicuro/a di finalizzare il torneo (non può essere annullato)?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Nessuna livestream di questo torneo disponibile al momento",
"team.setWins": "Set vinti",
"team.mapWins": "Mappe vinte",

View File

@ -70,9 +70,17 @@
"actions.addSub": "サブを追加",
"actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}",
"actions.sub.prompt_zero": "メンバーが上限に達しているので、これ以上サブを追加することができません",
"actions.finalize": "",
"actions.finalize.question": "報告されたスコアが正しいかどうかをチェックしたら、ここをクリックしてトーナメント内容を確定・結果処理をおこないます",
"actions.finalize.action": "トーナメント結果確定",
"actions.finalize.confirm": "トーナメント結果を確定しますか? (取り消すことはできません)?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "現在このトーナメントのストリーミングはありません",
"team.setWins": "勝利セット",
"team.mapWins": "勝利マップ",

View File

@ -70,9 +70,17 @@
"actions.addSub": "",
"actions.shareLink": "",
"actions.sub.prompt_zero": "",
"actions.finalize": "",
"actions.finalize.question": "",
"actions.finalize.action": "",
"actions.finalize.confirm": "",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"team.setWins": "",
"team.mapWins": "",

View File

@ -72,9 +72,17 @@
"actions.sub.prompt_one": "",
"actions.sub.prompt_other": "",
"actions.sub.prompt_zero": "",
"actions.finalize": "",
"actions.finalize.question": "",
"actions.finalize.action": "",
"actions.finalize.confirm": "",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"team.setWins": "",
"team.mapWins": "",

View File

@ -74,9 +74,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "",
"actions.sub.prompt_zero": "",
"actions.finalize": "",
"actions.finalize.question": "",
"actions.finalize.action": "",
"actions.finalize.confirm": "",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "",
"team.setWins": "",
"team.mapWins": "",

View File

@ -73,9 +73,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Você ainda pode adicionar {{count}} substitutos(as) à sua lista",
"actions.sub.prompt_zero": "Sua lista está cheia e mais nenhum(a) substituto(a) pode ser adicionado(a)",
"actions.finalize": "",
"actions.finalize.question": "Quando você verificar que as pontuações e resultados declarados estão corretos, clique aqui para finalizar o torneio e processar os resultados",
"actions.finalize.action": "Finalizar Torneio",
"actions.finalize.confirm": "Você tem certeza que quer finalizar o torneio? (Essa ação não pode ser desfeita)",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "Nenhuma transmissão desse evento está disponível no momento",
"team.setWins": "Vitórias de set",
"team.mapWins": "Vitórias em mapa",

View File

@ -74,9 +74,17 @@
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Вы ещё можете добавить {{count}} запасных",
"actions.sub.prompt_zero": "Состав заполнен, больше нельзя добавить запасных",
"actions.finalize": "",
"actions.finalize.question": "Как только вы убедитесь, что заявлённые счёты достоверны, нажмите здесь для завершения турнира и обработки результатов",
"actions.finalize.action": "Завершить",
"actions.finalize.confirm": "Вы точно хотите завершить турнир (действие нельзя отменить)?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "На данный момент нет трансляций этого турнира",
"team.setWins": "Победы в раундах",
"team.mapWins": "Победы в матчах",

View File

@ -70,9 +70,17 @@
"actions.addSub": "添加替补",
"actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
"actions.sub.prompt_zero": "您的阵容已满,无法再添加替补",
"actions.finalize": "",
"actions.finalize.question": "在您确认报告的比分无误后,点击这里结束比赛并生成结果",
"actions.finalize.action": "结束",
"actions.finalize.confirm": "您确定要结束比赛(此操作不可撤销)吗?",
"actions.finalize.action.withBadges": "",
"actions.finalize.info": "",
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
"streams.none": "该比赛目前没有直播",
"team.setWins": "胜利局数",
"team.mapWins": "胜利场数",

View File

@ -0,0 +1,10 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentBadgeOwner" add "tournamentId" integer`,
).run();
db.prepare(
`create unique index badge_owner_tournament_user_unique on "TournamentBadgeOwner"("tournamentId", "userId")`,
).run();
})();
}

View File

@ -17,5 +17,9 @@ sql
sql
.prepare(`update "Tournament" set "isFinalized" = 0 where "id" = @id`)
.run({ id });
sql.prepare(`delete from "Skill" where "tournamentId" = @id`).run({ id });
sql
.prepare(`delete from "TournamentBadgeOwner" where "tournamentId" = @id`)
.run({ id });
logger.info(`Reopened tournament with id ${id}`);