mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Add badges on tournament finalize (#2459)
This commit is contained in:
parent
b04d91d8f2
commit
fa60d0d03f
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
7
app/features/api-private/constants.ts
Normal file
7
app/features/api-private/constants.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const SEED_VARIATIONS = [
|
||||
"NO_TOURNAMENT_TEAMS",
|
||||
"DEFAULT",
|
||||
"REG_OPEN",
|
||||
"SMALL_SOS",
|
||||
"NZAP_IN_TEAM",
|
||||
] as const;
|
||||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) ?? [],
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -561,3 +561,9 @@
|
|||
min-width: 275px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.finalize__badge-container {
|
||||
padding: var(--s-2);
|
||||
background-color: black;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
32
app/features/user-page/components/ParticipationPill.tsx
Normal file
32
app/features/user-page/components/ParticipationPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "נצחונות במפות",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "勝利マップ",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Победы в матчах",
|
||||
|
|
|
|||
|
|
@ -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": "胜利场数",
|
||||
|
|
|
|||
10
migrations/094-badge-tournament-id.js
Normal file
10
migrations/094-badge-tournament-id.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user