diff --git a/app/components/Placement.tsx b/app/components/Placement.tsx index a556caa0a..ce7059614 100644 --- a/app/components/Placement.tsx +++ b/app/components/Placement.tsx @@ -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 ( diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 7feff1bc6..38be526e7 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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([ diff --git a/app/db/tables.ts b/app/db/tables.ts index 8c6934f42..1c7c636eb 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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 { diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index fd7ce83fe..163b824e6 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -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} > -

Seed

- Seed +
+ Seed + + {SEED_VARIATIONS.map((variation) => ( + + {variation} + + ))} + +
); } diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts new file mode 100644 index 000000000..d31acdfba --- /dev/null +++ b/app/features/api-private/constants.ts @@ -0,0 +1,7 @@ +export const SEED_VARIATIONS = [ + "NO_TOURNAMENT_TEAMS", + "DEFAULT", + "REG_OPEN", + "SMALL_SOS", + "NZAP_IN_TEAM", +] as const; diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index 79b4b1a6b..749c40d50 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -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< diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 26246cebb..fe784c390 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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)) diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts index 49cae90b7..0d07cb05e 100644 --- a/app/features/calendar/actions/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -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, diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts index b2fb069f3..86b671330 100644 --- a/app/features/calendar/actions/calendar.$id.server.ts +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -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) { diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 098899a74..82829fb81 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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({ diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index f6d098848..a57296c37 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -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; diff --git a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts index 1fcdd1ec1..4bf2ceed3 100644 --- a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts @@ -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({ diff --git a/app/features/calendar/loaders/calendar.$id.server.ts b/app/features/calendar/loaders/calendar.$id.server.ts index aa7e50aca..221e6f06d 100644 --- a/app/features/calendar/loaders/calendar.$id.server.ts +++ b/app/features/calendar/loaders/calendar.$id.server.ts @@ -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, }), diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index 700ed383a..cf8a32124 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -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, diff --git a/app/features/notifications/components/NotificationList.tsx b/app/features/notifications/components/NotificationList.tsx index 5279f6603..12b1e047c 100644 --- a/app/features/notifications/components/NotificationList.tsx +++ b/app/features/notifications/components/NotificationList.tsx @@ -24,7 +24,11 @@ export function NotificationItem({ const { t, i18n } = useTranslation(["common"]); return ( - + {!notification.seen ?
: null} diff --git a/app/features/sendouq-match/QMatchRepository.server.ts b/app/features/sendouq-match/QMatchRepository.server.ts index 09dcf1e27..4f7c232e8 100644 --- a/app/features/sendouq-match/QMatchRepository.server.ts +++ b/app/features/sendouq-match/QMatchRepository.server.ts @@ -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. */ diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts new file mode 100644 index 000000000..a461068d9 --- /dev/null +++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts @@ -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); + } +} diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 3119bdb20..6b92d2bb3 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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"); diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts new file mode 100644 index 000000000..71d6703b0 --- /dev/null +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts @@ -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; + +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) ?? [], + })), + }; + }); +} diff --git a/app/features/tournament-bracket/queries/addSummary.server.ts b/app/features/tournament-bracket/queries/addSummary.server.ts index 5a4459ed3..e3d3cfaae 100644 --- a/app/features/tournament-bracket/queries/addSummary.server.ts +++ b/app/features/tournament-bracket/queries/addSummary.server.ts @@ -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); diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx new file mode 100644 index 000000000..74ee6941c --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx @@ -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(); + const { t } = useTranslation(["tournament"]); + const location = useLocation(); + const [isAssignLaterSelected, setIsAssignLaterSelected] = + React.useState(false); + const [badgeReceivers, setBadgeReceivers] = + React.useState([]); + + const bracketUrl = location.pathname?.replace(/\/finalize$/, ""); + + const tournamentHasBadges = data.badges.length > 0; + + const badgesError = !isAssignLaterSelected + ? validateBadgeReceivers({ badgeReceivers, badges: data.badges }) + : null; + + return ( + + + {tournamentHasBadges ? ( + <> + + {t("tournament:actions.finalize.assignBadgesLater")} + + {!isAssignLaterSelected ? ( + <> + + + + ) : null} + + ) : null} + + + ); +} + +function FinalizeForm({ + children, + error, + isAssigningBadges, +}: { + children: React.ReactNode; + error: ReturnType; + isAssigningBadges: boolean; +}) { + const fetcher = useFetcher(); + const { t } = useTranslation(["tournament"]); + + return ( + + +
{children}
+
+ + {t( + isAssigningBadges + ? "tournament:actions.finalize.action.withBadges" + : "tournament:actions.finalize.action", + )} + +
+ {error ? ( + + {t(`tournament:actions.finalize.error.${error}`)} + + ) : ( + + {t("tournament:actions.finalize.info")} + + )} +
+ ); +} + +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) => { + 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 ( +
+ {badges.map((badge) => { + const receiver = badgeReceivers.find( + (owner) => owner.badgeId === badge.id, + ); + const standingToReceive = standings.find( + (standing) => + standing.tournamentTeamId === receiver?.tournamentTeamId, + ); + + return ( +
+

+
+ +
{" "} + {badge.displayName} +

+
+ + +
+ {standingToReceive?.members.map((member, i) => { + return ( +
+
+ + + {member.username} +
+
+ +
+ {i !== standingToReceive?.members.length - 1 && ( + + )} +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 2f5630b48..d42cd2ea2 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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 (
+ {visibility !== "hidden" && !tournament.everyBracketOver ? ( ) : null} {tournament.canFinalize(user) ? (
- - - {t("tournament:actions.finalize.question")} - - + {t("tournament:actions.finalize.question")} +
) : null} {bracket.preview && diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index c239af006..1cd559bdb 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -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; + +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()), +}); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.test.ts b/app/features/tournament-bracket/tournament-bracket-utils.test.ts index e20f2f487..6d79db497 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.test.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.test.ts @@ -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(); + }); + }); }); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 24b027ab8..1a29007bd 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -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; +} diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 08d626200..6385d0240 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -561,3 +561,9 @@ min-width: 275px; text-align: start; } + +.finalize__badge-container { + padding: var(--s-2); + background-color: black; + border-radius: var(--rounded); +} diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css new file mode 100644 index 000000000..dfde4b574 --- /dev/null +++ b/app/features/user-page/components/ParticipationPill.module.css @@ -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); +} diff --git a/app/features/user-page/components/ParticipationPill.tsx b/app/features/user-page/components/ParticipationPill.tsx new file mode 100644 index 000000000..6da932414 --- /dev/null +++ b/app/features/user-page/components/ParticipationPill.tsx @@ -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 ( +
+
{playedPercentage}%
+
+ {setResults.map((result, i) => ( +
+ ))} +
+
+ ); +} diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 62fc270b2..69234e17c 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -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({ ); } - -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 ( -
-
{playedPercentage}%
-
- {setResults.map((result, i) => ( -
- ))} -
-
- ); -} diff --git a/app/routes.ts b/app/routes.ts index 92ab3916c..20feb818b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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", diff --git a/app/styles/u.css b/app/styles/u.css index 45a0c86d5..7be8b0594 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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; diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 112cb848a..49d4bb131 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -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({ diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 6c032cdaf..457830d51 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -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; } diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index af54731dd..32a7a3c61 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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 diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 7c73b1a9b..458f60b8f 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -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", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 22d680f39..201bd8f4c 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -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", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 91dee04c5..8cbc78e34 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index e3f28b4fe..76a1e7258 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -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", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 650f8792e..1762087a4 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -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", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 923634964..a30db209c 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -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", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index dae1d5af0..0264460cf 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -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", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 5cd706617..336813592 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -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": "נצחונות במפות", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 2be6e1b7e..5aff2fbdc 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -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", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index f9af06af2..694e3e4cb 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -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": "勝利マップ", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index ad8aad8b4..fab420419 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -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": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 3ae51244d..2e138bcb4 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -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": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 167e9f577..7c50638f8 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -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": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 2ab890e27..0907d3a77 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -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", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 4a502c7c5..e6d0ef3d9 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -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": "Победы в матчах", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 2e3ad2546..260b768ba 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -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": "胜利场数", diff --git a/migrations/094-badge-tournament-id.js b/migrations/094-badge-tournament-id.js new file mode 100644 index 000000000..052fc9e22 --- /dev/null +++ b/migrations/094-badge-tournament-id.js @@ -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(); + })(); +} diff --git a/scripts/reopen-tournament.ts b/scripts/reopen-tournament.ts index d02441d75..b38c1ddc9 100644 --- a/scripts/reopen-tournament.ts +++ b/scripts/reopen-tournament.ts @@ -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}`);