From 3d4ac9f4524c4662e9ea287c8ff0c26ac5eea299 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:49:35 +0300 Subject: [PATCH 01/14] Add upsert tournament team write API Closes #3294 --- .../api-public/api-action-wrapper.server.ts | 14 +++ .../routes/tournament.$id.teams.upsert.ts | 87 +++++++++++++++++ app/features/api-public/schema.ts | 19 ++++ app/routes.ts | 4 + e2e/api-public.spec.ts | 97 +++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 app/features/api-public/routes/tournament.$id.teams.upsert.ts diff --git a/app/features/api-public/api-action-wrapper.server.ts b/app/features/api-public/api-action-wrapper.server.ts index 6c450d9d2..1d8bb226b 100644 --- a/app/features/api-public/api-action-wrapper.server.ts +++ b/app/features/api-public/api-action-wrapper.server.ts @@ -7,6 +7,7 @@ import type { ActionFunction, ActionFunctionArgs } from "react-router"; * The existing actions use: * - `successToast(message)` which returns `redirect("?__success=message")` * - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")` + * - `{ fieldErrors }` returns for form validation failures */ export async function wrapActionForApi( actionFn: ActionFunction, @@ -19,6 +20,19 @@ export async function wrapActionForApi( return new Response(null, { status: 200 }); } + if (response && typeof response === "object" && "fieldErrors" in response) { + return new Response( + JSON.stringify({ + error: "Validation failed", + fieldErrors: response.fieldErrors, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + return response as Response; } catch (e) { if (e instanceof Response && e.status === 302) { diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.ts new file mode 100644 index 000000000..bc9346e39 --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.ts @@ -0,0 +1,87 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server"; +import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas"; +import { existingImage } from "~/form/image-field"; +import { parseBody, parseParams } from "~/utils/remix.server"; +import { id } from "~/utils/zod"; +import { wrapActionForApi } from "../api-action-wrapper.server"; + +const paramsSchema = z.object({ + id, +}); + +const bodySchema = z.object({ + tournamentTeamId: id.optional(), + name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(), + teamId: id.optional(), + ownerUserId: id, + members: z + .array( + z.object({ + userId: id, + inGameName: z.string().optional(), + }), + ) + .min(1) + .max(ADMIN_REGISTRATION_MAX_MEMBERS), +}); + +export const action = async (args: ActionFunctionArgs) => { + const { id: tournamentId } = parseParams({ + params: args.params, + schema: paramsSchema, + }); + const body = await parseBody({ + request: args.request, + schema: bodySchema, + }); + + const existingTeam = + typeof body.tournamentTeamId === "number" + ? ( + await TournamentRepository.findTeamsFullByTournamentId(tournamentId) + ).find((team) => team.id === body.tournamentTeamId) + : undefined; + if (typeof body.tournamentTeamId === "number" && !existingTeam) { + return Response.json( + { error: "Invalid tournament team id" }, + { + status: 400, + }, + ); + } + + const linkedTeam = typeof body.teamId === "number"; + // the API can't upload logos, so an existing pickup logo is carried over as is + const logo = + !linkedTeam && existingTeam + ? existingImage(existingTeam.avatarImgId, existingTeam.pickupAvatarUrl) + : null; + + const internalRequest = new Request(args.request.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + _action: "UPSERT_REGISTRATION", + tournamentTeamId: body.tournamentTeamId, + linkedTeam, + pickUpName: body.name ?? null, + logo, + teamId: body.teamId ?? null, + ownerId: String(body.ownerUserId), + members: body.members.map((member) => ({ + userId: member.userId, + inGameName: member.inGameName ?? null, + })), + }), + }); + + return wrapActionForApi(adminAction, { + ...args, + params: { id: String(tournamentId) }, + request: internalRequest, + }); +}; diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 7c1a58bee..4fa9ca78c 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -564,6 +564,25 @@ export interface TournamentStartingBracketsBody { }>; } +/** POST /api/tournament/{id}/teams/upsert */ + +/** @lintignore */ +export interface TournamentUpsertTeamBody { + /** Present when editing an existing registration, absent when adding a new team. */ + tournamentTeamId?: number; + /** Team name for a pickup team. Either `name` or `teamId` must be given. */ + name?: string; + /** Linked sendou.ink team id. Name and logo are sourced from the team. */ + teamId?: number; + /** Roster member that is the team owner/captain. */ + ownerUserId: number; + /** Full roster; members missing from the list are removed from the team. */ + members: Array<{ + userId: number; + inGameName?: string; + }>; +} + /** POST /api/tournament/{id}/teams/{tournamentTeamId}/add-member */ /** POST /api/tournament/{id}/teams/{tournamentTeamId}/remove-member */ diff --git a/app/routes.ts b/app/routes.ts index c7cbd8165..2b138d7ae 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -416,6 +416,10 @@ export default [ "/tournament/:id/streams", "features/api-public/routes/tournament.$id.streams.ts", ), + route( + "/tournament/:id/teams/upsert", + "features/api-public/routes/tournament.$id.teams.upsert.ts", + ), route( "/tournament/:id/teams/:teamId/add-member", "features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts", diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 42b4c8e85..edb91fd16 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from "@playwright/test"; import { addHours } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; @@ -274,6 +275,87 @@ test.describe("Public API - Write endpoints", () => { expect(response.status()).toBe(200); }); + test("upserts tournament team registration via API", async ({ + page, + factories, + }) => { + const { tournamentId, token } = await organizedTournament(factories); + const roster = await factories.UserFactory.createMany(ROSTER_SIZE); + + await impersonate(page, ADMIN_ID); + + const createResponse = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + name: "Api Pickup", + ownerUserId: roster[0].id, + members: roster.map((user) => ({ userId: user.id })), + }, + }, + ); + expect(createResponse.status()).toBe(200); + + const createdTeam = await teamByName(page, token, { + tournamentId, + name: "Api Pickup", + }); + expect(createdTeam).toBeTruthy(); + expect(createdTeam.members).toHaveLength(ROSTER_SIZE); + + const editResponse = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + tournamentTeamId: createdTeam.id, + name: "Api Pickup Edited", + ownerUserId: roster[0].id, + members: roster + .slice(0, ROSTER_SIZE - 1) + .map((user) => ({ userId: user.id })), + }, + }, + ); + expect(editResponse.status()).toBe(200); + + const editedTeam = await teamByName(page, token, { + tournamentId, + name: "Api Pickup Edited", + }); + expect(editedTeam.id).toBe(createdTeam.id); + expect(editedTeam.members).toHaveLength(ROSTER_SIZE - 1); + }); + + test("returns 400 with field errors for invalid upsert registration body", async ({ + page, + factories, + }) => { + const { tournamentId, token } = await organizedTournament(factories); + const owner = await factories.UserFactory.create(); + + await impersonate(page, ADMIN_ID); + + const response = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + ownerUserId: owner.id, + members: [{ userId: owner.id }], + }, + }, + ); + + expect(response.status()).toBe(400); + const data = await response.json(); + expect(data.fieldErrors.pickUpName).toBeTruthy(); + }); + test("updates member IGN via API", async ({ page, factories }) => { const { tournamentId, teamId, memberUserIds, token } = await organizedTournament(factories); @@ -359,6 +441,21 @@ async function organizedTournament( }; } +async function teamByName( + page: Page, + token: string, + { tournamentId, name }: { tournamentId: number; name: string }, +) { + const response = await page.request.fetch( + `/api/tournament/${tournamentId}/teams`, + { headers: authorized(token) }, + ); + expect(response.status()).toBe(200); + const teams = await response.json(); + + return teams.find((team: { name: string }) => team.name === name); +} + async function readToken(factories: Factories, userId: number) { await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] }); From f808c4dcbc08dff6574098600a9b298c2d8ecb64 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:15:52 +0300 Subject: [PATCH 02/14] Restrict teams leaving invitational tournaments that were added by the TO --- app/db/tables.ts | 2 + ...tournament.$id.teams.$teamId.add-member.ts | 1 + .../tournament/TournamentRepository.server.ts | 1 + .../TournamentTeamRepository.server.test.ts | 65 ++++++++++++- .../TournamentTeamRepository.server.ts | 32 +++++- .../actions/to.$id.register.server.ts | 7 ++ .../tournament/routes/to.$id.register.tsx | 19 +++- .../tournament-admin-registration-page.ts | 4 +- e2e/pages/tournament/tournament-nav.ts | 1 + .../tournament/tournament-register-page.ts | 7 ++ e2e/pages/tournament/tournament-teams-page.ts | 4 + e2e/tournament-invitational.spec.ts | 97 +++++++++++++++++++ ...-tournament-team-member-organizer-added.ts | 12 +++ scripts/benchmark-db/cases.ts | 9 ++ 14 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 e2e/tournament-invitational.spec.ts create mode 100644 migrations/20260805190302-tournament-team-member-organizer-added.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index e614ef2f4..5b11eed60 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -715,6 +715,8 @@ export interface TournamentTeamMember { isStayAsSub: Generated; /** Set when the member was added to the roster after registration closed. */ isSub: Generated; + /** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */ + isOrganizerAdded: Generated; // denormalized from TournamentTeam.isLooking isLooking: Generated; } diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 775ad368f..1e8be51c8 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => { userId, newTeamId: team.id, previousTeamIdToDelete, + isOrganizerAdded: true, }); if (previousTeamPickupChat) { diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 8e156a16d..dc3e6f589 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -470,6 +470,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { "TournamentTeamMember.role", "TournamentTeamMember.createdAt", "TournamentTeamMember.isSub", + "TournamentTeamMember.isOrganizerAdded", sql /*sql*/`coalesce( "TournamentTeamMember"."inGameName", "User"."inGameName" diff --git a/app/features/tournament/TournamentTeamRepository.server.test.ts b/app/features/tournament/TournamentTeamRepository.server.test.ts index a661df569..1427d1d20 100644 --- a/app/features/tournament/TournamentTeamRepository.server.test.ts +++ b/app/features/tournament/TournamentTeamRepository.server.test.ts @@ -14,7 +14,11 @@ let anotherMember: { id: number }; const membersByTeamId = (tournamentTeamId: number) => db .selectFrom("TournamentTeamMember") - .select(["TournamentTeamMember.userId", "TournamentTeamMember.role"]) + .select([ + "TournamentTeamMember.userId", + "TournamentTeamMember.role", + "TournamentTeamMember.isOrganizerAdded", + ]) .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) .execute(); @@ -94,5 +98,64 @@ describe("TournamentTeamRepository", () => { expect(roleOf(members, member.id)).toBe("REGULAR"); expect(roleOf(members, anotherMember.id)).toBe("REGULAR"); }); + + test("marks added members as organizer added", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + + await withUserId(organizer.id, () => + TournamentTeamRepository.upsertRegistration({ + tournamentId: tournament.id, + name: "Team Olive", + teamId: null, + avatarImgId: null, + ownerUserId: owner.id, + ownerChange: null, + membersToAdd: [owner.id, member.id], + membersToRemove: [], + inGameNameUpdates: [], + }), + ); + + const team = await db + .selectFrom("TournamentTeam") + .select("TournamentTeam.id") + .where("TournamentTeam.tournamentId", "=", tournament.id) + .executeTakeFirstOrThrow(); + + const members = await membersByTeamId(team.id); + + expect(members.every((teamMember) => teamMember.isOrganizerAdded)).toBe( + true, + ); + }); + }); + + describe("join", () => { + test("joining on your own is not marked as organizer added", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [owner.id], + team: { name: "Team Olive", prefersNotToHost: 0, teamId: null }, + }); + + await withUserId(member.id, () => + TournamentTeamRepository.join({ + newTeamId: team.id, + userId: member.id, + }), + ); + + const members = await membersByTeamId(team.id); + + expect( + members.find((teamMember) => teamMember.userId === member.id) + ?.isOrganizerAdded, + ).toBe(0); + }); }); }); diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 17b0b01f5..da294d0e9 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -9,6 +9,7 @@ import { flatZip } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; +import { toDBBoolean } from "~/utils/sql"; import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; export function setActiveRoster({ @@ -318,7 +319,12 @@ export function upsertRegistration({ const members: Array< Pick< Tables["TournamentTeamMember"], - "tournamentTeamId" | "userId" | "inGameName" | "isSub" | "role" + | "tournamentTeamId" + | "userId" + | "inGameName" + | "isSub" + | "role" + | "isOrganizerAdded" > > = []; for (const userId of membersToAdd) { @@ -333,6 +339,7 @@ export function upsertRegistration({ isSub, // every row needs the same keys, otherwise Kysely inserts null for the missing ones role: isOwner ? "OWNER" : "REGULAR", + isOrganizerAdded: 1, }); } @@ -485,6 +492,7 @@ export function copyFromAnotherTournament({ "TournamentTeamMember.role", "TournamentTeamMember.userId", "TournamentTeamMember.isSub", + "TournamentTeamMember.isOrganizerAdded", // -- exclude these // "TournamentTeamMember.tournamentTeamId" @@ -776,12 +784,15 @@ export function join({ previousTeamIdToDelete, newTeamId, userId, + isOrganizerAdded = false, }: { /** Team to delete as the user joins, e.g. a solo team they leave behind. */ previousTeamIdToDelete?: number; newTeamId: number; /** The user joining the team. */ userId: number; + /** Was the user added to the team by the tournament organizer instead of joining on their own? */ + isOrganizerAdded?: boolean; }) { return db.transaction().execute(async (trx) => { if (previousTeamIdToDelete) { @@ -816,6 +827,7 @@ export function join({ userId, inGameName, isSub, + isOrganizerAdded: toDBBoolean(isOrganizerAdded), }) .execute(); @@ -852,6 +864,24 @@ export function deleteById(tournamentTeamId: number) { }); } +/** Was the user's membership in the given team added by the tournament organizer instead of the user joining on their own? */ +export async function isOrganizerAddedMember({ + tournamentTeamId, + userId, +}: { + tournamentTeamId: number; + userId: number; +}) { + const member = await db + .selectFrom("TournamentTeamMember") + .select("TournamentTeamMember.isOrganizerAdded") + .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) + .where("TournamentTeamMember.userId", "=", userId) + .executeTakeFirst(); + + return Boolean(member?.isOrganizerAdded); +} + export function leave({ teamId, userId, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index ed00867ab..116a6e77a 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -179,6 +179,13 @@ export const action: ActionFunction = async ({ request, params }) => { const teamMemberOf = tournament.teamMemberOfByUser(user); errorToastIfFalsy(teamMemberOf, "You are not in a team"); + errorToastIfFalsy( + !(await TournamentTeamRepository.isOrganizerAddedMember({ + tournamentTeamId: teamMemberOf.id, + userId: user.id, + })), + "You were added to the team by the organizer, contact the TO to leave the team", + ); errorToastIfFalsy( teamMemberOf.checkIns.length === 0, "You cannot leave after checking in", diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index d1ccaa412..4f75452a1 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -103,14 +103,21 @@ export default function TournamentRegisterPage() { } function LeaveTeamControl() { + const data = useLoaderData(); const user = useUser(); const tournament = useTournament(); const teamMemberOf = tournament.teamMemberOfByUser(user); - if (!teamMemberOf) return null; + if (!user || !teamMemberOf) return null; const checkedIn = teamMemberOf.checkIns.length > 0; - const cannotLeave = checkedIn || !tournament.registrationOpen; + const organizerAdded = Boolean( + data?.ownTeam?.members.some( + (member) => member.userId === user.id && member.isOrganizerAdded, + ), + ); + const cannotLeave = + organizerAdded || checkedIn || !tournament.registrationOpen; if (cannotLeave) { return ( @@ -121,9 +128,11 @@ function LeaveTeamControl() { } > - {checkedIn - ? "Your team has checked in. Contact the TO to leave the team." - : "Registration has closed. Contact the TO to leave the team."} + {organizerAdded + ? "You were added to the team by the organizer. Contact the TO to leave the team." + : checkedIn + ? "Your team has checked in. Contact the TO to leave the team." + : "Registration has closed. Contact the TO to leave the team."} ); } diff --git a/e2e/pages/tournament/tournament-admin-registration-page.ts b/e2e/pages/tournament/tournament-admin-registration-page.ts index 1b7d5ff0e..0a72a818e 100644 --- a/e2e/pages/tournament/tournament-admin-registration-page.ts +++ b/e2e/pages/tournament/tournament-admin-registration-page.ts @@ -61,7 +61,9 @@ export class TournamentAdminRegistrationPage { } async selectCaptain(userId: number) { - await this.page.getByLabel("Captain").selectOption(String(userId)); + await this.page + .getByLabel("Captain", { exact: true }) + .selectOption(String(userId)); } save() { diff --git a/e2e/pages/tournament/tournament-nav.ts b/e2e/pages/tournament/tournament-nav.ts index 240306e90..ccf2a0f7b 100644 --- a/e2e/pages/tournament/tournament-nav.ts +++ b/e2e/pages/tournament/tournament-nav.ts @@ -13,6 +13,7 @@ export class TournamentNav { this.page = page; this.locators = { teamsTab: page.locator('[data-testid="teams-tab"]:visible'), + registerTab: page.locator('[data-testid="register-tab"]:visible'), }; } diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 04a9b3283..4732f6f3d 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -26,6 +26,13 @@ export class TournamentRegisterPage { }); this.locators = { fillRosterHeading: page.getByText("Fill roster"), + registrationClosedAlert: page.getByText( + "Registration for this tournament has closed", + ), + leaveTeamButton: page.getByRole("button", { name: "Leave the team" }), + organizerAddedLeaveExplanation: page.getByText( + "You were added to the team by the organizer. Contact the TO to leave the team.", + ), }; } diff --git a/e2e/pages/tournament/tournament-teams-page.ts b/e2e/pages/tournament/tournament-teams-page.ts index 9cf522a89..fb164bd2f 100644 --- a/e2e/pages/tournament/tournament-teams-page.ts +++ b/e2e/pages/tournament/tournament-teams-page.ts @@ -21,4 +21,8 @@ export class TournamentTeamsPage { memberNamed(name: string) { return this.locators.teamMemberNames.getByText(name); } + + teamNamed(name: string) { + return this.locators.teamNames.getByText(name); + } } diff --git a/e2e/tournament-invitational.spec.ts b/e2e/tournament-invitational.spec.ts new file mode 100644 index 000000000..3b960bc8f --- /dev/null +++ b/e2e/tournament-invitational.spec.ts @@ -0,0 +1,97 @@ +import { addHours } from "date-fns"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import type { Factories } from "./helpers/factories"; +import { expect, impersonate, test } from "./helpers/playwright"; +import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page"; +import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page"; +import { TournamentRegisterPage } from "./pages/tournament/tournament-register-page"; +import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page"; + +test.describe("Invitational tournament", () => { + test("team can't register on their own, the TO adds them instead", async ({ + page, + factories, + }) => { + const tournament = await createInvitational(factories); + const captain = await factories.UserFactory.create({ + discordName: "Captain Carla", + }); + + await impersonate(page, captain.id); + + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + await expect(register.locators.registrationClosedAlert).toBeVisible(); + await expect(register.nav.locators.registerTab).toHaveCount(0); + + await impersonate(page, NZAP_TEST_ID); + + const registration = new TournamentAdminRegistrationPage(page); + await registration.gotoNew(tournament.id); + await expect(registration.locators.addHeading).toBeVisible(); + + await registration.form.fill("pickUpName", "Invited Squad"); + await registration.selectPlayer("Captain Carla"); + await registration.selectCaptain(captain.id); + await registration.save(); + + const admin = new TournamentAdminPage(page); + await expect(admin.teamName("Invited Squad")).toBeVisible(); + + await impersonate(page, captain.id); + + const teams = new TournamentTeamsPage(page); + await teams.goto(tournament.id); + await expect(teams.teamNamed("Invited Squad")).toBeVisible(); + await expect(teams.memberNamed("Captain Carla")).toBeVisible(); + + // as the captain of an invitational team they can now manage the registration + await expect(register.nav.locators.registerTab).toBeVisible(); + }); + + test("member added by the TO can't leave the team", async ({ + page, + factories, + }) => { + const tournament = await createInvitational(factories); + const captain = await factories.UserFactory.create({ + discordName: "Captain Carla", + }); + const member = await factories.UserFactory.create({ + discordName: "Member Mia", + }); + + await impersonate(page, NZAP_TEST_ID); + + const registration = new TournamentAdminRegistrationPage(page); + await registration.gotoNew(tournament.id); + await expect(registration.locators.addHeading).toBeVisible(); + + await registration.form.fill("pickUpName", "Invited Squad"); + await registration.selectPlayer("Captain Carla"); + await registration.addMember("Member Mia"); + await registration.selectCaptain(captain.id); + await registration.save(); + + const admin = new TournamentAdminPage(page); + await expect(admin.teamName("Invited Squad")).toBeVisible(); + + await impersonate(page, member.id); + + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + await register.locators.leaveTeamButton.click(); + await expect( + register.locators.organizerAddedLeaveExplanation, + ).toBeVisible(); + }); +}); + +function createInvitational(factories: Factories) { + return factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + isInvitational: true, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); +} diff --git a/migrations/20260805190302-tournament-team-member-organizer-added.ts b/migrations/20260805190302-tournament-team-member-organizer-added.ts new file mode 100644 index 000000000..163c16e37 --- /dev/null +++ b/migrations/20260805190302-tournament-team-member-organizer-added.ts @@ -0,0 +1,12 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("TournamentTeamMember") + .addColumn("isOrganizerAdded", "integer", (col) => + col.notNull().defaultTo(sql`0`), + ) + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 7bab47b1b..f65d016e8 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -1019,6 +1019,15 @@ export function buildCases(fx: Fixtures): { fx.tournamentTeamPair, (teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds), ); + add( + "TournamentTeamRepository.isOrganizerAddedMember", + both(fx.heavyTournamentTeamId, fx.heavyUser), + ([tournamentTeamId, user]) => + TournamentTeamRepository.isOrganizerAddedMember({ + tournamentTeamId, + userId: user.id, + }), + ); // TrophyRepository addStatic("TrophyRepository.all", () => TrophyRepository.all()); From 1c3d6da4d1145c6abd9eabe7f6ad35a8904d617b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:58:47 +0300 Subject: [PATCH 03/14] New bracket progression form UI (#3309) --- app/components/DateInput.tsx | 71 -- app/db/tables-json.ts | 2 +- .../calendar/actions/calendar.new.server.ts | 19 +- app/features/calendar/calendar-new-schemas.ts | 34 +- .../calendar-progression-form.test.ts | 235 +++++++ .../calendar/calendar-progression-form.ts | 386 +++++++++++ .../BracketProgressionFormFields.module.css | 22 + .../BracketProgressionFormFields.tsx | 321 +++++++++ .../BracketProgressionSelector.module.css | 11 - .../components/BracketProgressionSelector.tsx | 656 ------------------ app/features/calendar/routes/calendar.new.tsx | 52 +- .../routes/to.$id.admin.brackets.tsx | 80 ++- app/features/tournament/tournament-utils.ts | 35 +- app/form/FormField.tsx | 4 + app/form/SendouForm.browser.test.tsx | 46 ++ app/form/fields.ts | 22 +- app/form/fields/ArrayFormField.tsx | 6 + app/form/fields/FormFieldWrapper.tsx | 32 +- app/form/fields/InputFormField.tsx | 3 + app/utils/dates.ts | 23 - e2e/calendar.spec.ts | 2 +- e2e/helpers/playwright-form.ts | 2 +- e2e/pages/calendar/calendar-new-event-page.ts | 24 +- e2e/pages/tournament/tournament-admin-page.ts | 15 + e2e/tournament-admin.spec.ts | 37 + e2e/tournament-bracket-multi-stage.spec.ts | 6 +- locales/da/forms.json | 32 + locales/de/forms.json | 32 + locales/en/forms.json | 32 + locales/es-ES/forms.json | 32 + locales/es-US/forms.json | 32 + locales/fr-CA/forms.json | 32 + locales/fr-EU/forms.json | 32 + locales/he/forms.json | 32 + locales/it/forms.json | 32 + locales/ja/forms.json | 32 + locales/ko/forms.json | 32 + locales/nl/forms.json | 32 + locales/pl/forms.json | 32 + locales/pt-BR/forms.json | 32 + locales/ru/forms.json | 32 + locales/zh/forms.json | 32 + 42 files changed, 1748 insertions(+), 910 deletions(-) delete mode 100644 app/components/DateInput.tsx create mode 100644 app/features/calendar/calendar-progression-form.test.ts create mode 100644 app/features/calendar/calendar-progression-form.ts create mode 100644 app/features/calendar/components/BracketProgressionFormFields.module.css create mode 100644 app/features/calendar/components/BracketProgressionFormFields.tsx delete mode 100644 app/features/calendar/components/BracketProgressionSelector.module.css delete mode 100644 app/features/calendar/components/BracketProgressionSelector.tsx diff --git a/app/components/DateInput.tsx b/app/components/DateInput.tsx deleted file mode 100644 index b77a5714a..000000000 --- a/app/components/DateInput.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react"; -import { useHydrated } from "~/hooks/useHydrated"; -import { dateToYearMonthDayHourMinuteString, isValidDate } from "~/utils/dates"; -import { logger } from "~/utils/logger"; - -export interface DateInputProps - extends Omit< - React.InputHTMLAttributes, - "defaultValue" | "min" | "max" | "onChange" | "value" - > { - defaultValue?: Date; - min?: Date; - max?: Date; - onChange?: (newDate: Date | null) => void; -} - -export function DateInput({ - name, - defaultValue, - min, - max, - onChange, - ...inputProps -}: DateInputProps) { - // Keeping track of the value as a string is a nice fallback for browsers that - // don't show a date picker but actually expect the user to type in the date - // as a text. This was Safari Desktop until recently, but nowadays all current - // versions of the main browsers set the input to either a valid date string - // or "". (The browser will handle transitional invalid states internally). - const [[parsedDate, valueString], setDate] = React.useState< - [Date | null, string] - >(() => { - if (defaultValue) { - if (isValidDate(defaultValue)) { - return [defaultValue, dateToYearMonthDayHourMinuteString(defaultValue)]; - } - logger.warn("DateInput got invalid date as defaultValue"); - } - return [null, ""]; - }); - const isHydrated = useHydrated(); - - return ( - <> - {parsedDate && isHydrated && ( - - )} - { - const newValueString = e.target.value; - const parsedValue = new Date(newValueString); - const newDate = isValidDate(parsedValue) ? parsedValue : null; - - setDate([newDate, newValueString]); - onChange?.(newDate); - }} - // Firefox fix for hydration error "prop `disabled` did not match" */ - // https://github.com/facebook/react/issues/21459 - autoComplete="off" - /> - - ); -} diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index eaeb2000c..9d6d2c89a 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -202,7 +202,7 @@ export interface CustomPickBanFlow { postGame: CustomPickBanStep[]; } -// when updating this also update `defaultBracketSettings` in tournament-utils.ts +// when updating this also update `settingsFromFormValues` in calendar-progression-form.ts export interface TournamentStageSettings { // SE thirdPlaceMatch?: boolean; diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 8f0a94c1b..bb4633b30 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -6,6 +6,7 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { notify } from "~/features/notifications/core/notify.server"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import { clearTournamentDataCache, tournamentFromDB, @@ -28,6 +29,7 @@ import { pathnameFromPotentialURL } from "~/utils/strings"; import { calendarEventPage } from "~/utils/urls"; import { CALENDAR_EVENT } from "../calendar-constants"; import { calendarNewSchemaServer } from "../calendar-new-schemas.server"; +import { formValuesToInputBrackets } from "../calendar-progression-form"; import { canEditCalendarEvent, regClosesAtDate } from "../calendar-utils"; import { findValidOrganizations } from "../loaders/calendar.new.server"; @@ -108,7 +110,7 @@ export const action: ActionFunction = async ({ request }) => { toToolsEnabled: Number(data.toToolsEnabled), toToolsMode: rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, - bracketProgression: data.bracketProgression ?? null, + bracketProgression: bracketProgressionFromFormData(data), minMembersPerTeam: Number(data.minMembersPerTeam), maxMembersPerTeam: data.minMembersPerTeam === "4" && data.maxMembersPerTeam @@ -222,6 +224,21 @@ export const action: ActionFunction = async ({ request }) => { throw redirect(calendarEventPage(createdEventId)); }; +/** Resolves the validated bracket progression from the `brackets` + `progression` form fields (already validated by the schema's refine). */ +function bracketProgressionFromFormData(data: { + toToolsEnabled: boolean; + brackets: Parameters[0]; + progression: Parameters[1]; +}) { + if (!data.toToolsEnabled || data.brackets.length === 0) return null; + + const validated = Progression.validatedBrackets( + formValuesToInputBrackets(data.brackets, data.progression), + ); + + return Progression.isBrackets(validated) ? validated : null; +} + /** Checks user has permissions to create a tournament in this organization */ async function validateOrganization({ userId, diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index 21b3699be..d07287993 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -21,7 +21,11 @@ import { import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { id } from "~/utils/zod"; import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; -import { bracketProgressionSchema } from "./calendar-schemas"; +import { + bracketsFormField, + progressionFormField, + validateBracketProgressionFormValues, +} from "./calendar-progression-form"; import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; /** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */ @@ -120,10 +124,10 @@ export const calendarNewBaseSchema = z.object({ ], }), pool: customField({ initialValue: "" }, z.string().optional()), - bracketProgression: customField( - { initialValue: null }, - bracketProgressionSchema.nullish(), - ), + // the two bracket progression fields are only rendered (and validated) for + // tournaments; for calendar events both stay at their empty initial value + brackets: bracketsFormField, + progression: progressionFormField, isRanked: toggle({ label: "labels.ranked", bottomText: "bottomTexts.ranked", @@ -190,12 +194,20 @@ export function calendarNewSyncRefine( }); } - if (data.toToolsEnabled && !data.bracketProgression) { - ctx.addIssue({ - path: ["bracketProgression"], - code: z.ZodIssueCode.custom, - message: "forms:errors.bracketProgressionRequired", - }); + if (data.toToolsEnabled) { + if (data.brackets.length === 0) { + ctx.addIssue({ + path: ["brackets"], + code: z.ZodIssueCode.custom, + message: "forms:errors.bracketProgressionRequired", + }); + } else { + validateBracketProgressionFormValues( + data.brackets, + data.progression, + ctx, + ); + } } // "Prepicked by teams - All modes" requires one tiebreaker map per ranked mode diff --git a/app/features/calendar/calendar-progression-form.test.ts b/app/features/calendar/calendar-progression-form.test.ts new file mode 100644 index 000000000..203bf1327 --- /dev/null +++ b/app/features/calendar/calendar-progression-form.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { + defaultBracketsFormValues, + formValuesToInputBrackets, + progressionToFormValues, + validateBracketProgressionFormValues, +} from "./calendar-progression-form"; + +const DOUBLE_ELIMINATION: Progression.ParsedBracket[] = [ + { + name: "Main Bracket", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + }, +]; + +const RR_TO_SE_WITH_UNDERGROUND: Progression.ParsedBracket[] = [ + { + name: "Groups stage", + type: "round_robin", + settings: { teamsPerGroup: 4 }, + requiresCheckIn: false, + }, + { + name: "Top cut", + type: "single_elimination", + settings: { thirdPlaceMatch: false }, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + { + name: "Underground bracket", + type: "single_elimination", + settings: { thirdPlaceMatch: false }, + requiresCheckIn: true, + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, +]; + +const SWISS_EARLY_ADVANCE_TO_TOP_CUT: Progression.ParsedBracket[] = [ + { + name: "Swiss", + type: "swiss", + settings: { groupCount: 1, roundCount: 5, advanceThreshold: 3 }, + requiresCheckIn: false, + }, + { + name: "Top cut", + type: "single_elimination", + settings: { thirdPlaceMatch: true }, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [] }], + }, +]; + +function roundTrip(progression: Progression.ParsedBracket[]) { + const formValues = progressionToFormValues(progression); + return Progression.validatedBrackets( + formValuesToInputBrackets(formValues.brackets, formValues.progression), + ); +} + +function validationIssues(formValues: { + brackets: Parameters[0]; + progression: Parameters[1]; +}) { + const issues: z.ZodIssue[] = []; + const ctx = { + addIssue: (issue: z.ZodIssue) => issues.push(issue), + path: [], + } as unknown as z.RefinementCtx; + + validateBracketProgressionFormValues( + formValues.brackets, + formValues.progression, + ctx, + ); + + return issues; +} + +describe("progressionToFormValues + formValuesToInputBrackets", () => { + it("round-trips a single double elimination bracket", () => { + expect(roundTrip(DOUBLE_ELIMINATION)).toEqual(DOUBLE_ELIMINATION); + }); + + it("round-trips round robin to single elimination with an underground bracket", () => { + expect(roundTrip(RR_TO_SE_WITH_UNDERGROUND)).toEqual( + RR_TO_SE_WITH_UNDERGROUND, + ); + }); + + it("round-trips swiss with early advance (empty placements)", () => { + expect(roundTrip(SWISS_EARLY_ADVANCE_TO_TOP_CUT)).toEqual( + SWISS_EARLY_ADVANCE_TO_TOP_CUT, + ); + }); + + it("round-trips the N+ rest placements syntax", () => { + const progression: Progression.ParsedBracket[] = [ + RR_TO_SE_WITH_UNDERGROUND[0], + RR_TO_SE_WITH_UNDERGROUND[1], + { + ...RR_TO_SE_WITH_UNDERGROUND[2], + sources: [{ bracketIdx: 0, placements: [3, 4], rest: true }], + }, + ]; + + expect(roundTrip(progression)).toEqual(progression); + }); + + it("round-trips bracket start time", () => { + const progression: Progression.ParsedBracket[] = [ + RR_TO_SE_WITH_UNDERGROUND[0], + { ...RR_TO_SE_WITH_UNDERGROUND[1], startTime: 1735689600 }, + RR_TO_SE_WITH_UNDERGROUND[2], + ]; + + expect(roundTrip(progression)).toEqual(progression); + }); + + it("ignores stale settings of other format types", () => { + const { brackets, progression } = defaultBracketsFormValues(); + const withStaleSettings = [ + { ...brackets[0], hasAbDivisions: true, earlyAdvance: true }, + ]; + + const validated = Progression.validatedBrackets( + formValuesToInputBrackets(withStaleSettings, progression), + ); + + expect(validated).toEqual([ + { + name: "Main Bracket", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + }, + ]); + }); + + it("ignores placements and check-in of a bracket sourcing from sign-up", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[2] = { + ...formValues.progression[2], + source: "SIGN_UP", + }; + + const validated = Progression.validatedBrackets( + formValuesToInputBrackets(formValues.brackets, formValues.progression), + ); + + expect(Progression.isBrackets(validated)).toBe(true); + expect((validated as Progression.ParsedBracket[])[2]).toMatchObject({ + sources: undefined, + requiresCheckIn: false, + }); + }); +}); + +describe("validateBracketProgressionFormValues", () => { + it("accepts the default form values", () => { + expect(validationIssues(defaultBracketsFormValues())).toHaveLength(0); + }); + + it("attaches unparseable placements to the progression entry", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[1] = { + ...formValues.progression[1], + placements: "not placements", + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "placements"]); + expect(issues[0].message).toBe( + "tournament:progression.error.PLACEMENTS_PARSE_ERROR", + ); + }); + + it("attaches a duplicate bracket name to both name fields", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.brackets[2] = { ...formValues.brackets[2], name: "Top cut" }; + + const issues = validationIssues(formValues); + + expect(issues.map((issue) => issue.path)).toEqual([ + ["brackets", 1, "name"], + ["brackets", 2, "name"], + ]); + }); + + it("rejects an out of range source bracket", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[1] = { + ...formValues.progression[1], + sourceBracketIdx: "10", + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + }); + + it("rejects a non-canonical source bracket idx string", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[1] = { + ...formValues.progression[1], + sourceBracketIdx: "00", + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + }); + + it("rejects a bracket sourcing itself", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[1] = { + ...formValues.progression[1], + sourceBracketIdx: "1", + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + }); +}); diff --git a/app/features/calendar/calendar-progression-form.ts b/app/features/calendar/calendar-progression-form.ts new file mode 100644 index 000000000..f8ef53e75 --- /dev/null +++ b/app/features/calendar/calendar-progression-form.ts @@ -0,0 +1,386 @@ +import { z } from "zod"; +import type { Tables } from "~/db/tables"; +import type { TournamentStageSettings } from "~/db/tables-json"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { + array, + datetimeOptional, + fieldset, + radioGroup, + select, + selectDynamic, + textField, + textFieldOptional, + toggle, +} from "~/form/fields"; +import { assertUnreachable } from "~/utils/types"; + +const SWISS_DEFAULT_ADVANCE_THRESHOLD = 3; + +export interface BracketFormValue { + name: string; + type: Tables["TournamentStage"]["type"]; + thirdPlaceMatch: boolean; + teamsPerGroup: string; + hasAbDivisions: boolean; + groupCount: string; + roundCount: string; + earlyAdvance: boolean; + advanceThreshold: string; + startTime?: Date | null; + requiresCheckIn: boolean; +} + +export interface ProgressionFormValue { + source: "SIGN_UP" | "BRACKET"; + /** Index of the source bracket in the `brackets` form field, as a string (select value). */ + sourceBracketIdx: string; + placements: string | null; +} + +// extracted so their literal item values don't widen to `string` in the +// fieldset's inferred value type +const bracketTypeField = select({ + label: "labels.format", + items: [ + { + value: "single_elimination", + label: "options.format.single_elimination", + }, + { + value: "double_elimination", + label: "options.format.double_elimination", + }, + { value: "round_robin", label: "options.format.round_robin" }, + { value: "swiss", label: "options.format.swiss" }, + ], + initialValue: "double_elimination", +}); + +const progressionSourceField = radioGroup({ + label: "labels.teamsJoinFrom", + items: [ + { value: "SIGN_UP", label: "options.bracketSource.SIGN_UP" }, + { value: "BRACKET", label: "options.bracketSource.BRACKET" }, + ], +}); + +const bracketFieldset = fieldset({ + fields: z.object({ + name: textField({ + label: "labels.bracketName", + maxLength: TOURNAMENT.BRACKET_NAME_MAX_LENGTH, + }), + type: bracketTypeField, + thirdPlaceMatch: toggle({ + label: "labels.thirdPlaceMatch", + initialValue: TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH, + }), + teamsPerGroup: selectDynamic({ + label: "labels.teamsPerGroup", + bottomText: "bottomTexts.teamsPerGroup", + initialValue: String(TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP), + }), + hasAbDivisions: toggle({ + label: "labels.abDivisions", + bottomText: "bottomTexts.abDivisions", + }), + groupCount: select({ + label: "labels.groupCount", + items: [1, 2, 3, 4, 5, 6].map((count) => ({ + value: String(count), + label: () => String(count), + })), + initialValue: String(TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT), + }), + roundCount: select({ + label: "labels.roundCount", + items: [3, 4, 5, 6, 7, 8].map((count) => ({ + value: String(count), + label: () => String(count), + })), + initialValue: String(TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT), + }), + earlyAdvance: toggle({ + label: "labels.earlyAdvance", + bottomText: "bottomTexts.earlyAdvance", + }), + advanceThreshold: selectDynamic({ + label: "labels.advanceThreshold", + initialValue: String(SWISS_DEFAULT_ADVANCE_THRESHOLD), + }), + startTime: datetimeOptional({ + label: "labels.startTime", + bottomText: "bottomTexts.bracketStartTime", + }), + requiresCheckIn: toggle({ + label: "labels.requiresCheckIn", + bottomText: "bottomTexts.requiresCheckIn", + }), + }), +}); + +const progressionEntryFieldset = fieldset({ + fields: z.object({ + source: progressionSourceField, + sourceBracketIdx: selectDynamic({ + label: "labels.sourceBracket", + initialValue: "0", + }), + placements: textFieldOptional({ + label: "labels.placements", + placeholder: "placeholders.placements", + maxLength: 100, + }), + }), +}); + +export const bracketsFormField = array({ + label: "labels.brackets", + max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT, + field: bracketFieldset, +}); + +export const progressionFormField = array({ + label: "labels.progression", + max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT, + field: progressionEntryFieldset, + addable: false, +}); + +/** Standalone schema for forms that edit only the bracket progression (tournament admin page). */ +export const bracketProgressionFormSchema = z + .object({ + brackets: bracketsFormField, + progression: progressionFormField, + }) + .superRefine((data, ctx) => { + validateBracketProgressionFormValues(data.brackets, data.progression, ctx); + }); + +/** Form field values of a new tournament's single starting bracket. Used to seed form default values. */ +export function defaultBracketsFormValues(): { + brackets: BracketFormValue[]; + progression: ProgressionFormValue[]; +} { + return { + brackets: [{ ...newBracketFormValue(), name: "Main Bracket" }], + progression: [{ source: "SIGN_UP", sourceBracketIdx: "0", placements: "" }], + }; +} + +function newBracketFormValue(): BracketFormValue { + return { + name: "", + type: "double_elimination", + thirdPlaceMatch: TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH, + teamsPerGroup: String(TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP), + hasAbDivisions: false, + groupCount: String(TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT), + roundCount: String(TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT), + earlyAdvance: false, + advanceThreshold: String(SWISS_DEFAULT_ADVANCE_THRESHOLD), + startTime: null, + requiresCheckIn: false, + }; +} + +/** Progression form field value appended when a new bracket is added: a follow-up bracket sourcing teams from the first bracket. */ +export function newFollowUpProgressionEntry(): ProgressionFormValue { + return { source: "BRACKET", sourceBracketIdx: "0", placements: "" }; +} + +/** Converts the `brackets` + `progression` form values into {@link Progression.InputBracket} format ready for validation. */ +export function formValuesToInputBrackets( + brackets: BracketFormValue[], + progression: ProgressionFormValue[], +): Progression.InputBracket[] { + return brackets.map((bracket, bracketIdx) => { + const entry = progression[bracketIdx]; + const isFollowUp = bracketIdx > 0 && entry?.source === "BRACKET"; + + if (!isFollowUp) { + return { + id: String(bracketIdx), + name: bracket.name, + type: bracket.type, + settings: settingsFromFormValues(bracket, true), + requiresCheckIn: false, + }; + } + + return { + id: String(bracketIdx), + name: bracket.name, + type: bracket.type, + settings: settingsFromFormValues(bracket, false), + requiresCheckIn: bracket.requiresCheckIn, + startTime: bracket.startTime ?? undefined, + sources: [ + { + bracketId: entry.sourceBracketIdx, + placements: sourceBracketHasEarlyAdvance(brackets, entry) + ? "" + : (entry.placements ?? ""), + }, + ], + }; + }); +} + +/** Converts stored bracket progression into the `brackets` + `progression` form field values. */ +export function progressionToFormValues( + progression: Progression.ParsedBracket[], +): { + brackets: BracketFormValue[]; + progression: ProgressionFormValue[]; +} { + const input = Progression.validatedBracketsToInputFormat(progression); + + return { + brackets: input.map((bracket) => ({ + name: bracket.name, + type: bracket.type, + thirdPlaceMatch: Boolean( + bracket.settings.thirdPlaceMatch ?? + TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH, + ), + teamsPerGroup: String( + bracket.settings.teamsPerGroup ?? + TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP, + ), + hasAbDivisions: Boolean(bracket.settings.hasAbDivisions), + groupCount: String( + bracket.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT, + ), + roundCount: String( + bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + ), + earlyAdvance: typeof bracket.settings.advanceThreshold === "number", + advanceThreshold: String( + bracket.settings.advanceThreshold ?? SWISS_DEFAULT_ADVANCE_THRESHOLD, + ), + startTime: bracket.startTime ?? null, + requiresCheckIn: bracket.requiresCheckIn, + })), + progression: input.map((bracket) => ({ + source: bracket.sources ? "BRACKET" : "SIGN_UP", + sourceBracketIdx: bracket.sources?.[0]?.bracketId ?? "0", + placements: bracket.sources?.[0]?.placements ?? "", + })), + }; +} + +/** Does the source bracket of the given progression entry advance teams via a Swiss early advance threshold (meaning placements are not specified)? */ +export function sourceBracketHasEarlyAdvance( + brackets: BracketFormValue[], + entry: ProgressionFormValue, +) { + const sourceBracket = brackets[Number(entry.sourceBracketIdx)]; + return sourceBracket?.type === "swiss" && sourceBracket.earlyAdvance; +} + +/** Validates the `brackets` + `progression` form values together via {@link Progression.validatedBrackets}, attaching each error to the closest form field. */ +export function validateBracketProgressionFormValues( + brackets: BracketFormValue[], + progression: ProgressionFormValue[], + ctx: z.RefinementCtx, +) { + for (const [entryIdx, entry] of progression.entries()) { + if (entryIdx === 0 || entry.source !== "BRACKET") continue; + + const sourceIdx = Number(entry.sourceBracketIdx); + if ( + !Number.isInteger(sourceIdx) || + String(sourceIdx) !== entry.sourceBracketIdx || + sourceIdx < 0 || + sourceIdx >= brackets.length || + sourceIdx === entryIdx + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.invalidSourceBracket", + path: ["progression", entryIdx, "sourceBracketIdx"], + }); + return; + } + } + + const validated = Progression.validatedBrackets( + formValuesToInputBrackets(brackets, progression), + ); + if (!Progression.isError(validated)) return; + + for (const path of progressionErrorPaths(validated)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + validated.type === "PLACEMENT_TOO_HIGH" + ? "forms:errors.placementTooHigh" + : `tournament:progression.error.${validated.type}`, + path, + }); + } +} + +function progressionErrorPaths( + error: Progression.ValidationError, +): Array> { + switch (error.type) { + case "NOT_RESOLVING_WINNER": + return [["progression"]]; + case "NAME_MISSING": + return [["brackets", error.bracketIdx, "name"]]; + case "DUPLICATE_BRACKET_NAME": + return error.bracketIdxs.map((idx) => ["brackets", idx, "name"]); + case "SWISS_EARLY_ADVANCE_NO_DESTINATION": + return [["brackets", error.bracketIdx, "earlyAdvance"]]; + case "AB_DIVISIONS_NOT_ROUND_ROBIN": + case "AB_DIVISIONS_NOT_STARTING": + case "AB_DIVISIONS_ODD_TEAMS_PER_GROUP": + return [["brackets", error.bracketIdx, "hasAbDivisions"]]; + case "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": + case "GAP_IN_PLACEMENTS": + return error.bracketIdxs.map((idx) => ["progression", idx, "placements"]); + case "PLACEMENTS_PARSE_ERROR": + case "TOO_MANY_PLACEMENTS": + case "PLACEMENT_TOO_HIGH": + case "NEGATIVE_PROGRESSION": + case "NO_SE_POSITIVE": + case "NO_DE_POSITIVE": + case "EMPTY_PLACEMENTS_ON_NON_SWISS": + return [["progression", error.bracketIdx, "placements"]]; + default: + assertUnreachable(error); + } +} + +function settingsFromFormValues( + bracket: BracketFormValue, + isStartingBracket: boolean, +): TournamentStageSettings { + switch (bracket.type) { + case "single_elimination": + return { thirdPlaceMatch: bracket.thirdPlaceMatch }; + case "double_elimination": + return {}; + case "round_robin": + return { + teamsPerGroup: Number(bracket.teamsPerGroup), + ...(isStartingBracket && bracket.hasAbDivisions + ? { hasAbDivisions: true } + : {}), + }; + case "swiss": + return { + groupCount: Number(bracket.groupCount), + roundCount: Number(bracket.roundCount), + ...(bracket.earlyAdvance + ? { advanceThreshold: Number(bracket.advanceThreshold) } + : {}), + }; + default: + assertUnreachable(bracket.type); + } +} diff --git a/app/features/calendar/components/BracketProgressionFormFields.module.css b/app/features/calendar/components/BracketProgressionFormFields.module.css new file mode 100644 index 000000000..45bc0d6f9 --- /dev/null +++ b/app/features/calendar/components/BracketProgressionFormFields.module.css @@ -0,0 +1,22 @@ +.syntaxCode { + background-color: var(--color-bg-higher); + padding: 2px 6px; + border-radius: var(--radius-field); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + display: inline-block; + min-width: 3.5rem; + text-align: center; +} + +.syntaxExample { + display: flex; + gap: var(--s-2); + align-items: baseline; + font-size: var(--font-xs); + margin-block: var(--s-2); +} + +.syntaxExplanation { + flex: 1; +} diff --git a/app/features/calendar/components/BracketProgressionFormFields.tsx b/app/features/calendar/components/BracketProgressionFormFields.tsx new file mode 100644 index 000000000..17697ff93 --- /dev/null +++ b/app/features/calendar/components/BracketProgressionFormFields.tsx @@ -0,0 +1,321 @@ +import { useTranslation } from "react-i18next"; +import { FormMessage } from "~/components/FormMessage"; +import { InfoPopover } from "~/components/InfoPopover"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; +import { FormField } from "~/form/FormField"; +import { useFormFieldContext } from "~/form/SendouForm"; +import type { ArrayItemRenderContext } from "~/form/types"; +import { + type BracketFormValue, + newFollowUpProgressionEntry, + type ProgressionFormValue, + sourceBracketHasEarlyAdvance, +} from "../calendar-progression-form"; +import styles from "./BracketProgressionFormFields.module.css"; + +const DEFAULT_ADVANCE_THRESHOLD = "3"; + +export function BracketProgressionFormFields({ + isInvitational, + disabledBracketIdxs = [], + isTournamentInProgress = false, +}: { + isInvitational: boolean; + /** Idxs of brackets that have already started and can no longer be edited or deleted. */ + disabledBracketIdxs?: number[]; + /** When the tournament is in progress, which brackets are starting brackets can no longer be changed. */ + isTournamentInProgress?: boolean; +}) { + const { values, setValue } = useFormFieldContext(); + const brackets = (values.brackets ?? []) as BracketFormValue[]; + const progression = (values.progression ?? []) as ProgressionFormValue[]; + + // the array field's own add/remove buttons only report the new value, so the + // removed bracket is located by reference diffing against the previous value + const handleBracketsChanged = (newValue: unknown) => { + const newBrackets = newValue as BracketFormValue[]; + + if (newBrackets.length > progression.length) { + setValue("progression", [ + ...progression, + ...Array.from( + { length: newBrackets.length - progression.length }, + newFollowUpProgressionEntry, + ), + ]); + return; + } + + if (newBrackets.length < progression.length) { + const removedIdx = brackets.findIndex( + (bracket, idx) => newBrackets[idx] !== bracket, + ); + setValue( + "progression", + progressionAfterBracketDelete( + progression, + removedIdx === -1 ? progression.length - 1 : removedIdx, + ).slice(0, Math.max(newBrackets.length, 1)), + ); + } + }; + + return ( + <> + + idx !== 0 && + disabledBracketIdxs.every((disabledIdx) => disabledIdx < idx) + } + onValueChange={handleBracketsChanged} + > + {(renderContext: ArrayItemRenderContext) => ( + + )} + + {brackets.length > 1 ? ( + false}> + {(renderContext: ArrayItemRenderContext) => ( + + )} + + ) : null} + + ); +} + +function BracketFields({ + renderContext, + isDisabled, +}: { + renderContext: ArrayItemRenderContext; + isDisabled: boolean; +}) { + const { t } = useTranslation(["forms"]); + const { index, itemName, values, formValues, setItemField } = renderContext; + const bracket = values as unknown as BracketFormValue; + const progression = (formValues.progression ?? []) as ProgressionFormValue[]; + + const isFollowUp = index > 0 && progression[index]?.source === "BRACKET"; + + return ( +
+ + + + {bracket.type === "single_elimination" ? ( + + ) : null} + + {bracket.type === "round_robin" ? ( + ({ value: String(count), label: String(count) }))} + /> + ) : null} + + {bracket.type === "round_robin" && !isFollowUp ? ( + { + const teamsPerGroup = Number(bracket.teamsPerGroup); + const maxWithoutAb = Math.max( + ...TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS, + ); + + if (isSelected && teamsPerGroup % 2 !== 0) { + setItemField("teamsPerGroup", String(teamsPerGroup + 1)); + } else if (!isSelected && teamsPerGroup > maxWithoutAb) { + setItemField("teamsPerGroup", String(maxWithoutAb)); + } + }} + /> + ) : null} + + {bracket.type === "swiss" ? ( + <> + + { + if (!bracket.earlyAdvance) return; + if ( + !Swiss.isValidAdvanceThreshold({ + roundCount: Number(newRoundCount), + advanceThreshold: Number(bracket.advanceThreshold), + }) + ) { + setItemField("advanceThreshold", DEFAULT_ADVANCE_THRESHOLD); + } + }} + /> + + + ) : null} + + {bracket.type === "swiss" && bracket.earlyAdvance ? ( +
+ ({ + value: String(threshold), + label: String(threshold), + }))} + /> + + {t("forms:bottomTexts.advanceThresholdMaxLosses", { + maxLosses: + Swiss.eliminationThreshold({ + roundCount: Number(bracket.roundCount), + advanceThreshold: Number(bracket.advanceThreshold), + }) - 1, + })} + +
+ ) : null} + + {isFollowUp ? ( + <> + + + + ) : null} +
+ ); +} + +function ProgressionEntryFields({ + renderContext, + isInvitational, + isDisabled, + isSourceLocked, +}: { + renderContext: ArrayItemRenderContext; + isInvitational: boolean; + isDisabled: boolean; + isSourceLocked: boolean; +}) { + const { t } = useTranslation(["forms"]); + const { index, itemName, values, formValues } = renderContext; + const entry = values as unknown as ProgressionFormValue; + const brackets = (formValues.brackets ?? []) as BracketFormValue[]; + + const isFirstBracket = index === 0; + + const sourceBracketOptions = brackets.flatMap((bracket, bracketIdx) => + bracketIdx === index || !bracket.name + ? [] + : [{ value: String(bracketIdx), label: bracket.name }], + ); + + return ( +
+ {brackets[index]?.name ? ( +
{brackets[index].name}
+ ) : null} + + {!isFirstBracket && entry.source === "BRACKET" ? ( + <> + + {!sourceBracketHasEarlyAdvance(brackets, entry) ? ( + } + /> + ) : null} + + ) : ( + + {isInvitational + ? t("forms:progression.addedByOrganizer") + : t("forms:progression.joinFromSignUp")} + + )} +
+ ); +} + +function PlacementsSyntaxPopover() { + return ( + +
+ Which teams of the source bracket move to this bracket. Examples: +
+
+ 1,2,3 + Places 1, 2 and 3 +
+
+ 1-4 + Places 1 to 4 +
+
+ 5+ + + Place 5 and every place after + +
+
+ -1,-2 + + Teams eliminated in (losers) rounds 1 & 2 (elimination brackets only) + +
+
+ ); +} + +function progressionAfterBracketDelete( + progression: ProgressionFormValue[], + deletedIdx: number, +): ProgressionFormValue[] { + return progression + .filter((_, idx) => idx !== deletedIdx) + .map((entry) => { + const sourceIdx = Number(entry.sourceBracketIdx); + + if (sourceIdx === deletedIdx) { + return { ...entry, sourceBracketIdx: "0" }; + } + if (sourceIdx > deletedIdx) { + return { ...entry, sourceBracketIdx: String(sourceIdx - 1) }; + } + return entry; + }); +} diff --git a/app/features/calendar/components/BracketProgressionSelector.module.css b/app/features/calendar/components/BracketProgressionSelector.module.css deleted file mode 100644 index e65abcf1c..000000000 --- a/app/features/calendar/components/BracketProgressionSelector.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.count { - color: var(--color-accent-high); - font-size: var(--font-sm); - white-space: nowrap; -} - -.divider { - background-color: var(--color-accent-high); - width: 2px; - align-self: stretch; -} diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx deleted file mode 100644 index e6cf1137a..000000000 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ /dev/null @@ -1,656 +0,0 @@ -import { Plus } from "lucide-react"; -import { nanoid } from "nanoid"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { DateInput } from "~/components/DateInput"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { FormMessage } from "~/components/FormMessage"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; -import * as Progression from "~/features/tournament-bracket/core/Progression"; -import { defaultBracketSettings } from "../../tournament/tournament-utils"; -import styles from "./BracketProgressionSelector.module.css"; - -const defaultBracket = (): Progression.InputBracket => ({ - id: nanoid(), - name: "Main Bracket", - type: "double_elimination", - requiresCheckIn: false, - settings: {}, -}); - -/** Bracket progression the selector reports before the user makes any changes. Used to seed form default values. */ -export function defaultBracketProgression(): - | Progression.ParsedBracket[] - | null { - const validated = Progression.validatedBrackets([defaultBracket()]); - return Progression.isBrackets(validated) ? validated : null; -} - -export function BracketProgressionSelector({ - initialBrackets, - isInvitationalTournament, - onChange, - isTournamentInProgress, -}: { - initialBrackets?: Progression.InputBracket[]; - isInvitationalTournament: boolean; - /** Emits the validated brackets while valid, or `null` while invalid/incomplete. */ - onChange: (value: Progression.ParsedBracket[] | null) => void; - isTournamentInProgress: boolean; -}) { - const [brackets, setBrackets] = React.useState( - initialBrackets ?? [defaultBracket()], - ); - - const emit = (next: Progression.InputBracket[]) => { - const validatedNext = Progression.validatedBrackets(next); - onChange(Progression.isBrackets(validatedNext) ? validatedNext : null); - }; - - const handleAddBracket = () => { - const newBrackets = [ - ...brackets, - { - ...defaultBracket(), - id: nanoid(), - name: "", - sources: [ - { - bracketId: brackets[0].id, - placements: "", - }, - ], - }, - ]; - - setBrackets(newBrackets); - emit(newBrackets); - }; - - const handleDeleteBracket = (idx: number) => { - const newBrackets = brackets.filter((_, i) => i !== idx); - const newBracketIds = new Set(newBrackets.map((b) => b.id)); - - const updatedBrackets = newBrackets.map((b) => ({ - ...b, - sources: - newBrackets.length === 1 - ? undefined - : b.sources?.map((source) => ({ - ...source, - bracketId: newBracketIds.has(source.bracketId) - ? source.bracketId - : newBrackets[0].id, - })), - })); - - setBrackets(updatedBrackets); - emit(updatedBrackets); - }; - - const validated = Progression.validatedBrackets(brackets); - - return ( -
-
- {brackets.map((bracket, i) => ( - { - const newBrackets = structuredClone(brackets); - newBrackets[i] = newBracket; - - if (newBracket.settings.advanceThreshold) { - const destinationIdx = newBrackets.findIndex((b) => - b.sources?.some( - (source) => source.bracketId === newBracket.id, - ), - ); - - if (destinationIdx !== -1) { - newBrackets[destinationIdx].sources = newBrackets[ - destinationIdx - ].sources?.map((source) => ({ - ...source, - placements: "", - })); - } - } - - setBrackets(newBrackets); - emit(newBrackets); - }} - onDelete={ - i !== 0 && !bracket.disabled - ? () => handleDeleteBracket(i) - : undefined - } - count={i + 1} - isInvitationalTournament={isInvitationalTournament} - isTournamentInProgress={isTournamentInProgress} - /> - ))} -
- } - size="small" - variant="outlined" - onPress={handleAddBracket} - isDisabled={brackets.length >= TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT} - data-testid="add-bracket-button" - > - Add bracket - - {Progression.isError(validated) ? ( - - ) : null} -
- ); -} - -function TournamentFormatBracketSelector({ - bracket, - brackets, - onChange, - onDelete, - count, - isInvitationalTournament, - isTournamentInProgress, -}: { - bracket: Progression.InputBracket; - brackets: Progression.InputBracket[]; - onChange: (newBracket: Progression.InputBracket) => void; - onDelete?: () => void; - count: number; - isInvitationalTournament: boolean; - isTournamentInProgress: boolean; -}) { - const id = React.useId(); - - const createId = (name: string) => { - return `${id}-${name}`; - }; - - const isFirstBracket = count === 1; - - const updateBracket = (newProps: Partial) => { - const defaultSettings = newProps.type - ? defaultBracketSettings(newProps.type) - : undefined; - - onChange({ - ...bracket, - ...newProps, - settings: newProps.settings ?? defaultSettings ?? bracket.settings, - }); - }; - - return ( -
-
-
Bracket #{count}
- {onDelete ? ( - - Delete - - ) : null} -
-
-
-
- - updateBracket({ name: e.target.value })} - maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH} - readOnly={bracket.disabled} - /> -
- - {bracket.sources ? ( -
- - - updateBracket({ startTime: newDate ?? undefined }) - } - readOnly={bracket.disabled} - /> - - If missing, bracket can be started when the previous brackets have - finished - -
- ) : null} - - {bracket.sources ? ( -
- - - updateBracket({ requiresCheckIn: isSelected }) - } - isDisabled={bracket.disabled} - /> - - Check-in starts 1 hour before start time or right after the - previous bracket finishes if no start time is set - -
- ) : null} - -
- - -
- - {bracket.type === "single_elimination" ? ( -
- - - updateBracket({ - settings: { - ...bracket.settings, - thirdPlaceMatch: isSelected, - }, - }) - } - isDisabled={bracket.disabled} - /> -
- ) : null} - - {bracket.type === "round_robin" ? ( -
- - - - Participants are distributed equally, so groups may have fewer - than selected - -
- ) : null} - - {bracket.type === "round_robin" && !bracket.sources ? ( -
- - { - const currentTeamsPerGroup = - bracket.settings.teamsPerGroup ?? - TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; - - const maxWithoutAb = Math.max( - ...TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS, - ); - - let nextTeamsPerGroup = currentTeamsPerGroup; - if (isSelected && currentTeamsPerGroup % 2 !== 0) { - nextTeamsPerGroup = currentTeamsPerGroup + 1; - } else if (!isSelected && currentTeamsPerGroup > maxWithoutAb) { - nextTeamsPerGroup = maxWithoutAb; - } - - updateBracket({ - settings: { - ...bracket.settings, - hasAbDivisions: isSelected, - teamsPerGroup: nextTeamsPerGroup, - }, - }); - }} - isDisabled={bracket.disabled} - /> - - Teams split into A and B pools; every A plays every B once - -
- ) : null} - - {bracket.type === "swiss" ? ( -
- - -
- ) : null} - - {bracket.type === "swiss" ? ( -
- - -
- ) : null} - - {bracket.type === "swiss" ? ( -
- - - updateBracket({ - settings: { - ...bracket.settings, - advanceThreshold: isSelected ? 3 : undefined, - }, - }) - } - isDisabled={bracket.disabled} - /> - - Teams stop playing once they reach required wins or exceed maximum - losses - -
- ) : null} - - {bracket.type === "swiss" && bracket.settings.advanceThreshold ? ( -
- - - - Maximum losses allowed:{" "} - {Swiss.eliminationThreshold({ - roundCount: - bracket.settings.roundCount ?? - TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, - advanceThreshold: bracket.settings.advanceThreshold, - }) - 1} - -
- ) : null} - -
-
- {" "} -
- {!isFirstBracket ? ( -
- - updateBracket({ - sources: isSelected ? [] : undefined, - requiresCheckIn: false, - startTime: undefined, - }) - } - isDisabled={bracket.disabled || isTournamentInProgress} - data-testid="follow-up-bracket-switch" - /> - -
- ) : null} - {!bracket.sources ? ( - - {isInvitationalTournament - ? "Participants added by the organizer" - : "Participants join from sign-up"} - - ) : ( - bracket.id !== bracket2.id && bracket2.name, - )} - source={bracket.sources?.[0] ?? null} - onChange={(source) => updateBracket({ sources: [source] })} - /> - )} -
-
-
- ); -} - -function SourcesSelector({ - brackets, - source, - onChange, -}: { - brackets: Progression.InputBracket[]; - source: Progression.EditableSource | null; - onChange: (sources: Progression.EditableSource) => void; -}) { - const id = React.useId(); - - const createId = (label: string) => { - return `${id}-${label}`; - }; - - const inputBracket = brackets.find((b) => b.id === source?.bracketId); - - return ( -
-
-
- - -
- {!inputBracket?.settings.advanceThreshold ? ( -
- - - onChange({ - bracketId: brackets[0].id, - ...source, - placements: e.target.value, - }) - } - /> -
- ) : null} -
- {!inputBracket?.settings.advanceThreshold ? ( - - Use N+ for Nth place and every placement after - - ) : null} -
- ); -} - -function ErrorMessage({ error }: { error: Progression.ValidationError }) { - const { t } = useTranslation(["tournament"]); - - const bracketIdxsArr = (() => { - if (typeof (error as { bracketIdx: number }).bracketIdx === "number") { - return [(error as { bracketIdx: number }).bracketIdx]; - } - if ((error as { bracketIdxs: number[] }).bracketIdxs) { - return (error as { bracketIdxs: number[] }).bracketIdxs; - } - - return null; - })(); - - return ( - - Problems with the bracket progression - {bracketIdxsArr ? ( - <> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")}) - ) : null} - :{" "} - {t(`tournament:progression.error.${error.type}`, { - max: TOURNAMENT.PLACEMENT_MAX, - })} - - ); -} diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 54a00b173..9cbd4f157 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -13,7 +13,6 @@ import { MapPoolSelector } from "~/components/MapPoolSelector"; import { SubmitButton } from "~/components/SubmitButton"; import type { Tables } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import * as Progression from "~/features/tournament-bracket/core/Progression"; import { Trophy } from "~/features/trophies/components/Trophy"; import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; import { existingImage } from "~/form/image-field"; @@ -30,11 +29,12 @@ import { action } from "../actions/calendar.new.server"; import type { RegClosesAtOption } from "../calendar-constants"; import styles from "../calendar-new.module.css"; import { calendarNewBaseSchema } from "../calendar-new-schemas"; -import { datesToRegClosesAt } from "../calendar-utils"; import { - BracketProgressionSelector, - defaultBracketProgression, -} from "../components/BracketProgressionSelector"; + defaultBracketsFormValues, + progressionToFormValues, +} from "../calendar-progression-form"; +import { datesToRegClosesAt } from "../calendar-utils"; +import { BracketProgressionFormFields } from "../components/BracketProgressionFormFields"; import { loader } from "../loaders/calendar.new.server"; export { action, loader }; @@ -172,6 +172,12 @@ function useDefaultValues() { return ""; })(); + const bracketProgressionValues = settings?.bracketProgression + ? progressionToFormValues(settings.bracketProgression) + : data.isAddingTournament + ? defaultBracketsFormValues() + : { brackets: [], progression: [] }; + return { toToolsEnabled: data.isAddingTournament, eventToEditId: data.eventToEdit?.eventId, @@ -214,9 +220,8 @@ function useDefaultValues() { maxMembersPerTeam: settings?.maxMembersPerTeam ?? undefined, toToolsMode, pool, - bracketProgression: - settings?.bracketProgression ?? - (data.isAddingTournament ? defaultBracketProgression() : null), + brackets: bracketProgressionValues.brackets, + progression: bracketProgressionValues.progression, isRanked: settings?.isRanked ?? true, enableNoScreenToggle: settings?.enableNoScreenToggle ?? true, enableSubs: settings?.enableSubs ?? true, @@ -598,41 +603,16 @@ function TiebreakerMapPoolField() { } function BracketProgressionField() { - const { t } = useTranslation(); const { values } = useFormFieldContext(); - const baseEvent = useBaseEvent(); - - const initialBrackets = baseEvent?.tournament?.ctx.settings.bracketProgression - ? Progression.validatedBracketsToInputFormat( - baseEvent.tournament.ctx.settings.bracketProgression, - ) - : undefined; return (
Tournament format - - {({ onChange, error }: CustomFieldRenderProps) => ( - <> - - {error ? ( - - {t(error as never)} - - ) : null} - - )} - +
); } diff --git a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx index d1c1b0e9a..c4b5c7f67 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx @@ -10,8 +10,16 @@ import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-con import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { SendouForm } from "~/form/SendouForm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; +import invariant from "~/utils/invariant"; import { tournamentAdminPage } from "~/utils/urls"; -import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; +import { + bracketProgressionFormSchema, + formValuesToInputBrackets, + progressionToFormValues, +} from "../../calendar/calendar-progression-form"; +import { BracketProgressionFormFields } from "../../calendar/components/BracketProgressionFormFields"; import { adminBracketsActionSchema } from "../tournament-admin-schemas"; export { action } from "../actions/to.$id.admin.brackets.server"; @@ -127,45 +135,53 @@ function BracketReset() { function BracketProgressionEdit() { const tournament = useTournament(); - const fetcher = useFetcher(); - const [bracketProgression, setBracketProgression] = React.useState< - Progression.ParsedBracket[] | null - >(tournament.ctx.settings.bracketProgression); + const { submit } = useActionSubmit(adminBracketsActionSchema); const disabledBracketIdxs = tournament.bracketsMeta .filter((bracket) => !bracket.preview) .map((bracket) => bracket.idx); return ( - - {bracketProgression ? ( - - ) : null} - ({ - ...bracket, - disabled: disabledBracketIdxs.includes(idx), - }))} - isInvitationalTournament={tournament.isInvitational} - onChange={setBracketProgression} + { + const inputBrackets = formValuesToInputBrackets( + values.brackets, + values.progression, + ); + + // started brackets can't be edited in the form, so pass their stored + // version through untouched — re-deriving their settings from form + // values could register them as changed and fail the server's guard + const originalInputBrackets = + Progression.validatedBracketsToInputFormat( + tournament.ctx.settings.bracketProgression, + ); + for (const idx of disabledBracketIdxs) { + if (originalInputBrackets[idx]) { + inputBrackets[idx] = originalInputBrackets[idx]; + } + } + + const validated = Progression.validatedBrackets(inputBrackets); + invariant(Progression.isBrackets(validated), "Invalid progression"); + + submit("UPDATE_TOURNAMENT_PROGRESSION", { + bracketProgression: validated, + }); + }} + > + -
- - Save changes - -
-
+ ); } diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index ae4b98e1a..cec1ea16c 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -1,15 +1,11 @@ import { sub } from "date-fns"; import * as R from "remeda"; -import type { - CastedMatchesInfo, - TournamentStageSettings, -} from "~/db/tables-json"; +import type { CastedMatchesInfo } from "~/db/tables-json"; import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { weekNumberToDate } from "~/utils/dates"; import { SHORT_NANOID_LENGTH } from "~/utils/id"; import type { Tables } from "../../db/tables"; -import { assertUnreachable } from "../../utils/types"; import { MapPool } from "../map-list-generator/core/map-pool"; import { BANNED_MAPS } from "../match-profile/banned-maps"; import * as Seasons from "../mmr/core/Seasons"; @@ -234,35 +230,6 @@ export function isLeagueRoundLocked( return sub(date, { hours: EARLIEST_TIMEZONE_OFFSET_HOURS }) > new Date(); } -export function defaultBracketSettings( - type: Tables["TournamentStage"]["type"], -): TournamentStageSettings { - switch (type) { - case "single_elimination": { - return { - thirdPlaceMatch: true, - }; - } - case "double_elimination": { - return {}; - } - case "round_robin": { - return { - teamsPerGroup: 4, - }; - } - case "swiss": { - return { - roundCount: 5, - groupCount: 1, - }; - } - default: { - assertUnreachable(type); - } - } -} - export function validateCanJoinTeam({ inviteCode, teamToJoin, diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 5412c4bb3..1be9f20f4 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -56,6 +56,8 @@ const EMPTY_FORM_VALUES: Record = {}; interface FormFieldProps { name: string; label?: string; + /** Extra element rendered next to the label, e.g. an `` explaining the field's syntax. Only `text-field` supports it. */ + labelPopover?: React.ReactNode; disabled?: boolean; /** Focuses the field on mount. Only `text-field` and `text-area` support it. */ autoFocus?: boolean; @@ -81,6 +83,7 @@ const FIELD_TYPES_WITH_RENDER_PROP = ["custom", "array"]; export function FormField({ name, label, + labelPopover, disabled, autoFocus, maxCount, @@ -223,6 +226,7 @@ export function FormField({ {...formField} disabled={isDisabled} autoFocus={autoFocus} + labelPopover={labelPopover} value={value as string} onChange={handleChange as (v: string) => void} /> diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index cf528ad54..58f7e1554 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -12,6 +12,7 @@ import { fieldset, radioGroup, select, + selectDynamic, selectOptional, textArea, textAreaOptional, @@ -698,6 +699,51 @@ describe("SendouForm", () => { .element(screen.getByLabelText("Clock format")) .toHaveValue("auto"); }); + + test("toggle falls back to schema initial value when no default provided", async () => { + const schema = z.object({ + noScreen: toggleField({ label: "labels.noScreen", initialValue: true }), + }); + + const screen = await renderForm(schema); + + await expect.element(screen.getByRole("switch")).toBeChecked(); + }); + + test("dynamic select falls back to schema initial value when no default provided", async () => { + const schema = z.object({ + threshold: selectDynamic({ + label: "labels.advanceThreshold", + initialValue: "4", + }), + }); + + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + ({ + value, + label: value, + }))} + /> + + ), + }, + ], + { initialEntries: ["/"] }, + ); + + const screen = await render(); + + await expect + .element(screen.getByLabelText("Wins needed to advance")) + .toHaveValue("4"); + }); }); describe("server error fallback", () => { diff --git a/app/form/fields.ts b/app/form/fields.ts index 3bd6318c6..327556665 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -338,7 +338,10 @@ function registerTextArea>( export function toggle( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> - >, + > & { + /** Value used when the form has no default value for the field. Defaults to `false`. */ + initialValue?: boolean; + }, ) { return z .boolean() @@ -349,7 +352,7 @@ export function toggle( label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "switch", - initialValue: false, + initialValue: args.initialValue ?? false, }); } @@ -411,14 +414,17 @@ export function selectDynamic( Extract, "type" | "initialValue" | "clearable" > - >, + > & { + /** Value used when the form has no default value for the field. Defaults to no selection. */ + initialValue?: string; + }, ) { return z.string().register(formRegistry, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "select-dynamic", - initialValue: null, + initialValue: args.initialValue ?? null, clearable: false, }) as unknown as z.ZodType & FieldWithOptions; } @@ -767,9 +773,9 @@ export function timeRangeOptional(args: TimeRangeArgs) { export function fieldset( args: WithTypedTranslationKeys< - Omit, "type" | "initialValue"> - >, -) { + Omit, "type" | "initialValue" | "fields"> + > & { fields: z.ZodObject }, +): z.ZodObject { // @ts-expect-error Complex generic type with registry return args.fields.register(formRegistry, { ...args, @@ -777,7 +783,7 @@ export function fieldset( bottomText: prefixKey(args.bottomText), type: "fieldset", initialValue: {}, - }); + }) as z.ZodObject; } type UserSearchArgs = WithTypedTranslationKeys< diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index 4a4bc6c10..441ee989c 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -137,6 +137,7 @@ export function ArrayFormField({ key={itemKey(idx)} index={idx} canRemove={canRemoveAt(idx)} + removeButtonTestId={`${name}-remove-item-button`} onRemove={() => handleRemoveAt(idx)} sortable={isSortable} canMoveUp={idx > 0} @@ -163,6 +164,7 @@ export function ArrayFormField({ variant="minimal-destructive" onPress={() => handleRemoveAt(idx)} className={styles.removeButton} + data-testid={`${name}-remove-item-button`} /> ) : null}
@@ -181,6 +183,7 @@ export function ArrayFormField({ onPress={handleAdd} isDisabled={count >= max || disabled} className="m-0-auto" + data-testid={`${name}-add-item-button`} > {t("common:actions.add")} @@ -193,6 +196,7 @@ function ArrayItemFieldset({ index, children, canRemove, + removeButtonTestId, onRemove, sortable, canMoveUp, @@ -203,6 +207,7 @@ function ArrayItemFieldset({ index: number; children: React.ReactNode; canRemove: boolean; + removeButtonTestId?: string; onRemove: () => void; sortable?: boolean; canMoveUp?: boolean; @@ -245,6 +250,7 @@ function ArrayItemFieldset({ variant="minimal-destructive" onPress={onRemove} isDisabled={!canRemove} + data-testid={removeButtonTestId} />
{children}
diff --git a/app/form/fields/FormFieldWrapper.tsx b/app/form/fields/FormFieldWrapper.tsx index 8640b272e..e6ac89551 100644 --- a/app/form/fields/FormFieldWrapper.tsx +++ b/app/form/fields/FormFieldWrapper.tsx @@ -67,6 +67,8 @@ interface FormFieldWrapperProps { id: string; name?: string; label?: string; + /** Extra element rendered next to the label, e.g. an `` explaining the field's syntax. */ + labelPopover?: React.ReactNode; required?: boolean; error?: string; bottomText?: string; @@ -78,6 +80,7 @@ export function FormFieldWrapper({ id, name, label, + labelPopover, required, error, bottomText, @@ -86,19 +89,28 @@ export function FormFieldWrapper({ }: FormFieldWrapperProps) { const { translatedLabel } = useTranslatedTexts({ label }); + const labelElement = translatedLabel ? ( + + ) : null; + return (
- {translatedLabel ? ( - - ) : null} + {labelElement && labelPopover ? ( +
+ {labelElement} + {labelPopover} +
+ ) : ( + labelElement + )} {children}
diff --git a/app/form/fields/InputFormField.tsx b/app/form/fields/InputFormField.tsx index 3b859e9a3..47a82d4cf 100644 --- a/app/form/fields/InputFormField.tsx +++ b/app/form/fields/InputFormField.tsx @@ -7,6 +7,7 @@ import { FormFieldWrapper } from "./FormFieldWrapper"; type InputFormFieldProps = FormFieldProps<"text-field"> & { disabled?: boolean; autoFocus?: boolean; + labelPopover?: React.ReactNode; value: string; onChange: (value: string) => void; }; @@ -14,6 +15,7 @@ type InputFormFieldProps = FormFieldProps<"text-field"> & { export function InputFormField({ name, label, + labelPopover, bottomText, leftAddon, transformValue, @@ -40,6 +42,7 @@ export function InputFormField({ id={id} name={name} label={label} + labelPopover={labelPopover} required={required} error={error} bottomText={bottomText} diff --git a/app/utils/dates.ts b/app/utils/dates.ts index e3dc8f036..7b3895393 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -197,29 +197,6 @@ export function isValidDate(date: Date) { return !Number.isNaN(date.getTime()); } -/** Returns date as a string with the format YYYY-MM-DDThh:mm in user's time zone */ -export function dateToYearMonthDayHourMinuteString(date: Date) { - const copiedDate = new Date(date.getTime()); - - if (!isValidDate(copiedDate)) { - throw new Error("tried to format string from invalid date"); - } - - const year = copiedDate.getFullYear(); - const month = copiedDate.getMonth() + 1; - const day = copiedDate.getDate(); - const hour = copiedDate.getHours(); - const minute = copiedDate.getMinutes(); - - return `${year}-${prefixZero(month)}-${prefixZero(day)}T${prefixZero( - hour, - )}:${prefixZero(minute)}`; -} - -function prefixZero(number: number) { - return number < 10 ? `0${number}` : number; -} - export function getDateAtNextFullHour(date: Date) { const copiedDate = new Date(date.getTime()); if ( diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 9fd2b4220..05943393c 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -197,7 +197,7 @@ test.describe("Calendar", () => { await newTournament.addFollowUpBracket({ name: "Underground bracket", - format: "Single-elimination", + format: "Single elimination", placements: "-1", }); diff --git a/e2e/helpers/playwright-form.ts b/e2e/helpers/playwright-form.ts index 8a7338fda..ef4e00f27 100644 --- a/e2e/helpers/playwright-form.ts +++ b/e2e/helpers/playwright-form.ts @@ -94,7 +94,7 @@ export function createFormHelpers( // match the whole label (allowing the trailing space and optional required " *" the // Label component always renders) so a short label like "Name" doesn't also pick up - // "Bracket's name" or "Require in-game names" + // "Bracket name" or "Require in-game names" const byLabel = (label: string) => { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return page.getByLabel(new RegExp(`^${escaped} *\\*?$`, "i")); diff --git a/e2e/pages/calendar/calendar-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts index cb7b42e1f..cc481166e 100644 --- a/e2e/pages/calendar/calendar-new-event-page.ts +++ b/e2e/pages/calendar/calendar-new-event-page.ts @@ -19,12 +19,12 @@ export class CalendarNewEventPage { noTournamentPermissionsAlert: page.getByText( "No permissions to add tournaments", ), - addBracketButton: page.getByTestId("add-bracket-button"), - bracketNameInputs: page.getByLabel("Bracket's name"), + addBracketButton: page.getByTestId("brackets-add-item-button"), + bracketNameInputs: page.getByLabel(/^Bracket name *\*?$/), bracketFormatSelects: page.getByLabel("Format"), - placementsInputs: page.getByTestId("placements-input"), - deleteBracketButtons: page.getByTestId("delete-bracket-button"), - followUpBracketSwitches: page.getByTestId("follow-up-bracket-switch"), + placementsInputs: page.getByLabel("Placements"), + deleteBracketButtons: page.getByTestId("brackets-remove-item-button"), + signUpSourceRadios: page.getByRole("radio", { name: "Sign-up" }), mapPoolTemplateSelect: page.getByLabel("Template"), clearMapPoolButton: page.getByRole("button", { name: "Clear" }), }; @@ -79,10 +79,12 @@ export class CalendarNewEventPage { await this.locators.bracketFormatSelects.nth(nth).selectOption(formatLabel); } - /** Toggles every bracket's follow-up switch, making them starting brackets. */ - async toggleFollowUpBracketSwitches() { - for (const bracketSwitch of await this.locators.followUpBracketSwitches.all()) { - await bracketSwitch.click(); + /** Selects the "Sign-up" source for every bracket, making them all starting brackets. */ + async makeAllBracketsStartingBrackets() { + for (const radio of await this.locators.signUpSourceRadios.all()) { + if (await radio.isEnabled()) { + await radio.check(); + } } } @@ -98,8 +100,8 @@ export class CalendarNewEventPage { return submit(this.page); } - // a freshly added bracket is already a follow-up (sources default on), so it only - // needs its name, format and source placements filled in + // a freshly added bracket is already a follow-up (sourcing from the first + // bracket), so it only needs its name, format and source placements filled in async addFollowUpBracket({ name, format, diff --git a/e2e/pages/tournament/tournament-admin-page.ts b/e2e/pages/tournament/tournament-admin-page.ts index 8e6b547e0..899c1ab3f 100644 --- a/e2e/pages/tournament/tournament-admin-page.ts +++ b/e2e/pages/tournament/tournament-admin-page.ts @@ -42,6 +42,8 @@ export class TournamentAdminPage { unregisterDialogHeading: page.getByRole("heading", { name: /Unregister .* and delete its registration info\?/, }), + bracketNameInputs: page.getByLabel(/^Bracket name *\*?$/), + removeBracketButtons: page.getByTestId("brackets-remove-item-button"), }; } @@ -107,6 +109,19 @@ export class TournamentAdminPage { }); } + /** Opens the Brackets tab, where started brackets are locked and the rest of the progression is editable. */ + async openBrackets() { + await this.adminTab("Brackets").click(); + } + + async renameBracket(nth: number, name: string) { + await this.locators.bracketNameInputs.nth(nth).fill(name); + } + + async saveProgression() { + await submit(this.page); + } + /** Resets a bracket from the admin Brackets tab, typing out its name to confirm. */ async resetBracket(bracketName: string) { await this.adminTab("Brackets").click(); diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts index 139dcaeb5..e109dfa7e 100644 --- a/e2e/tournament-admin.spec.ts +++ b/e2e/tournament-admin.spec.ts @@ -7,6 +7,7 @@ import { expect, impersonate, test } from "./helpers/playwright"; import { createTeams, DOUBLE_ELIMINATION, + RR_TO_SE, startedTournamentTimes, teamSeeds, } from "./helpers/tournament"; @@ -270,6 +271,42 @@ test.describe("Tournament admin team management", () => { }); }); +test.describe("Tournament admin bracket progression editing", () => { + test("edits an unstarted follow-up bracket while the started bracket stays locked", async ({ + page, + factories, + }) => { + const tournament = await factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + startTimes: startedTournamentTimes(), + bracketProgression: RR_TO_SE, + }); + await createTeams(factories, tournament.id, teamSeeds(4)); + await factories.TournamentFactory.startBracket(tournament.id); + + await impersonate(page, NZAP_TEST_ID); + + const admin = new TournamentAdminPage(page); + await admin.goto(tournament.id); + await admin.openBrackets(); + + // the started groups stage is locked and can not be removed + await expect(admin.locators.bracketNameInputs.first()).toBeDisabled(); + await expect(admin.locators.bracketNameInputs.nth(1)).toBeEnabled(); + await expect(admin.locators.removeBracketButtons.first()).toBeDisabled(); + await expect(admin.locators.removeBracketButtons.nth(1)).toBeEnabled(); + + await admin.renameBracket(1, "Top Cut"); + await admin.saveProgression(); + + await admin.goto(tournament.id); + await admin.openBrackets(); + await expect(admin.locators.bracketNameInputs.nth(1)).toHaveValue( + "Top Cut", + ); + }); +}); + /** A tournament whose check-in window is open but that has not started. */ function createTournament(factories: Factories) { return factories.TournamentFactory.create({ diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts index bc30e739d..386fe9d90 100644 --- a/e2e/tournament-bracket-multi-stage.spec.ts +++ b/e2e/tournament-bracket-multi-stage.spec.ts @@ -254,10 +254,10 @@ test.describe("Tournament bracket multi stage", () => { const eventEdit = await admin.editEventInfo(); await eventEdit.deleteLastBracket(); - await eventEdit.toggleFollowUpBracketSwitches(); + await eventEdit.makeAllBracketsStartingBrackets(); - await eventEdit.setBracketFormat(0, "Single-elimination"); - await eventEdit.setBracketFormat(1, "Single-elimination"); + await eventEdit.setBracketFormat(0, "Single elimination"); + await eventEdit.setBracketFormat(1, "Single elimination"); await eventEdit.setBracketFormat(2, "Swiss"); await eventEdit.setBracketFormat(3, "Swiss"); diff --git a/locales/da/forms.json b/locales/da/forms.json index f08b8e1ef..91e803569 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Antal deltagere", "labels.teams": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index c434875c7..1b0d41f4c 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Anzahl Teilnehmer", "labels.teams": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index ddda34772..aed447b43 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "Map pool must contain a map for each ranked mode if using \"Prepicked by teams - All modes\"", "errors.bracketUrlRequired": "Bracket URL is required", "errors.bracketProgressionRequired": "Bracket progression must be set for tournaments", + "labels.brackets": "Brackets", + "labels.progression": "Progression", + "labels.bracketName": "Bracket name", + "labels.format": "Format", + "options.format.single_elimination": "Single elimination", + "options.format.double_elimination": "Double elimination", + "options.format.round_robin": "Round robin", + "options.format.swiss": "Swiss", + "labels.thirdPlaceMatch": "Third place match", + "labels.teamsPerGroup": "Max teams per group", + "bottomTexts.teamsPerGroup": "Teams are distributed equally, so groups may have fewer than selected", + "labels.abDivisions": "A/B divisions", + "bottomTexts.abDivisions": "Teams split into A and B pools; every A plays every B once", + "labels.groupCount": "Group count", + "labels.roundCount": "Round count", + "labels.earlyAdvance": "Early advance/elimination", + "bottomTexts.earlyAdvance": "Teams stop playing once they reach required wins or exceed maximum losses", + "labels.advanceThreshold": "Wins needed to advance", + "bottomTexts.advanceThresholdMaxLosses": "Maximum losses allowed: {{maxLosses}}", + "bottomTexts.bracketStartTime": "If missing, bracket can be started when the previous brackets have finished", + "labels.requiresCheckIn": "Check-in required", + "bottomTexts.requiresCheckIn": "Check-in starts 1 hour before start time or right after the previous bracket finishes if no start time is set", + "labels.teamsJoinFrom": "Teams join from", + "options.bracketSource.SIGN_UP": "Sign-up", + "options.bracketSource.BRACKET": "Another bracket", + "labels.sourceBracket": "Bracket", + "labels.placements": "Placements", + "placeholders.placements": "1,2,3", + "progression.joinFromSignUp": "Teams join from sign-up", + "progression.addedByOrganizer": "Teams added by the organizer", + "errors.placementTooHigh": "Placement is too high (max 100)", + "errors.invalidSourceBracket": "Invalid source bracket", "errors.maxMembersRange": "Max team size must be between 4 and 10", "labels.participantCount": "Participant count", "labels.teams": "Teams", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index f52471b67..c210644e9 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -387,6 +387,38 @@ "errors.allModePool": "La rotación de escenarios debe contener un escenario para cada modo competitivo si se usa \"Preseleccionado por los equipos - Todos los modos\"", "errors.bracketUrlRequired": "La URL del bracket es obligatoria", "errors.bracketProgressionRequired": "La progresión del bracket debe configurarse para los torneos", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "El tamaño máximo del equipo debe estar entre 4 y 10", "labels.participantCount": "Participantes", "labels.teams": "Equipos", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 8eab014de..f71b8c436 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Participantes", "labels.teams": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 2ca03cbbb..014baff1d 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Nombre de participants", "labels.teams": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 6186e704a..97e9128bc 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Nombre de participants", "labels.teams": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 558621c19..7e02a45af 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "כמות משתתפים", "labels.teams": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 518567893..b77eef09d 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Numero partecipante", "labels.teams": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 981446638..828b9b158 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "参加人数", "labels.teams": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 995518535..a53c67c77 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "참여자 수", "labels.teams": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index e472c6826..02af876a1 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Aantal deelnemers", "labels.teams": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 533f8438c..f7358e0e8 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Ilość osób biorących udział", "labels.teams": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index b511bd3ac..c80072ada 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Contagem de participantes", "labels.teams": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index e21e62855..aff20c7fc 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "", "labels.participantCount": "Количество участников", "labels.teams": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 62bfec027..fc302b08f 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -390,6 +390,38 @@ "errors.allModePool": "如果使用“由队伍预选 - 所有模式”,场地池必须包含可用于每个蛮颓比赛模式的场地", "errors.bracketUrlRequired": "必须填写对战表 URL", "errors.bracketProgressionRequired": "必须为赛事设置对战表晋级规则", + "labels.brackets": "", + "labels.progression": "", + "labels.bracketName": "", + "labels.format": "", + "options.format.single_elimination": "", + "options.format.double_elimination": "", + "options.format.round_robin": "", + "options.format.swiss": "", + "labels.thirdPlaceMatch": "", + "labels.teamsPerGroup": "", + "bottomTexts.teamsPerGroup": "", + "labels.abDivisions": "", + "bottomTexts.abDivisions": "", + "labels.groupCount": "", + "labels.roundCount": "", + "labels.earlyAdvance": "", + "bottomTexts.earlyAdvance": "", + "labels.advanceThreshold": "", + "bottomTexts.advanceThresholdMaxLosses": "", + "bottomTexts.bracketStartTime": "", + "labels.requiresCheckIn": "", + "bottomTexts.requiresCheckIn": "", + "labels.teamsJoinFrom": "", + "options.bracketSource.SIGN_UP": "", + "options.bracketSource.BRACKET": "", + "labels.sourceBracket": "", + "labels.placements": "", + "placeholders.placements": "", + "progression.joinFromSignUp": "", + "progression.addedByOrganizer": "", + "errors.placementTooHigh": "", + "errors.invalidSourceBracket": "", "errors.maxMembersRange": "队伍人数上限必须介于 4 到 10 人之间", "labels.participantCount": "参赛者数量", "labels.teams": "", From 632113e0dca6bdd0250063df17cf6ab731a5648d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:11 +0300 Subject: [PATCH 04/14] build(deps-dev): bump @biomejs/biome from 2.5.5 to 2.5.6 (#3311) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 74 +++++++++++++++++++++++++------------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 7a6d09cfa..0c118f8d8 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ }, "devDependencies": { "@babel/preset-typescript": "7.29.7", - "@biomejs/biome": "2.5.5", + "@biomejs/biome": "2.5.6", "@playwright/test": "1.62.0", "@react-router/dev": "8.3.0", "@types/node": "26.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dab7d949..bf8c121d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,8 +185,8 @@ importers: specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) '@biomejs/biome': - specifier: 2.5.5 - version: 2.5.5 + specifier: 2.5.6 + version: 2.5.6 '@playwright/test': specifier: 1.62.0 version: 1.62.0 @@ -494,59 +494,59 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.5': - resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} + '@biomejs/biome@2.5.6': + resolution: {integrity: sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.5': - resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==} + '@biomejs/cli-darwin-arm64@2.5.6': + resolution: {integrity: sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.5': - resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==} + '@biomejs/cli-darwin-x64@2.5.6': + resolution: {integrity: sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.5': - resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==} + '@biomejs/cli-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.5': - resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==} + '@biomejs/cli-linux-arm64@2.5.6': + resolution: {integrity: sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.5': - resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==} + '@biomejs/cli-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.5': - resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==} + '@biomejs/cli-linux-x64@2.5.6': + resolution: {integrity: sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.5': - resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==} + '@biomejs/cli-win32-arm64@2.5.6': + resolution: {integrity: sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.5': - resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==} + '@biomejs/cli-win32-x64@2.5.6': + resolution: {integrity: sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -5034,39 +5034,39 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.5': + '@biomejs/biome@2.5.6': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.5 - '@biomejs/cli-darwin-x64': 2.5.5 - '@biomejs/cli-linux-arm64': 2.5.5 - '@biomejs/cli-linux-arm64-musl': 2.5.5 - '@biomejs/cli-linux-x64': 2.5.5 - '@biomejs/cli-linux-x64-musl': 2.5.5 - '@biomejs/cli-win32-arm64': 2.5.5 - '@biomejs/cli-win32-x64': 2.5.5 + '@biomejs/cli-darwin-arm64': 2.5.6 + '@biomejs/cli-darwin-x64': 2.5.6 + '@biomejs/cli-linux-arm64': 2.5.6 + '@biomejs/cli-linux-arm64-musl': 2.5.6 + '@biomejs/cli-linux-x64': 2.5.6 + '@biomejs/cli-linux-x64-musl': 2.5.6 + '@biomejs/cli-win32-arm64': 2.5.6 + '@biomejs/cli-win32-x64': 2.5.6 - '@biomejs/cli-darwin-arm64@2.5.5': + '@biomejs/cli-darwin-arm64@2.5.6': optional: true - '@biomejs/cli-darwin-x64@2.5.5': + '@biomejs/cli-darwin-x64@2.5.6': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.5': + '@biomejs/cli-linux-arm64-musl@2.5.6': optional: true - '@biomejs/cli-linux-arm64@2.5.5': + '@biomejs/cli-linux-arm64@2.5.6': optional: true - '@biomejs/cli-linux-x64-musl@2.5.5': + '@biomejs/cli-linux-x64-musl@2.5.6': optional: true - '@biomejs/cli-linux-x64@2.5.5': + '@biomejs/cli-linux-x64@2.5.6': optional: true - '@biomejs/cli-win32-arm64@2.5.5': + '@biomejs/cli-win32-arm64@2.5.6': optional: true - '@biomejs/cli-win32-x64@2.5.5': + '@biomejs/cli-win32-x64@2.5.6': optional: true '@blazediff/core@1.9.1': {} From a2f61f0d1c7fb36f039533faf0b29ba064602c90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:19 +0300 Subject: [PATCH 05/14] build(deps): bump the minor-and-patch group with 3 updates (#3310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 6 +- pnpm-lock.yaml | 185 ++++++++++++++++++++++++------------------------- 2 files changed, 94 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 0c118f8d8..c06c0c230 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "knip": "knip" }, "dependencies": { - "@aws-sdk/client-s3": "3.1096.0", - "@aws-sdk/lib-storage": "3.1096.0", + "@aws-sdk/client-s3": "3.1097.0", + "@aws-sdk/lib-storage": "3.1097.0", "@date-fns/tz": "1.5.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -63,7 +63,7 @@ "gray-matter": "4.0.3", "i18next": "26.3.6", "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "4.0.0", + "i18next-http-backend": "4.0.1", "ics": "3.12.0", "isbot": "5.2.1", "kysely": "0.29.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf8c121d7..879f9201a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,11 +13,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1096.0 - version: 3.1096.0 + specifier: 3.1097.0 + version: 3.1097.0 '@aws-sdk/lib-storage': - specifier: 3.1096.0 - version: 3.1096.0(@aws-sdk/client-s3@3.1096.0) + specifier: 3.1097.0 + version: 3.1097.0(@aws-sdk/client-s3@3.1097.0) '@date-fns/tz': specifier: 1.5.0 version: 1.5.0 @@ -82,8 +82,8 @@ importers: specifier: 8.2.1 version: 8.2.1 i18next-http-backend: - specifier: 4.0.0 - version: 4.0.0 + specifier: 4.0.1 + version: 4.0.1 ics: specifier: 3.12.0 version: 3.12.0 @@ -264,73 +264,70 @@ packages: '@apm-js-collab/tracing-hooks@0.13.0': resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} - '@aws-sdk/checksums@3.1000.25': - resolution: {integrity: sha512-zUjEceMw6vhAxMayAlF/vkkKqP9gHbENqvz11t4FbfDlxB/WtW/Az1orJAQ00Pc/yORLQJXKE24w11Ktu/XBcg==} + '@aws-sdk/checksums@3.1000.26': + resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1096.0': - resolution: {integrity: sha512-sEx7KAEtkp1UqOoWQv2baM1rreClZG7o9YE8EfPvYpPBvGad1fQRG3sMHrOPMkIdZ9VjGRl25GiaG1MSeie2Mw==} + '@aws-sdk/client-s3@3.1097.0': + resolution: {integrity: sha512-iCBD95hrynpxiOzD301pUW9H3mxKcEfMErLqdg58WcIZnEqJuOd7JwcARsV3/y6OWj1t6j2tpS9lGt6X4OnPFw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.5': - resolution: {integrity: sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ==} - engines: {node: '>=20.0.0'} - deprecated: |- - Deprecated due to Document number parsing bug in JSON, see - https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. - - '@aws-sdk/credential-provider-env@3.972.66': - resolution: {integrity: sha512-bOzP2+zdJ0XrghywB4FaJXtGZCx9yS0AGps+VJ5yEgg30wVyHNmVDBwVDXcRypzQY5iLGCS3NSn0nsuISqjFCQ==} + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.68': - resolution: {integrity: sha512-lkunS8X+H6V76WE+t/uGQm/U8v0JXK5mLfNFTUAMlE1kqaCjwlmqKJrgCVtqjK/vqnlrSWsLK4Lr4NANBWlfTQ==} + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.11': - resolution: {integrity: sha512-KoDEolYtLHG/8C+IiZpXbJWyBOMkrHV+j66Kb9PBXmLv5euGb7aELvuCmLenoGAV6gBW2wM7TsG/1e5iulH4kA==} + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.73': - resolution: {integrity: sha512-tjsxMkTAFkmiV9ycmymapb9nLECWVOwFs0bZMQ9gB9bnbY8/HwfukHZlWbXZZp7qkPU6EXAfOcMm3DioFFEywA==} + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.77': - resolution: {integrity: sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw==} + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.66': - resolution: {integrity: sha512-YOnX6bIhdjx0QfaENu2PB0eFm5MEc9ft8XNGQ+NxMfeLSq9aE+XjWCwDupEnV4UWv5ZFpBLJbTREIx7KNOoqpQ==} + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.10': - resolution: {integrity: sha512-IsXnQ35j5VE+3ZK6aIhT5ypB+Jim3zRwVz0nYuVwyBKZyu/SYx+O2/LQpng8c2EiuwyqceabsDlYrICHDlJPsA==} + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.72': - resolution: {integrity: sha512-nj9Zlsy7ya+fy+jhWTJwgfr7YdtDM4xHyZvgKuftuny0UgROVx9lxwvsWJSLvpKk4lig0m0tHng3k1fEnt0LeA==} + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1096.0': - resolution: {integrity: sha512-A9ZoQFUawEO2l7jVoBRtDbIJ3hPBnxMRy3oIGQREOsMfwQFvoiyyV0f1Fl5CuIfoovv508TuW+Pe6WZIfh7NOg==} + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/lib-storage@3.1097.0': + resolution: {integrity: sha512-BntU0TisTIpoH54zIaAxmzsETRLi+RsJN3GdQCvUVH88UlgXGdQMDx99KRRwKCGW2j8o+kjDzqXVzHf94zP8eQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1096.0 + '@aws-sdk/client-s3': ^3.1097.0 - '@aws-sdk/middleware-sdk-s3@3.972.71': - resolution: {integrity: sha512-5fpExT7JOIZSIWExXCgJVbZzbaOlNrS/rE5Eoesnp0ONMbnCtiyYsCkahNIdlXtKrO6XHqXI6ceqssVWMYKmWQ==} + '@aws-sdk/middleware-sdk-s3@3.972.72': + resolution: {integrity: sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.40': - resolution: {integrity: sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ==} + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.43': resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1102.0': - resolution: {integrity: sha512-Ua700vVvM1q105yABSUQWkCK6FeTrNfU6ORGetJe5BzkZWY7QhkF7SVTOlmDGWRDNd6jbyY0Dv5e+E4bMBEmLg==} + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.974.2': @@ -3355,8 +3352,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-http-backend@4.0.0: - resolution: {integrity: sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==} + i18next-http-backend@4.0.1: + resolution: {integrity: sha512-O+7MwPCJIKCu68vho8JtD1HF8QHyMPzoChIDFfYCrpaDqX6oq3RqASujnpKEdnNCEOb+kLMOHTNTcGKpj7B1BA==} engines: {node: '>=18'} i18next-locales-sync@2.1.1: @@ -4660,20 +4657,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@aws-sdk/checksums@3.1000.25': + '@aws-sdk/checksums@3.1000.26': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1096.0': + '@aws-sdk/client-s3@3.1097.0': dependencies: - '@aws-sdk/checksums': 3.1000.25 - '@aws-sdk/core': 3.977.5 - '@aws-sdk/credential-provider-node': 3.972.77 - '@aws-sdk/middleware-sdk-s3': 3.972.71 + '@aws-sdk/checksums': 3.1000.26 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/middleware-sdk-s3': 3.972.72 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 @@ -4682,7 +4679,7 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/core@3.977.5': + '@aws-sdk/core@3.977.6': dependencies: '@aws-sdk/types': 3.974.2 '@aws-sdk/xml-builder': 3.972.37 @@ -4693,17 +4690,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.66': + '@aws-sdk/credential-provider-env@3.972.67': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.68': + '@aws-sdk/credential-provider-http@3.972.69': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/fetch-http-handler': 5.6.13 @@ -4711,75 +4708,75 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.11': + '@aws-sdk/credential-provider-ini@3.973.12': dependencies: - '@aws-sdk/core': 3.977.5 - '@aws-sdk/credential-provider-env': 3.972.66 - '@aws-sdk/credential-provider-http': 3.972.68 - '@aws-sdk/credential-provider-login': 3.972.73 - '@aws-sdk/credential-provider-process': 3.972.66 - '@aws-sdk/credential-provider-sso': 3.973.10 - '@aws-sdk/credential-provider-web-identity': 3.972.72 - '@aws-sdk/nested-clients': 3.997.40 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.4.16 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.73': + '@aws-sdk/credential-provider-login@3.972.74': dependencies: - '@aws-sdk/core': 3.977.5 - '@aws-sdk/nested-clients': 3.997.40 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.77': + '@aws-sdk/credential-provider-node@3.972.78': dependencies: - '@aws-sdk/credential-provider-env': 3.972.66 - '@aws-sdk/credential-provider-http': 3.972.68 - '@aws-sdk/credential-provider-ini': 3.973.11 - '@aws-sdk/credential-provider-process': 3.972.66 - '@aws-sdk/credential-provider-sso': 3.973.10 - '@aws-sdk/credential-provider-web-identity': 3.972.72 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.4.16 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.66': + '@aws-sdk/credential-provider-process@3.972.67': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.10': + '@aws-sdk/credential-provider-sso@3.973.11': dependencies: - '@aws-sdk/core': 3.977.5 - '@aws-sdk/nested-clients': 3.997.40 - '@aws-sdk/token-providers': 3.1102.0 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.72': + '@aws-sdk/credential-provider-web-identity@3.972.73': dependencies: - '@aws-sdk/core': 3.977.5 - '@aws-sdk/nested-clients': 3.997.40 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1096.0(@aws-sdk/client-s3@3.1096.0)': + '@aws-sdk/lib-storage@3.1097.0(@aws-sdk/client-s3@3.1097.0)': dependencies: - '@aws-sdk/client-s3': 3.1096.0 + '@aws-sdk/client-s3': 3.1097.0 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 buffer: 5.6.0 @@ -4787,18 +4784,18 @@ snapshots: stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.71': + '@aws-sdk/middleware-sdk-s3@3.972.72': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.40': + '@aws-sdk/nested-clients@3.997.41': dependencies: - '@aws-sdk/core': 3.977.5 + '@aws-sdk/core': 3.977.6 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 @@ -4814,10 +4811,10 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1102.0': + '@aws-sdk/token-providers@3.1103.0': dependencies: - '@aws-sdk/core': 3.977.5 - '@aws-sdk/nested-clients': 3.997.40 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 @@ -7903,7 +7900,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next-http-backend@4.0.0: {} + i18next-http-backend@4.0.1: {} i18next-locales-sync@2.1.1: dependencies: From 00a95bba6aa40271b56db6e6756d28e59b240fa1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:23:02 +0300 Subject: [PATCH 06/14] Allow toggling private note in user card with avatar click --- app/components/NoteAvatar.module.css | 7 +++++++ app/components/NoteAvatar.tsx | 18 ++++++++++++++++-- app/features/user-card/components/UserCard.tsx | 5 ++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/components/NoteAvatar.module.css b/app/components/NoteAvatar.module.css index 666758db9..ee71b19cb 100644 --- a/app/components/NoteAvatar.module.css +++ b/app/components/NoteAvatar.module.css @@ -5,6 +5,13 @@ width: fit-content; } +.clickable { + cursor: pointer; + border: none; + padding: 0; + background: none; +} + .badge { position: absolute; bottom: 15%; diff --git a/app/components/NoteAvatar.tsx b/app/components/NoteAvatar.tsx index 01a099cbf..f63c3f3af 100644 --- a/app/components/NoteAvatar.tsx +++ b/app/components/NoteAvatar.tsx @@ -29,20 +29,34 @@ const SIZE_CLASS = { * `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the * children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match * the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones). + * + * `onClick` makes the whole wrapper (avatar and badge) clickable. It is kept out of the tab order, so + * only use it as a shortcut to an action that is also available elsewhere. */ export function NoteAvatar({ sentiment, size = "md", className, + onClick, children, }: { sentiment?: Sentiment | null; size?: keyof typeof SIZE_CLASS; className?: string; + onClick?: () => void; children: React.ReactNode; }) { + const Wrapper = onClick ? "button" : "div"; + return ( -
+ {children} {sentiment ? ( ) : null} -
+ ); } diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index 5adde66a7..8aca95eab 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -316,7 +316,10 @@ function CardContent({ )}
- +
From fb22fb698a00eb3386771db3f5edb5e3594adeea Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:31:17 +0300 Subject: [PATCH 07/14] Remove pronouns from GroupCards --- .../sendouq-match/SQMatchRepository.server.ts | 1 - app/features/sendouq/SQGroupRepository.server.ts | 1 - .../sendouq/components/GroupCard.browser.test.tsx | 2 -- app/features/sendouq/components/GroupCard.tsx | 5 ----- .../TournamentLFGRepository.server.ts | 1 - .../tournament-lfg/components/LFGGroupCard.tsx | 7 ------- .../tournament-lfg/loaders/to.$id.looking.server.ts | 13 ------------- 7 files changed, 30 deletions(-) diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index b96c5414a..e9ef28fea 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -157,7 +157,6 @@ function groupWithTeamAndMembers( "GroupMember.role", "GroupMember.note", "User.inGameName", - "User.pronouns", "User.vc", "User.languages", "User.noScreen", diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index f4c2184eb..4ae632291 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -75,7 +75,6 @@ export async function findCurrentGroups() { "Group.status", "GroupMatch.id as matchId", commonUserMembersAgg(eb, { - pronouns: eb.ref("User.pronouns"), mapModePreferences: eb.ref("User.mapModePreferences"), noScreen: eb.ref("User.noScreen"), role: eb.ref("GroupMember.role"), diff --git a/app/features/sendouq/components/GroupCard.browser.test.tsx b/app/features/sendouq/components/GroupCard.browser.test.tsx index 4b58eb9b9..623e506a0 100644 --- a/app/features/sendouq/components/GroupCard.browser.test.tsx +++ b/app/features/sendouq/components/GroupCard.browser.test.tsx @@ -32,7 +32,6 @@ function createMember(overrides: Partial = {}): SQGroupMember { friendCode: null, inGameName: null, note: null, - pronouns: null, skillDifference: undefined, noScreen: undefined, @@ -84,7 +83,6 @@ function createOwnGroupMember( friendCode: null, inGameName: null, note: null, - pronouns: null, skillDifference: undefined, noScreen: undefined, diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index ead7671e5..946168960 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -291,11 +291,6 @@ function GroupMember({ - {member.pronouns ? ( - - {member.pronouns.subject}/{member.pronouns.object} - - ) : null}
{member.username} - {member.pronouns ? ( - - {member.pronouns.subject}/{member.pronouns.object} - - ) : null}
{showActions || (!showActions && member.role === "OWNER") ? ( diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index 377236cad..4dc428394 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -1,6 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; -import type { Pronouns } from "~/db/tables-json"; import type { getUser } from "~/features/auth/core/user.server"; import { tournamentFromDBCached, @@ -183,7 +182,6 @@ async function resolveOwnTeam({ customUrl: m.customUrl, languages: [], vc: null, - pronouns: null, role: m.role, isStayAsSub: false, weapons: null, @@ -210,7 +208,6 @@ function transformMembers( const languages = m.languages ?? []; const weapons = parseWeapons(m.weapons); - const pronouns = parsePronouns(m.pronouns); return { id: m.id, @@ -221,7 +218,6 @@ function transformMembers( customUrl: m.customUrl, languages, vc: m.vc, - pronouns, role: m.role, isStayAsSub: m.isStayAsSub === 1, weapons, @@ -252,12 +248,3 @@ function parseWeapons(raw: unknown): Array<{ }), ); } - -function parsePronouns(raw: unknown): Pronouns | null { - if (!raw) return null; - - const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; - if (!parsed || typeof parsed !== "object") return null; - - return parsed as Pronouns; -} From 9b0754fad88140122087f406cf4cbba5b332eb45 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:04:40 +0300 Subject: [PATCH 08/14] Disable compression Should be done on Render's side already so an optimization --- patches/@react-router__serve@8.1.0.patch | 12 ++++++++++-- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/patches/@react-router__serve@8.1.0.patch b/patches/@react-router__serve@8.1.0.patch index 2cdd5638e..22beaff6c 100644 --- a/patches/@react-router__serve@8.1.0.patch +++ b/patches/@react-router__serve@8.1.0.patch @@ -1,8 +1,16 @@ diff --git a/dist/cli.js b/dist/cli.js -index 7871cebe46f6df886b76364db5346adfa1838622..495960a0a12d3d027ababaacc0b92a7dc39274a6 100644 +index 7871cebe46f6df886b76364db5346adfa1838622..cbb32659bb2a57c2cd847ddfd35a9a07ab22a033 100644 --- a/dist/cli.js +++ b/dist/cli.js -@@ -118,7 +118,6 @@ async function run() { +@@ -110,7 +110,6 @@ async function run() { + }; + let app = express(); + app.disable("x-powered-by"); +- if (!isRSCBuild) app.use(compression()); + let expressPublicPath = getExpressPath(build.publicPath); + app.use(path.posix.join(expressPublicPath, "assets"), express.static(path.join(build.assetsBuildDirectory, "assets"), { + immutable: true, +@@ -118,7 +117,6 @@ async function run() { })); app.use(expressPublicPath, express.static(build.assetsBuildDirectory)); app.use(express.static("public", { maxAge: "1h" })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 879f9201a..b092e564f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - '@react-router/serve@8.1.0': 4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6 + '@react-router/serve@8.1.0': 0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738 kysely@0.29.0: a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a importers: @@ -47,7 +47,7 @@ importers: version: 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) '@react-router/serve': specifier: 8.1.0 - version: 8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) + version: 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) '@sentry/react-router': specifier: 10.68.0 version: 10.68.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) @@ -192,7 +192,7 @@ importers: version: 1.62.0 '@react-router/dev': specifier: 8.3.0 - version: 8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: 26.1.2 version: 26.1.2 @@ -5771,7 +5771,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': + '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -5802,7 +5802,7 @@ snapshots: valibot: 1.4.2(typescript@7.0.2) vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: - '@react-router/serve': 8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) + '@react-router/serve': 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) typescript: 7.0.2 transitivePeerDependencies: - babel-plugin-macros @@ -5830,7 +5830,7 @@ snapshots: optionalDependencies: typescript: 7.0.2 - '@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)': + '@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)': dependencies: '@react-router/express': 8.1.0(express@5.2.1)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) '@react-router/node': 8.1.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) From 58b83122054b407442a55a2c3a05473119ea4ae4 Mon Sep 17 00:00:00 2001 From: PedroFlores199 <98963275+PedroFlores199@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:11:22 +0200 Subject: [PATCH 09/14] =?UTF-8?q?Traduciones=20espa=C3=B1ol=20(#3307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/es-ES/analyzer.json | 6 +++--- locales/es-ES/common.json | 6 +++--- locales/es-ES/game-misc.json | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index 6d330cc5d..ee7d3f1aa 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -40,8 +40,8 @@ "stat.mainWeaponInkConsumptionPercentage.DUALIE_ROLL": "Consumo por voltereta", "stat.squidFormInkRecoverySeconds": "Tiempo de recuperación completa del tanque de tinta (forma calamar)", "stat.humanoidFormInkRecoverySeconds": "Tiempo de recuperación completa del tanque de tinta (forma humanoide)", - "stat.quickRespawnTime": "Tiempo de regeneración rápida", - "stat.quickRespawnTimeSplattedByRP": "Tiempo de regeneración rápida (al ser reventado por jugador con Castigo Póstumo)", + "stat.quickRespawnTime": "Tiempo de Retorno exprés", + "stat.quickRespawnTimeSplattedByRP": "Tiempo de Retorno exprés (al ser reventado por jugador con Castigo póstumo)", "stat.superJumpTimeGround": "Fotogramas vulnerables de supersalto", "stat.superJumpTimeTotal": "Tiempo de supersalto (total)", "stat.superJumpTimeTotal.stealthJumpExplanation": "El tiempo mostrado es el máximo. La penalización real depende de la distancia del salto.", @@ -201,7 +201,7 @@ "comp.hideWeaponGrid": "Ocultar selector de armas", "comp.hits_one": "{{count}} golpe", "comp.hits_other": "{{count}} golpes", - "comp.enemyRes": "Resistencia a Tinta Rival del enemigo", + "comp.enemyRes": "Impermeabilidad del enemigo", "comp.enemySubDef": "Resistencia Secundaria del enemigo", "comp.weaponRanges": "Alcances de armas", "comp.damageCombos": "Combos de daño", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 9bd40a180..84b97bc04 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -225,8 +225,8 @@ "tag.name.ART": "Premios de arte", "tag.name.MONEY": "Premios en metálico", "tag.name.REGION": "Bloqueo regional", - "tag.name.LOW": "Skill cap", - "tag.name.HIGH": "Skill floor", + "tag.name.LOW": "Límite de nivel", + "tag.name.HIGH": "Nivel mínimo", "tag.name.COUNT": "Límite de inscripciones", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Clasificatorio", @@ -246,7 +246,7 @@ "weapon.category.SLOSHERS": "Derramatic", "weapon.category.SPLATLINGS": "Tintralladoras", "weapon.category.DUALIES": "Duales", - "weapon.category.BRELLAS": "Parasoles", + "weapon.category.BRELLAS": "Paratintas", "weapon.category.STRINGERS": "Arcromatizador", "weapon.category.SPLATANAS": "Azotintadores", "weapon.category.subs": "Secundarias", diff --git a/locales/es-ES/game-misc.json b/locales/es-ES/game-misc.json index 4ac229cd4..220de4704 100644 --- a/locales/es-ES/game-misc.json +++ b/locales/es-ES/game-misc.json @@ -19,7 +19,7 @@ "STAGE_17": "Centro Comercial Fletán", "STAGE_18": "Distrito Rascalópodo", "STAGE_19": "Carguero Platija", - "STAGE_20": "Mina costera", + "STAGE_20": "Mina Costera", "STAGE_21": "Factoría RAMen", "STAGE_22": "Aeropuerto Marlín", "STAGE_23": "Terminal Dragón", @@ -61,7 +61,7 @@ "MODE_LONG_RM": "Pez dorado", "MODE_LONG_CB": "Almeja", "MODE_LONG_SR": "Salmon Run", - "MODE_LONG_TB": "Batallón de cartas", + "MODE_LONG_TB": "Lucha carterritorial", "GAME_S1": "Splatoon 1", "GAME_S2": "Splatoon 2", "GAME_S3": "Splatoon 3", From 0b005ce30a8ecdf9b8dcbe64af8c00581a0890a7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:36:42 +0300 Subject: [PATCH 10/14] Many source brackets (#3312) --- .../calendar-progression-form.test.ts | 69 ++- .../calendar/calendar-progression-form.ts | 111 ++-- .../BracketProgressionFormFields.tsx | 136 ++++- .../tournament-bracket/core/Bracket.test.ts | 168 ++++++ .../core/Bracket/Bracket.ts | 27 + .../core/Bracket/DoubleEliminationBracket.ts | 17 +- .../core/Bracket/SingleEliminationBracket.ts | 11 +- .../core/Progression.test.ts | 468 +++++++++++++++- .../tournament-bracket/core/Progression.ts | 517 ++++++++++++++---- .../tournament-bracket/core/Tournament.ts | 12 +- .../core/tests/test-utils.ts | 163 ++++++ .../routes/to.$id.brackets.tsx | 76 +-- .../tournament/core/Standings.test.ts | 177 ++++++ app/features/tournament/core/Standings.ts | 35 +- e2e/pages/calendar/calendar-new-event-page.ts | 16 + .../tournament/tournament-brackets-page.ts | 4 + e2e/tournament-bracket-multi-stage.spec.ts | 85 +++ locales/da/tournament.json | 11 +- locales/de/tournament.json | 11 +- locales/en/tournament.json | 11 +- locales/es-ES/analyzer.json | 4 + locales/es-ES/art.json | 1 + locales/es-ES/badges.json | 1 + locales/es-ES/calendar.json | 2 + locales/es-ES/forms.json | 4 + locales/es-ES/friends.json | 1 + locales/es-ES/tournament.json | 13 +- locales/es-ES/user.json | 3 + locales/es-US/tournament.json | 11 +- locales/fr-CA/tournament.json | 11 +- locales/fr-EU/tournament.json | 11 +- locales/he/tournament.json | 11 +- locales/it/tournament.json | 11 +- locales/ja/tournament.json | 11 +- locales/ko/tournament.json | 11 +- locales/nl/tournament.json | 11 +- locales/pl/tournament.json | 11 +- locales/pt-BR/tournament.json | 11 +- locales/ru/tournament.json | 11 +- locales/zh/tournament.json | 11 +- 40 files changed, 1993 insertions(+), 293 deletions(-) diff --git a/app/features/calendar/calendar-progression-form.test.ts b/app/features/calendar/calendar-progression-form.test.ts index 203bf1327..59f483124 100644 --- a/app/features/calendar/calendar-progression-form.test.ts +++ b/app/features/calendar/calendar-progression-form.test.ts @@ -112,6 +112,22 @@ describe("progressionToFormValues + formValuesToInputBrackets", () => { expect(roundTrip(progression)).toEqual(progression); }); + it("round-trips a bracket sourcing teams from two brackets", () => { + const progression: Progression.ParsedBracket[] = [ + RR_TO_SE_WITH_UNDERGROUND[0], + RR_TO_SE_WITH_UNDERGROUND[2], + { + ...RR_TO_SE_WITH_UNDERGROUND[1], + sources: [ + { bracketIdx: 0, placements: [1, 2] }, + { bracketIdx: 1, placements: [1] }, + ], + }, + ]; + + expect(roundTrip(progression)).toEqual(progression); + }); + it("round-trips bracket start time", () => { const progression: Progression.ParsedBracket[] = [ RR_TO_SE_WITH_UNDERGROUND[0], @@ -170,13 +186,13 @@ describe("validateBracketProgressionFormValues", () => { const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); formValues.progression[1] = { ...formValues.progression[1], - placements: "not placements", + sources: [{ bracketIdx: "0", placements: "not placements" }], }; const issues = validationIssues(formValues); expect(issues).toHaveLength(1); - expect(issues[0].path).toEqual(["progression", 1, "placements"]); + expect(issues[0].path).toEqual(["progression", 1, "sources"]); expect(issues[0].message).toBe( "tournament:progression.error.PLACEMENTS_PARSE_ERROR", ); @@ -198,38 +214,75 @@ describe("validateBracketProgressionFormValues", () => { const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); formValues.progression[1] = { ...formValues.progression[1], - sourceBracketIdx: "10", + sources: [{ bracketIdx: "10", placements: "1,2" }], }; const issues = validationIssues(formValues); expect(issues).toHaveLength(1); - expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + expect(issues[0].path).toEqual([ + "progression", + 1, + "sources", + 0, + "bracketIdx", + ]); }); it("rejects a non-canonical source bracket idx string", () => { const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); formValues.progression[1] = { ...formValues.progression[1], - sourceBracketIdx: "00", + sources: [{ bracketIdx: "00", placements: "1,2" }], }; const issues = validationIssues(formValues); expect(issues).toHaveLength(1); - expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + expect(issues[0].path).toEqual([ + "progression", + 1, + "sources", + 0, + "bracketIdx", + ]); }); it("rejects a bracket sourcing itself", () => { const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); formValues.progression[1] = { ...formValues.progression[1], - sourceBracketIdx: "1", + sources: [{ bracketIdx: "1", placements: "1,2" }], }; const issues = validationIssues(formValues); expect(issues).toHaveLength(1); - expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]); + expect(issues[0].path).toEqual([ + "progression", + 1, + "sources", + 0, + "bracketIdx", + ]); + }); + + it("rejects the same source bracket twice for one bracket", () => { + const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND); + formValues.progression[1] = { + ...formValues.progression[1], + sources: [ + { bracketIdx: "0", placements: "1,2" }, + { bracketIdx: "0", placements: "3,4" }, + ], + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "sources"]); + expect(issues[0].message).toBe( + "tournament:progression.error.DUPLICATE_SOURCE_BRACKET", + ); }); }); diff --git a/app/features/calendar/calendar-progression-form.ts b/app/features/calendar/calendar-progression-form.ts index f8ef53e75..d11930abe 100644 --- a/app/features/calendar/calendar-progression-form.ts +++ b/app/features/calendar/calendar-progression-form.ts @@ -32,11 +32,15 @@ export interface BracketFormValue { requiresCheckIn: boolean; } +export interface ProgressionSourceFormValue { + /** Index of the source bracket in the `brackets` form field, as a string (select value). */ + bracketIdx: string; + placements: string | null; +} + export interface ProgressionFormValue { source: "SIGN_UP" | "BRACKET"; - /** Index of the source bracket in the `brackets` form field, as a string (select value). */ - sourceBracketIdx: string; - placements: string | null; + sources: ProgressionSourceFormValue[]; } // extracted so their literal item values don't widen to `string` in the @@ -121,10 +125,9 @@ const bracketFieldset = fieldset({ }), }); -const progressionEntryFieldset = fieldset({ +const progressionSourceFieldset = fieldset({ fields: z.object({ - source: progressionSourceField, - sourceBracketIdx: selectDynamic({ + bracketIdx: selectDynamic({ label: "labels.sourceBracket", initialValue: "0", }), @@ -136,6 +139,17 @@ const progressionEntryFieldset = fieldset({ }), }); +const progressionEntryFieldset = fieldset({ + fields: z.object({ + source: progressionSourceField, + sources: array({ + min: 1, + max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT - 1, + field: progressionSourceFieldset, + }), + }), +}); + export const bracketsFormField = array({ label: "labels.brackets", max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT, @@ -166,7 +180,7 @@ export function defaultBracketsFormValues(): { } { return { brackets: [{ ...newBracketFormValue(), name: "Main Bracket" }], - progression: [{ source: "SIGN_UP", sourceBracketIdx: "0", placements: "" }], + progression: [{ source: "SIGN_UP", sources: [newProgressionSource()] }], }; } @@ -188,7 +202,12 @@ function newBracketFormValue(): BracketFormValue { /** Progression form field value appended when a new bracket is added: a follow-up bracket sourcing teams from the first bracket. */ export function newFollowUpProgressionEntry(): ProgressionFormValue { - return { source: "BRACKET", sourceBracketIdx: "0", placements: "" }; + return { source: "BRACKET", sources: [newProgressionSource()] }; +} + +/** Source form field value of a bracket that takes its teams from the first bracket. */ +export function newProgressionSource(): ProgressionSourceFormValue { + return { bracketIdx: "0", placements: "" }; } /** Converts the `brackets` + `progression` form values into {@link Progression.InputBracket} format ready for validation. */ @@ -217,14 +236,12 @@ export function formValuesToInputBrackets( settings: settingsFromFormValues(bracket, false), requiresCheckIn: bracket.requiresCheckIn, startTime: bracket.startTime ?? undefined, - sources: [ - { - bracketId: entry.sourceBracketIdx, - placements: sourceBracketHasEarlyAdvance(brackets, entry) - ? "" - : (entry.placements ?? ""), - }, - ], + sources: entry.sources.map((source) => ({ + bracketId: source.bracketIdx, + placements: sourceBracketHasEarlyAdvance(brackets, source) + ? "" + : (source.placements ?? ""), + })), }; }); } @@ -266,18 +283,22 @@ export function progressionToFormValues( })), progression: input.map((bracket) => ({ source: bracket.sources ? "BRACKET" : "SIGN_UP", - sourceBracketIdx: bracket.sources?.[0]?.bracketId ?? "0", - placements: bracket.sources?.[0]?.placements ?? "", + sources: bracket.sources?.length + ? bracket.sources.map((source) => ({ + bracketIdx: source.bracketId, + placements: source.placements, + })) + : [newProgressionSource()], })), }; } -/** Does the source bracket of the given progression entry advance teams via a Swiss early advance threshold (meaning placements are not specified)? */ +/** Does the bracket of the given progression source advance teams via a Swiss early advance threshold (meaning placements are not specified)? */ export function sourceBracketHasEarlyAdvance( brackets: BracketFormValue[], - entry: ProgressionFormValue, + source: ProgressionSourceFormValue, ) { - const sourceBracket = brackets[Number(entry.sourceBracketIdx)]; + const sourceBracket = brackets[Number(source.bracketIdx)]; return sourceBracket?.type === "swiss" && sourceBracket.earlyAdvance; } @@ -290,20 +311,28 @@ export function validateBracketProgressionFormValues( for (const [entryIdx, entry] of progression.entries()) { if (entryIdx === 0 || entry.source !== "BRACKET") continue; - const sourceIdx = Number(entry.sourceBracketIdx); - if ( - !Number.isInteger(sourceIdx) || - String(sourceIdx) !== entry.sourceBracketIdx || - sourceIdx < 0 || - sourceIdx >= brackets.length || - sourceIdx === entryIdx - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.invalidSourceBracket", - path: ["progression", entryIdx, "sourceBracketIdx"], - }); - return; + for (const [sourceRowIdx, source] of entry.sources.entries()) { + const sourceIdx = Number(source.bracketIdx); + if ( + !Number.isInteger(sourceIdx) || + String(sourceIdx) !== source.bracketIdx || + sourceIdx < 0 || + sourceIdx >= brackets.length || + sourceIdx === entryIdx + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.invalidSourceBracket", + path: [ + "progression", + entryIdx, + "sources", + sourceRowIdx, + "bracketIdx", + ], + }); + return; + } } } @@ -342,15 +371,19 @@ function progressionErrorPaths( return [["brackets", error.bracketIdx, "hasAbDivisions"]]; case "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": case "GAP_IN_PLACEMENTS": - return error.bracketIdxs.map((idx) => ["progression", idx, "placements"]); + case "CYCLIC_PROGRESSION": + return error.bracketIdxs.map((idx) => ["progression", idx, "sources"]); + // a bracket can have many sources but the error only identifies the bracket, + // so the message attaches to the sources list rather than one source's placements case "PLACEMENTS_PARSE_ERROR": case "TOO_MANY_PLACEMENTS": case "PLACEMENT_TOO_HIGH": case "NEGATIVE_PROGRESSION": - case "NO_SE_POSITIVE": - case "NO_DE_POSITIVE": + case "MIXED_POSITIVE_NEGATIVE_PLACEMENTS": + case "DUPLICATE_SOURCE_BRACKET": case "EMPTY_PLACEMENTS_ON_NON_SWISS": - return [["progression", error.bracketIdx, "placements"]]; + case "MERGED_STARTING_BRACKETS": + return [["progression", error.bracketIdx, "sources"]]; default: assertUnreachable(error); } diff --git a/app/features/calendar/components/BracketProgressionFormFields.tsx b/app/features/calendar/components/BracketProgressionFormFields.tsx index 17697ff93..b6a2e5fca 100644 --- a/app/features/calendar/components/BracketProgressionFormFields.tsx +++ b/app/features/calendar/components/BracketProgressionFormFields.tsx @@ -9,7 +9,9 @@ import type { ArrayItemRenderContext } from "~/form/types"; import { type BracketFormValue, newFollowUpProgressionEntry, + newProgressionSource, type ProgressionFormValue, + type ProgressionSourceFormValue, sourceBracketHasEarlyAdvance, } from "../calendar-progression-form"; import styles from "./BracketProgressionFormFields.module.css"; @@ -224,17 +226,37 @@ function ProgressionEntryFields({ isSourceLocked: boolean; }) { const { t } = useTranslation(["forms"]); - const { index, itemName, values, formValues } = renderContext; + const { index, itemName, values, formValues, setItemField } = renderContext; const entry = values as unknown as ProgressionFormValue; const brackets = (formValues.brackets ?? []) as BracketFormValue[]; + const sources = entry.sources ?? []; const isFirstBracket = index === 0; - const sourceBracketOptions = brackets.flatMap((bracket, bracketIdx) => - bracketIdx === index || !bracket.name - ? [] - : [{ value: String(bracketIdx), label: bracket.name }], - ); + // a newly added row defaults to the first bracket, which is usually already a + // source of this bracket, so it gets moved to the first one not sourced yet + const handleSourcesChanged = (newValue: unknown) => { + const newSources = newValue as ProgressionSourceFormValue[]; + if (newSources.length <= sources.length) return; + + const usedBracketIdxs = new Set( + newSources.slice(0, -1).map((source) => source.bracketIdx), + ); + const unusedBracketIdx = brackets.findIndex( + (_, bracketIdx) => + bracketIdx !== index && !usedBracketIdxs.has(String(bracketIdx)), + ); + if (unusedBracketIdx === -1) return; + + setItemField( + "sources", + newSources.map((source, sourceIdx) => + sourceIdx === newSources.length - 1 + ? { ...source, bracketIdx: String(unusedBracketIdx) } + : source, + ), + ); + }; return (
@@ -246,20 +268,19 @@ function ProgressionEntryFields({ disabled={isFirstBracket || isSourceLocked} /> {!isFirstBracket && entry.source === "BRACKET" ? ( - <> - - {!sourceBracketHasEarlyAdvance(brackets, entry) ? ( - } + + {(sourceRenderContext: ArrayItemRenderContext) => ( + - ) : null} - + )} + ) : ( {isInvitational @@ -271,6 +292,52 @@ function ProgressionEntryFields({ ); } +function SourceFields({ + renderContext, + destinationBracketIdx, + isDisabled, +}: { + renderContext: ArrayItemRenderContext; + destinationBracketIdx: number; + isDisabled: boolean; +}) { + const { index, itemName, values, formValues } = renderContext; + const source = values as unknown as ProgressionSourceFormValue; + const brackets = (formValues.brackets ?? []) as BracketFormValue[]; + const progression = (formValues.progression ?? []) as ProgressionFormValue[]; + const siblingSources = progression[destinationBracketIdx]?.sources ?? []; + + // a bracket can be sourced only once, so the brackets taken by the other rows + // are not offered here + const bracketOptions = brackets.flatMap((bracket, bracketIdx) => + bracketIdx === destinationBracketIdx || + !bracket.name || + siblingSources.some( + (siblingSource, siblingIdx) => + siblingIdx !== index && siblingSource.bracketIdx === String(bracketIdx), + ) + ? [] + : [{ value: String(bracketIdx), label: bracket.name }], + ); + + return ( +
+ + {!sourceBracketHasEarlyAdvance(brackets, source) ? ( + } + /> + ) : null} +
+ ); +} + function PlacementsSyntaxPopover() { return ( @@ -307,15 +374,24 @@ function progressionAfterBracketDelete( ): ProgressionFormValue[] { return progression .filter((_, idx) => idx !== deletedIdx) - .map((entry) => { - const sourceIdx = Number(entry.sourceBracketIdx); - - if (sourceIdx === deletedIdx) { - return { ...entry, sourceBracketIdx: "0" }; - } - if (sourceIdx > deletedIdx) { - return { ...entry, sourceBracketIdx: String(sourceIdx - 1) }; - } - return entry; - }); + .map((entry) => ({ + ...entry, + // sources of the deleted bracket are dropped, the rest shift down with it + sources: withFallbackSource( + (entry.sources ?? []) + .filter((source) => Number(source.bracketIdx) !== deletedIdx) + .map((source) => { + const sourceIdx = Number(source.bracketIdx); + return sourceIdx > deletedIdx + ? { ...source, bracketIdx: String(sourceIdx - 1) } + : source; + }), + ), + })); +} + +function withFallbackSource(sources: ProgressionSourceFormValue[]) { + if (sources.length === 0) return [newProgressionSource()]; + + return sources; } diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 25ae7bcca..ef00fbbfc 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -1037,6 +1037,174 @@ describe("single elimination source - underground", () => { }); }); +describe("single elimination source - positive placements", () => { + // 8-team SE without a third place match; lower id always wins so the final + // standings are 1st: team 1, 2nd: team 2, tied 3rd: teams 3 & 4, tied 5th: the rest + const singleEliminationTournament = ({ + playedRounds, + }: { + playedRounds: "all" | "first"; + }) => { + let data = createResolved({ + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: {}, + }); + + if (playedRounds === "first") { + for (const match of readyMatches(data, () => true)) { + data = reportLowerIdWinner(data, match.id); + } + } else { + let ready = readyMatches(data, () => true); + while (ready.length) { + for (const match of ready) { + data = reportLowerIdWinner(data, match.id); + } + ready = readyMatches(data, () => true); + } + } + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "SE", + requiresCheckIn: false, + settings: {}, + sources: [], + }, + ], + }, + }, + data, + }); + }; + + it("sources the winner when placements are [1]", () => { + const tournament = singleEliminationTournament({ playedRounds: "all" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1] }); + + expect(relevantMatchesFinished).toBe(true); + expect(teams).toEqual([1]); + }); + + it("sources the top 2 when placements are [1, 2]", () => { + const tournament = singleEliminationTournament({ playedRounds: "all" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1, 2] }); + + expect(relevantMatchesFinished).toBe(true); + expect(teams).toEqual([1, 2]); + }); + + it("sources both tied semifinal losers when placements are [3]", () => { + const tournament = singleEliminationTournament({ playedRounds: "all" }); + + const { teams } = tournament.bracketByIdx(0)!.source({ placements: [3] }); + + expect([...teams].sort((a, b) => a - b)).toEqual([3, 4]); + }); + + it("reports relevant matches unfinished while the bracket is underway", () => { + const tournament = singleEliminationTournament({ playedRounds: "first" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1] }); + + expect(relevantMatchesFinished).toBe(false); + expect(teams).toEqual([]); + }); +}); + +describe("double elimination source - positive placements", () => { + // 4-team DE; lower id always wins so the grand finals winner is team 1 and no + // bracket reset is played, leaving the standings 1st: team 1 ... 4th: team 4 + const doubleEliminationTournament = ({ + playedRounds, + }: { + playedRounds: "all" | "first"; + }) => { + let data = createResolved({ + type: "double_elimination", + seeding: [1, 2, 3, 4], + settings: {}, + }); + + if (playedRounds === "first") { + for (const match of readyMatches(data, () => true)) { + data = reportLowerIdWinner(data, match.id); + } + } else { + let ready = readyMatches(data, () => true); + while (ready.length) { + for (const match of ready) { + data = reportLowerIdWinner(data, match.id); + } + ready = readyMatches(data, () => true); + } + } + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "double_elimination", + name: "DE", + requiresCheckIn: false, + settings: {}, + sources: [], + }, + ], + }, + }, + data, + }); + }; + + it("sources the winner when placements are [1]", () => { + const tournament = doubleEliminationTournament({ playedRounds: "all" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1] }); + + expect(relevantMatchesFinished).toBe(true); + expect(teams).toEqual([1]); + }); + + it("sources the top 2 when placements are [1, 2]", () => { + const tournament = doubleEliminationTournament({ playedRounds: "all" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1, 2] }); + + expect(relevantMatchesFinished).toBe(true); + expect(teams).toEqual([1, 2]); + }); + + it("reports relevant matches unfinished while the bracket is underway", () => { + const tournament = doubleEliminationTournament({ playedRounds: "first" }); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [1] }); + + expect(relevantMatchesFinished).toBe(false); + expect(teams).toEqual([]); + }); +}); + describe("swiss between rounds", () => { const SWISS_MAIN_BRACKET = { type: "swiss" as const, diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 5312028c9..22bd8eb21 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -491,6 +491,33 @@ export abstract class Bracket { teams: number[]; }; + /** Advances top finishers by their standings placement. Only settled teams appear in + * the standings, so placements are matched raw until the full standings resolve and + * only then normalized (1,3,5 -> 1,2,3) the way group brackets source. */ + protected sourceByStandings(placements: number[], rest: boolean) { + const standings = this.standings; + const relevantMatchesFinished = + standings.length === this.participantTournamentTeamIds.length && + this.participantTournamentTeamIds.length > 0; + + const maxExplicit = Math.max(...placements); + const matchesPlacement = (placement: number) => + placements.includes(placement) || (rest && placement >= maxExplicit); + + const uniquePlacements = R.unique(standings.map((s) => s.placement)); + const placementNormalized = (placement: number) => + relevantMatchesFinished + ? uniquePlacements.indexOf(placement) + 1 + : placement; + + return { + relevantMatchesFinished, + teams: standings + .filter((s) => matchesPlacement(placementNormalized(s.placement))) + .map((s) => s.team.id), + }; + } + teamsWithNames(teams: { id: number }[]) { return teams.map((team) => { const name = this.tournament.ctx.teams.find( diff --git a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts index f6aa73c9d..54ca5e9fb 100644 --- a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts @@ -218,8 +218,18 @@ export class DoubleEliminationBracket extends Bracket { return true; } - source({ placements }: { placements: number[] }) { + source({ placements, rest }: { placements: number[]; rest?: boolean }) { invariant(placements.length > 0, "Empty placements not supported"); + invariant( + placements.every((placement) => placement < 0) || + placements.every((placement) => placement > 0), + "Mixed positive and negative placements not supported", + ); + + if (placements.every((placement) => placement > 0)) { + return this.sourceByStandings(placements, rest === true); + } + const resolveLosersGroupId = (data: BracketData) => { const minGroupId = Math.min(...data.round.map((round) => round.groupId)); @@ -257,11 +267,6 @@ export class DoubleEliminationBracket extends Bracket { return orderedRoundsIds.slice(0, amountOfRounds); }; - invariant( - placements.every((placement) => placement < 0), - "Positive placements in DE not implemented", - ); - const losersGroupId = resolveLosersGroupId(this.data); const sourceRoundsIds = placementsToRoundsIds( this.data, diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 26f4af2be..1fb579169 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -161,13 +161,18 @@ export class SingleEliminationBracket extends Bracket { return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken); } - source({ placements }: { placements: number[] }) { + source({ placements, rest }: { placements: number[]; rest?: boolean }) { invariant(placements.length > 0, "Empty placements not supported"); invariant( - placements.every((placement) => placement < 0), - "Positive placements in SE not implemented", + placements.every((placement) => placement < 0) || + placements.every((placement) => placement > 0), + "Mixed positive and negative placements not supported", ); + if (placements.every((placement) => placement > 0)) { + return this.sourceByStandings(placements, rest === true); + } + // third place match lives in a separate (higher) group; the winners // group teams get eliminated from is the lowest group id const mainGroupId = Math.min(...this.data.group.map((group) => group.id)); diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index e10a1e687..13f7909c6 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -34,6 +34,12 @@ describe("bracketsToValidationError - valid formats", () => { Progression.bracketsToValidationError(progressions.swissOneGroup), ).toBeNull(); }); + + it("accepts a bracket with many source brackets", () => { + expect( + Progression.bracketsToValidationError(progressions.multiSourceTopCut), + ).toBeNull(); + }); }); describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => { @@ -917,8 +923,8 @@ describe("validatedSources - other rules", () => { expect((error as any).bracketIdx).toEqual(1); }); - it("handles NO_SE_POSITIVE", () => { - const error = getValidatedBrackets([ + it("allows single elimination positive progression", () => { + const result = getValidatedBrackets([ { settings: {}, type: "single_elimination", @@ -933,9 +939,30 @@ describe("validatedSources - other rules", () => { }, ], }, + ]); + + expect(Progression.isBrackets(result)).toBe(true); + }); + + it("handles MIXED_POSITIVE_NEGATIVE_PLACEMENTS", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "single_elimination", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1,-1", + }, + ], + }, ]) as Progression.ValidationError; - expect(error.type).toBe("NO_SE_POSITIVE"); + expect(error.type).toBe("MIXED_POSITIVE_NEGATIVE_PLACEMENTS"); expect((error as any).bracketIdx).toEqual(1); }); @@ -960,8 +987,8 @@ describe("validatedSources - other rules", () => { expect(Progression.isBrackets(result)).toBe(true); }); - it("handles NO_DE_POSITIVE", () => { - const error = getValidatedBrackets([ + it("allows double elimination positive progression", () => { + const result = getValidatedBrackets([ { settings: {}, type: "double_elimination", @@ -976,10 +1003,9 @@ describe("validatedSources - other rules", () => { }, ], }, - ]) as Progression.ValidationError; + ]); - expect(error.type).toBe("NO_DE_POSITIVE"); - expect((error as any).bracketIdx).toEqual(1); + expect(Progression.isBrackets(result)).toBe(true); }); it("handles SWISS_EARLY_ADVANCE_NO_DESTINATION", () => { @@ -1314,6 +1340,18 @@ describe("isUnderground", () => { ).toBe(true); }); + it("redemption bracket feeding the finals is not underground", () => { + expect(Progression.isUnderground(0, progressions.multiSourceTopCut)).toBe( + false, + ); + expect(Progression.isUnderground(1, progressions.multiSourceTopCut)).toBe( + false, + ); + expect(Progression.isUnderground(2, progressions.multiSourceTopCut)).toBe( + false, + ); + }); + it("throws if given idx is out of bounds", () => { expect(() => Progression.isUnderground(1, progressions.singleElimination), @@ -1359,9 +1397,9 @@ describe("bracketIdxsForStandings", () => { it("handles low ink", () => { expect(Progression.bracketIdxsForStandings(progressions.lowInk)).toEqual([ - 3, 1, + 3, 2, 1, 0, - // NOTE: 2 is omitted as it's an "intermediate" bracket + // NOTE: 2 is included so that teams eliminated in it are not dropped down to the starting bracket ]); }); @@ -1400,6 +1438,37 @@ describe("bracketIdxsForStandings", () => { ), ).toEqual([1, 2, 0]); // missing 3 because it's underground }); + + it("keeps a finals bracket sourced positively from a SE redemption bracket", () => { + expect( + Progression.bracketIdxsForStandings(progressions.multiSourceTopCut), + ).toEqual([2, 1, 0]); + }); + + it("places a redemption bracket above the brackets taking lower placements from the same source", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.multiSourceTopCutWithConsolation, + ), + ).toEqual([2, 1, 3, 0]); + }); + + it("orders brackets by the placement of their teams in the shared ancestor bracket", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.poolsToBracketsViaIntermediateBrackets, + ), + ).toEqual([ + 2, // Alpha (pools 1) + 3, // Beta (pools 2-4, via Redemption) + 1, // Redemption (pools 2-4) + 4, // Gamma (pools 5-6) + 5, // Delta (pools 7-8) + 7, // Epsilon (pools 9-11, via Epsilon Seeding) + 6, // Epsilon Seeding (pools 9-11) + 0, // Day 1 Pools + ]); + }); }); describe("startingBrackets", () => { @@ -1566,3 +1635,382 @@ describe("bracketDepth", () => { ).toThrow(); }); }); + +describe("validatedSources - DUPLICATE_SOURCE_BRACKET", () => { + it("flags a destination sourcing the same bracket twice", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + { + bracketId: "0", + placements: "3-4", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("DUPLICATE_SOURCE_BRACKET"); + expect((error as any).bracketIdx).toBe(1); + }); + + it("accepts different destinations sourcing the same bracket", () => { + expect( + Progression.bracketsToValidationError(progressions.lowInk), + ).toBeNull(); + }); +}); + +describe("validatedSources - CYCLIC_PROGRESSION", () => { + it("flags two brackets sourcing each other", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + { + bracketId: "2", + placements: "1", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "1", + placements: "1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("CYCLIC_PROGRESSION"); + expect((error as any).bracketIdxs).toEqual([1, 2]); + }); + + it("flags a bracket sourcing itself", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "1", + placements: "1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("CYCLIC_PROGRESSION"); + expect((error as any).bracketIdxs).toEqual([1]); + }); + + it("accepts a bracket sourcing one that comes later in the list", () => { + const result = getValidatedBrackets([ + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "1", + placements: "1-4", + }, + ], + }, + { + settings: {}, + type: "round_robin", + }, + ]); + + expect(Progression.isBrackets(result)).toBe(true); + }); + + it("accepts brackets sharing a source (diamond shaped progression)", () => { + expect( + Progression.bracketsToValidationError(progressions.lowInk), + ).toBeNull(); + }); +}); + +describe("validatedSources - MERGED_STARTING_BRACKETS", () => { + it("flags a bracket sourcing two starting brackets", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + { + bracketId: "1", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("MERGED_STARTING_BRACKETS"); + expect((error as any).bracketIdx).toBe(2); + }); + + it("flags a merge that happens through intermediate brackets", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "1", + placements: "1-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "2", + placements: "1", + }, + { + bracketId: "3", + placements: "1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("MERGED_STARTING_BRACKETS"); + expect((error as any).bracketIdx).toBe(4); + }); + + it("reports the bracket where the merge happens, not the ones after it", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "3", + placements: "1-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + { + bracketId: "1", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("MERGED_STARTING_BRACKETS"); + expect((error as any).bracketIdx).toBe(3); + }); + + it("accepts many starting brackets that never merge", () => { + expect( + Progression.bracketsToValidationError(progressions.manyStartBrackets), + ).toBeNull(); + }); + + it("accepts many sources that all come from the same starting bracket", () => { + expect( + Progression.bracketsToValidationError(progressions.multiSourceTopCut), + ).toBeNull(); + }); +}); + +describe("sortedSourcesForSeeding", () => { + it("orders a direct source above one that took a redemption route", () => { + const topCut: Progression.ParsedBracket = progressions.multiSourceTopCut[2]; + + const sorted = Progression.sortedSourcesForSeeding( + topCut.sources!, + progressions.multiSourceTopCut, + ); + + expect(sorted.map((source) => source.bracketIdx)).toEqual([0, 1]); + }); + + it("keeps the original order when sources share no ancestor bracket", () => { + const progression: Progression.ParsedBracket[] = [ + { + name: "Group A", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Group B", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Finals", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { bracketIdx: 1, placements: [1, 2] }, + { bracketIdx: 0, placements: [1, 2] }, + ], + }, + ]; + + const sorted = Progression.sortedSourcesForSeeding( + progression[2].sources!, + progression, + ); + + expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]); + }); + + it("compares at the deepest common ancestor", () => { + const progression: Progression.ParsedBracket[] = [ + { + name: "Pools", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Redemption 1", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [5, 6, 7, 8] }], + }, + { + name: "Redemption 2", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + sources: [{ bracketIdx: 1, placements: [3, 4] }], + }, + { + name: "Finals", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { bracketIdx: 2, placements: [1, 2] }, + { bracketIdx: 1, placements: [1, 2] }, + ], + }, + ]; + + const sorted = Progression.sortedSourcesForSeeding( + progression[3].sources!, + progression, + ); + + expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 2]); + }); + + it("orders teams eliminated from a follow-up bracket above lower direct placements", () => { + const progression: Progression.ParsedBracket[] = [ + { + name: "Pools", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Top Cut", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4, 5, 6, 7, 8] }], + }, + { + name: "Consolation", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { bracketIdx: 0, placements: [9, 10] }, + { bracketIdx: 1, placements: [-1] }, + ], + }, + ]; + + const sorted = Progression.sortedSourcesForSeeding( + progression[2].sources!, + progression, + ); + + expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]); + }); +}); diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index e5817e662..57e59b513 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -31,8 +31,6 @@ interface BracketBase { requiresCheckIn: boolean; } -// Note sources is array for future proofing reasons. Currently the array is always of length 1 if it exists. - export interface InputBracket extends BracketBase { id: string; sources?: EditableSource[]; @@ -91,14 +89,9 @@ export type ValidationError = type: "NEGATIVE_PROGRESSION"; bracketIdx: number; } - // no SE positive placements (single elimination can only source underground brackets) + // a single source can not take both top finishers and eliminated teams | { - type: "NO_SE_POSITIVE"; - bracketIdx: number; - } - // no DE positive placements (might change in the future) - | { - type: "NO_DE_POSITIVE"; + type: "MIXED_POSITIVE_NEGATIVE_PLACEMENTS"; bracketIdx: number; } // Swiss bracket with early advance/elimination must have a destination bracket @@ -125,6 +118,21 @@ export type ValidationError = | { type: "EMPTY_PLACEMENTS_ON_NON_SWISS"; bracketIdx: number; + } + // one destination bracket can source each bracket only once + | { + type: "DUPLICATE_SOURCE_BRACKET"; + bracketIdx: number; + } + // brackets can not source each other in a loop e.g. A sources B and B sources A + | { + type: "CYCLIC_PROGRESSION"; + bracketIdxs: number[]; + } + // teams that started in different brackets can never meet, so the routes from many starting brackets can not merge + | { + type: "MERGED_STARTING_BRACKETS"; + bracketIdx: number; }; /** Takes validated brackets and returns them in the format that is ready for user input. */ @@ -152,7 +160,8 @@ export function validatedBracketsToInputFormat( }); } -function placementsToString(placements: number[], rest = false): string { +/** Formats a placements array into the compact user-facing string form, e.g. [1, 2, 3] -> "1-3" and [5, 6] with rest -> "5,6+". */ +export function placementsToString(placements: number[], rest = false): string { if (placements.length === 0) return ""; placements.sort((a, b) => a - b); @@ -222,12 +231,37 @@ export function validatedBrackets( export function bracketsToValidationError( brackets: ParsedBracket[], ): ValidationError | null { + // must be checked first, other validations assume the progression is a directed acyclic graph + const cyclicBracketIdxs = cyclicProgression(brackets); + if (cyclicBracketIdxs) { + return { + type: "CYCLIC_PROGRESSION", + bracketIdxs: cyclicBracketIdxs, + }; + } + + const mergedStartingBracketsIdx = mergedStartingBrackets(brackets); + if (typeof mergedStartingBracketsIdx === "number") { + return { + type: "MERGED_STARTING_BRACKETS", + bracketIdx: mergedStartingBracketsIdx, + }; + } + if (!resolvesWinner(brackets)) { return { type: "NOT_RESOLVING_WINNER", }; } + const duplicateSourceBracketIdx = duplicateSourceBracket(brackets); + if (typeof duplicateSourceBracketIdx === "number") { + return { + type: "DUPLICATE_SOURCE_BRACKET", + bracketIdx: duplicateSourceBracketIdx, + }; + } + let faultyBracketIdxs: number[] | null = null; faultyBracketIdxs = samePlacementToMultipleBrackets(brackets); @@ -288,18 +322,10 @@ export function bracketsToValidationError( }; } - faultyBracketIdx = noSingleEliminationPositive(brackets); + faultyBracketIdx = mixedPositiveNegativePlacements(brackets); if (typeof faultyBracketIdx === "number") { return { - type: "NO_SE_POSITIVE", - bracketIdx: faultyBracketIdx, - }; - } - - faultyBracketIdx = noDoubleEliminationPositive(brackets); - if (typeof faultyBracketIdx === "number") { - return { - type: "NO_DE_POSITIVE", + type: "MIXED_POSITIVE_NEGATIVE_PLACEMENTS", bracketIdx: faultyBracketIdx, }; } @@ -672,29 +698,12 @@ function negativeProgression(brackets: ParsedBracket[]) { return null; } -function noSingleEliminationPositive(brackets: ParsedBracket[]) { +function mixedPositiveNegativePlacements(brackets: ParsedBracket[]) { for (const [bracketIdx, bracket] of brackets.entries()) { for (const source of bracket.sources ?? []) { - const sourceBracket = brackets[source.bracketIdx]; if ( - sourceBracket.type === "single_elimination" && - source.placements.some((placement) => placement > 0) - ) { - return bracketIdx; - } - } - } - - return null; -} - -function noDoubleEliminationPositive(brackets: ParsedBracket[]) { - for (const [bracketIdx, bracket] of brackets.entries()) { - for (const source of bracket.sources ?? []) { - const sourceBracket = brackets[source.bracketIdx]; - if ( - sourceBracket.type === "double_elimination" && - source.placements.some((placement) => placement > 0) + source.placements.some((placement) => placement > 0) && + source.placements.some((placement) => placement < 0) ) { return bracketIdx; } @@ -762,6 +771,22 @@ function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) { return null; } +function duplicateSourceBracket(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + if (!bracket.sources) continue; + + const seen = new Set(); + for (const source of bracket.sources) { + if (seen.has(source.bracketIdx)) { + return bracketIdx; + } + seen.add(source.bracketIdx); + } + } + + return null; +} + function emptyPlacementsOnNonSwiss(brackets: ParsedBracket[]) { for (const [bracketIdx, bracket] of brackets.entries()) { for (const source of bracket.sources ?? []) { @@ -781,6 +806,78 @@ function emptyPlacementsOnNonSwiss(brackets: ParsedBracket[]) { return null; } +/** Returns the bracket indexes forming a loop of sources or null if the progression has no loops. */ +function cyclicProgression(brackets: ParsedBracket[]) { + const visited = new Set(); + const currentPath: number[] = []; + + const findCycle = (bracketIdx: number): number[] | null => { + const pathIdx = currentPath.indexOf(bracketIdx); + if (pathIdx !== -1) return currentPath.slice(pathIdx); + if (visited.has(bracketIdx)) return null; + + visited.add(bracketIdx); + currentPath.push(bracketIdx); + + for (const source of brackets[bracketIdx]?.sources ?? []) { + const cycle = findCycle(source.bracketIdx); + if (cycle) return cycle; + } + + currentPath.pop(); + + return null; + }; + + for (const bracketIdx of brackets.keys()) { + const cycle = findCycle(bracketIdx); + if (cycle) return cycle.sort((a, b) => a - b); + } + + return null; +} + +/** Returns the index of the bracket where routes from many starting brackets merge or null if they never merge. */ +function mergedStartingBrackets(brackets: ParsedBracket[]) { + const cache = new Map>(); + + const startingAncestors = (bracketIdx: number): Set => { + const cached = cache.get(bracketIdx); + if (cached) return cached; + + const sources = brackets[bracketIdx]?.sources; + const result = new Set(); + + if (!sources?.length) { + result.add(bracketIdx); + } else { + for (const source of sources) { + for (const ancestorIdx of startingAncestors(source.bracketIdx)) { + result.add(ancestorIdx); + } + } + } + + cache.set(bracketIdx, result); + + return result; + }; + + for (const [bracketIdx, bracket] of brackets.entries()) { + if (startingAncestors(bracketIdx).size <= 1) continue; + + // the merge already happened earlier in the progression, that bracket is reported instead + const mergedEarlier = (bracket.sources ?? []).some( + (source) => startingAncestors(source.bracketIdx).size > 1, + ); + if (mergedEarlier) continue; + + return bracketIdx; + } + + return null; +} + /** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */ export function isBrackets( input: ParsedBracket[] | ValidationError, @@ -818,13 +915,34 @@ export function hasAbDivisionsFinals(brackets: ParsedBracket[]): boolean { export function isUnderground(idx: number, brackets: ParsedBracket[]) { invariant(idx < brackets.length, "Bracket index out of bounds"); - const startBrackets = startingBrackets(brackets); + const mainBracketIdxs = new Set( + startingBrackets(brackets).flatMap((startBracketIdx) => + resolveMainBracketProgression(brackets, startBracketIdx), + ), + ); - for (const startBracketIdx of startBrackets) { - if ( - resolveMainBracketProgression(brackets, startBracketIdx).includes(idx) - ) { - return false; + if (mainBracketIdxs.has(idx)) return false; + + // a bracket whose top finishers advance (transitively) into the main progression + // is a redemption style intermediate bracket, not an underground one + const queue = [idx]; + const visited = new Set(); + while (queue.length > 0) { + const currentIdx = queue.shift()!; + if (visited.has(currentIdx)) continue; + visited.add(currentIdx); + + for (const [destinationIdx, bracket] of brackets.entries()) { + const advancesPositively = bracket.sources?.some( + (source) => + source.bracketIdx === currentIdx && + (source.placements.length === 0 || + source.placements.some((placement) => placement > 0)), + ); + if (!advancesPositively) continue; + + if (mainBracketIdxs.has(destinationIdx)) return false; + queue.push(destinationIdx); } } @@ -839,6 +957,17 @@ export function isUnderground(idx: number, brackets: ParsedBracket[]) { export function bracketDepth(idx: number, brackets: ParsedBracket[]): number { invariant(idx < brackets.length, "Bracket index out of bounds"); + return depthFromStartingBracket(idx, brackets, new Set()); +} + +function depthFromStartingBracket( + idx: number, + brackets: ParsedBracket[], + pathToBracket: Set, +): number { + // only possible with an invalid progression, see CYCLIC_PROGRESSION + if (pathToBracket.has(idx)) return 0; + const bracket = brackets[idx]; if (!bracket.sources || bracket.sources.length === 0) { @@ -846,7 +975,11 @@ export function bracketDepth(idx: number, brackets: ParsedBracket[]): number { } const sourceDepths = bracket.sources.map((source) => - bracketDepth(source.bracketIdx, brackets), + depthFromStartingBracket( + source.bracketIdx, + brackets, + new Set(pathToBracket).add(idx), + ), ); return Math.max(...sourceDepths) + 1; @@ -860,6 +993,7 @@ function resolveMainBracketProgression( let bracketIdxToFind = startBracketIdx; const result = [startBracketIdx]; + const visited = new Set([startBracketIdx]); while (true) { const bracket = brackets.findIndex((bracket) => bracket.sources?.some( @@ -870,9 +1004,12 @@ function resolveMainBracketProgression( ), ); - if (bracket === -1) break; + // -1 = end of the progression, already visited is only possible + // with an invalid progression, see CYCLIC_PROGRESSION + if (bracket === -1 || visited.has(bracket)) break; bracketIdxToFind = bracket; + visited.add(bracketIdxToFind); result.push(bracketIdxToFind); } @@ -925,75 +1062,108 @@ export function changedBracketProgressionFormat( * Returns the order of brackets as is to be considered for standings. Teams from the bracket of lower index are considered to be above those from the lower bracket. * A participant's standing is the first bracket to appear in order that has the participant in it. * - * The order is so that most significant brackets (i.e. finals) appear first. + * The order is so that most significant brackets (i.e. finals) appear first. A bracket always appears after every bracket + * it advances teams to, so the teams it eliminated end up below the teams that advanced out of it. + * + * Underground brackets are omitted as they are only used to break ties within their source bracket, see `tiebrokenByUndergroundBrackets`. */ export function bracketIdxsForStandings(progression: ParsedBracket[]) { const bracketsToConsider = bracketsReachableFrom(0, progression); - const withoutIntermediateBrackets = bracketsToConsider.filter( - (bracketIdx) => { - if (bracketIdx === 0) return true; + const ordered = destinationsFirstOrder(bracketsToConsider, progression); - // underground brackets don't make their source bracket an intermediate one - const undergrounds = new Set( - undergroundBracketIdxs(bracketIdx, progression), - ); + return ordered.filter((bracketIdx) => { + const sources = progression[bracketIdx].sources; - return progression.every( - (b, idx) => - undergrounds.has(idx) || - !b.sources?.some((s) => s.bracketIdx === bracketIdx), - ); - }, - ); + if (!sources) return true; - const withoutUnderground = withoutIntermediateBrackets.filter( - (bracketIdx) => { - const sources = progression[bracketIdx].sources; - - if (!sources) return true; - - return !sources.some( - (source) => - progression[source.bracketIdx].type === "double_elimination" || - progression[source.bracketIdx].type === "single_elimination", - ); - }, - ); - - const minSourcedPlacements = new Map( - withoutUnderground.map((idx) => [ - idx, - minSourcedPlacement(progression, idx), - ]), - ); - - return [...withoutUnderground].sort((a, b) => { - const minA = minSourcedPlacements.get(a)!; - const minB = minSourcedPlacements.get(b)!; - - if (minA === minB) { - return a - b; - } - - return minA - minB; + return !sources.some( + (source) => + (progression[source.bracketIdx].type === "double_elimination" || + progression[source.bracketIdx].type === "single_elimination") && + source.placements.some((placement) => placement < 0), + ); }); } -function minSourcedPlacement( +/** + * Orders the given brackets so that every bracket appears after all the brackets it is a source of. + * Among the brackets that are free to be placed next, the one whose teams placed the highest in the + * deepest bracket they have in common (e.g. a top cut over a consolation bracket) goes first. The comparison + * follows the whole route the teams took, so e.g. a bracket taking the low placements of a redemption bracket + * can still rank above a bracket taking mid placements straight from the pools that fed that redemption bracket. + */ +function destinationsFirstOrder( + bracketIdxs: number[], progression: ParsedBracket[], - bracketIdx: number, -): number { - const sources = progression[bracketIdx].sources; - if (!sources || sources.length === 0) return Number.POSITIVE_INFINITY; +): number[] { + const included = new Set(bracketIdxs); - let min = Number.POSITIVE_INFINITY; - for (const source of sources) { - for (const placement of source.placements) { - if (placement < min) min = placement; + const sourcedPlacements = new Map( + bracketIdxs.map((bracketIdx) => [ + bracketIdx, + ancestorPlacements(bracketIdx, progression), + ]), + ); + + const pendingDestinations = new Map( + bracketIdxs.map((bracketIdx) => [ + bracketIdx, + new Set( + destinationsFromBracketIdx(bracketIdx, progression).filter( + (destinationIdx) => included.has(destinationIdx), + ), + ), + ]), + ); + + const result: number[] = []; + const remaining = new Set(bracketIdxs); + + while (remaining.size > 0) { + const withoutPendingDestinations = Array.from(remaining).filter( + (bracketIdx) => pendingDestinations.get(bracketIdx)!.size === 0, + ); + // a cyclic progression is invalid but shouldn't cause an infinite loop here + const candidates = + withoutPendingDestinations.length > 0 + ? withoutPendingDestinations + : Array.from(remaining); + + const next = bestSourcedBracket(candidates, sourcedPlacements, progression); + + result.push(next); + remaining.delete(next); + + for (const bracketIdx of remaining) { + pendingDestinations.get(bracketIdx)!.delete(next); } } - return min; + + return result; +} + +/** Of the given brackets, the one whose teams took the best route there, ties broken by the lowest bracket index. */ +function bestSourcedBracket( + bracketIdxs: number[], + sourcedPlacements: Map>, + progression: ParsedBracket[], +): number { + let result = bracketIdxs[0]; + + for (const bracketIdx of bracketIdxs.slice(1)) { + const comparison = compareSourcedPlacements( + sourcedPlacements.get(bracketIdx)!, + sourcedPlacements.get(result)!, + progression, + ); + + if (comparison < 0 || (comparison === 0 && bracketIdx < result)) { + result = bracketIdx; + } + } + + return result; } export function bracketsReachableFrom( @@ -1096,3 +1266,144 @@ export function startingBrackets(progression: ParsedBracket[]): number[] { .filter(({ bracket }) => !bracket.sources) .map(({ idx }) => idx); } + +/** + * Orders a bracket's sources for seeding purposes. Teams sourced with a better placement + * in a shared ancestor bracket seed above teams that took a longer route there, e.g. if the top cut + * sources both the top 2 of "Day 1 Pools" directly and the winners of a "Redemption" bracket + * (itself sourcing pools placements 3-4), the direct pools source is ordered first. + * + * Sources that share no ancestor bracket keep their original relative order. + */ +export function sortedSourcesForSeeding( + sources: DBSource[], + progression: ParsedBracket[], +): DBSource[] { + const placementMaps = sources.map((source) => + sourcePlacementsByBracket(source, progression), + ); + + return sources + .map((source, idx) => ({ source, idx })) + .sort((a, b) => + compareSourcedPlacements( + placementMaps[a.idx], + placementMaps[b.idx], + progression, + ), + ) + .map(({ source }) => source); +} + +/** Best (lowest positive) placement the source's teams achieved in each bracket on their route, keyed by bracket index. */ +function sourcePlacementsByBracket( + source: DBSource, + progression: ParsedBracket[], +): Map { + const result = new Map(); + + result.set(source.bracketIdx, bestPositivePlacement(source.placements)); + + for (const [ancestorIdx, placement] of ancestorPlacements( + source.bracketIdx, + progression, + )) { + mergeMinPlacement(result, ancestorIdx, placement); + } + + return result; +} + +function ancestorPlacements( + bracketIdx: number, + progression: ParsedBracket[], + visited: Set = new Set(), +): Map { + const result = new Map(); + + if (visited.has(bracketIdx)) return result; + visited.add(bracketIdx); + + for (const source of progression[bracketIdx].sources ?? []) { + mergeMinPlacement( + result, + source.bracketIdx, + bestPositivePlacement(source.placements), + ); + + for (const [ancestorIdx, placement] of ancestorPlacements( + source.bracketIdx, + progression, + visited, + )) { + mergeMinPlacement(result, ancestorIdx, placement); + } + } + + return result; +} + +function bestPositivePlacement(placements: number[]) { + const positives = placements.filter((placement) => placement > 0); + + // empty placements = swiss early advancers i.e. the top teams of that bracket + if (positives.length === 0 && placements.length === 0) return 1; + + // negative placements only = teams eliminated from the source bracket + if (positives.length === 0) return Number.POSITIVE_INFINITY; + + return Math.min(...positives); +} + +function mergeMinPlacement( + map: Map, + bracketIdx: number, + placement: number, +) { + const existing = map.get(bracketIdx); + if (existing === undefined || placement < existing) { + map.set(bracketIdx, placement); + } +} + +/** Compares two routes by the placement they got in the deepest bracket they have in common. */ +function compareSourcedPlacements( + placementsA: Map, + placementsB: Map, + progression: ParsedBracket[], +): number { + const commonBracketIdx = deepestCommonBracket( + placementsA, + placementsB, + progression, + ); + if (commonBracketIdx === null) return 0; + + const placementA = placementsA.get(commonBracketIdx)!; + const placementB = placementsB.get(commonBracketIdx)!; + + if (placementA === placementB) return 0; + + return placementA - placementB; +} + +function deepestCommonBracket( + placementsA: Map, + placementsB: Map, + progression: ParsedBracket[], +): number | null { + let result: number | null = null; + let resultDepth = -1; + + for (const bracketIdx of placementsA.keys()) { + if (!placementsB.has(bracketIdx)) continue; + + const depth = bracketDepth(bracketIdx, progression); + if (depth > resultDepth) { + result = bracketIdx; + resultDepth = depth; + } + } + + return result; +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index f7721ef7b..c777eedcf 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -364,9 +364,14 @@ export class Tournament { } private resolveTeamsFromSources( - sources: NonNullable, + unsortedSources: NonNullable, bracketIdx: number, ) { + const sources = Progression.sortedSourcesForSeeding( + unsortedSources, + this.ctx.settings.bracketProgression, + ); + const teams: number[] = []; let allRelevantMatchesFinished = true; @@ -493,7 +498,10 @@ export class Tournament { } const sources: Seeding.FollowUpBracketSource[] = []; - for (const source of bracket.sources) { + for (const source of Progression.sortedSourcesForSeeding( + bracket.sources, + this.ctx.settings.bracketProgression, + )) { const sourceBracket = this.bracketByIdx(source.bracketIdx); if (!sourceBracket) { logger.warn("followUpBracketSeeding: Source bracket not found"); diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index b1302d2c2..5daec49c6 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -335,6 +335,169 @@ export const progressions = { ], }, ], + multiSourceTopCut: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Redemption", + sources: [ + { + bracketIdx: 0, + placements: [3, 4], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Top Cut", + sources: [ + { + bracketIdx: 1, + placements: [1, 2], + }, + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + ], + multiSourceTopCutWithConsolation: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Redemption", + sources: [ + { + bracketIdx: 0, + placements: [3, 4], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Top Cut", + sources: [ + { + bracketIdx: 1, + placements: [1, 2], + }, + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Consolation", + sources: [ + { + bracketIdx: 0, + placements: [5, 6, 7, 8], + }, + ], + }, + ], + poolsToBracketsViaIntermediateBrackets: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + name: "Day 1 Pools", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Redemption", + sources: [ + { + bracketIdx: 0, + placements: [2, 3, 4], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Alpha", + sources: [ + { + bracketIdx: 0, + placements: [1], + }, + { + bracketIdx: 1, + placements: [1, 2, 3, 4, 5, 6, 7, 8], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Beta", + sources: [ + { + bracketIdx: 1, + placements: [9, 10, 11, 12], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Gamma", + sources: [ + { + bracketIdx: 0, + placements: [5, 6], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Delta", + sources: [ + { + bracketIdx: 0, + placements: [7, 8], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + name: "Epsilon Seeding", + sources: [ + { + bracketIdx: 0, + placements: [9, 10, 11], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Epsilon", + sources: [ + { + bracketIdx: 6, + placements: [1, 2, 3, 4], + }, + ], + }, + ], swissToTwoSingleEliminationsWithUnderground: [ { ...DEFAULT_PROGRESSION_ARGS, diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 6c71bcbb7..0a22cc505 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -55,6 +55,7 @@ import { TournamentTeamActions } from "../components/TournamentTeamActions"; import * as AbDivisions from "../core/AbDivisions"; import type { Bracket as BracketType } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; +import * as Progression from "../core/Progression"; import type { BracketMeta, Tournament } from "../core/Tournament"; import { loader, @@ -154,44 +155,53 @@ function TournamentBracketsView() { }; const teamsSourceText = (bracket: BracketType) => { - const firstBracket = tournament.bracketsMeta[0]; + const progression = tournament.ctx.settings.bracketProgression; + const sources = progression[bracket.idx].sources; + if (!sources || sources.length === 0) return null; - if (firstBracket.type === "round_robin" && !bracket.isUnderground) { - return `Teams that place in the top ${Math.max( - ...(bracket.sources ?? []).flatMap((s) => s.placements), - )} of their group will advance to this stage`; - } + const sourceDescriptions = Progression.sortedSourcesForSeeding( + sources, + progression, + ).map((source) => { + const sourceBracket = progression[source.bracketIdx]; - if (firstBracket.type === "round_robin" && bracket.isUnderground) { - const placements = ( - bracket.sources?.flatMap((s) => s.placements) ?? [] - ).sort((a, b) => a - b); + if (source.placements.length === 0) { + return t("tournament:bracket.sources.earlyAdvancers", { + bracket: sourceBracket.name, + count: sourceBracket.settings?.advanceThreshold, + }); + } - return `Teams that don't advance to the final stage can play in this bracket (placements: ${placements.join(", ")})`; - } + if (source.placements.every((placement) => placement < 0)) { + return t("tournament:bracket.sources.eliminated", { + bracket: sourceBracket.name, + count: Math.abs(Math.min(...source.placements)), + }); + } - if (firstBracket.type === "double_elimination" && bracket.isUnderground) { - return `Teams that get eliminated in the first ${Math.abs( - Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)), - )} rounds of the losers bracket can play in this bracket`; - } + const isTopN = + !source.rest && + Math.min(...source.placements) === 1 && + Math.max(...source.placements) === source.placements.length; + if (isTopN) { + return t("tournament:bracket.sources.top", { + bracket: sourceBracket.name, + count: Math.max(...source.placements), + }); + } - if (firstBracket.type === "single_elimination" && bracket.isUnderground) { - return `Teams that get eliminated in the first ${Math.abs( - Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)), - )} rounds can play in this bracket`; - } + return t("tournament:bracket.sources.placements", { + bracket: sourceBracket.name, + placements: Progression.placementsToString( + [...source.placements], + source.rest, + ), + }); + }); - const advanceThreshold = firstBracket.settings?.advanceThreshold; - if ( - advanceThreshold && - tournament.ctx.settings.bracketProgression[bracket.idx].sources?.[0] - .placements.length === 0 - ) { - return `Teams that win at least ${advanceThreshold} sets in the Swiss bracket will advance to this stage`; - } - - return null; + return t("tournament:bracket.sources.header", { + sources: sourceDescriptions.join(", "), + }); }; if (tournament.isLeagueSignup) { @@ -743,7 +753,7 @@ function StartBracketAlert({ ? "Tournament start time is in the future" : bracket.startTime && bracket.startTime > new Date() ? "Bracket start time is in the future" - : "Teams pending from the previous bracket"}{" "} + : "Teams pending from the source brackets"}{" "} (blocks starting)
) : null} diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index a13eee60f..391a789b7 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -134,6 +134,22 @@ describe("tournamentStandings", () => { ]); }); + it("places teams eliminated in a redemption bracket above the teams of a lower placed bracket", () => { + const tournament = groupsToRedemptionAndConsolationTournament(); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + // team 3 lost the redemption bracket, which it reached by placing 3rd in the groups, + // so it is above the teams that placed 5th-8th there and went to the consolation bracket + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 4, 3, 5, 6, 7, 8, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + }); + it("does not break ties with an underground bracket that was never started", () => { // an underground bracket set in the progression can be skipped altogether const tournament = singleEliminationWithUndergroundTournament({ @@ -228,6 +244,15 @@ describe("matchesPlayed", () => { expect(roundRobinMatches).toHaveLength(3); expect(singleEliminationMatches).toHaveLength(1); }); + + it("includes matches of brackets that are not part of the standings, in the order they were played", () => { + const tournament = roundRobinWithRedemptionTournament(); + + const matches = matchesPlayed({ tournament, teamId: 4 }); + + // 3 round robin matches, the redemption bracket match and the final stage match + expect(matches.map((match) => match.bracketIdx)).toEqual([0, 0, 0, 2, 1]); + }); }); function roundRobinToSingleEliminationTournament() { @@ -262,6 +287,158 @@ function roundRobinToSingleEliminationTournament() { }); } +function roundRobinWithRedemptionTournament() { + const merged = mergeStages( + playOutLowerIdWins( + createResolved({ + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1 }, + }), + ), + playOut( + createResolved({ + type: "single_elimination", + seeding: [3, 4], + settings: {}, + }), + (one, two) => one > two, + ), + playOutLowerIdWins( + createResolved({ + type: "single_elimination", + seeding: [1, 2, 4], + settings: {}, + }), + ), + ); + + // the redemption bracket (idx 2) was played before the final stage (idx 1) + const stageNames = ["Groups Stage", "Redemption", "Final Stage"]; + const data = { + ...merged, + stage: merged.stage.map((stage, stageIdx) => ({ + ...stage, + name: stageNames[stageIdx], + createdAt: stageIdx + 1, + })), + }; + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "round_robin", + name: "Groups Stage", + requiresCheckIn: false, + settings: {}, + }, + { + type: "single_elimination", + name: "Final Stage", + requiresCheckIn: false, + settings: {}, + sources: [ + { bracketIdx: 0, placements: [1, 2] }, + { bracketIdx: 2, placements: [1] }, + ], + }, + { + type: "single_elimination", + name: "Redemption", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, + ], + }, + teams: [1, 2, 3, 4].map((id) => + tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }), + ), + }, + data, + }); +} + +function groupsToRedemptionAndConsolationTournament() { + const data = mergeStages( + playOutLowerIdWins( + createResolved({ + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { groupCount: 1 }, + }), + ), + // the higher seed wins so team 4 advances to the top cut and team 3 is eliminated + playOut( + createResolved({ + type: "single_elimination", + seeding: [3, 4], + settings: {}, + }), + (one, two) => one > two, + ), + playOutLowerIdWins( + createResolved({ + type: "single_elimination", + seeding: [1, 2, 4], + settings: {}, + }), + ), + playOutLowerIdWins( + createResolved({ + type: "single_elimination", + seeding: [5, 6, 7, 8], + settings: { consolationFinal: true }, + }), + ), + ); + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "round_robin", + name: "Groups", + requiresCheckIn: false, + settings: { groupCount: 1 }, + }, + { + type: "single_elimination", + name: "Redemption", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [ + { bracketIdx: 1, placements: [1] }, + { bracketIdx: 0, placements: [1, 2] }, + ], + }, + { + type: "single_elimination", + name: "Consolation", + requiresCheckIn: false, + settings: { thirdPlaceMatch: true }, + sources: [{ bracketIdx: 0, placements: [5, 6, 7, 8] }], + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) => + tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }), + ), + }, + data, + }); +} + function singleEliminationTournament() { const data = playOutLowerIdWins( createResolved({ diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index 013ccedbb..a903b5988 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -86,7 +86,7 @@ export function calculateSPR({ return expectedIndex - actualIndex; } -/** Teams matches that contributed to the standings, in the order they were played in */ +/** Every match the team played, in the order they were played in */ export function matchesPlayed({ tournament, teamId, @@ -94,32 +94,13 @@ export function matchesPlayed({ tournament: Tournament; teamId: number; }) { - const startingBracketIdx = tournament.teamById(teamId)?.startingBracketIdx; + const bracketsInPlayedOrder = R.sortBy( + tournament.brackets, + (bracket) => bracket.createdAt ?? Number.POSITIVE_INFINITY, + (bracket) => bracket.idx, + ); - let bracketIdxs: number[]; - - if (typeof startingBracketIdx !== "number" || startingBracketIdx === 0) { - bracketIdxs = Progression.bracketIdxsForStandings( - tournament.ctx.settings.bracketProgression, - ); - } else { - const reachableBrackets = Progression.bracketsReachableFrom( - startingBracketIdx, - tournament.ctx.settings.bracketProgression, - ); - const reachableSet = new Set(reachableBrackets); - - const allBracketIdxs = tournament.ctx.settings.bracketProgression - .map((_, idx) => idx) - .sort((a, b) => b - a); - bracketIdxs = allBracketIdxs.filter((idx) => reachableSet.has(idx)); - } - - const brackets = bracketIdxs - .reverse() - .map((bracketIdx) => tournament.bracketByIdx(bracketIdx)!); - - const matches = brackets.flatMap((bracket, i) => + const matches = bracketsInPlayedOrder.flatMap((bracket) => bracket.data.match .filter( (match) => @@ -130,7 +111,7 @@ export function matchesPlayed({ ) .map((match) => ({ ...match, - bracketIdx: bracketIdxs[i], + bracketIdx: bracket.idx, })), ); diff --git a/e2e/pages/calendar/calendar-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts index cc481166e..d348a5d81 100644 --- a/e2e/pages/calendar/calendar-new-event-page.ts +++ b/e2e/pages/calendar/calendar-new-event-page.ts @@ -25,6 +25,11 @@ export class CalendarNewEventPage { placementsInputs: page.getByLabel("Placements"), deleteBracketButtons: page.getByTestId("brackets-remove-item-button"), signUpSourceRadios: page.getByRole("radio", { name: "Sign-up" }), + // the sources array is nested inside a progression item, so its add button + // test id is prefixed by the item's path e.g. "progression[1].sources" + addSourceButtons: page.locator( + '[data-testid$="sources-add-item-button"]', + ), mapPoolTemplateSelect: page.getByLabel("Template"), clearMapPoolButton: page.getByRole("button", { name: "Clear" }), }; @@ -117,4 +122,15 @@ export class CalendarNewEventPage { await this.locators.bracketFormatSelects.last().selectOption(format); await this.locators.placementsInputs.last().fill(placements); } + + async renameBracket(nth: number, name: string) { + await this.locators.bracketNameInputs.nth(nth).fill(name); + } + + /** Adds another source bracket to the last bracket of the progression. The new + * row preselects the first bracket not sourced by it yet. */ + async addSourceToLastBracket(placements: string) { + await this.locators.addSourceButtons.last().click(); + await this.locators.placementsInputs.last().fill(placements); + } } diff --git a/e2e/pages/tournament/tournament-brackets-page.ts b/e2e/pages/tournament/tournament-brackets-page.ts index 1af10541c..45d74e072 100644 --- a/e2e/pages/tournament/tournament-brackets-page.ts +++ b/e2e/pages/tournament/tournament-brackets-page.ts @@ -30,6 +30,10 @@ export class TournamentBracketsPage { streamPopover: page.getByTestId("stream-popover"), streamPopoverStreams: page.getByTestId("tournament-stream"), finalizeTournamentButton: page.getByTestId("finalize-tournament-button"), + finalizeBracketButton: page.getByTestId("finalize-bracket-button"), + teamsPendingFromSourcesText: page.getByText( + "Teams pending from the source brackets", + ), startRoundButton: page.getByTestId("start-round-button"), byeTeam: page.getByTestId("bye-team"), prepareMapsButton: page.getByTestId("prepare-maps-button"), diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts index 386fe9d90..6fe198809 100644 --- a/e2e/tournament-bracket-multi-stage.spec.ts +++ b/e2e/tournament-bracket-multi-stage.spec.ts @@ -1,3 +1,4 @@ +import { subMinutes } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { expect, impersonate, isNotVisible, test } from "./helpers/playwright"; import { @@ -11,6 +12,7 @@ import { TO_MAP_POOL, teamSeeds, } from "./helpers/tournament"; +import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page"; import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page"; import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page"; import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page"; @@ -299,6 +301,89 @@ test.describe("Tournament bracket multi stage", () => { await expect(brackets.match(11)).toBeVisible(); }); + test("plays out a redemption bracket set up in the tournament creation form", async ({ + page, + factories, + }) => { + test.slow(); + const organizer = await factories.UserFactory.create(null, { + roles: ["TOURNAMENT_ORGANIZER"], + }); + + await impersonate(page, organizer.id); + + const newTournament = new CalendarNewEventPage(page); + await newTournament.gotoNewTournament(); + + await newTournament.form.fill("name", "Redemption Arc"); + // start time in the past so the brackets can be started right away + await newTournament.setFirstDate(subMinutes(new Date(), 30)); + + await newTournament.form.select("toToolsMode", "TO"); + await newTournament.selectMapPoolTemplate("preset:SZ"); + + // groups of 4: top 2 advance to the finals directly, 3rd placers get + // another shot at the last finals spot through the redemption bracket + await newTournament.renameBracket(0, "Groups"); + await newTournament.setBracketFormat(0, "Round robin"); + await newTournament.addFollowUpBracket({ + name: "Redemption", + format: "Single elimination", + placements: "3", + }); + await newTournament.addFollowUpBracket({ + name: "Finals", + format: "Single elimination", + placements: "1-2", + }); + await newTournament.addSourceToLastBracket("1"); + + await newTournament.form.submit(); + + await expect(page).toHaveURL(/\/to\/\d+/); + const tournamentId = Number(page.url().match(/\/to\/(\d+)/)![1]); + + await createTeams(factories, tournamentId, teamSeeds(8)); + await factories.TournamentFactory.playOut(tournamentId, 0); + + const brackets = new TournamentBracketsPage(page); + await brackets.goto(tournamentId); + + await brackets.bracketTab("Groups").click(); + const groups = await brackets.groupStandingsTeamNames(2); + const redemptionTeamNames = groups.map((group) => group[2]); + + // the finals can not be started before the redemption bracket has been played out + await brackets.bracketTab("Finals").click(); + await expect(brackets.locators.teamsPendingFromSourcesText).toBeVisible(); + await isNotVisible(brackets.locators.finalizeBracketButton); + + await brackets.bracketTab("Redemption").click(); + await brackets.finalize(); + + const redemptionMatchId = Number( + await brackets.locators.matches.first().getAttribute("data-match-id"), + ); + const redemptionMatch = await brackets.openMatch(redemptionMatchId); + await redemptionMatch.openTab("action"); + await redemptionMatch.reportResultForTeam({ + teamName: redemptionTeamNames[0], + mapsToReport: 3, + }); + await redemptionMatch.backToBracket(); + + await brackets.bracketTab("Finals").click(); + await isNotVisible(brackets.locators.teamsPendingFromSourcesText); + await brackets.finalize(); + + // the redemption bracket's winner took the last spot in the finals + await expect( + brackets.locators.bracketsViewer + .getByText(redemptionTeamNames[0]) + .first(), + ).toBeVisible(); + }); + test("prepares maps (including third place match linking)", async ({ page, factories, diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 5b0deab6d..be9803ac4 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -155,6 +155,11 @@ "bracket.waiting": "Her vil turneringsplanen blive vist, så snart{{count}} hold har registreret sig", "bracket.waiting.checkin": "Her vil turneringsplanen blive vist, så snart{{count}} hold er tjekket ind", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Denne turneringsplan er en forhåndsvisning og kan blive ændret", "bracket.progress.thanksForPlaying": "Tak fordi du deltog i {{eventName}}!", "bracket.progress.match": "Nuværende modstander: {{opponent}}", @@ -237,13 +242,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 4788bfee6..18c286714 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -155,6 +155,11 @@ "bracket.waiting": "Bracket wird hier angezeigt, sobald mindestens {{count}} Teams registriert sind", "bracket.waiting.checkin": "Bracket wird hier angezeigt, sobald mindestens {{count}} Teams eingecheckt sind", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Dieses Bracket ist eine Vorschau und kann sich ändern", "bracket.progress.thanksForPlaying": "Danke fürs Spielen von {{eventName}}!", "bracket.progress.match": "Aktueller Gegner: {{opponent}}", @@ -237,13 +242,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index b1747cf4f..f4e8b8b08 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -155,6 +155,11 @@ "bracket.waiting": "Bracket will be shown here when at least {{count}} teams have registered", "bracket.waiting.checkin": "Bracket will be shown here when at least {{count}} teams have checked in", "bracket.waiting.advanced": "Bracket will be shown here when at least {{count}} teams have advanced", + "bracket.sources.header": "Teams joining this bracket: {{sources}}", + "bracket.sources.top": "{{bracket}} (top {{count}})", + "bracket.sources.placements": "{{bracket}} (placements {{placements}})", + "bracket.sources.eliminated": "{{bracket}} (eliminated in the first {{count}} rounds)", + "bracket.sources.earlyAdvancers": "{{bracket}} (teams that win {{count}} sets)", "bracket.wip": "This bracket is a preview and subject to change", "bracket.progress.thanksForPlaying": "Thanks for playing in {{eventName}}!", "bracket.progress.match": "Current opponent: {{opponent}}", @@ -237,13 +242,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name", "progression.error.NAME_MISSING": "Bracket name missing", "progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination", - "progression.error.NO_SE_POSITIVE": "Single elimination is not valid for positive progression", - "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "One source can't mix advancing placements with eliminated teams", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets", "progression.error.AB_DIVISIONS_NOT_STARTING": "A/B divisions can only be enabled on starting brackets", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B divisions requires an even number of teams per group", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Empty placements are only valid when sourcing from a Swiss bracket with early advance", + "progression.error.DUPLICATE_SOURCE_BRACKET": "Same bracket can be a source only once per bracket", + "progression.error.CYCLIC_PROGRESSION": "Brackets can't source each other in a loop", + "progression.error.MERGED_STARTING_BRACKETS": "Teams that started in different brackets can't meet", "lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue", "customFlow.beforeSet": "Before set", "customFlow.afterMap": "After map", diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index ee7d3f1aa..aaacc957a 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -24,6 +24,7 @@ "stat.specialLost": "Especial perdido al ser reventado", "stat.specialLostSplattedByRP": "Especial perdido al ser reventado por jugador con Castigo Póstumo", "stat.tenacitySecondsToSpecial_one": "Tiempo para el especial con Ventaja ({{count}} menos)", + "stat.tenacitySecondsToSpecial_many": "", "stat.tenacitySecondsToSpecial_other": "Tiempo para el especial con Ventaja ({{count}} menos)", "stat.tenacitySecondsToSpecial.explanation": "El tiempo que tarda Ventaja en llenar el medidor especial desde cero mientras tu equipo tiene menos jugadores activos que el rival, ej. {{teamPlayerCount}} contra {{opponentPlayerCount}}. Solo importa la diferencia entre los equipos, por lo que un duelo igualado como 3 contra 3 no carga el medidor en absoluto.", "stat.whiteInk": "Tiempo sin recuperar tinta después de su uso", @@ -108,6 +109,7 @@ "damage.header.baseDamage.short": "Base", "damage.header.distance": "Distancia", "damage.toSplat_one": "{{count}} golpe para liquidar", + "damage.toSplat_many": "", "damage.toSplat_other": "{{count}} golpes para liquidar", "damage.NORMAL_MIN": "Mínimo", "damage.NORMAL_MAX": "Máximo", @@ -179,6 +181,7 @@ "dmgHtdExplanation": "DPD = Disparos para destruir", "noDmgData": "No hay información sobre esta arma. Revisa más tarde.", "perInkTankGrid.header_one": "{{weapon}} disparos después de ×{{count}} arma secundaria usada", + "perInkTankGrid.header_many": "", "perInkTankGrid.header_other": "{{weapon}} disparos después de ×{{count}} armas secundarias usadas", "bigBubblerExplanation": "La duración de {{weapon}} también aumenta junto con su durabilidad.", "button.showChart": "Mostrar gráfico", @@ -200,6 +203,7 @@ "comp.showWeaponGrid": "Mostrar selector de armas", "comp.hideWeaponGrid": "Ocultar selector de armas", "comp.hits_one": "{{count}} golpe", + "comp.hits_many": "", "comp.hits_other": "{{count}} golpes", "comp.enemyRes": "Impermeabilidad del enemigo", "comp.enemySubDef": "Resistencia Secundaria del enemigo", diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json index 30faf3801..9a5ff6b6c 100644 --- a/locales/es-ES/art.json +++ b/locales/es-ES/art.json @@ -1,5 +1,6 @@ { "pendingApproval_one": "Tienes {{count}} imagen esperando aprobación.", + "pendingApproval_many": "", "pendingApproval_other": "Tienes {{count}} imágenes esperando aprobación.", "madeBy": "Creada por", "radios.all": "Todos", diff --git a/locales/es-ES/badges.json b/locales/es-ES/badges.json index f406ead7a..cdf8651f1 100644 --- a/locales/es-ES/badges.json +++ b/locales/es-ES/badges.json @@ -3,6 +3,7 @@ "patreon+": "Supporter+ de sendou.ink en Patreon", "xp": "Recibido por alcanzar {{xpText}}", "tournament_one": "Recibido por ganar {{tournament}}", + "tournament_many": "", "tournament_other": "Recibido por ganar {{tournament}} (×{{count}})", "forYourEvent": "¿Insignia para tu evento?", "managedBy": "Administrado por <0>", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index 39317dd05..ba063531a 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -10,8 +10,10 @@ "results": "Resultados", "createMapList": "Crear lista de mapas", "count.teams_one": "{{count}} equipo", + "count.teams_many": "", "count.teams_other": "{{count}} equipos", "count.players_one": "{{count}} jugador", + "count.players_many": "", "count.players_other": "{{count}} jugadores", "forms.dates": "Fechas", "forms.bracketUrl": "Enlace de cuadros", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index c210644e9..8440a5803 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -71,6 +71,7 @@ "errors.customRoleRequired": "Introduce un nombre para el rol personalizado", "labels.weaponPool": "Selección de armas", "placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más", + "placeholders.vodStartTimestamp": "", "labels.voiceChat": "Puede usar chat de voz", "labels.languages": "Tus idiomas", "options.voiceChat.yes": "Sí", @@ -96,6 +97,7 @@ "labels.scrimManagedByAnyone": "Cualquiera puede gestionar", "bottomTexts.scrimManagedByAnyone": "Si se activa, todos los usuarios de esta publicación pueden aceptar solicitudes y eliminarla, no solo el propietario.", "labels.castTwitchAccounts": "Cuentas de Twitch", + "placeholders.castTwitchAccounts": "", "bottomTexts.castTwitchAccounts": "Cuenta de Twitch donde se retransmite el torneo. Los directos de los jugadores se añaden automáticamente basándose en la información de su perfil.", "labels.scrimMaps": "Mapas", "labels.scrimMaxDiv": "Div. máxima", @@ -103,6 +105,7 @@ "labels.scrimMapSource": "Fuente", "labels.scrimMapPool": "Rotación de escenarios", "labels.scrimMapsTournament": "Torneo", + "placeholders.scrimMapPool": "", "options.scrimMapSource.POOL": "URL de la rotación", "options.scrimMapSource.TOURNAMENT": "Torneo", "options.scrimFlexibility.notFlexible": "Sin flexibilidad", @@ -447,6 +450,7 @@ "options.patronTier.1": "Support", "options.patronTier.2": "Supporter", "options.patronTier.3": "Supporter+", + "placeholders.friendCode": "", "unsavedChanges.title": "Cambios sin guardar", "unsavedChanges.body": "¿Estás seguro de que quieres salir? Los cambios que has hecho no se guardarán.", "unsavedChanges.discard": "Salir de la página", diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json index 734f60e83..2c5dca420 100644 --- a/locales/es-ES/friends.json +++ b/locales/es-ES/friends.json @@ -22,5 +22,6 @@ "view.all": "Todos", "teamMembers.empty": "Aún no hay miembros en el equipo", "unseenRequests_one": "{{count}} solicitud de amistad sin ver", + "unseenRequests_many": "", "unseenRequests_other": "{{count}} solicitudes de amistad sin ver" } diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 83cd631c9..2e8d94eef 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -70,6 +70,7 @@ "pickInfo.default": "Elección de la comunidad", "pickInfo.default.explanation": "No había un mapa adecuado en los grupos de los participantes. Este mapa fue seleccionado del conjunto de mapas populares.", "pickInfo.votes_one": "{{count}} voto", + "pickInfo.votes_many": "", "pickInfo.votes_other": "{{count}} votos", "pickInfo.teamMapList": "Lista de mapas de {{teamName}}", "pickInfo.counterpick": "Contraselección", @@ -124,6 +125,7 @@ "actions.addSub": "Añadir sub", "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}", "actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo", + "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": "Finalizando torneo", @@ -155,6 +157,11 @@ "bracket.waiting": "El cuadro se mostrará aquí cuando al menos {{count}} equipos se hayan inscrito", "bracket.waiting.checkin": "El cuadro se mostrará aquí cuando al menos {{count}} equipos hayan hecho check-in", "bracket.waiting.advanced": "El bracket se mostrará aquí cuando al menos {{count}} equipos hayan avanzado", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Este cuadro es temporal y puede cambiar", "bracket.progress.thanksForPlaying": "¡Gracias por participar en {{eventName}}!", "bracket.progress.match": "Oponente actual: {{opponent}}", @@ -237,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Nombre de cuadro duplicado", "progression.error.NAME_MISSING": "Falta el nombre del cuadro", "progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble", - "progression.error.NO_SE_POSITIVE": "La eliminación directa no es válida para la progresión positiva", - "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "Las divisiones A/B solo se pueden activar en brackets de todos contra todos", "progression.error.AB_DIVISIONS_NOT_STARTING": "Las divisiones A/B solo se pueden activar en brackets iniciales", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "Las divisiones A/B requieren un número par de equipos por grupo", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Las posiciones vacías solo son válidas cuando provienen de un bracket suizo con avance anticipado", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "Pide al capitán de tu equipo o a un manager que se una a la cola", "customFlow.beforeSet": "Antes del set", "customFlow.afterMap": "Después del mapa", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 11ad4770c..18f18f0e8 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -202,8 +202,10 @@ "seasons.summary.bestTournament": "Mejor torneo", "seasons.summary.opponentSp": "Sendou Power rival", "seasons.summary.count.sets_one": "{{count}} set", + "seasons.summary.count.sets_many": "", "seasons.summary.count.sets_other": "{{count}} sets", "seasons.summary.count.maps_one": "{{count}} mapa", + "seasons.summary.count.maps_many": "", "seasons.summary.count.maps_other": "{{count}} mapas", "seasons.summary.export": "Exportar imagen", "seasons.summary.export.supporterPerk": "Exportar la imagen del resumen de esta temporada es una ventaja de supporter. Todos pueden exportar la imagen de la última temporada finalizada durante la pretemporada (off-season).", @@ -226,6 +228,7 @@ "commissions.closed": "Cerradas", "mutualFriends": "Amigos en común", "mutualFriends.count_one": "amigo en común", + "mutualFriends.count_many": "", "mutualFriends.count_other": "amigos en común", "card.viewUserPage": "Ver página de usuario", "card.sendFriendRequest": "Enviar solicitud de amistad", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 6e04fcfda..3984997b4 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "Cuadro se muestra aquí cuando al menos {{count}} equipos sean registrados", "bracket.waiting.checkin": "Cuadro se muestra aquí cuando al menos {{count}} equipos se hagan check-in", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Este cuadro es temporal y puede cambiar", "bracket.progress.thanksForPlaying": "¡Gracias por participar en {{eventName}}!", "bracket.progress.match": "Oponente actual: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index a567af998..8e8f7f3cf 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "Le bracket sera affiché ici quand au moins {{count}} équipes seront inscrites", "bracket.waiting.checkin": "", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Ce bracket est un aperçu et sujet à changement", "bracket.progress.thanksForPlaying": "Merci d'avoir participé à {{eventName}} !", "bracket.progress.match": "Adversaire actuel: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 5dfdd1033..e4addfa29 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "Le bracket sera affiché ici quand au moins {{count}} équipes seront inscrites", "bracket.waiting.checkin": "Le bracket sera affiché ici lorsqu'au moins {{count}} équipes se seront enregistrées", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Ce bracket est un aperçu et sujet à changement", "bracket.progress.thanksForPlaying": "Merci d'avoir participé à {{eventName}} !", "bracket.progress.match": "Adversaire actuel: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name", "progression.error.NAME_MISSING": "Bracket name missing", "progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index cc77dc741..ef9ef4658 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "מערכים יופיעו כאן כאשר לפחות {{count}} צוותים נרשמו", "bracket.waiting.checkin": "", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "מערך זה הוא תצוגה מקדימה ונתון לשינויים", "bracket.progress.thanksForPlaying": "תודה ששיחקתם ב-{{eventName}}!", "bracket.progress.match": "יריב נוכחי: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index a7b63cd84..9deba33c1 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "Il bracket verrà mostrato qui una volta che {{count}} team si saranno iscritti", "bracket.waiting.checkin": "Il bracket verrà mostrato qui una volta che {{count}} team avranno completato il check-in", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Questo bracket è un anteprima ed è soggetto a cambiamenti", "bracket.progress.thanksForPlaying": "Grazie per aver giocato in {{eventName}}!", "bracket.progress.match": "Avversario attuale: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Nome bracket duplicato", "progression.error.NAME_MISSING": "Nome bracket mancante", "progression.error.NEGATIVE_PROGRESSION": "La progressione negativa è disponibile solo in doppia eliminazione", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 9e32f75a5..5f51b507c 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -151,6 +151,11 @@ "bracket.waiting": "ブラケットは、少なくとも {{count}} チームが登録した時点で表示されます", "bracket.waiting.checkin": "ブラケットは最低{{count}}チームがチェックインしてから表示されるよ", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "このブラケットはまだプレビューで、変更される可能性があります", "bracket.progress.thanksForPlaying": "{{eventName}} への参加ありがとうございます!", "bracket.progress.match": "現在の対戦者: {{opponent}}", @@ -233,13 +238,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "ブラケットの名前が重複しています", "progression.error.NAME_MISSING": "ブラケットの名前がありません", "progression.error.NEGATIVE_PROGRESSION": "逆の進行はダブルエリ三ネーションの時のみ可能です", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 206a03945..51bb6d85e 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -151,6 +151,11 @@ "bracket.waiting": "", "bracket.waiting.checkin": "", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "", "bracket.progress.thanksForPlaying": "", "bracket.progress.match": "", @@ -233,13 +238,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 71b5963a0..1d1d050aa 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -155,6 +155,11 @@ "bracket.waiting": "", "bracket.waiting.checkin": "", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "", "bracket.progress.thanksForPlaying": "", "bracket.progress.match": "", @@ -237,13 +242,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index ef79292d9..c26c7178d 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -159,6 +159,11 @@ "bracket.waiting": "", "bracket.waiting.checkin": "", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "", "bracket.progress.thanksForPlaying": "", "bracket.progress.match": "", @@ -241,13 +246,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 6e6cb3274..9f2ed4ccb 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -157,6 +157,11 @@ "bracket.waiting": "O bracket será mostrado aqui quando ao menos {{count}} times estiverem registrados", "bracket.waiting.checkin": "O bracket será mostrado aqui quando pelo menos {{count}} times tiverem feito o check-in", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Esse bracket é uma prévia e poderá mudar", "bracket.progress.thanksForPlaying": "Obrigado por participar do(a) {{eventName}}!", "bracket.progress.match": "Oponente atual: {{opponent}}", @@ -239,13 +244,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 447a01c45..6acae6d3f 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -159,6 +159,11 @@ "bracket.waiting": "Сетка будет показана как только {{count}} команд зарегистрируется", "bracket.waiting.checkin": "Сетка будет показана как только {{count}} команд пройдут чек-ин", "bracket.waiting.advanced": "", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "Данная сетка является предварительной и может быть изменена.", "bracket.progress.thanksForPlaying": "Спасибо за участие в {{eventName}}!", "bracket.progress.match": "Текущий противник: {{opponent}}", @@ -241,13 +246,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Дубликат имени сетки", "progression.error.NAME_MISSING": "Имя сетки отсутствует", "progression.error.NEGATIVE_PROGRESSION": "Отрицательная прогрессия возможна только в Double Elimination турнирах", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 6aba89afa..2625f86fa 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -153,6 +153,11 @@ "bracket.waiting": "当至少有 {{count}} 支队伍报名后,对战表将在此显示", "bracket.waiting.checkin": "当至少有 {{count}} 支队伍签到后,对战表将在此显示", "bracket.waiting.advanced": "当至少有 {{count}} 支队伍晋级后,对战表将在此显示", + "bracket.sources.header": "", + "bracket.sources.top": "", + "bracket.sources.placements": "", + "bracket.sources.eliminated": "", + "bracket.sources.earlyAdvancers": "", "bracket.wip": "此对战表为预览版本,可能会有变动", "bracket.progress.thanksForPlaying": "感谢您参加 {{eventName}}!", "bracket.progress.match": "当前对手: {{opponent}}", @@ -235,13 +240,15 @@ "progression.error.DUPLICATE_BRACKET_NAME": "对战表名称重复", "progression.error.NAME_MISSING": "缺少对战表名称", "progression.error.NEGATIVE_PROGRESSION": "负序晋级仅在双败淘汰赛中可行", - "progression.error.NO_SE_POSITIVE": "", - "progression.error.NO_DE_POSITIVE": "双败淘汰赛不适用于正序晋级", + "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "包含提前晋级/淘汰的瑞士轮对战表必须导向另一个对战表", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B 分组只能在循环赛对战表中启用", "progression.error.AB_DIVISIONS_NOT_STARTING": "A/B 分组只能在初始对战表中启用", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B 分组要求每个小组的队伍数量为偶数", "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "空名次仅在来源于包含提前晋级的瑞士轮对战表时有效", + "progression.error.DUPLICATE_SOURCE_BRACKET": "", + "progression.error.CYCLIC_PROGRESSION": "", + "progression.error.MERGED_STARTING_BRACKETS": "", "lfg.askCaptainToJoinQueue": "请让您的队伍队长或管理员加入队列", "customFlow.beforeSet": "本轮对局前", "customFlow.afterMap": "本局结束后", From 0c50b45cdb585f11cdd7ae2d11e3edbce0f9fc59 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:37:08 +0300 Subject: [PATCH 11/14] Unified filters (#3313) --- .../filter-bar/FilterBar.browser.test.tsx | 197 +++++++ .../filter-bar/FilterBar.module.css | 139 +++++ app/components/filter-bar/FilterBar.tsx | 183 ++++++ app/features/builds/builds-schemas.ts | 25 +- .../builds/builds-search-params.test.ts | 26 +- app/features/builds/builds-search-params.ts | 16 +- app/features/builds/builds-types.ts | 27 +- .../components/FilterSection.module.css | 38 -- .../builds/components/FilterSection.tsx | 259 --------- app/features/builds/core/filter.server.ts | 85 ++- app/features/builds/core/filter.test.ts | 85 +-- .../builds/loaders/builds.$slug.server.ts | 10 +- .../builds/routes/builds.$slug.module.css | 27 + app/features/builds/routes/builds.$slug.tsx | 459 +++++++++++---- app/features/calendar/calendar-schemas.ts | 127 +--- .../calendar/calendar-search-params.test.ts | 77 +-- .../calendar/calendar-search-params.ts | 68 ++- .../calendar/components/FiltersBar.tsx | 541 ++++++++++++++++++ .../calendar/components/FiltersDialog.tsx | 105 ---- .../calendar/core/CalendarEvent.test.ts | 37 ++ app/features/calendar/core/CalendarEvent.ts | 32 +- .../calendar/loaders/calendar.server.ts | 22 +- .../calendar/loaders/calendar[.]ics.server.ts | 21 +- .../calendar/routes/calendar.module.css | 9 +- app/features/calendar/routes/calendar.tsx | 70 +-- .../components-showcase/routes/components.tsx | 78 ++- .../lfg/components/LFGAddFilterButton.tsx | 50 -- .../lfg/components/LFGFilters.module.css | 5 - app/features/lfg/components/LFGFilters.tsx | 302 ---------- app/features/lfg/core/filtering.test.ts | 21 +- app/features/lfg/core/filtering.ts | 198 ++++--- app/features/lfg/lfg-constants.ts | 1 + app/features/lfg/lfg-search-params.test.ts | 43 +- app/features/lfg/lfg-search-params.ts | 47 +- app/features/lfg/lfg-types.ts | 168 +----- app/features/lfg/routes/lfg.module.css | 5 - app/features/lfg/routes/lfg.tsx | 261 ++++++++- .../scrims/components/ScrimFiltersDialog.tsx | 126 ---- app/features/scrims/loaders/scrims.server.ts | 19 +- app/features/scrims/routes/scrims.tsx | 221 ++++++- app/features/scrims/scrims-schemas.ts | 66 ++- .../scrims/scrims-search-params.test.ts | 75 +-- app/features/scrims/scrims-search-params.ts | 12 +- app/features/tournament/core/tiering.ts | 6 + .../user-page/UserRepository.server.ts | 432 +++++++++----- app/features/user-page/UserRepository.test.ts | 190 ++++++ .../components/ResultsFiltersBar.tsx | 398 +++++++++++++ .../user-page/components/UserResultsTable.tsx | 11 +- .../loaders/u.$identifier.results.server.ts | 52 +- .../routes/u.$identifier.results.tsx | 81 +-- app/features/user-page/user-page-constants.ts | 12 + .../user-page/user-page-search-params.test.ts | 49 +- .../user-page/user-page-search-params.ts | 65 ++- app/features/user-page/user-page.module.css | 29 +- app/features/vods/routes/vods.module.css | 4 - app/features/vods/routes/vods.tsx | 182 +++--- app/utils/urls.ts | 15 +- e2e/builds.spec.ts | 4 +- e2e/calendar.spec.ts | 18 +- e2e/pages/builds/weapon-builds-page.ts | 17 +- e2e/pages/calendar/calendar-page.ts | 75 +-- e2e/pages/lfg/lfg-page.ts | 4 +- e2e/pages/scrims/scrims-page.ts | 45 ++ e2e/pages/vods/vods-page.ts | 3 + e2e/scrims.spec.ts | 41 ++ locales/da/builds.json | 11 +- locales/da/calendar.json | 14 +- locales/da/common.json | 2 + locales/da/forms.json | 17 - locales/da/lfg.json | 2 - locales/da/scrims.json | 9 +- locales/da/user.json | 24 +- locales/de/builds.json | 11 +- locales/de/calendar.json | 14 +- locales/de/common.json | 2 + locales/de/forms.json | 17 - locales/de/lfg.json | 2 - locales/de/scrims.json | 9 +- locales/de/user.json | 24 +- locales/en/builds.json | 11 +- locales/en/calendar.json | 14 +- locales/en/common.json | 2 + locales/en/forms.json | 17 - locales/en/lfg.json | 2 - locales/en/scrims.json | 9 +- locales/en/user.json | 24 +- locales/es-ES/builds.json | 11 +- locales/es-ES/calendar.json | 14 +- locales/es-ES/common.json | 2 + locales/es-ES/forms.json | 17 - locales/es-ES/lfg.json | 2 - locales/es-ES/scrims.json | 9 +- locales/es-ES/user.json | 24 +- locales/es-US/builds.json | 11 +- locales/es-US/calendar.json | 14 +- locales/es-US/common.json | 2 + locales/es-US/forms.json | 17 - locales/es-US/lfg.json | 2 - locales/es-US/scrims.json | 9 +- locales/es-US/user.json | 24 +- locales/fr-CA/builds.json | 11 +- locales/fr-CA/calendar.json | 14 +- locales/fr-CA/common.json | 2 + locales/fr-CA/forms.json | 17 - locales/fr-CA/lfg.json | 2 - locales/fr-CA/scrims.json | 9 +- locales/fr-CA/user.json | 24 +- locales/fr-EU/builds.json | 11 +- locales/fr-EU/calendar.json | 14 +- locales/fr-EU/common.json | 2 + locales/fr-EU/forms.json | 17 - locales/fr-EU/lfg.json | 2 - locales/fr-EU/scrims.json | 9 +- locales/fr-EU/user.json | 24 +- locales/he/builds.json | 11 +- locales/he/calendar.json | 14 +- locales/he/common.json | 2 + locales/he/forms.json | 17 - locales/he/lfg.json | 2 - locales/he/scrims.json | 9 +- locales/he/user.json | 24 +- locales/it/builds.json | 11 +- locales/it/calendar.json | 14 +- locales/it/common.json | 2 + locales/it/forms.json | 17 - locales/it/lfg.json | 2 - locales/it/scrims.json | 9 +- locales/it/user.json | 24 +- locales/ja/builds.json | 11 +- locales/ja/calendar.json | 14 +- locales/ja/common.json | 2 + locales/ja/forms.json | 17 - locales/ja/lfg.json | 2 - locales/ja/scrims.json | 9 +- locales/ja/user.json | 24 +- locales/ko/builds.json | 11 +- locales/ko/calendar.json | 14 +- locales/ko/common.json | 2 + locales/ko/forms.json | 17 - locales/ko/lfg.json | 2 - locales/ko/scrims.json | 9 +- locales/ko/user.json | 24 +- locales/nl/builds.json | 11 +- locales/nl/calendar.json | 14 +- locales/nl/common.json | 2 + locales/nl/forms.json | 17 - locales/nl/lfg.json | 2 - locales/nl/scrims.json | 9 +- locales/nl/user.json | 24 +- locales/pl/builds.json | 11 +- locales/pl/calendar.json | 14 +- locales/pl/common.json | 2 + locales/pl/forms.json | 17 - locales/pl/lfg.json | 2 - locales/pl/scrims.json | 9 +- locales/pl/user.json | 24 +- locales/pt-BR/builds.json | 11 +- locales/pt-BR/calendar.json | 14 +- locales/pt-BR/common.json | 2 + locales/pt-BR/forms.json | 17 - locales/pt-BR/lfg.json | 2 - locales/pt-BR/scrims.json | 9 +- locales/pt-BR/user.json | 24 +- locales/ru/builds.json | 11 +- locales/ru/calendar.json | 14 +- locales/ru/common.json | 2 + locales/ru/forms.json | 17 - locales/ru/lfg.json | 2 - locales/ru/scrims.json | 9 +- locales/ru/user.json | 24 +- locales/zh/builds.json | 11 +- locales/zh/calendar.json | 14 +- locales/zh/common.json | 2 + locales/zh/forms.json | 17 - locales/zh/lfg.json | 2 - locales/zh/scrims.json | 9 +- locales/zh/user.json | 24 +- 177 files changed, 4494 insertions(+), 2886 deletions(-) create mode 100644 app/components/filter-bar/FilterBar.browser.test.tsx create mode 100644 app/components/filter-bar/FilterBar.module.css create mode 100644 app/components/filter-bar/FilterBar.tsx delete mode 100644 app/features/builds/components/FilterSection.module.css delete mode 100644 app/features/builds/components/FilterSection.tsx create mode 100644 app/features/calendar/components/FiltersBar.tsx delete mode 100644 app/features/calendar/components/FiltersDialog.tsx delete mode 100644 app/features/lfg/components/LFGAddFilterButton.tsx delete mode 100644 app/features/lfg/components/LFGFilters.module.css delete mode 100644 app/features/lfg/components/LFGFilters.tsx delete mode 100644 app/features/scrims/components/ScrimFiltersDialog.tsx create mode 100644 app/features/user-page/components/ResultsFiltersBar.tsx diff --git a/app/components/filter-bar/FilterBar.browser.test.tsx b/app/components/filter-bar/FilterBar.browser.test.tsx new file mode 100644 index 000000000..3546e8719 --- /dev/null +++ b/app/components/filter-bar/FilterBar.browser.test.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { describe, expect, test } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { SendouButton } from "../elements/Button"; +import { FilterBar } from "./FilterBar"; + +const MODES = ["SZ", "TC", "RM"]; + +function TestFilterBar(props: { + initialMode?: string | null; + initialWeapon?: string | null; + initialRank?: string | null; +}) { + const [mode, setMode] = useState(props.initialMode ?? null); + const [weapon, setWeapon] = useState( + props.initialWeapon ?? null, + ); + // unlike the other pills this one seeds a value when added from the menu + const [rank, setRank] = useState(props.initialRank ?? null); + + return ( + setMode(null), + popover: ( +
+ {MODES.map((value) => ( + + ))} +
+ ), + }, + { + key: "weapon", + name: "Weapon", + formattedValue: weapon, + onRemove: () => setWeapon(null), + popover: ( + + ), + }, + { + key: "rank", + name: "Rank", + formattedValue: rank, + onAdd: () => setRank("S+"), + onRemove: () => setRank(null), + popover: ( + + ), + }, + ]} + onReset={ + mode !== null || weapon !== null || rank !== null + ? () => { + setMode(null); + setWeapon(null); + setRank(null); + } + : undefined + } + actions={Save as default} + /> + ); +} + +describe("FilterBar", () => { + test("renders a set pill with its name and formatted value", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: /Mode.*SZ/ })) + .toBeVisible(); + }); + + test("updates the pill value instantly when changed in the popover", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Mode SZ" }).click(); + await screen.getByRole("button", { name: "Set TC" }).click(); + + await expect + .element(screen.getByRole("button", { name: "Mode TC" })) + .toBeVisible(); + }); + + test("hides a pill at its default value behind the add filter menu", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: /Mode/ })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + + await screen.getByRole("button", { name: "Filter" }).click(); + + await expect + .element(screen.getByRole("menuitem", { name: "Weapon" })) + .toBeVisible(); + }); + + test("adding a pill opens its popover and keeps the pill visible while unset", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter" }).click(); + await screen.getByRole("menuitem", { name: "Weapon" }).click(); + + await expect + .element(screen.getByRole("button", { name: "Set Splattershot" })) + .toBeVisible(); + + await screen.getByRole("button", { name: "Set Splattershot" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon.*Splattershot/ })) + .toBeVisible(); + }); + + test("removing a pill hides it again", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Remove Weapon filter" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + }); + + test("adding a pill seeds its starting value via onAdd", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter" }).click(); + await screen.getByRole("menuitem", { name: "Rank" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Rank.*S\+/ })) + .toBeVisible(); + }); + + test("renders the reset button and the actions slot", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: "Reset" })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Save as default" })) + .toBeVisible(); + }); + + test("resetting hides an added pill that was left unset", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter", exact: true }).click(); + await screen.getByRole("menuitem", { name: "Weapon" }).click(); + + // adding a pill opens its popover, which blocks the reset button beneath it + await userEvent.keyboard("{Escape}"); + + await screen.getByRole("button", { name: "Reset" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + }); + + test("hides the add filter menu when every pill is visible", async () => { + const screen = await render( + , + ); + + await expect + .element(screen.getByRole("button", { name: "Filter", exact: true })) + .not.toBeInTheDocument(); + }); +}); diff --git a/app/components/filter-bar/FilterBar.module.css b/app/components/filter-bar/FilterBar.module.css new file mode 100644 index 000000000..d551e8e89 --- /dev/null +++ b/app/components/filter-bar/FilterBar.module.css @@ -0,0 +1,139 @@ +.popover { + min-width: 14rem; +} + +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-1-5); +} + +.pill { + display: inline-flex; + align-items: center; + height: var(--selector-size); + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); + transition: background-color 0.15s; + + &:has(.trigger[data-hovered]) { + background-color: var(--color-bg-high); + } +} + +.trigger { + display: inline-flex; + align-items: center; + gap: var(--s-1); + height: 100%; + padding: 0 var(--s-2); + border: none; + border-radius: inherit; + background-color: transparent; + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + cursor: pointer; + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } + + .pill:has(.removeButton) & { + padding-right: var(--s-1); + } +} + +.removeButton { + display: inline-flex; + align-items: center; + height: 100%; + padding: 0 var(--s-1-5); + border: none; + border-radius: inherit; + background-color: transparent; + color: var(--color-text-high); + cursor: pointer; + + & > svg { + width: 14px; + height: 14px; + } + + &[data-hovered] { + color: var(--color-error); + } + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } +} + +.actions { + display: contents; +} + +.actions button { + display: inline-flex; + align-items: center; + gap: var(--s-1); + height: var(--selector-size); + padding: 0 var(--s-2); + border: var(--border-style-high); + border-radius: var(--radius-selector); + background-color: transparent; + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.15s, + color 0.15s; + + &[data-hovered] { + background-color: var(--color-bg-high); + color: var(--color-text); + } + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + & svg { + width: 14px; + min-width: 14px; + max-width: 14px; + height: 14px; + margin-inline-end: 0; + } +} + +.icon { + display: inline-flex; + + & > svg { + width: 14px; + height: 14px; + } +} + +.value { + color: var(--color-text-accent); +} + +.chevron, +.plus { + width: 14px; + height: 14px; + color: var(--color-text-high); +} diff --git a/app/components/filter-bar/FilterBar.tsx b/app/components/filter-bar/FilterBar.tsx new file mode 100644 index 000000000..ba2105532 --- /dev/null +++ b/app/components/filter-bar/FilterBar.tsx @@ -0,0 +1,183 @@ +import clsx from "clsx"; +import { ChevronDown, Plus, RotateCcw, X } from "lucide-react"; +import * as React from "react"; +import { Button } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "../elements/Button"; +import { SendouMenu, SendouMenuItem } from "../elements/Menu"; +import { SendouPopover } from "../elements/Popover"; +import styles from "./FilterBar.module.css"; + +export interface FilterBarPill { + key: string; + /** Translated filter name shown on the pill and in the add filter menu. */ + name: string; + /** Translated current value shown on the pill. Null when the filter is at its default. */ + formattedValue: React.ReactNode | null; + /** Popover content. Inputs inside write search params directly (instant apply). */ + popover: React.ReactNode; + /** Resets the pill's param(s) to defaults. Renders the remove button. */ + onRemove?: () => void; + /** Writes a starting value when the pill is added from the menu. */ + onAdd?: () => void; + icon?: React.ReactNode; + popoverClassName?: string; + testId?: string; +} + +export function FilterBar({ + pills, + onReset, + actions, +}: { + pills: FilterBarPill[]; + /** Resets every pill's param(s) to defaults. Renders the reset button. */ + onReset?: () => void; + actions?: React.ReactNode; +}) { + const { t } = useTranslation(); + const [justAddedKeys, setJustAddedKeys] = React.useState>( + new Set(), + ); + const [openPillKey, setOpenPillKey] = React.useState(null); + + const isVisible = (pill: FilterBarPill) => + pill.formattedValue !== null || justAddedKeys.has(pill.key); + + const hiddenPills = pills.filter((pill) => !isVisible(pill)); + + const addPill = (pill: FilterBarPill) => { + setJustAddedKeys((prev) => new Set(prev).add(pill.key)); + setOpenPillKey(pill.key); + pill.onAdd?.(); + }; + + const removePill = (pill: FilterBarPill) => { + setJustAddedKeys((prev) => { + const next = new Set(prev); + next.delete(pill.key); + return next; + }); + if (openPillKey === pill.key) { + setOpenPillKey(null); + } + pill.onRemove?.(); + }; + + const resetPills = () => { + setJustAddedKeys(new Set()); + setOpenPillKey(null); + onReset?.(); + }; + + return ( +
+ {pills.filter(isVisible).map((pill) => ( + setOpenPillKey(isOpen ? pill.key : null)} + onRemove={pill.onRemove ? () => removePill(pill) : undefined} + /> + ))} + {hiddenPills.length > 0 ? ( + + ) : null} + {onReset || actions ? ( +
+ {onReset ? ( + } onPress={resetPills}> + {t("actions.reset")} + + ) : null} + {actions} +
+ ) : null} +
+ ); +} + +function FilterPill({ + pill, + isOpen, + onOpenChange, + onRemove, +}: { + pill: FilterBarPill; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onRemove?: () => void; +}) { + return ( +
+ + {pill.icon ? ( + {pill.icon} + ) : null} + {pill.name} + {pill.formattedValue !== null ? ( + {pill.formattedValue} + ) : null} + + + } + > + {pill.popover} + + {onRemove ? ( + + ) : null} +
+ ); +} + +function AddFilterMenu({ + pills, + onAdd, +}: { + pills: FilterBarPill[]; + onAdd: (pill: FilterBarPill) => void; +}) { + const { t } = useTranslation(); + + return ( + + +
+ } + > + {pills.map((pill) => ( + onAdd(pill)} + data-testid={pill.testId ? `menu-item-${pill.testId}` : undefined} + > + {pill.name} + + ))} + + ); +} diff --git a/app/features/builds/builds-schemas.ts b/app/features/builds/builds-schemas.ts index 3893e732b..c1326aebc 100644 --- a/app/features/builds/builds-schemas.ts +++ b/app/features/builds/builds-schemas.ts @@ -1,10 +1,10 @@ import { z } from "zod"; import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; -import { ability, modeShort } from "~/utils/zod"; +import { isValidDate } from "~/utils/dates"; +import { ability } from "~/utils/zod"; import { MAX_BUILD_FILTERS } from "./builds-constants"; -const abilityFilterSchema = z.object({ - type: z.literal("ability"), +const abilityConditionSchema = z.object({ ability: z.string().toUpperCase().pipe(ability), value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]), comparison: z @@ -14,18 +14,11 @@ const abilityFilterSchema = z.object({ .optional(), }); -const modeFilterSchema = z.object({ - type: z.literal("mode"), - mode: z.string().toUpperCase().pipe(modeShort), -}); - -const dateFilterSchema = z.object({ - type: z.literal("date"), - date: z.iso.date(), -}); - -export const buildFiltersSchema = z - .array(z.union([abilityFilterSchema, modeFilterSchema, dateFilterSchema])) +export const abilityConditionsSchema = z + .array(abilityConditionSchema) .max(MAX_BUILD_FILTERS); -export type BuildFiltersFromSearchParams = z.infer; +export const buildsDateFilterSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => isValidDate(new Date(value))); diff --git a/app/features/builds/builds-search-params.test.ts b/app/features/builds/builds-search-params.test.ts index d4b083641..48a3c111e 100644 --- a/app/features/builds/builds-search-params.test.ts +++ b/app/features/builds/builds-search-params.test.ts @@ -9,15 +9,17 @@ describe("buildsSearchParams", () => { it("round-trips", () => { assertRoundTrips(buildsSearchParams, { limit: [24, 48, 1, 240], - f: [ + abilities: [ [], [ - { type: "ability", ability: "ISM", comparison: "AT_LEAST", value: 3 }, - { type: "mode", mode: "SZ" }, - { type: "date", date: "2026-01-28" }, + { ability: "ISM", comparison: "AT_LEAST", value: 3 }, + { ability: "SSU", comparison: "AT_MOST", value: 12 }, ], - [{ type: "ability", ability: "LDE", value: true }], + [{ ability: "LDE", value: true }], + [{ ability: "CB", value: false }], ], + mode: [null, "SZ", "TW"], + date: [null, "2026-01-28"], }); }); @@ -28,11 +30,17 @@ describe("buildsSearchParams", () => { ["241"], ["abc"], ]); - assertDecodesToDefault(buildsSearchParams, "f", [ + assertDecodesToDefault(buildsSearchParams, "abilities", [ ["not-json"], - ['[{"type":"ability"}]'], - ['{"type":"mode","mode":"SZ"}'], - ['[{"type":"mode","mode":"XX"}]'], + ['[{"ability":"XXX","value":true}]'], + ['{"ability":"ISM","value":3}'], + ['[{"ability":"ISM","value":100,"comparison":"AT_LEAST"}]'], + ]); + assertDecodesToDefault(buildsSearchParams, "mode", [["XX"], ["zz"]]); + assertDecodesToDefault(buildsSearchParams, "date", [ + ["not-a-date"], + ["2026-13-99"], + ["2026-1-1"], ]); }); }); diff --git a/app/features/builds/builds-search-params.ts b/app/features/builds/builds-search-params.ts index e38ff475b..c9828bbe7 100644 --- a/app/features/builds/builds-search-params.ts +++ b/app/features/builds/builds-search-params.ts @@ -1,20 +1,32 @@ import { z } from "zod"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; +import { modeShort } from "~/utils/zod"; import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS, } from "./builds-constants"; -import { buildFiltersSchema } from "./builds-schemas"; +import { + abilityConditionsSchema, + buildsDateFilterSchema, +} from "./builds-schemas"; export const buildsSearchParams = SearchParams.define({ limit: SP.param(z.number().int().min(1).max(BUILDS_PAGE_MAX_BUILDS), { default: BUILDS_PAGE_BATCH_SIZE, loader: true, }), - f: SP.json(buildFiltersSchema, { + abilities: SP.json(abilityConditionsSchema, { default: [], resets: ["limit"], loader: true, }), + mode: SP.param(modeShort.nullable(), { + resets: ["limit"], + loader: true, + }), + date: SP.param(buildsDateFilterSchema.nullable(), { + resets: ["limit"], + loader: true, + }), }); diff --git a/app/features/builds/builds-types.ts b/app/features/builds/builds-types.ts index f1e3c1f0f..cc58f41c5 100644 --- a/app/features/builds/builds-types.ts +++ b/app/features/builds/builds-types.ts @@ -1,34 +1,13 @@ -import type { - Ability, - MainWeaponId, - ModeShort, -} from "~/modules/in-game-lists/types"; +import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types"; export interface BuildWeaponWithTop500Info { weaponSplId: MainWeaponId; isTop500: number; } -export type AbilityBuildFilter = { - type: "ability"; +export interface AbilityCondition { ability: Ability; /** Ability points value or "has"/"doesn't have" */ value: number | boolean; comparison?: "AT_LEAST" | "AT_MOST"; -}; - -export type ModeBuildFilter = { - type: "mode"; - mode: ModeShort; -}; - -export type DateBuildFilter = { - type: "date"; - /** YYYY-MM-DD */ - date: string; -}; - -export type BuildFilter = - | AbilityBuildFilter - | ModeBuildFilter - | DateBuildFilter; +} diff --git a/app/features/builds/components/FilterSection.module.css b/app/features/builds/components/FilterSection.module.css deleted file mode 100644 index 5395f12b7..000000000 --- a/app/features/builds/components/FilterSection.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.filter { - display: flex; - flex-direction: column; - padding: var(--s-3); - border-radius: var(--radius-box); - background-color: var(--color-bg-high); - gap: var(--s-2); -} - -.filterMode { - gap: var(--s-6); - flex-wrap: wrap; -} - -.filterDate { - display: flex; - align-items: center; -} - -@container (width >= 560px) { - .filter { - flex-direction: row; - } -} - -.abilityContainer { - display: flex; - width: 32px; - align-items: center; -} - -.apSelect { - width: 75px; -} - -.dateSelect { - width: 275px; -} diff --git a/app/features/builds/components/FilterSection.tsx b/app/features/builds/components/FilterSection.tsx deleted file mode 100644 index 308eafe96..000000000 --- a/app/features/builds/components/FilterSection.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import clsx from "clsx"; -import { X } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { Ability } from "~/components/Ability"; -import { SendouButton } from "~/components/elements/Button"; -import { ModeImage } from "~/components/Image"; -import { possibleApValues } from "~/features/build-analyzer/analyzer-constants"; -import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; -import { abilities } from "~/modules/in-game-lists/abilities"; -import { modesShort } from "~/modules/in-game-lists/modes"; -import type { - Ability as AbilityType, - ModeShort, -} from "~/modules/in-game-lists/types"; -import { dateToYYYYMMDD, isValidDate } from "~/utils/dates"; -import { RECENT_PATCHES } from "../builds-constants"; -import type { - AbilityBuildFilter, - BuildFilter, - DateBuildFilter, - ModeBuildFilter, -} from "../builds-types"; - -import styles from "./FilterSection.module.css"; - -export function FilterSection({ - number, - nthOfSame, - filter, - onChange, - remove, -}: { - number: number; - nthOfSame: number; - filter: BuildFilter; - onChange: (filter: Partial) => void; - remove: () => void; -}) { - const { t } = useTranslation(["builds"]); - - return ( -
-
-
- {t(`builds:filters.${filter.type}.title`)}{" "} - {nthOfSame > 1 ? nthOfSame : ""} -
-
- } - size="small" - variant="minimal-destructive" - onPress={remove} - aria-label="Delete filter" - data-testid="delete-filter-button" - /> -
-
- {filter.type === "ability" ? ( - - ) : null} - {filter.type === "mode" ? ( - - ) : null} - {filter.type === "date" ? ( - - ) : null} -
- ); -} - -function AbilityFilter({ - filter, - onChange, -}: { - filter: AbilityBuildFilter; - onChange: (filter: Partial) => void; -}) { - const { t } = useTranslation(["analyzer", "game-misc", "builds"]); - const abilityObject = abilities.find((a) => a.name === filter.ability)!; - - return ( -
-
- -
- - {abilityObject.type !== "STACKABLE" ? ( - - ) : null} - {abilityObject.type === "STACKABLE" ? ( - - ) : null} - {abilityObject.type === "STACKABLE" ? ( -
- -
{t("analyzer:abilityPoints.short")}
-
- ) : null} -
- ); -} - -function ModeFilter({ - filter, - onChange, - number, -}: { - filter: ModeBuildFilter; - onChange: (filter: Partial) => void; - number: number; -}) { - const { t } = useTranslation(["game-misc"]); - - const inputId = (mode: ModeShort) => `${number}-${mode}`; - - return ( -
- {modesShort.map((mode) => { - return ( -
- onChange({ mode })} - /> - -
- ); - })} -
- ); -} - -function DateFilter({ - filter, - onChange, -}: { - filter: DateBuildFilter; - onChange: (filter: Partial) => void; -}) { - const { t } = useTranslation(["builds"]); - const { formatter: patchDateFormatter } = useDateTimeFormat({ - day: "numeric", - month: "numeric", - year: "numeric", - }); - - const selectValue = () => - RECENT_PATCHES.some(({ date }) => date === filter.date) - ? filter.date - : "CUSTOM"; - - // on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays) - const oneMonthAgoOnSaturday = new Date(); - oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30); - oneMonthAgoOnSaturday.setUTCDate( - oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6, - ); - - const customDate = isValidDate(new Date(filter.date)) - ? new Date(filter.date) - : oneMonthAgoOnSaturday; - - return ( -
- - - {selectValue() === "CUSTOM" ? ( - onChange({ date: e.target.value })} - max={dateToYYYYMMDD(new Date())} - data-testid="date-input" - /> - ) : null} -
- ); -} diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index d379ec24f..6ae621b15 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -5,13 +5,7 @@ import type { ModeShort, } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; -import { assertUnreachable } from "~/utils/types"; -import type { BuildFiltersFromSearchParams } from "../builds-schemas"; -import type { - AbilityBuildFilter, - DateBuildFilter, - ModeBuildFilter, -} from "../builds-types"; +import type { AbilityCondition } from "../builds-types"; type PartialBuild = { abilities: BuildAbilitiesTuple; @@ -19,17 +13,24 @@ type PartialBuild = { updatedAt: Tables["Build"]["updatedAt"]; }; +interface BuildFilters { + abilities: AbilityCondition[]; + mode: ModeShort | null; + date: string | null; +} + /** * Filters an array of builds based on the provided filter criteria and returns up to a specified count of matching builds. * * Filters are applied on "AND" basis, meaning all filters must match for a build to be included in the result. */ export function filterBuilds({ - filters, + abilities, + mode, + date, count, builds, -}: { - filters: BuildFiltersFromSearchParams; +}: BuildFilters & { count: number; builds: T[]; }) { @@ -38,7 +39,7 @@ export function filterBuilds({ for (const build of builds) { if (result.length === count) break; - if (buildMatchesFilters({ build, filters })) { + if (buildMatchesFilters({ build, abilities, mode, date })) { result.push(build); } } @@ -48,42 +49,38 @@ export function filterBuilds({ function buildMatchesFilters({ build, - filters, -}: { - build: T; - filters: BuildFiltersFromSearchParams; -}) { - for (const filter of filters) { - if (filter.type === "ability") { - if (!matchesAbilityFilter({ build, filter })) return false; - } else if (filter.type === "mode") { - if (!matchesModeFilter({ build, filter })) return false; - } else if (filter.type === "date") { - if (!matchesDateFilter({ build, filter })) return false; - } else { - assertUnreachable(filter); - } + abilities, + mode, + date, +}: BuildFilters & { build: T }) { + for (const condition of abilities) { + if (!matchesAbilityCondition({ build, condition })) return false; } + if (mode !== null && !matchesModeFilter({ build, mode })) return false; + if (date !== null && !matchesDateFilter({ build, date })) return false; + return true; } -function matchesAbilityFilter({ +function matchesAbilityCondition({ build, - filter, + condition, }: { build: PartialBuild; - filter: AbilityBuildFilter; + condition: AbilityCondition; }) { - if (typeof filter.value === "boolean") { - const hasAbility = build.abilities.flat().includes(filter.ability); - if (filter.value && !hasAbility) return false; - if (!filter.value && hasAbility) return false; - } else if (typeof filter.value === "number") { + if (typeof condition.value === "boolean") { + const hasAbility = build.abilities.flat().includes(condition.ability); + if (condition.value && !hasAbility) return false; + if (!condition.value && hasAbility) return false; + } else if (typeof condition.value === "number") { const abilityPoints = buildToAbilityPoints(build.abilities); - const ap = abilityPoints.get(filter.ability) ?? 0; - if (filter.comparison === "AT_LEAST" && ap < filter.value) return false; - if (filter.comparison === "AT_MOST" && ap > filter.value) return false; + const ap = abilityPoints.get(condition.ability) ?? 0; + if (condition.comparison === "AT_LEAST" && ap < condition.value) + return false; + if (condition.comparison === "AT_MOST" && ap > condition.value) + return false; } return true; @@ -91,24 +88,22 @@ function matchesAbilityFilter({ function matchesModeFilter({ build, - filter, + mode, }: { build: PartialBuild; - filter: ModeBuildFilter; + mode: ModeShort; }) { if (!build.modes) return false; - return build.modes.includes(filter.mode); + return build.modes.includes(mode); } function matchesDateFilter({ build, - filter, + date, }: { build: PartialBuild; - filter: DateBuildFilter; + date: string; }) { - const date = new Date(filter.date); - - return date < databaseTimestampToDate(build.updatedAt); + return new Date(date) < databaseTimestampToDate(build.updatedAt); } diff --git a/app/features/builds/core/filter.test.ts b/app/features/builds/core/filter.test.ts index e381b53b6..85f1a623f 100644 --- a/app/features/builds/core/filter.test.ts +++ b/app/features/builds/core/filter.test.ts @@ -33,6 +33,8 @@ const createBuild = ({ }; }; +const noFilters = { abilities: [], mode: null, date: null }; + describe("Filter builds", () => { test("returns correct build back based on abilities (AT_LEAST)", () => { const filtered = filterBuilds({ @@ -41,9 +43,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "ISM", value: 10, comparison: "AT_LEAST", @@ -62,9 +64,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "ISM", value: 6, comparison: "AT_MOST", @@ -83,9 +85,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: true, }, @@ -103,9 +105,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: false, }, @@ -130,45 +132,8 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: [] }), ], count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - ], - }); - - expect(filtered.length).toBe(1); - expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); - }); - - test("filters based on many modes", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - modes: ["SZ", "TC"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["SZ"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["TC"], - }), - ], - count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - { - type: "mode", - mode: "TC", - }, - ], + ...noFilters, + mode: "SZ", }); expect(filtered.length).toBe(1); @@ -188,19 +153,15 @@ describe("Filter builds", () => { }), ], count: 2, - filters: [ - { - type: "date", - date: "2022-01-01", - }, - ], + ...noFilters, + date: "2022-01-01", }); expect(filtered.length).toBe(1); expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - test("combines filters of same type", () => { + test("combines multiple ability conditions", () => { const filtered = filterBuilds({ builds: [ createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), @@ -208,14 +169,13 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: true, }, { - type: "ability", ability: "ISM", value: 9, comparison: "AT_LEAST", @@ -247,13 +207,10 @@ describe("Filter builds", () => { }), ], count: 2, - filters: [ + ...noFilters, + date: "2022-01-01", + abilities: [ { - type: "date", - date: "2022-01-01", - }, - { - type: "ability", ability: "ISM", value: 9, comparison: "AT_LEAST", @@ -273,7 +230,7 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [], + ...noFilters, }); expect(filtered.length).toBe(2); diff --git a/app/features/builds/loaders/builds.$slug.server.ts b/app/features/builds/loaders/builds.$slug.server.ts index 7df7e305f..350413512 100644 --- a/app/features/builds/loaders/builds.$slug.server.ts +++ b/app/features/builds/loaders/builds.$slug.server.ts @@ -18,13 +18,14 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { throw new Response(null, { status: 404 }); } - const { limit, f: filters } = buildsSearchParams.parse(url); + const { limit, abilities, mode, date } = buildsSearchParams.parse(url); const weaponName = t(`weapons:MAIN_${weaponId}`); const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" })); - const hasActiveFilters = filters.length > 0; + const hasActiveFilters = + abilities.length > 0 || mode !== null || date !== null; const builds = await BuildRepository.findAllByWeaponId(weaponId, { limit: hasActiveFilters ? BUILDS_PAGE_MAX_BUILDS : limit + 1, @@ -34,7 +35,9 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { const filteredBuilds = hasActiveFilters ? filterBuilds({ builds, - filters, + abilities, + mode, + date, count: limit + 1, }) : builds; @@ -55,6 +58,5 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { limit, hasMoreBuilds, slug, - filters, }; }; diff --git a/app/features/builds/routes/builds.$slug.module.css b/app/features/builds/routes/builds.$slug.module.css index 0b180ea0c..0340a9e18 100644 --- a/app/features/builds/routes/builds.$slug.module.css +++ b/app/features/builds/routes/builds.$slug.module.css @@ -22,3 +22,30 @@ flex-direction: row; } } + +.abilityConditions { + display: flex; + flex-direction: column; + gap: var(--s-3); + width: 100%; + min-width: 14rem; +} + +.abilityConditionRow { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--s-1-5); +} + +.abilityConditionValueRow { + display: grid; + grid-column: 2 / -1; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--s-1-5); +} + +.abilityConditionApSelect { + min-width: 4.5rem; +} diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index 8efdddb78..6fd27a06f 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -3,17 +3,25 @@ import { ChartColumnBig, Flame, FlaskConical, - Funnel, Map as MapIcon, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import { Ability } from "~/components/Ability"; import { BuildCard } from "~/components/BuildCard"; import { LinkButton, SendouButton } from "~/components/elements/Button"; -import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { ModeImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { possibleApValues } from "~/features/build-analyzer/analyzer-constants"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { abilities } from "~/modules/in-game-lists/abilities"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { Ability as AbilityType } from "~/modules/in-game-lists/types"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import { dateToYYYYMMDD, isValidDate } from "~/utils/dates"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { @@ -31,8 +39,7 @@ import { RECENT_PATCHES, } from "../builds-constants"; import { buildsSearchParams } from "../builds-search-params"; -import type { AbilityBuildFilter, BuildFilter } from "../builds-types"; -import { FilterSection } from "../components/FilterSection"; +import type { AbilityCondition } from "../builds-types"; import { loader } from "../loaders/builds.$slug.server"; @@ -95,110 +102,21 @@ export function BuildCards({ data }: { data: SerializeFrom }) { export default function WeaponsBuildsPage() { const data = useLoaderData(); const { t } = useTranslation(["common", "builds"]); - const [{ f: filters }, setParams] = useSearchParamsTyped(buildsSearchParams); - - const syncSearchParams = ( - newFilters: BuildFilter[], - opts?: { loader?: boolean }, - ) => { - setParams({ f: newFilters }, opts); - }; - - const handleFilterAdd = (type: BuildFilter["type"]) => { - const newFilter: BuildFilter = - type === "ability" - ? { - type: "ability", - ability: "ISM", - comparison: "AT_LEAST", - value: 0, - } - : type === "date" - ? { - type: "date", - date: RECENT_PATCHES[0].date, - } - : { - type: "mode", - mode: "SZ", - }; - - // a fresh "at least 0" ability filter matches every build, so no need to refetch - syncSearchParams( - [...filters, newFilter], - type === "ability" ? { loader: false } : undefined, - ); - }; - - const handleFilterChange = (i: number, newFilter: Partial) => { - const newFilters = filters.map((f, index) => - index === i - ? ({ - ...(f as AbilityBuildFilter), - ...(newFilter as AbilityBuildFilter), - } as BuildFilter) - : f, - ); - - syncSearchParams(newFilters); - }; - - const handleFilterDelete = (i: number) => { - syncSearchParams(filters.filter((_, index) => index !== i)); - }; + const [{ abilities: abilityConditions, mode, date }] = + useSearchParamsTyped(buildsSearchParams); const loadMoreLink = () => buildsSearchParams.href("", { limit: data.limit + BUILDS_PAGE_BATCH_SIZE, - f: filters, + abilities: abilityConditions, + mode, + date, }); - const nthOfSameFilter = (index: number) => { - const type = filters[index].type; - - return filters.slice(0, index).filter((f) => f.type === type).length + 1; - }; - return (
- } - isDisabled={filters.length >= MAX_BUILD_FILTERS} - data-testid="add-filter-button" - > - {t("builds:addFilter")} - - } - > - } - isDisabled={filters.length >= MAX_BUILD_FILTERS} - onAction={() => handleFilterAdd("ability")} - data-testid="menu-item-ability" - > - {t("builds:filters.type.ability")} - - } - onAction={() => handleFilterAdd("mode")} - data-testid="menu-item-mode" - > - {t("builds:filters.type.mode")} - - } - isDisabled={filters.some((filter) => filter.type === "date")} - onAction={() => handleFilterAdd("date")} - data-testid="menu-item-date" - > - {t("builds:filters.type.date")} - - +
- {filters.length > 0 ? ( -
- {filters.map((filter, i) => ( - handleFilterChange(i, newFilter)} - remove={() => handleFilterDelete(i)} - nthOfSame={nthOfSameFilter(i)} - /> - ))} -
- ) : null} {data.limit < BUILDS_PAGE_MAX_BUILDS && data.hasMoreBuilds ? ( ); } + +function Filters() { + const { t } = useTranslation(["builds", "game-misc"]); + const [{ abilities: abilityConditions, mode, date }, setParams] = + useSearchParamsTyped(buildsSearchParams); + + return ( + , + formattedValue: + abilityConditions.length > 0 + ? formatAbilityConditions(abilityConditions) + : null, + onRemove: () => setParams({ abilities: [] }), + testId: "ability", + popover: ( + + setParams({ abilities: newConditions }, opts) + } + /> + ), + }, + { + key: "mode", + name: t("builds:filters.mode"), + icon: , + formattedValue: + mode !== null ? t(`game-misc:MODE_SHORT_${mode}`) : null, + onAdd: () => setParams({ mode: "SZ" }), + onRemove: () => setParams({ mode: null }), + testId: "mode", + popover: ( +
+ {modesShort.map((option) => ( +
+ setParams({ mode: option })} + /> + +
+ ))} +
+ ), + }, + { + key: "date", + name: t("builds:filters.date"), + icon: , + formattedValue: date !== null ? : null, + onAdd: () => setParams({ date: RECENT_PATCHES[0].date }), + onRemove: () => setParams({ date: null }), + testId: "date", + popover: ( + setParams({ date: newDate })} + /> + ), + }, + ]} + /> + ); +} + +function formatAbilityConditions(conditions: AbilityCondition[]) { + const label = abilityConditionLabel(conditions[0]); + + return conditions.length > 1 ? `${label} +${conditions.length - 1}` : label; +} + +function abilityConditionLabel(condition: AbilityCondition) { + if (condition.value === true) return condition.ability; + if (condition.value === false) return `✗ ${condition.ability}`; + + return `${condition.ability} ${ + condition.comparison === "AT_MOST" ? "≤" : "≥" + } ${condition.value}`; +} + +function AbilityConditionsPopover({ + conditions, + onChange, +}: { + conditions: AbilityCondition[]; + onChange: ( + conditions: AbilityCondition[], + opts?: { loader: boolean }, + ) => void; +}) { + const { t } = useTranslation(["builds"]); + + const addCondition = () => { + const newCondition: AbilityCondition = { + ability: "ISM", + comparison: "AT_LEAST", + value: 0, + }; + + // a fresh "at least 0" ability condition matches every build, so no need to refetch + onChange([...conditions, newCondition], { loader: false }); + }; + + return ( +
+ {conditions.map((condition, i) => ( + + onChange( + conditions.map((c, index) => (index === i ? newCondition : c)), + ) + } + remove={() => onChange(conditions.filter((_, index) => index !== i))} + /> + ))} + = MAX_BUILD_FILTERS} + onPress={addCondition} + data-testid="add-ability-condition" + > + {t("builds:filters.addAbility")} + +
+ ); +} + +function AbilityConditionRow({ + condition, + onChange, + remove, +}: { + condition: AbilityCondition; + onChange: (condition: AbilityCondition) => void; + remove: () => void; +}) { + const { t } = useTranslation(["analyzer", "game-misc", "builds"]); + const abilityObject = abilities.find((a) => a.name === condition.ability)!; + + return ( +
+ + + } + size="miniscule" + variant="minimal-destructive" + onPress={remove} + aria-label="Delete ability condition" + data-testid="delete-ability-condition" + /> +
+ {abilityObject.type === "STACKABLE" ? ( + <> + + +
{t("analyzer:abilityPoints.short")}
+ + ) : ( + + )} +
+
+ ); +} + +function FormattedDate({ date }: { date: string }) { + const { formatter } = useDateTimeFormat({ + day: "numeric", + month: "numeric", + year: "numeric", + }); + + const patch = RECENT_PATCHES.find( + ({ date: patchDate }) => patchDate === date, + ); + if (patch) return <>{patch.patch}; + + return <>{formatter.format(new Date(date))}; +} + +function DatePopover({ + date, + onChange, +}: { + date: string | null; + onChange: (date: string) => void; +}) { + const { t } = useTranslation(["builds"]); + const { formatter: patchDateFormatter } = useDateTimeFormat({ + day: "numeric", + month: "numeric", + year: "numeric", + }); + + const selectValue = () => + RECENT_PATCHES.some(({ date: patchDate }) => patchDate === date) + ? date + : "CUSTOM"; + + // on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays) + const oneMonthAgoOnSaturday = new Date(); + oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30); + oneMonthAgoOnSaturday.setUTCDate( + oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6, + ); + + const customDate = + date !== null && isValidDate(new Date(date)) + ? new Date(date) + : oneMonthAgoOnSaturday; + + return ( +
+ + + {selectValue() === "CUSTOM" ? ( + onChange(e.target.value)} + max={dateToYYYYMMDD(new Date())} + data-testid="date-input" + /> + ) : null} +
+ ); +} diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index ef30f0c72..acea4c7a0 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import type { CalendarEventTag } from "~/features/calendar/calendar-types"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { TOURNAMENT, TOURNAMENT_STAGE_TYPES, @@ -8,16 +12,10 @@ import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-sta import * as Progression from "~/features/tournament-bracket/core/Progression"; import { array, - checkboxGroup, customField, fieldset, numberField, - numberFieldOptional, - radioGroup, textField, - textFieldOptional, - toggle, - userSearchOptional, } from "~/form/fields"; import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; @@ -33,6 +31,10 @@ const calendarEventTagSchema = z .string() .refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)); +export const calendarFilterTagsArr = z + .array(calendarEventTagSchema) + .max(CALENDAR_EVENT.TAGS.length); + const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10); const calendarFiltersIdsArr = z.array(id).max(10); const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3); @@ -45,11 +47,16 @@ const modeArr = z .array(modeShortWithSpecial) .min(1) .max(modesShortWithSpecial.length); +const tierNumber = z.coerce + .number() + .int() + .min(BEST_TIER_NUMBER) + .max(WORST_TIER_NUMBER); export const calendarFiltersSearchParamsSchema = z.object({ preferredStartTime: preferredStartTime.catch("ANY"), - tagsIncluded: z.array(calendarEventTagSchema).catch([]), - tagsExcluded: z.array(calendarEventTagSchema).catch([]), + tagsIncluded: calendarFilterTagsArr.catch([]), + tagsExcluded: calendarFilterTagsArr.catch([]), isSendou: z.boolean().catch(false), isRanked: z.boolean().catch(false), orgsIncluded: calendarFiltersPlainStringArr.catch([]), @@ -60,6 +67,8 @@ export const calendarFiltersSearchParamsSchema = z.object({ modes: modeArr.catch([...modesShortWithSpecial]), modesExact: z.boolean().catch(false), minTeamCount: z.coerce.number().int().nonnegative().catch(0), + minTier: tierNumber.catch(BEST_TIER_NUMBER), + maxTier: tierNumber.catch(WORST_TIER_NUMBER), }); const TAGS_TO_OMIT: CalendarEventTag[] = [ @@ -72,110 +81,10 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [ "TRIOS", ]; -const filterTags = CALENDAR_EVENT.TAGS.filter( +export const calendarFilterTags = CALENDAR_EVENT.TAGS.filter( (tag) => !TAGS_TO_OMIT.includes(tag), ); -const tagItems = filterTags.map((tag) => ({ - label: `options.tag.${tag}` as const, - value: tag, -})); - -export const calendarFiltersFormSchema = z - .object({ - modes: checkboxGroup({ - label: "labels.buildModes", - items: [ - { label: "modes.TW", value: "TW" }, - { label: "modes.SZ", value: "SZ" }, - { label: "modes.TC", value: "TC" }, - { label: "modes.RM", value: "RM" }, - { label: "modes.CB", value: "CB" }, - { label: () => "Salmon Run", value: "SR" }, - { label: () => "Tricolor", value: "TB" }, - ], - minLength: 1, - }), - modesExact: toggle({ - label: "labels.modesExact", - bottomText: "bottomTexts.modesExact", - }), - games: checkboxGroup({ - label: "labels.games", - items: [ - { label: "options.game.S1", value: "S1" }, - { label: "options.game.S2", value: "S2" }, - { label: "options.game.S3", value: "S3" }, - ], - minLength: 1, - }), - preferredVersus: checkboxGroup({ - label: "labels.vs", - items: [ - { label: () => "4v4", value: "4v4" }, - { label: () => "3v3", value: "3v3" }, - { label: () => "2v2", value: "2v2" }, - { label: () => "1v1", value: "1v1" }, - ], - minLength: 1, - }), - preferredStartTime: radioGroup({ - label: "labels.startTime", - items: [ - { label: "options.startTime.any", value: "ANY" }, - { label: "options.startTime.eu", value: "EU" }, - { label: "options.startTime.na", value: "NA" }, - { label: "options.startTime.au", value: "AU" }, - ], - }), - tagsIncluded: checkboxGroup({ - label: "labels.tagsIncluded", - items: tagItems, - }), - tagsExcluded: checkboxGroup({ - label: "labels.tagsExcluded", - items: tagItems, - }), - isSendou: toggle({ label: "labels.onlySendouEvents" }), - isRanked: toggle({ label: "labels.onlyRankedEvents" }), - minTeamCount: numberFieldOptional({ - label: "labels.minTeamCount", - }), - orgsIncluded: array({ - label: "labels.orgsIncluded", - field: textFieldOptional({ maxLength: 100 }), - max: 10, - }), - orgsExcluded: array({ - label: "labels.orgsExcluded", - field: textFieldOptional({ maxLength: 100 }), - max: 10, - }), - authorIdsExcluded: array({ - label: "labels.authorIdsExcluded", - field: userSearchOptional({}), - max: 10, - }), - }) - .superRefine((filters, ctx) => { - if ( - filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag)) - ) { - ctx.addIssue({ - path: ["tagsExcluded"], - message: "Can't include and exclude the same tag", - code: z.ZodIssueCode.custom, - }); - } - - if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) { - ctx.addIssue({ - path: ["orgsExcluded"], - message: "Can't both include and exclude organizations", - code: z.ZodIssueCode.custom, - }); - } - }); const reportedPlayerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("USER"), id: id.nullable() }), z.object({ diff --git a/app/features/calendar/calendar-search-params.test.ts b/app/features/calendar/calendar-search-params.test.ts index 482fc81fe..f5be37c71 100644 --- a/app/features/calendar/calendar-search-params.test.ts +++ b/app/features/calendar/calendar-search-params.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { assertDecodesToDefault, assertRoundTrips, @@ -13,24 +13,26 @@ import * as CalendarEvent from "./core/CalendarEvent"; describe("calendarSearchParams", () => { it("round-trips", () => { assertRoundTrips(calendarSearchParams, { - filters: [ - CalendarEvent.defaultFilters(), - { - preferredStartTime: "EU", - tagsIncluded: ["ART"], - tagsExcluded: ["MONEY"], - isSendou: true, - isRanked: true, - orgsIncluded: ["Splat Org"], - orgsExcluded: [], - authorIdsExcluded: [1, 274], - games: ["S3"], - preferredVersus: ["4v4"], - modes: ["SZ", "TC"], - modesExact: true, - minTeamCount: 16, - }, + modes: [CalendarEvent.defaultFilters().modes, ["SZ", "TC"], ["TB"]], + modesExact: [false, true], + games: [CalendarEvent.defaultFilters().games, ["S3"], ["S1", "S2"]], + preferredVersus: [ + CalendarEvent.defaultFilters().preferredVersus, + ["4v4"], + ["1v1", "2v2"], ], + preferredStartTime: ["ANY", "EU", "NA", "AU"], + tagsIncluded: [[], ["ART"], ["ART", "MONEY"]], + tagsExcluded: [[], ["MONEY"]], + isSendou: [false, true], + isRanked: [false, true], + minTeamCount: [0, 16], + minTier: [1, 3, 9], + maxTier: [1, 5, 9], + orgsIncluded: [[], ["Splat Org"], ["A", "B"]], + orgsExcluded: [[], ["Bad Org"]], + authorIdsExcluded: [[], [1, 274]], + useDefaults: [true, false], day: [null, 1, 15, 31], month: [null, 0, 11], year: [null, 2015, 2026, 2100], @@ -38,12 +40,26 @@ describe("calendarSearchParams", () => { }); it("decodes garbage to defaults", () => { - assertDecodesToDefault(calendarSearchParams, "filters", [ - ["not-json"], - ["[1,2,3]"], - ['"foo"'], - ['{"preferredStartTime":"XX"}'], + assertDecodesToDefault(calendarSearchParams, "preferredStartTime", [ + ["XX"], + ["eu"], ]); + assertDecodesToDefault(calendarSearchParams, "modesExact", [ + ["1"], + ["yes"], + ]); + assertDecodesToDefault(calendarSearchParams, "minTeamCount", [ + ["-1"], + ["abc"], + ["1.5"], + ]); + assertDecodesToDefault(calendarSearchParams, "minTier", [ + ["0"], + ["10"], + ["abc"], + ]); + assertDecodesToDefault(calendarSearchParams, "maxTier", [["0"], ["10"]]); + assertDecodesToDefault(calendarSearchParams, "games", [["BAD"]]); assertDecodesToDefault(calendarSearchParams, "day", [ ["0"], ["32"], @@ -57,21 +73,6 @@ describe("calendarSearchParams", () => { ["nope"], ]); }); - - it("keeps valid fields when part of the filters blob is invalid", () => { - const parsed = calendarSearchParams.parse( - new URL( - `http://localhost/calendar?filters=${encodeURIComponent( - JSON.stringify({ isSendou: true, games: ["BAD"] }), - )}`, - ), - ); - - expect(parsed.filters).toEqual({ - ...CalendarEvent.defaultFilters(), - isSendou: true, - }); - }); }); describe("calendarEventsSearchParams", () => { diff --git a/app/features/calendar/calendar-search-params.ts b/app/features/calendar/calendar-search-params.ts index 3675ea54a..5dfb894b1 100644 --- a/app/features/calendar/calendar-search-params.ts +++ b/app/features/calendar/calendar-search-params.ts @@ -1,9 +1,18 @@ import { z } from "zod"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { dayMonthYear } from "~/utils/zod"; -import { calendarFiltersSearchParamsSchema } from "./calendar-schemas"; -import * as CalendarEvent from "./core/CalendarEvent"; +import { + dayMonthYear, + gamesShortSchema, + modeShortWithSpecial, +} from "~/utils/zod"; +import { calendarFilterTagsArr } from "./calendar-schemas"; export const VIEW_FILTERS = [ "registered", @@ -14,11 +23,60 @@ export const VIEW_FILTERS = [ ] as const; export type ViewFilter = (typeof VIEW_FILTERS)[number]; +const tierNumber = z + .number() + .int() + .min(BEST_TIER_NUMBER) + .max(WORST_TIER_NUMBER); + export const calendarSearchParams = SearchParams.define({ - filters: SP.json(calendarFiltersSearchParamsSchema, { - default: CalendarEvent.defaultFilters(), + modes: SP.param( + z.array(modeShortWithSpecial).min(1).max(modesShortWithSpecial.length), + { default: [...modesShortWithSpecial], loader: true }, + ), + modesExact: SP.param(z.boolean(), { default: false, loader: true }), + games: SP.param(z.array(gamesShortSchema).min(1).max(gamesShort.length), { + default: [...gamesShort], loader: true, }), + preferredVersus: SP.param( + z.array(z.enum(versusShort)).min(1).max(versusShort.length), + { default: [...versusShort], loader: true }, + ), + preferredStartTime: SP.param(z.enum(["ANY", "EU", "NA", "AU"]), { + default: "ANY", + loader: true, + }), + tagsIncluded: SP.param(calendarFilterTagsArr, { + default: [], + loader: true, + }), + tagsExcluded: SP.param(calendarFilterTagsArr, { + default: [], + loader: true, + }), + isSendou: SP.param(z.boolean(), { default: false, loader: true }), + isRanked: SP.param(z.boolean(), { default: false, loader: true }), + minTeamCount: SP.param(z.number().int().nonnegative(), { + default: 0, + loader: true, + }), + minTier: SP.param(tierNumber, { default: BEST_TIER_NUMBER, loader: true }), + maxTier: SP.param(tierNumber, { default: WORST_TIER_NUMBER, loader: true }), + orgsIncluded: SP.param(z.array(z.string().max(100)).max(10), { + default: [], + loader: true, + }), + orgsExcluded: SP.param(z.array(z.string().max(100)).max(10), { + default: [], + loader: true, + }), + authorIdsExcluded: SP.param(z.array(z.number().int().positive()).max(10), { + default: [], + loader: true, + }), + /** False once the user has edited the filters, making the URL win over their saved defaults. */ + useDefaults: SP.param(z.boolean(), { default: true, loader: true }), day: SP.param(dayMonthYear.shape.day.nullable(), { loader: true }), month: SP.param(dayMonthYear.shape.month.nullable(), { loader: true }), year: SP.param(dayMonthYear.shape.year.nullable(), { loader: true }), diff --git a/app/features/calendar/components/FiltersBar.tsx b/app/features/calendar/components/FiltersBar.tsx new file mode 100644 index 000000000..38040a790 --- /dev/null +++ b/app/features/calendar/components/FiltersBar.tsx @@ -0,0 +1,541 @@ +import { Star, X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher, useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { UserSearch } from "~/components/elements/UserSearch"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { useUser } from "~/features/auth/core/user"; +import { calendarFilterTags } from "~/features/calendar/calendar-schemas"; +import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; +import type { CalendarFilters } from "~/features/calendar/calendar-types"; +import { + TIER_NUMBERS, + tierNumberToName, +} from "~/features/tournament/core/tiering"; +import { + CheckboxGroupFormField, + RadioGroupFormField, +} from "~/form/fields/InputGroupFormField"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import * as CalendarEvent from "../core/CalendarEvent"; +import type { CalendarLoaderData } from "../loaders/calendar.server"; + +export function FiltersBar() { + const { t } = useTranslation(["calendar", "common", "forms"]); + const user = useUser(); + const data = useLoaderData(); + const [, setParams] = useSearchParamsTyped(calendarSearchParams); + const persistFetcher = useFetcher(); + + const filters = data.filters; + const defaults = CalendarEvent.defaultFilters(); + + const tagItems = calendarFilterTags.map((tag) => ({ + label: t(`forms:options.tag.${tag}`), + value: tag, + })); + + const writeFilters = (partial: Partial) => { + setParams({ ...filters, ...partial, useDefaults: false }); + }; + + const modesFormatted = () => { + const parts = []; + if (filters.modes.length < modesShortWithSpecial.length) { + parts.push(filters.modes.join(", ")); + } + if (filters.modesExact) { + parts.push(t("calendar:filter.exactModes")); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const eventTypeFormatted = () => { + const parts = []; + if (filters.games.length < gamesShort.length) { + parts.push(filters.games.join(", ")); + } + if (filters.preferredVersus.length < versusShort.length) { + parts.push(filters.preferredVersus.join(", ")); + } + if (filters.isSendou) { + parts.push(t("calendar:filterBar.sendou")); + } + if (filters.isRanked) { + parts.push(t("calendar:filterBar.ranked")); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const tierFormatted = () => { + if ( + filters.minTier === defaults.minTier && + filters.maxTier === defaults.maxTier + ) { + return null; + } + + const bestTier = tierNumberToName(filters.minTier); + const worstTier = tierNumberToName(filters.maxTier); + + return bestTier === worstTier ? bestTier : `${bestTier}–${worstTier}`; + }; + + const tagsFormatted = () => { + const parts = []; + if (filters.tagsIncluded.length > 0) { + parts.push(`+${filters.tagsIncluded.length}`); + } + if (filters.tagsExcluded.length > 0) { + parts.push(`−${filters.tagsExcluded.length}`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const organizersFormatted = () => { + const parts = []; + if (filters.orgsIncluded.length > 0) { + parts.push(`+${filters.orgsIncluded.length}`); + } + const excludedCount = + filters.orgsExcluded.length + filters.authorIdsExcluded.length; + if (excludedCount > 0) { + parts.push(`−${excludedCount}`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const timeAndSizeFormatted = () => { + const parts = []; + if (filters.preferredStartTime !== "ANY") { + parts.push( + t( + `calendar:filter.startTime.${filters.preferredStartTime.toLowerCase() as Lowercase>}`, + ), + ); + } + if (filters.minTeamCount > 0) { + parts.push(`${filters.minTeamCount}+`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + return ( + + writeFilters({ modes: defaults.modes, modesExact: false }), + testId: "modes-filter", + popover: ( +
+ + modes.length > 0 ? writeFilters({ modes }) : undefined + } + minLength={1} + onBlur={() => {}} + /> + writeFilters({ modesExact })} + > + {t("calendar:filter.exactModes")} + +
+ ), + }, + { + key: "eventType", + name: t("calendar:filterBar.eventType"), + formattedValue: eventTypeFormatted(), + onRemove: () => + writeFilters({ + games: defaults.games, + preferredVersus: defaults.preferredVersus, + isSendou: false, + isRanked: false, + }), + testId: "event-type-filter", + popover: ( +
+ ({ + label: t(`forms:options.game.${game}`), + value: game, + }))} + value={filters.games} + onChange={(games) => + games.length > 0 ? writeFilters({ games }) : undefined + } + minLength={1} + onBlur={() => {}} + /> + ({ + label: versus, + value: versus, + }))} + value={filters.preferredVersus} + onChange={(preferredVersus) => + preferredVersus.length > 0 + ? writeFilters({ preferredVersus }) + : undefined + } + minLength={1} + onBlur={() => {}} + /> + writeFilters({ isSendou })} + > + {t("calendar:filter.isSendou")} + + writeFilters({ isRanked })} + > + {t("calendar:filter.isRanked")} + +
+ ), + }, + { + key: "tier", + name: t("calendar:filterBar.tier"), + formattedValue: tierFormatted(), + onRemove: () => + writeFilters({ + minTier: defaults.minTier, + maxTier: defaults.maxTier, + }), + testId: "tier-filter", + popover: ( +
+ + writeFilters({ + minTier, + maxTier: Math.max(minTier, filters.maxTier), + }) + } + /> + + writeFilters({ + maxTier, + minTier: Math.min(maxTier, filters.minTier), + }) + } + /> +
+ ), + }, + { + key: "tags", + name: t("calendar:filterBar.tags"), + formattedValue: tagsFormatted(), + onRemove: () => writeFilters({ tagsIncluded: [], tagsExcluded: [] }), + testId: "tags-filter", + popover: ( +
+ + writeFilters({ + tagsIncluded, + tagsExcluded: filters.tagsExcluded.filter( + (tag) => !tagsIncluded.includes(tag), + ), + }) + } + minLength={0} + onBlur={() => {}} + /> + + writeFilters({ + tagsExcluded, + tagsIncluded: filters.tagsIncluded.filter( + (tag) => !tagsExcluded.includes(tag), + ), + }) + } + minLength={0} + onBlur={() => {}} + /> +
+ ), + }, + { + key: "organizers", + name: t("calendar:filterBar.organizers"), + formattedValue: organizersFormatted(), + onRemove: () => + writeFilters({ + orgsIncluded: [], + orgsExcluded: [], + authorIdsExcluded: [], + }), + testId: "organizers-filter", + popover: ( +
+ writeFilters({ orgsIncluded })} + disabled={filters.orgsExcluded.length > 0} + /> + writeFilters({ orgsExcluded })} + disabled={filters.orgsIncluded.length > 0} + /> + + writeFilters({ authorIdsExcluded }) + } + /> +
+ ), + }, + { + key: "timeAndSize", + name: t("calendar:filterBar.timeAndSize"), + formattedValue: timeAndSizeFormatted(), + onRemove: () => + writeFilters({ preferredStartTime: "ANY", minTeamCount: 0 }), + testId: "time-and-size-filter", + popover: ( +
+ + writeFilters({ preferredStartTime }) + } + onBlur={() => {}} + /> + +
+ ), + }, + ]} + onReset={ + !CalendarEvent.isDefaultFilters(filters) + ? () => writeFilters(defaults) + : undefined + } + actions={ + user && data.canSaveAsDefault ? ( + } + isDisabled={persistFetcher.state !== "idle"} + onPress={() => + persistFetcher.submit(filters, { + method: "post", + encType: "application/json", + }) + } + data-testid="save-filters-as-default-button" + > + {t("common:filterBar.saveAsDefault")} + + ) : null + } + /> + ); +} + +function TierSelect({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (value: number) => void; +}) { + return ( + ({ id: tier }))} + selectedKey={value} + onSelectionChange={(key) => onChange(Number(key))} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + + ); +} + +function OrgListEditor({ + label, + values, + onChange, + disabled, +}: { + label: string; + values: string[]; + onChange: (values: string[]) => void; + disabled: boolean; +}) { + const { t } = useTranslation(["common"]); + const [draft, setDraft] = React.useState(""); + + const addDraft = () => { + const org = draft.trim(); + if (!org || values.includes(org)) return; + + onChange([...values, org]); + setDraft(""); + }; + + return ( +
+ {label} + {values.map((org) => ( +
+ {org} + } + variant="minimal-destructive" + size="miniscule" + aria-label={`Remove ${org}`} + onPress={() => onChange(values.filter((value) => value !== org))} + /> +
+ ))} + {values.length < 10 ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addDraft(); + } + }} + /> + + {t("common:actions.add")} + +
+ ) : null} +
+ ); +} + +function ExcludedAuthorsEditor({ + label, + values, + onChange, +}: { + label: string; + values: number[]; + onChange: (values: number[]) => void; +}) { + return ( +
+ {label} + {values.map((userId) => ( +
+ + } + variant="minimal-destructive" + size="miniscule" + aria-label="Remove excluded author" + onPress={() => onChange(values.filter((value) => value !== userId))} + /> +
+ ))} + {values.length < 10 ? ( + { + if (user && !values.includes(user.id)) { + onChange([...values, user.id]); + } + }} + /> + ) : null} +
+ ); +} diff --git a/app/features/calendar/components/FiltersDialog.tsx b/app/features/calendar/components/FiltersDialog.tsx deleted file mode 100644 index 8771ba6a1..000000000 --- a/app/features/calendar/components/FiltersDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Funnel } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import type { z } from "zod"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { useUser } from "~/features/auth/core/user"; -import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas"; -import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; -import type { CalendarFilters } from "~/features/calendar/calendar-types"; -import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; - -type FormValues = z.infer; - -export function FiltersDialog({ filters }: { filters: CalendarFilters }) { - const { t } = useTranslation(["calendar"]); - const [isOpen, setIsOpen] = React.useState(false); - - return ( - <> - } - onPress={() => setIsOpen(true)} - data-testid="filter-events-button" - > - {t("calendar:filter.button")} - - setIsOpen(false)} - > - { - setIsOpen(false); - }} - /> - - - ); -} - -function FiltersForm({ - filters, - closeDialog, -}: { - filters: CalendarFilters; - closeDialog: () => void; -}) { - const user = useUser(); - const { t } = useTranslation(["calendar"]); - const [, setSearchParams] = useSearchParamsTyped(calendarSearchParams); - - const handleApply = (values: FormValues) => { - setSearchParams({ filters: values as unknown as CalendarFilters }); - closeDialog(); - }; - - return ( - : null} - > - {({ FormField }) => ( - <> - - - - - - - - - - - - - - - )} - - ); -} - -function ApplyAndPersistButton() { - const { t } = useTranslation(["calendar"]); - const { values, submitToServer, fetcherState } = useFormFieldContext(); - - return ( - submitToServer(values as CalendarFilters)} - isDisabled={fetcherState !== "idle"} - > - {t("calendar:filter.applyAndDefault")} - - ); -} diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index 8220ad7da..12dd4d207 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -200,6 +200,43 @@ describe("CalendarEvent.applyFilters", () => { expect(result[0].events.shown.map((e) => e.id)).toEqual([2]); }); + it("filters by tier range, taking the tentative tier into account", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tier: 1 }), + makeEvent({ id: 2, tier: 3 }), + makeEvent({ id: 3, tentativeTier: 4 }), + makeEvent({ id: 4, tier: 6 }), + makeEvent({ id: 5, tentativeTier: 8 }), + makeEvent({ id: 6 }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + minTier: 2, + maxTier: 6, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3, 4]); + }); + + it("shows untiered events when the tier range is at its default", () => { + const events = [ + { + at: 123, + events: [makeEvent({ id: 1 }), makeEvent({ id: 2, tier: 5 })], + }, + ]; + const result = CalendarEvent.applyFilters( + events, + CalendarEvent.defaultFilters(), + ); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]); + }); + it("filters by orgsIncluded", () => { const events = [ { diff --git a/app/features/calendar/core/CalendarEvent.ts b/app/features/calendar/core/CalendarEvent.ts index 004cf6ff2..5009ad0bc 100644 --- a/app/features/calendar/core/CalendarEvent.ts +++ b/app/features/calendar/core/CalendarEvent.ts @@ -1,5 +1,9 @@ import { TZDate } from "@date-fns/tz"; import { isWeekend } from "date-fns"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import { assertType } from "~/utils/types"; @@ -9,7 +13,7 @@ import type { GroupedCalendarEvents, } from "../calendar-types"; -const FILTERS_KEYS = [ +export const FILTERS_KEYS = [ "preferredStartTime", "tagsIncluded", "tagsExcluded", @@ -22,6 +26,8 @@ const FILTERS_KEYS = [ "modes", "modesExact", "minTeamCount", + "minTier", + "maxTier", "preferredVersus", ] as const; @@ -46,6 +52,8 @@ export function defaultFilters(): CalendarFilters { orgsExcluded: [], authorIdsExcluded: [], minTeamCount: 0, + minTier: BEST_TIER_NUMBER, + maxTier: WORST_TIER_NUMBER, }; } @@ -58,10 +66,7 @@ export function isDefaultFilters(filters: CalendarFilters): boolean { return filtersToString(filters) === defaultFiltersString; } -/** - * Serializes the given calendar filters object into a string representation to be used as e.g. React key. - */ -export function filtersToString(filters: CalendarFilters): string { +function filtersToString(filters: CalendarFilters): string { let result = ""; for (const key of FILTERS_KEYS) { @@ -252,6 +257,23 @@ function matchesFilter( return event.teamsCount >= minTeamCount; } + case "minTier": { + const { minTier, maxTier } = filters; + if (minTier === BEST_TIER_NUMBER && maxTier === WORST_TIER_NUMBER) { + return true; + } + + const tier = event.tier ?? event.tentativeTier; + if (tier === null) { + return false; + } + + return tier >= minTier && tier <= maxTier; + } + case "maxTier": { + // handled in the minTier filter + return true; + } case "orgsIncluded": { const orgsIncluded = filters[key]; if (orgsIncluded.length === 0) { diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index a5dc52d96..bb178ca82 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -1,5 +1,6 @@ import { add, startOfWeek, sub } from "date-fns"; import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; import type { UserPreferences } from "~/db/tables-json"; import { getUser } from "~/features/auth/core/user.server"; import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; @@ -41,6 +42,17 @@ export const loader = async (args: LoaderFunctionArgs) => { const filters = resolveFilters(args.request, user?.preferences); const filtered = CalendarEvent.applyFilters(events, filters); + const canSaveAsDefault = + user != null && + !R.isDeepEqual( + filters, + user.preferences?.defaultCalendarFilters + ? calendarFiltersSearchParamsSchema.parse( + user.preferences.defaultCalendarFilters, + ) + : CalendarEvent.defaultFilters(), + ); + const eventTimes = canAccessTrophies(user) ? filtered : filtered.map((time) => ({ @@ -61,6 +73,7 @@ export const loader = async (args: LoaderFunctionArgs) => { eventTimes, dateViewed, filters, + canSaveAsDefault, }; }; @@ -68,7 +81,14 @@ function resolveFilters( request: Request, preferences?: UserPreferences | null, ) { - const parsed = calendarSearchParams.parse(request).filters; + const searchParams = calendarSearchParams.parse(request); + const parsed = R.pick(searchParams, [...CalendarEvent.FILTERS_KEYS]); + + // the user cleared or edited the filters, so the URL is the whole truth + // even when it ends up holding no filters at all + if (!searchParams.useDefaults) { + return parsed; + } if (!CalendarEvent.isDefaultFilters(parsed)) { return parsed; diff --git a/app/features/calendar/loaders/calendar[.]ics.server.ts b/app/features/calendar/loaders/calendar[.]ics.server.ts index 1e2b68a30..a4613de90 100644 --- a/app/features/calendar/loaders/calendar[.]ics.server.ts +++ b/app/features/calendar/loaders/calendar[.]ics.server.ts @@ -1,11 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import { safeJSONParse } from "~/utils/zod"; import * as CalendarRepository from "../CalendarRepository.server"; +import { calendarFiltersSearchParamsSchema } from "../calendar-schemas"; import { calendarSearchParams } from "../calendar-search-params"; import * as CalendarEvent from "../core/CalendarEvent"; import * as ICal from "../core/ICal.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const { filters } = calendarSearchParams.parse(request); + const filters = resolveFilters(request); const startTime = new Date(); const endTime = new Date(startTime); @@ -39,3 +42,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, }); }; + +/** Subscribed feed URLs may still carry the pre-FilterBar `filters` JSON param. */ +function resolveFilters(request: Request) { + // biome-ignore lint/plugin: legacy param no current route produces + const legacyFilters = new URL(request.url).searchParams.get("filters"); + if (legacyFilters !== null) { + const parsed = calendarFiltersSearchParamsSchema.safeParse( + safeJSONParse(legacyFilters), + ); + if (parsed.success) return parsed.data; + } + + return R.pick(calendarSearchParams.parse(request), [ + ...CalendarEvent.FILTERS_KEYS, + ]); +} diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css index 2447dcca3..8fde8d771 100644 --- a/app/features/calendar/routes/calendar.module.css +++ b/app/features/calendar/routes/calendar.module.css @@ -9,15 +9,18 @@ ); } +.columnsWidthContainer { + width: 100%; + max-width: var(--columns-width); + margin-inline: auto; +} + .buttonsContainer { display: flex; justify-content: space-between; gap: var(--s-6); align-items: start; flex-wrap: wrap-reverse; - width: 100%; - max-width: var(--columns-width); - margin-inline: auto; } .navigateButtonsContainer { diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index fed0c30a7..cf3b581c4 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -25,21 +25,17 @@ import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; import { useCollapsableEvents } from "~/features/calendar/calendar-hooks"; +import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { dayMonthYearToDateValue } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { - CALENDAR_PAGE, - calendarIcalFeed, - calendarPage, - navIconUrl, -} from "~/utils/urls"; +import { CALENDAR_PAGE, calendarIcalFeed, navIconUrl } from "~/utils/urls"; import type { DayMonthYear } from "~/utils/zod"; import { action } from "../actions/calendar"; import { daysForCalendar } from "../calendar-utils"; -import { FiltersDialog } from "../components/FiltersDialog"; +import { FiltersBar } from "../components/FiltersBar"; import { TournamentCard } from "../components/TournamentCard"; -import * as CalendarEvent from "../core/CalendarEvent"; import { type CalendarLoaderData, loader } from "../loaders/calendar.server"; export { action, loader }; @@ -77,25 +73,18 @@ export default function CalendarPage() { className={clsx("stack lg", styles.container)} style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME } as React.CSSProperties} > -
+
- } - daysInterval={previous} - filters={data.filters} - > + } daysInterval={previous}> {t("common:actions.previous")} - } - daysInterval={next} - filters={data.filters} - > + } daysInterval={next}> {t("common:actions.next")}
@@ -108,12 +97,11 @@ export default function CalendarPage() { } url={calendarIcalFeed(data.filters)} /> -
+
+ +
["shown"]; - filters?: CalendarLoaderData["filters"]; }) { + const dayHref = useCalendarDayHref(); + const lowestDate = daysInterval[0]; const highestDate = daysInterval[daysInterval.length - 1]; @@ -159,7 +147,7 @@ function NavigateButton({ return ( @@ -177,24 +165,16 @@ function NavigateButton({ ); } -function CalendarDatePicker({ - dayMonthYear, - filters, -}: { - dayMonthYear: DayMonthYear; - filters?: CalendarLoaderData["filters"]; -}) { +function CalendarDatePicker({ dayMonthYear }: { dayMonthYear: DayMonthYear }) { const navigate = useNavigate(); + const dayHref = useCalendarDayHref(); const onChange = (date: DateValue) => { navigate( - calendarPage({ - filters, - dayMonthYear: { - day: date.day, - month: date.month - 1, - year: date.year, - }, + dayHref({ + day: date.day, + month: date.month - 1, + year: date.year, }), ); }; @@ -216,6 +196,14 @@ function CalendarDatePicker({ ); } +/** Href to another day, carrying the current filter search params over unchanged. */ +function useCalendarDayHref() { + const [params] = useSearchParamsTyped(calendarSearchParams); + + return (dayMonthYear: DayMonthYear) => + calendarSearchParams.href(CALENDAR_PAGE, { ...params, ...dayMonthYear }); +} + /** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */ function scrollTodayToCenter(container: HTMLDivElement | null) { if (!container) return; diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 2c26d0fb5..e10168f46 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,6 +1,6 @@ import { parseDate } from "@internationalized/date"; import clsx from "clsx"; -import { Check, Plus, Search, SquarePen, Trash } from "lucide-react"; +import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; import { Ability } from "~/components/Ability"; import { Alert } from "~/components/Alert"; @@ -29,6 +29,7 @@ import { import { toastQueue } from "~/components/elements/Toast"; import { Flag } from "~/components/Flag"; import { FormMessage } from "~/components/FormMessage"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { ModeImage, SpecialWeaponImage, @@ -101,6 +102,7 @@ export const SECTIONS = [ { title: "Dialog", id: "dialog", component: DialogSection }, { title: "Popover", id: "popover", component: PopoverSection }, { title: "Menu", id: "menu", component: MenuSection }, + { title: "Filter Bar", id: "filter-bar", component: FilterBarSection }, { title: "Toast", id: "toast", component: ToastSection }, { title: "Divider", id: "divider", component: DividerSection }, { title: "Table", id: "table", component: TableSection }, @@ -1328,6 +1330,80 @@ function MenuSection({ id }: { id: string }) { ); } +const SHOWCASE_MODES = ["SZ", "TC", "RM", "CB"]; +const SHOWCASE_STAGES = ["Scorch Gorge", "Eeltail Alley", "Hagglefish Market"]; + +function FilterBarSection({ id }: { id: string }) { + const [mode, setMode] = useState("SZ"); + const [stage, setStage] = useState(null); + + return ( +
+ Filter Bar + + setMode(null), + popover: ( + + {SHOWCASE_MODES.map((value) => ( + + {value} + + ))} + + ), + }, + { + key: "stage", + name: "Stage", + formattedValue: stage, + onRemove: () => setStage(null), + popover: ( + + {SHOWCASE_STAGES.map((value) => ( + + {value} + + ))} + + ), + }, + ]} + actions={ + mode !== null || stage !== null ? ( + } + onPress={() => { + setMode(null); + setStage(null); + }} + > + Reset + + ) : null + } + /> +
+ ); +} + function ToastSection({ id }: { id: string }) { return (
diff --git a/app/features/lfg/components/LFGAddFilterButton.tsx b/app/features/lfg/components/LFGAddFilterButton.tsx deleted file mode 100644 index 84830f896..000000000 --- a/app/features/lfg/components/LFGAddFilterButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Filter } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; -import type { LFGFilter } from "../lfg-types"; - -const defaultFilters: Record = { - Weapon: { _tag: "Weapon", weaponSplIds: [] }, - Type: { _tag: "Type", type: "PLAYER_FOR_TEAM" }, - Language: { _tag: "Language", language: "en" }, - PlusTier: { _tag: "PlusTier", tier: 3 }, - Timezone: { _tag: "Timezone", maxHourDifference: 3 }, - MinTier: { _tag: "MinTier", tier: "GOLD" }, - MaxTier: { _tag: "MaxTier", tier: "PLATINUM" }, -}; - -export function LFGAddFilterButton({ - filters, - addFilter, -}: { - filters: LFGFilter[]; - addFilter: (filter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( - } - data-testid="add-filter-button" - > - {t("lfg:addFilter")} - - } - > - {Object.entries(defaultFilters).map(([tag, defaultFilter]) => ( - filter._tag === tag)} - onAction={() => addFilter(defaultFilter)} - > - {t(`lfg:filters.${tag as LFGFilter["_tag"]}`)} - - ))} - - ); -} diff --git a/app/features/lfg/components/LFGFilters.module.css b/app/features/lfg/components/LFGFilters.module.css deleted file mode 100644 index c3acf3c27..000000000 --- a/app/features/lfg/components/LFGFilters.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.filter { - padding: var(--s-1-5) var(--s-2); - background-color: var(--bg-lighter); - border-radius: var(--rounded); -} diff --git a/app/features/lfg/components/LFGFilters.tsx b/app/features/lfg/components/LFGFilters.tsx deleted file mode 100644 index 7c289ed0a..000000000 --- a/app/features/lfg/components/LFGFilters.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { X } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import * as R from "remeda"; -import { SendouButton } from "~/components/elements/Button"; -import { WeaponImage } from "~/components/Image"; -import { Label } from "~/components/Label"; -import { WeaponSelect } from "~/components/WeaponSelect"; -import type { Tables } from "~/db/tables"; -import type { TierName } from "~/features/mmr/mmr-constants"; -import { TIERS } from "~/features/mmr/mmr-constants"; -import { - languagesUnified, - type UnifiedLanguageCode, -} from "~/modules/i18n/config"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { LFG } from "../lfg-constants"; -import type { LFGFilter } from "../lfg-types"; - -import styles from "./LFGFilters.module.css"; - -export function LFGFilters({ - filters, - changeFilter, - removeFilterByTag, -}: { - filters: LFGFilter[]; - changeFilter: (newFilter: LFGFilter) => void; - removeFilterByTag: (tag: string) => void; -}) { - if (filters.length === 0) { - return null; - } - - return ( -
- {filters.map((filter) => ( - removeFilterByTag(filter._tag)} - /> - ))} -
- ); -} - -function Filter({ - filter, - changeFilter, - removeFilter, -}: { - filter: LFGFilter; - changeFilter: (newFilter: LFGFilter) => void; - removeFilter: () => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
-
- - } - size="small" - variant="minimal-destructive" - onPress={removeFilter} - aria-label="Delete filter" - /> -
-
- {filter._tag === "Weapon" && ( - - )} - {filter._tag === "Type" && ( - - )} - {filter._tag === "Timezone" && ( - - )} - {filter._tag === "Language" && ( - - )} - {filter._tag === "PlusTier" && ( - - )} - {filter._tag === "MaxTier" && ( - - )} - {filter._tag === "MinTier" && ( - - )} -
-
- ); -} - -function WeaponFilterFields({ - value, - changeFilter, -}: { - value: MainWeaponId[]; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- - changeFilter({ - _tag: "Weapon", - weaponSplIds: - value.length >= 10 - ? [...value.slice(1, 10), weaponId] - : [...value, weaponId], - }) - } - key={value.join("-")} - /> - {value.map((weapon) => ( - - changeFilter({ - _tag: "Weapon", - weaponSplIds: value.filter((weaponId) => weaponId !== weapon), - }) - } - > - - - ))} -
- ); -} - -function TypeFilterFields({ - value, - changeFilter, -}: { - value: Tables["LFGPost"]["type"]; - changeFilter: (newFilter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
- -
- ); -} - -function TimezoneFilterFields({ - value, - changeFilter, -}: { - value: number; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- { - changeFilter({ - _tag: "Timezone", - maxHourDifference: Number(e.target.value), - }); - }} - /> -
- ); -} - -function LanguageFilterFields({ - value, - changeFilter, -}: { - value: string; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- -
- ); -} - -function PlusTierFilterFields({ - value, - changeFilter, -}: { - value: number; - changeFilter: (newFilter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
- -
- ); -} - -function TierFilterFields({ - _tag, - value, - changeFilter, -}: { - _tag: "MaxTier" | "MinTier"; - value: TierName; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- -
- ); -} diff --git a/app/features/lfg/core/filtering.test.ts b/app/features/lfg/core/filtering.test.ts index 2cdd2a5d6..40a295e47 100644 --- a/app/features/lfg/core/filtering.test.ts +++ b/app/features/lfg/core/filtering.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; +import type { LFGFilterValues } from "../lfg-types"; import type { LFGLoaderPost } from "../routes/lfg"; import { filterPosts } from "./filtering"; @@ -9,15 +10,21 @@ const postOfType = (type: LFGLoaderPost["type"]) => team: null, }) as unknown as LFGLoaderPost; +const noFilters: LFGFilterValues = { + weapons: [], + type: null, + timezone: null, + language: null, + plusTier: null, + minTier: null, + maxTier: null, +}; + describe("filterPosts", () => { - test("a weapon filter with no weapons selected shows every post", () => { + test("no weapons selected shows every post", () => { const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")]; - const filtered = filterPosts( - posts, - [{ _tag: "Weapon", weaponSplIds: [] }], - new Map(), - ); + const filtered = filterPosts(posts, noFilters, new Map()); expect(filtered).toHaveLength(2); }); @@ -45,7 +52,7 @@ describe("filterPosts", () => { const filtered = filterPosts( [post], - [{ _tag: "Timezone", maxHourDifference: 3 }], + { ...noFilters, timezone: 3 }, new Map(), ); diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts index 432f1cffe..85990c75c 100644 --- a/app/features/lfg/core/filtering.ts +++ b/app/features/lfg/core/filtering.ts @@ -1,119 +1,135 @@ +import type { TierName } from "~/features/mmr/mmr-constants"; import { compareTwoTiers } from "~/features/mmr/mmr-utils"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds, weaponIdToBaseWeaponId, } from "~/modules/in-game-lists/weapon-ids"; -import { assertUnreachable } from "~/utils/types"; -import type { LFGFilter } from "../lfg-types"; +import type { LFGFilterValues } from "../lfg-types"; import type { LFGLoaderData, LFGLoaderPost, TiersMap } from "../routes/lfg"; import { hourDifferenceBetweenTimezones } from "./timezone"; export function filterPosts( posts: LFGLoaderData["posts"], - filters: LFGFilter[], + filters: LFGFilterValues, tiersMap: TiersMap, ) { - return posts.filter((post) => { - for (const filter of filters) { - if (!filterMatchesPost(post, filter, tiersMap)) return false; + return posts.filter((post) => postMatchesFilters(post, filters, tiersMap)); +} + +function postMatchesFilters( + post: LFGLoaderPost, + filters: LFGFilterValues, + tiersMap: TiersMap, +) { + if ( + post.type === "COACH_FOR_TEAM" && + // not visible in the UI + (filters.weapons.length > 0 || + filters.minTier !== null || + filters.maxTier !== null) + ) { + return false; + } + + if (filters.weapons.length > 0 && !matchesWeapons(post, filters.weapons)) { + return false; + } + if (filters.type !== null && post.type !== filters.type) return false; + if (filters.timezone !== null && !matchesTimezone(post, filters.timezone)) { + return false; + } + if ( + filters.language !== null && + !post.languages?.includes(filters.language) + ) { + return false; + } + if (filters.plusTier !== null && !matchesPlusTier(post, filters.plusTier)) { + return false; + } + if ( + filters.maxTier !== null && + !matchesMaxTier(post, filters.maxTier, tiersMap) + ) { + return false; + } + if ( + filters.minTier !== null && + !matchesMinTier(post, filters.minTier, tiersMap) + ) { + return false; + } + + return true; +} + +function matchesWeapons(post: LFGLoaderPost, weapons: MainWeaponId[]) { + const weaponIdsWithRelated = weapons.flatMap(weaponIdToRelated); + + return checkMatchesSomeUserInPost(post, (user) => + user.weaponPool.some(({ weaponSplId }) => + weaponIdsWithRelated.includes(weaponSplId), + ), + ); +} + +function matchesTimezone(post: LFGLoaderPost, maxHourDifference: number) { + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( + Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <= + maxHourDifference + ); +} + +function matchesPlusTier(post: LFGLoaderPost, plusTier: number) { + return checkMatchesSomeUserInPost( + post, + (user) => user.plusTier && user.plusTier <= plusTier, + ); +} + +function matchesMaxTier( + post: LFGLoaderPost, + maxTier: TierName, + tiersMap: TiersMap, +) { + return checkMatchesSomeUserInPost(post, (user) => { + const tiers = tiersMap.get(user.id); + if (!tiers) return false; + + if (tiers.latest && compareTwoTiers(tiers.latest.name, maxTier) >= 0) { + return true; } - return true; + if (tiers.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) { + return true; + } + + return false; }); } -function filterMatchesPost( +function matchesMinTier( post: LFGLoaderPost, - filter: LFGFilter, + minTier: TierName, tiersMap: TiersMap, ) { - if (post.type === "COACH_FOR_TEAM") { - // not visible in the UI - if ( - (filter._tag === "Weapon" && filter.weaponSplIds.length > 0) || - filter._tag === "MaxTier" || - filter._tag === "MinTier" - ) { - return false; + return checkMatchesSomeUserInPost(post, (user) => { + const tiers = tiersMap.get(user.id); + if (!tiers) return false; + + if (tiers.latest && compareTwoTiers(tiers.latest.name, minTier) <= 0) { + return true; } - } - switch (filter._tag) { - case "Weapon": { - if (filter.weaponSplIds.length === 0) return true; - - const weaponIdsWithRelated = - filter.weaponSplIds.flatMap(weaponIdToRelated); - - return checkMatchesSomeUserInPost(post, (user) => - user.weaponPool.some(({ weaponSplId }) => - weaponIdsWithRelated.includes(weaponSplId), - ), - ); + if (tiers.previous && compareTwoTiers(tiers.previous.name, minTier) <= 0) { + return true; } - case "Type": - return post.type === filter.type; - case "Timezone": { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return ( - Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <= - filter.maxHourDifference - ); - } - case "Language": - return !!post.languages?.includes(filter.language); - case "PlusTier": - return checkMatchesSomeUserInPost( - post, - (user) => user.plusTier && user.plusTier <= filter.tier, - ); - case "MaxTier": - return checkMatchesSomeUserInPost(post, (user) => { - const tiers = tiersMap.get(user.id); - if (!tiers) return false; - - if ( - tiers.latest && - compareTwoTiers(tiers.latest.name, filter.tier) >= 0 - ) { - return true; - } - - if ( - tiers.previous && - compareTwoTiers(tiers.previous.name, filter.tier) >= 0 - ) { - return true; - } - - return false; - }); - case "MinTier": - return checkMatchesSomeUserInPost(post, (user) => { - const tiers = tiersMap.get(user.id); - if (!tiers) return false; - - if ( - tiers.latest && - compareTwoTiers(tiers.latest.name, filter.tier) <= 0 - ) { - return true; - } - - if ( - tiers.previous && - compareTwoTiers(tiers.previous.name, filter.tier) <= 0 - ) { - return true; - } - - return false; - }); - default: - assertUnreachable(filter); - } + return false; + }); } const checkMatchesSomeUserInPost = ( diff --git a/app/features/lfg/lfg-constants.ts b/app/features/lfg/lfg-constants.ts index d5dfd4196..b9b05598e 100644 --- a/app/features/lfg/lfg-constants.ts +++ b/app/features/lfg/lfg-constants.ts @@ -15,6 +15,7 @@ export const LFG = { MIN_TEXT_LENGTH: 1, MAX_TEXT_LENGTH: 2_000, POST_FRESHNESS_DAYS: 30 as const, + MAX_WEAPON_FILTERS: 10, types: LFG_TYPES, }; diff --git a/app/features/lfg/lfg-search-params.test.ts b/app/features/lfg/lfg-search-params.test.ts index 3bc9d06ca..d609a0864 100644 --- a/app/features/lfg/lfg-search-params.test.ts +++ b/app/features/lfg/lfg-search-params.test.ts @@ -4,36 +4,31 @@ import { assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; import { lfgNewSearchParams, lfgSearchParams } from "./lfg-search-params"; -import type { LFGFilter } from "./lfg-types"; - -const weaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [0, 10] }; -const typeFilter: LFGFilter = { _tag: "Type", type: "PLAYER_FOR_TEAM" }; -const timezoneFilter: LFGFilter = { _tag: "Timezone", maxHourDifference: 3 }; -const languageFilter: LFGFilter = { _tag: "Language", language: "en" }; -const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 }; -const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" }; -const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" }; - -// the filter LFGAddFilterButton inserts when the user picks "Weapon" -const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] }; describe("lfgSearchParams", () => { it("round-trips", () => { assertRoundTrips(lfgSearchParams, { - q: [ - [], - [weaponFilter], - [emptyWeaponFilter], - [typeFilter], - [timezoneFilter], - [languageFilter], - [plusTierFilter], - [maxTierFilter], - [minTierFilter], - [weaponFilter, typeFilter, minTierFilter], - ], + weapons: [[], [0], [0, 10, 4001]], + type: [null, "PLAYER_FOR_TEAM", "COACH_FOR_TEAM"], + timezone: [null, 0, 3, 12], + language: [null, "en", "ja"], + plusTier: [null, 1, 3], + minTier: [null, "GOLD", "LEVIATHAN"], + maxTier: [null, "PLATINUM", "IRON"], }); }); + + it("decodes garbage to defaults", () => { + assertDecodesToDefault(lfgSearchParams, "type", [["NOT_A_TYPE"], [""]]); + assertDecodesToDefault(lfgSearchParams, "timezone", [ + ["13"], + ["-1"], + ["abc"], + ]); + assertDecodesToDefault(lfgSearchParams, "language", [["xx"]]); + assertDecodesToDefault(lfgSearchParams, "plusTier", [["0"], ["4"]]); + assertDecodesToDefault(lfgSearchParams, "minTier", [["gold"], ["XX"]]); + }); }); describe("lfgNewSearchParams", () => { diff --git a/app/features/lfg/lfg-search-params.ts b/app/features/lfg/lfg-search-params.ts index 4d153bb1c..b1116f6ab 100644 --- a/app/features/lfg/lfg-search-params.ts +++ b/app/features/lfg/lfg-search-params.ts @@ -1,29 +1,36 @@ import { z } from "zod"; +import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; +import { + languagesUnified, + type UnifiedLanguageCode, +} from "~/modules/i18n/config"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { - filterToSmallStr, - type LFGFilter, - smallStrToFilter, -} from "./lfg-types"; +import { numericEnum } from "~/utils/zod"; +import { LFG, LFG_TYPES } from "./lfg-constants"; -const lfgFiltersCodec = z.codec( - z.string(), - z.custom((value) => Array.isArray(value)), - { - decode: (queryString) => - queryString === "" - ? [] - : queryString - .split("-") - .map(smallStrToFilter) - .filter((filter) => filter !== null), - encode: (filters) => filters.map(filterToSmallStr).join("-"), - }, -); +const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [ + UnifiedLanguageCode, + ...UnifiedLanguageCode[], +]; +const TIER_NAMES = TIERS.map((tier) => tier.name) as [TierName, ...TierName[]]; export const lfgSearchParams = SearchParams.define({ - q: SP.custom(lfgFiltersCodec, { default: [], loader: false }), + weapons: SP.param( + z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS), + { default: [], loader: false }, + ), + type: SP.param(z.enum(LFG_TYPES).nullable(), { loader: false }), + timezone: SP.param(z.number().int().min(0).max(12).nullable(), { + loader: false, + }), + language: SP.param(z.enum(LANGUAGE_CODES).nullable(), { loader: false }), + plusTier: SP.param(z.number().int().min(1).max(3).nullable(), { + loader: false, + }), + minTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }), + maxTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }), }); export const lfgNewSearchParams = SearchParams.define({ diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts index 450afb334..3483ae4f2 100644 --- a/app/features/lfg/lfg-types.ts +++ b/app/features/lfg/lfg-types.ts @@ -1,160 +1,14 @@ -import { LFG_TYPES, type LFGType } from "~/features/lfg/lfg-constants"; -import { - languagesUnified, - type UnifiedLanguageCode, -} from "~/modules/i18n/config"; +import type { LFGType } from "~/features/lfg/lfg-constants"; +import type { TierName } from "~/features/mmr/mmr-constants"; +import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { assertUnreachable } from "~/utils/types"; -import { TIERS, type TierName } from "../mmr/mmr-constants"; -export type LFGFilter = - | WeaponFilter - | TypeFilter - | TimezoneFilter - | LanguageFilter - | PlusTierFilter - | MaxTierFilter - | MinTierFilter; - -type WeaponFilter = { - _tag: "Weapon"; - weaponSplIds: MainWeaponId[]; -}; - -type TypeFilter = { - _tag: "Type"; - type: LFGType; -}; - -type TimezoneFilter = { - _tag: "Timezone"; - maxHourDifference: number; -}; - -type LanguageFilter = { - _tag: "Language"; - language: UnifiedLanguageCode; -}; - -type PlusTierFilter = { - _tag: "PlusTier"; - tier: number; -}; - -type MaxTierFilter = { - _tag: "MaxTier"; - tier: TierName; -}; - -type MinTierFilter = { - _tag: "MinTier"; - tier: TierName; -}; - -const typeToNum = new Map(LFG_TYPES.map((tier, index) => [tier, `${index}`])); - -const numToType = new Map( - Array.from(typeToNum).map(([type, num]) => [`${num}`, type]), -); - -const tierToNum = new Map( - TIERS.map((tier, index) => { - return [tier.name, `${index}`]; - }), -); - -const numToTier = new Map( - Array.from(tierToNum).map(([tier, num]) => [`${num}`, tier]), -); - -export function filterToSmallStr(filter: LFGFilter): string { - switch (filter._tag) { - case "Weapon": { - const weapons = filter.weaponSplIds.map((wid) => `${wid}`).join(","); - return `w.${weapons}`; - } - case "Type": - return `t.${typeToNum.get(filter.type)}`; - case "Timezone": - return `tz.${filter.maxHourDifference}`; - case "Language": - return `l.${filter.language}`; - case "PlusTier": - return `pt.${filter.tier}`; - case "MaxTier": - return `mx.${tierToNum.get(filter.tier)}`; - case "MinTier": - return `mn.${tierToNum.get(filter.tier)}`; - default: - assertUnreachable(filter); - } -} - -export function smallStrToFilter(s: string): LFGFilter | null { - const [tag, val] = s.split("."); - if (!tag || val === undefined) return null; - - switch (tag) { - case "w": { - // an empty weapon filter is valid, it's what the add filter button inserts - const weaponIds = val - .split(",") - .filter(Boolean) - .map((x) => Number.parseInt(x, 10) as MainWeaponId) - .filter((x) => !Number.isNaN(x)); - return { - _tag: "Weapon", - weaponSplIds: weaponIds, - }; - } - case "t": { - const filterType = numToType.get(val); - if (!filterType) return null; - return { - _tag: "Type", - type: filterType, - }; - } - case "tz": { - const n = Number.parseInt(val, 10); - if (Number.isNaN(n)) return null; - return { - _tag: "Timezone", - maxHourDifference: n, - }; - } - case "l": { - const language = languagesUnified.find((lang) => lang.code === val)?.code; - if (!language) return null; - return { - _tag: "Language", - language, - }; - } - case "pt": { - const n = Number.parseInt(val, 10); - if (Number.isNaN(n)) return null; - return { - _tag: "PlusTier", - tier: n, - }; - } - case "mx": { - const tier = numToTier.get(val); - if (!tier) return null; - return { - _tag: "MaxTier", - tier: tier, - }; - } - case "mn": { - const tier = numToTier.get(val); - if (!tier) return null; - return { - _tag: "MinTier", - tier: tier, - }; - } - } - return null; +export interface LFGFilterValues { + weapons: MainWeaponId[]; + type: LFGType | null; + timezone: number | null; + language: UnifiedLanguageCode | null; + plusTier: number | null; + minTier: TierName | null; + maxTier: TierName | null; } diff --git a/app/features/lfg/routes/lfg.module.css b/app/features/lfg/routes/lfg.module.css index 221073e6e..0ef45816d 100644 --- a/app/features/lfg/routes/lfg.module.css +++ b/app/features/lfg/routes/lfg.module.css @@ -1,8 +1,3 @@ -.topRow { - display: flex; - justify-content: flex-end; -} - .post { scroll-margin-top: 6rem; } diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index c8ae60913..a2fad104e 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -4,19 +4,25 @@ import React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { WeaponSelect } from "~/components/WeaponSelect"; import { useUser } from "~/features/auth/core/user"; -import { useSearchParam } from "~/modules/search-params/hooks"; +import { TIERS } from "~/features/mmr/mmr-constants"; +import { languagesUnified } from "~/modules/i18n/config"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; import { LFG_PAGE, navIconUrl } from "~/utils/urls"; import { action } from "../actions/lfg.server"; -import { LFGAddFilterButton } from "../components/LFGAddFilterButton"; -import { LFGFilters } from "../components/LFGFilters"; import { LFGPost } from "../components/LFGPost"; import { filterPosts } from "../core/filtering"; import { LFG } from "../lfg-constants"; @@ -57,11 +63,11 @@ export default function LFGPage() { const { t } = useTranslation(["common", "lfg"]); const user = useUser(); const data = useLoaderData(); - const [filters, setFilters] = useSearchParam(lfgSearchParams, "q"); + const [filterValues] = useSearchParamsTyped(lfgSearchParams); const tiersMap = React.useMemo(() => unserializeTiers(data), [data]); - const filteredPosts = filterPosts(data.posts, filters, tiersMap); + const filteredPosts = filterPosts(data.posts, filterValues, tiersMap); const showExpiryAlert = (post: Unpacked) => { if (post.author.id !== user?.id) return false; @@ -78,25 +84,7 @@ export default function LFGPage() { return (
-
- setFilters([...filters, newFilter])} - filters={filters} - /> -
- - setFilters( - filters.map((filter) => - filter._tag === newFilter._tag ? newFilter : filter, - ), - ) - } - removeFilterByTag={(tag) => - setFilters(filters.filter((filter) => filter._tag !== tag)) - } - /> + {filteredPosts.map((post) => (
0 ? ( + + {weapons.map((weaponSplId) => ( + + ))} + + ) : null, + onRemove: () => setParams({ weapons: [] }), + popover: ( + setParams({ weapons: newWeapons })} + /> + ), + }, + { + key: "type", + name: t("lfg:filters.Type"), + formattedValue: type !== null ? t(`lfg:types.${type}`) : null, + onAdd: () => setParams({ type: "PLAYER_FOR_TEAM" }), + onRemove: () => setParams({ type: null }), + popover: ( + + ), + }, + { + key: "language", + name: t("lfg:filters.Language"), + formattedValue: + language !== null + ? (languagesUnified.find((lang) => lang.code === language) + ?.name ?? language) + : null, + onAdd: () => setParams({ language: "en" }), + onRemove: () => setParams({ language: null }), + popover: ( + + ), + }, + { + key: "plusTier", + name: t("lfg:filters.PlusTier"), + formattedValue: + plusTier !== null + ? plusTier === 1 + ? "+1" + : `+${plusTier} ${t("lfg:filters.orAbove")}` + : null, + onAdd: () => setParams({ plusTier: 3 }), + onRemove: () => setParams({ plusTier: null }), + popover: ( + + ), + }, + { + key: "timezone", + name: t("lfg:filters.Timezone"), + formattedValue: timezone !== null ? `±${timezone}h` : null, + onAdd: () => setParams({ timezone: 3 }), + onRemove: () => setParams({ timezone: null }), + popover: ( + setParams({ timezone: Number(e.target.value) })} + /> + ), + }, + { + key: "minTier", + name: t("lfg:filters.MinTier"), + formattedValue: + minTier !== null ? R.capitalize(minTier.toLowerCase()) : null, + onAdd: () => setParams({ minTier: "GOLD" }), + onRemove: () => setParams({ minTier: null }), + popover: ( + setParams({ minTier: tier })} + /> + ), + }, + { + key: "maxTier", + name: t("lfg:filters.MaxTier"), + formattedValue: + maxTier !== null ? R.capitalize(maxTier.toLowerCase()) : null, + onAdd: () => setParams({ maxTier: "PLATINUM" }), + onRemove: () => setParams({ maxTier: null }), + popover: ( + setParams({ maxTier: tier })} + /> + ), + }, + ]} + /> + ); +} + +function WeaponsPopover({ + weapons, + onChange, +}: { + weapons: MainWeaponId[]; + onChange: (weapons: MainWeaponId[]) => void; +}) { + return ( +
+ + onChange( + weapons.length >= LFG.MAX_WEAPON_FILTERS + ? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId] + : [...weapons, weaponId], + ) + } + key={weapons.join("-")} + /> + {weapons.length > 0 ? ( +
+ {weapons.map((weapon) => ( + + onChange(weapons.filter((weaponId) => weaponId !== weapon)) + } + > + + + ))} +
+ ) : null} +
+ ); +} + +function TierSelect({ + label, + value, + onChange, +}: { + label: string; + value: (typeof TIERS)[number]["name"]; + onChange: (tier: (typeof TIERS)[number]["name"]) => void; +}) { + return ( + + ); +} + function PostExpiryAlert({ postId }: { postId: number }) { const { t } = useTranslation(["common", "lfg"]); diff --git a/app/features/scrims/components/ScrimFiltersDialog.tsx b/app/features/scrims/components/ScrimFiltersDialog.tsx deleted file mode 100644 index 07609c38e..000000000 --- a/app/features/scrims/components/ScrimFiltersDialog.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Funnel } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import type { z } from "zod"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { useUser } from "~/features/auth/core/user"; -import type { ScrimFilters } from "~/features/scrims/scrims-types"; -import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; -import { scrimsFiltersFormSchema } from "../scrims-schemas"; -import { scrimsSearchParams } from "../scrims-search-params"; -import type { LutiDiv } from "../scrims-types"; - -type FormValues = z.infer; - -export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) { - const { t } = useTranslation(["scrims"]); - const [isOpen, setIsOpen] = React.useState(false); - - return ( - <> - } - onPress={() => setIsOpen(true)} - data-testid="filter-scrims-button" - > - {t("scrims:filters.button")} - - setIsOpen(false)} - > - { - setIsOpen(false); - }} - /> - - - ); -} - -function filtersToFormValues(filters: ScrimFilters): FormValues { - return { - weekdayTimes: filters.weekdayTimes, - weekendTimes: filters.weekendTimes, - divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null], - }; -} - -function formValuesToFilters(values: FormValues): ScrimFilters { - const [max, min] = values.divs ?? [null, null]; - return { - weekdayTimes: values.weekdayTimes, - weekendTimes: values.weekendTimes, - divs: - max || min - ? { max: max as LutiDiv | null, min: min as LutiDiv | null } - : null, - }; -} - -function FiltersForm({ - filters, - closeDialog, -}: { - filters: ScrimFilters; - closeDialog: () => void; -}) { - const user = useUser(); - const { t } = useTranslation(["scrims"]); - const [, setSearchParams] = useSearchParamsTyped(scrimsSearchParams); - - const defaultValues = filtersToFormValues(filters); - - const handleApply = (values: FormValues) => { - setSearchParams({ filters: formValuesToFilters(values) }); - closeDialog(); - }; - - return ( - : null} - > - {({ FormField }) => ( - <> - - - - - )} - - ); -} - -function ApplyAndPersistButton() { - const { t } = useTranslation(["scrims"]); - const { values, submitToServer, fetcherState } = useFormFieldContext(); - - const handlePress = () => { - submitToServer({ - _action: "PERSIST_SCRIM_FILTERS", - filters: formValuesToFilters(values as FormValues), - }); - }; - - return ( - - {t("scrims:filters.applyAndDefault")} - - ); -} diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 81a7dfea0..70d578157 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -17,11 +17,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ? await AssociationsRepository.findByMemberUserId(user?.id) : null; - const filtersFromSearchParams = scrimsSearchParams.parse(request).filters; + const { weekdayTimes, weekendTimes, divs, useDefaults } = + scrimsSearchParams.parse(request); + const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs }; - const filters = Scrim.filtersAreDefault(filtersFromSearchParams) - ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters()) - : filtersFromSearchParams; + // when the user cleared or edited the filters the URL is the whole truth + // even when it ends up holding no filters at all + const filters = + useDefaults && Scrim.filtersAreDefault(filtersFromSearchParams) + ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters()) + : filtersFromSearchParams; const posts = (await ScrimPostRepository.findAllRelevant()) .filter( @@ -57,5 +62,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { posts: dividePosts(posts, user?.id), teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [], filters, + canSaveAsDefault: + user != null && + !R.isDeepEqual( + filters, + user.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters(), + ), }; }; diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index fdd62d4ee..04a5f5625 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -7,14 +7,22 @@ import { useLoaderData } from "react-router"; import * as R from "remeda"; import type { z } from "zod"; import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { LocaleTime } from "~/components/LocaleTime"; import { useUser } from "~/features/auth/core/user"; +import { DualSelectFormField } from "~/form/fields/DualSelectFormField"; +import { TimeRangeFormField } from "~/form/fields/TimeRangeFormField"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHydrated } from "~/hooks/useHydrated"; -import { useSearchParam } from "~/modules/search-params/hooks"; +import { + useSearchParam, + useSearchParamsTyped, +} from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { associationsPage, navIconUrl, scrimsPage } from "~/utils/urls"; +import { timeString } from "~/utils/zod"; import { SendouTab, SendouTabList, @@ -24,16 +32,16 @@ import { import { Main } from "../../../components/Main"; import { action } from "../actions/scrims.server"; import { ScrimPostCard, ScrimRequestCard } from "../components/ScrimCard"; -import { ScrimFiltersDialog } from "../components/ScrimFiltersDialog"; import * as Scrim from "../core/Scrim"; import { loader } from "../loaders/scrims.server"; -import type { newRequestSchema } from "../scrims-schemas"; +import { LUTI_DIVS } from "../scrims-constants"; +import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas"; import { scrimsSearchParams } from "../scrims-search-params"; -import type { ScrimFilters, ScrimPost } from "../scrims-types"; +import type { LutiDiv, ScrimFilters, ScrimPost } from "../scrims-types"; export { action, loader }; -import { Check, Download, Funnel, Megaphone } from "lucide-react"; +import { Check, Download, Funnel, Megaphone, Star } from "lucide-react"; import styles from "./scrims.module.css"; @@ -87,23 +95,16 @@ export default function ScrimsPage() { return (
-
-
- - {t("scrims:associations.title")} - - {user ? ( - - ) : null} -
+
+ + {t("scrims:associations.title")} + +
(); + const [, setParams] = useSearchParamsTyped(scrimsSearchParams); + const persistFilters = useActionSubmit(scrimsActionSchema, { + encType: "application/json", + }); + + const filters = data.filters; + + const writeFilters = (partial: Partial) => { + setParams({ ...filters, ...partial, useDefaults: false }); + }; + + return ( + writeFilters({ weekdayTimes: null }), + testId: "weekday-times-filter", + popover: ( + + writeFilters({ weekdayTimes: timeRange }) + } + /> + ), + }, + { + key: "weekendTimes", + name: t("scrims:filters.weekendTimes"), + formattedValue: filters.weekendTimes + ? `${filters.weekendTimes.start}–${filters.weekendTimes.end}` + : null, + onRemove: () => writeFilters({ weekendTimes: null }), + testId: "weekend-times-filter", + popover: ( + + writeFilters({ weekendTimes: timeRange }) + } + /> + ), + }, + { + key: "divs", + name: t("scrims:filters.divs"), + formattedValue: filters.divs + ? `${filters.divs.max}–${filters.divs.min}` + : null, + onRemove: () => writeFilters({ divs: null }), + testId: "divs-filter", + popover: ( + writeFilters({ divs })} + /> + ), + }, + ]} + onReset={ + !Scrim.filtersAreDefault(filters) + ? () => + writeFilters({ + weekdayTimes: null, + weekendTimes: null, + divs: null, + }) + : undefined + } + actions={ + data.canSaveAsDefault ? ( + } + isDisabled={persistFilters.state !== "idle"} + onPress={() => + persistFilters.submit("PERSIST_SCRIM_FILTERS", { filters }) + } + data-testid="save-filters-as-default-button" + > + {t("common:filterBar.saveAsDefault")} + + ) : null + } + /> + ); +} + +function TimeRangePopover({ + name, + value, + onChange, +}: { + name: string; + value: ScrimFilters["weekdayTimes"]; + onChange: (value: ScrimFilters["weekdayTimes"]) => void; +}) { + const { t } = useTranslation(["forms"]); + const [draft, setDraft] = React.useState(value); + + const handleChange = (timeRange: { start: string; end: string } | null) => { + setDraft(timeRange); + + if (timeRange === null) { + onChange(null); + return; + } + + if ( + timeString.safeParse(timeRange.start).success && + timeString.safeParse(timeRange.end).success + ) { + onChange(timeRange); + } + }; + + return ( + + ); +} + +function DivsPopover({ + value, + onChange, +}: { + value: ScrimFilters["divs"]; + onChange: (value: ScrimFilters["divs"]) => void; +}) { + const { t } = useTranslation(["forms"]); + const [draft, setDraft] = React.useState<[LutiDiv | null, LutiDiv | null]>([ + value?.max ?? null, + value?.min ?? null, + ]); + + const divItems = LUTI_DIVS.map((div) => ({ label: div, value: div })); + + const handleChange = (newValue: [LutiDiv | null, LutiDiv | null]) => { + setDraft(newValue); + + const [max, min] = newValue; + if (max !== null && min !== null) { + onChange({ max, min }); + } else if (max === null && min === null) { + onChange(null); + } + }; + + return ( + {}} + /> + ); +} + function ScrimsDaySeparatedCards({ posts, filters, diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index 62c68b73f..5ccd08000 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -14,7 +14,6 @@ import { textArea, textAreaOptional, textFieldOptional, - timeRangeOptional, toggle, tournamentSearchOptional, } from "~/form/fields"; @@ -90,7 +89,7 @@ const timeRangeSchema = z.object({ end: timeString, }); -export const divsSchema = z +const divsBaseSchema = z .object({ min: z.enum(LUTI_DIVS).nullable(), max: z.enum(LUTI_DIVS).nullable(), @@ -107,26 +106,53 @@ export const divsSchema = z { message: "forms:errors.divBothOrNeither", }, - ) - .transform((divs) => { - if (!divs.min || !divs.max) return divs; + ); - const minIndex = LUTI_DIVS.indexOf(divs.min); - const maxIndex = LUTI_DIVS.indexOf(divs.max); +export const divsSchema = divsBaseSchema.transform(normalizeDivs); - if (maxIndex > minIndex) { - return { min: divs.max, max: divs.min }; - } +function normalizeDivs( + divs: T, +): T { + if (!divs.min || !divs.max) return divs; - return divs; - }); + const minIndex = LUTI_DIVS.indexOf(divs.min as (typeof LUTI_DIVS)[number]); + const maxIndex = LUTI_DIVS.indexOf(divs.max as (typeof LUTI_DIVS)[number]); + if (minIndex === -1 || maxIndex === -1) return divs; -export const scrimsFiltersSchema = z.object({ + if (maxIndex > minIndex) { + return { ...divs, min: divs.max, max: divs.min }; + } + + return divs; +} + +const scrimsFiltersSchema = z.object({ weekdayTimes: timeRangeSchema.nullable().catch(null), weekendTimes: timeRangeSchema.nullable().catch(null), divs: divsSchema.nullable().catch(null), }); +export const timeRangeCodec = z.codec(z.string(), timeRangeSchema.nullable(), { + decode: (encoded) => { + if (encoded[5] !== "-") return null; + + return { start: encoded.slice(0, 5), end: encoded.slice(6) }; + }, + encode: (timeRange) => + timeRange === null ? "" : `${timeRange.start}-${timeRange.end}`, +}); + +export const divsCodec = z.codec(z.string(), divsBaseSchema.nullable(), { + decode: (encoded) => { + const [max, min] = encoded.split("-"); + + return normalizeDivs({ max: max ?? null, min: min ?? null }) as z.output< + typeof divsBaseSchema + >; + }, + encode: (divs) => (divs === null ? "" : `${divs.max}-${divs.min}`), +}); + const divsFormField = dualSelectOptional({ fields: [ { @@ -147,20 +173,6 @@ const divsFormField = dualSelectOptional({ }, }); -export const scrimsFiltersFormSchema = z.object({ - weekdayTimes: timeRangeOptional({ - label: "labels.weekdayTimes", - startLabel: "labels.start", - endLabel: "labels.end", - }), - weekendTimes: timeRangeOptional({ - label: "labels.weekendTimes", - startLabel: "labels.start", - endLabel: "labels.end", - }), - divs: divsFormField, -}); - const persistScrimFiltersSchema = z.object({ _action: _action("PERSIST_SCRIM_FILTERS"), filters: scrimsFiltersSchema, diff --git a/app/features/scrims/scrims-search-params.test.ts b/app/features/scrims/scrims-search-params.test.ts index 73df1a1ba..c3e291945 100644 --- a/app/features/scrims/scrims-search-params.test.ts +++ b/app/features/scrims/scrims-search-params.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { assertDecodesToDefault, assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; -import * as Scrim from "./core/Scrim"; import { scrimsSearchParams } from "./scrims-search-params"; describe("scrimsSearchParams", () => { @@ -11,39 +10,38 @@ describe("scrimsSearchParams", () => { // divs examples are in the normalized shape the divsSchema transform // produces (max is the higher div) so decode(encode(x)) equals x assertRoundTrips(scrimsSearchParams, { - filters: [ - Scrim.defaultFilters(), - { - weekdayTimes: { start: "18:00", end: "22:30" }, - weekendTimes: { start: "10:00", end: "23:59" }, - divs: { min: "5", max: "1" }, - }, - { - weekdayTimes: null, - weekendTimes: { start: "00:00", end: "12:00" }, - divs: { min: "3", max: "3" }, - }, - { - weekdayTimes: null, - weekendTimes: null, - divs: { min: "11", max: "X" }, - }, - { - weekdayTimes: { start: "20:00", end: "02:00" }, - weekendTimes: null, - divs: { min: null, max: null }, - }, + weekdayTimes: [ + null, + { start: "18:00", end: "22:30" }, + { start: "00:00", end: "23:59" }, + { start: "20:00", end: "02:00" }, + ], + weekendTimes: [null, { start: "10:00", end: "23:59" }], + divs: [ + null, + { min: "5", max: "1" }, + { min: "3", max: "3" }, + { min: "11", max: "X" }, ], pendingRequestPostId: [null, 1, 987654], + useDefaults: [true, false], }); }); it("decodes garbage to defaults", () => { - assertDecodesToDefault(scrimsSearchParams, "filters", [ - ["not-json"], - ["[]"], - ['{"divs":{"min":"1","max":null}}'], - ['{"weekdayTimes":{"start":"25:00","end":"22:00"}}'], + assertDecodesToDefault(scrimsSearchParams, "weekdayTimes", [ + ["25:00-22:00"], + ["18:00x22:00"], + ["18:00"], + [""], + ["18:60-22:00"], + ]); + assertDecodesToDefault(scrimsSearchParams, "divs", [ + ["1-"], + ["-5"], + ["not-a-div-XX"], + ["12-13"], + [""], ]); assertDecodesToDefault(scrimsSearchParams, "pendingRequestPostId", [ ["abc"], @@ -52,23 +50,4 @@ describe("scrimsSearchParams", () => { ["1.5"], ]); }); - - it("keeps valid fields when part of the filters blob is invalid", () => { - const parsed = scrimsSearchParams.parse( - new URL( - `http://localhost/scrims?filters=${encodeURIComponent( - JSON.stringify({ - weekdayTimes: { start: "18:00", end: "20:00" }, - divs: "bad", - }), - )}`, - ), - ); - - expect(parsed.filters).toEqual({ - weekdayTimes: { start: "18:00", end: "20:00" }, - weekendTimes: null, - divs: null, - }); - }); }); diff --git a/app/features/scrims/scrims-search-params.ts b/app/features/scrims/scrims-search-params.ts index 5ac86997c..6a68dc811 100644 --- a/app/features/scrims/scrims-search-params.ts +++ b/app/features/scrims/scrims-search-params.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import * as Scrim from "./core/Scrim"; -import { scrimsFiltersSchema } from "./scrims-schemas"; +import { divsCodec, timeRangeCodec } from "./scrims-schemas"; export const scrimsSearchParams = SearchParams.define({ - filters: SP.json(scrimsFiltersSchema, { - default: Scrim.defaultFilters(), - loader: true, - }), + weekdayTimes: SP.custom(timeRangeCodec, { loader: true }), + weekendTimes: SP.custom(timeRangeCodec, { loader: true }), + divs: SP.custom(divsCodec, { loader: true }), + /** False once the user has edited the filters, making the URL win over their saved defaults. */ + useDefaults: SP.param(z.boolean(), { default: true, loader: true }), pendingRequestPostId: SP.param(z.number().int().positive().nullable(), { loader: false, }), diff --git a/app/features/tournament/core/tiering.ts b/app/features/tournament/core/tiering.ts index 0fe39d06f..a191d538e 100644 --- a/app/features/tournament/core/tiering.ts +++ b/app/features/tournament/core/tiering.ts @@ -51,6 +51,12 @@ const NUMBER_TO_TIER = { export type TournamentTier = keyof typeof TIER_TO_NUMBER; export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier]; +/** Every tier number, from the best tier (X) to the worst (C). */ +export const TIER_NUMBERS = Object.values(TIER_TO_NUMBER); + +export const BEST_TIER_NUMBER = TIER_TO_NUMBER.X; +export const WORST_TIER_NUMBER = TIER_TO_NUMBER.C; + export function calculateAdjustedScore( rawScore: number, teamCount: number, diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index c20821d53..42ad2247e 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -6,7 +6,15 @@ import { db } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; import type { CustomTheme, UserPreferences } from "~/db/tables-json"; import { actorId } from "~/features/auth/core/user.server"; -import type { BuildSort } from "~/features/user-page/user-page-constants"; +import { + BEST_TIER_NUMBER, + type TournamentTierNumber, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import type { + BuildSort, + ResultSource, +} from "~/features/user-page/user-page-constants"; import { userRoles } from "~/modules/permissions/mapper.server"; import { isSupporter } from "~/modules/permissions/utils"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; @@ -392,6 +400,16 @@ export function findByFriendCode(friendCode: string) { .execute(); } +export async function findUsernameById(id: number) { + const user = await db + .selectFrom("User") + .select("User.username") + .where("User.id", "=", id) + .executeTakeFirst(); + + return user?.username ?? null; +} + export async function findLeanById(id: number) { const user = await db .selectFrom("User") @@ -525,16 +543,61 @@ export async function findChatUsersByUserIds(userIds: number[]) { return result; } -const withMaxEventStartTime = (eb: ExpressionBuilder) => { - return eb +export interface ResultsFilters { + showHighlightsOnly?: boolean; + tournamentName?: string; + teamName?: string; + mateUserId?: number; + minTier?: TournamentTierNumber; + maxTier?: TournamentTierNumber; + maxPlacement?: number; + fromYear?: number; + toYear?: number; + source?: ResultSource; + minParticipantCount?: number; +} + +const withMaxEventStartTime = (eb: ExpressionBuilder) => + eb .selectFrom("CalendarEventDate") .select(({ fn }) => [fn.max("CalendarEventDate.startsAt").as("startsAt")]) .whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id") .as("startsAt"); -}; -const baseCalendarEventResultsQuery = (userId: number) => - db +const maxEventStartTimeExpr = sql`(select max(${sql.ref("CalendarEventDate.startsAt")}) from ${sql.table("CalendarEventDate")} where ${sql.ref("CalendarEventDate.eventId")} = ${sql.ref("CalendarEvent.id")})`; + +const maxEventStartTimeAtLeastExpr = (year: number) => + sql`${maxEventStartTimeExpr} >= ${yearStartsAt(year)}`; + +const maxEventStartTimeAtMostExpr = (year: number) => + sql`${maxEventStartTimeExpr} <= ${yearEndsAt(year)}`; + +const NEVER_MATCHES = sql`0`; + +const isTierFiltered = ({ + minTier = BEST_TIER_NUMBER, + maxTier = WORST_TIER_NUMBER, +}: ResultsFilters) => + minTier !== BEST_TIER_NUMBER || maxTier !== WORST_TIER_NUMBER; + +/** Results reported on a calendar event have no tier, so filtering by tier excludes them. */ +const includesCalendarEventResults = (filters: ResultsFilters) => + filters.source !== "SENDOU" && !isTierFiltered(filters); + +const includesTournamentResults = (filters: ResultsFilters) => + filters.source !== "EXTERNAL"; + +const yearStartsAt = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year, 0, 1))); + +const yearEndsAt = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year + 1, 0, 1))) - 1; + +const baseCalendarEventResultsQuery = ( + userId: number, + filters: ResultsFilters, +) => { + let query = db .selectFrom("CalendarEventResultPlayer") .innerJoin( "CalendarEventResultTeam", @@ -553,8 +616,71 @@ const baseCalendarEventResultsQuery = (userId: number) => ) .where("CalendarEventResultPlayer.userId", "=", userId); -const baseTournamentResultsQuery = (userId: number) => - db + if (!includesCalendarEventResults(filters)) { + return query.where(NEVER_MATCHES); + } + + if (filters.showHighlightsOnly) { + query = query.where("UserResultHighlight.userId", "is not", null); + } + + if (filters.tournamentName) { + query = query.where( + nameLikeExpr("CalendarEvent.name", filters.tournamentName), + ); + } + + if (filters.teamName) { + query = query.where( + nameLikeExpr("CalendarEventResultTeam.name", filters.teamName), + ); + } + + if (filters.mateUserId) { + const mateUserId = filters.mateUserId; + query = query.where((eb) => + eb.exists( + eb + .selectFrom("CalendarEventResultPlayer as MatePlayer") + .select("MatePlayer.userId") + .whereRef("MatePlayer.teamId", "=", "CalendarEventResultTeam.id") + .where("MatePlayer.userId", "=", mateUserId), + ), + ); + } + + if (filters.maxPlacement) { + query = query.where( + "CalendarEventResultTeam.placement", + "<=", + filters.maxPlacement, + ); + } + + if (filters.minParticipantCount) { + query = query.where( + "CalendarEvent.participantCount", + ">=", + filters.minParticipantCount, + ); + } + + if (filters.fromYear) { + query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear)); + } + + if (filters.toYear) { + query = query.where(maxEventStartTimeAtMostExpr(filters.toYear)); + } + + return query; +}; + +const baseTournamentResultsQuery = ( + userId: number, + filters: ResultsFilters, +) => { + let query = db .selectFrom("TournamentResult") .innerJoin( "TournamentTeam", @@ -569,124 +695,167 @@ const baseTournamentResultsQuery = (userId: number) => .innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId") .where("TournamentResult.userId", "=", userId); + if (!includesTournamentResults(filters)) { + return query.where(NEVER_MATCHES); + } + + if (filters.showHighlightsOnly) { + query = query.where("TournamentResult.isHighlight", "=", 1); + } + + if (filters.tournamentName) { + query = query.where( + nameLikeExpr("CalendarEvent.name", filters.tournamentName), + ); + } + + if (filters.teamName) { + query = query.where(nameLikeExpr("TournamentTeam.name", filters.teamName)); + } + + if (filters.mateUserId) { + const mateUserId = filters.mateUserId; + query = query.where((eb) => + eb.exists( + eb + .selectFrom("TournamentResult as MateResult") + .select("MateResult.userId") + .whereRef( + "MateResult.tournamentTeamId", + "=", + "TournamentResult.tournamentTeamId", + ) + .where("MateResult.userId", "=", mateUserId), + ), + ); + } + + if (isTierFiltered(filters)) { + query = query + .where("Tournament.tier", ">=", filters.minTier ?? BEST_TIER_NUMBER) + .where("Tournament.tier", "<=", filters.maxTier ?? WORST_TIER_NUMBER); + } + + if (filters.maxPlacement) { + query = query.where( + "TournamentResult.placement", + "<=", + filters.maxPlacement, + ); + } + + if (filters.minParticipantCount) { + query = query.where( + "TournamentResult.participantCount", + ">=", + filters.minParticipantCount, + ); + } + + if (filters.fromYear) { + query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear)); + } + + if (filters.toYear) { + query = query.where(maxEventStartTimeAtMostExpr(filters.toYear)); + } + + return query; +}; + const escapeLikePattern = (value: string) => value.replace(/[\\%_]/g, (char) => `\\${char}`); -const tournamentNameLikeExpr = (tournamentName: string) => { - const pattern = `%${escapeLikePattern(tournamentName)}%`; - return sql`${sql.ref("CalendarEvent.name")} like ${pattern} escape '\\'`; +const nameLikeExpr = (column: string, name: string) => { + const pattern = `%${escapeLikePattern(name)}%`; + return sql`${sql.ref(column)} like ${pattern} escape '\\'`; }; export function findResultsByUserId( userId: number, { - showHighlightsOnly = false, limit, offset, - tournamentName, - }: { - showHighlightsOnly?: boolean; + ...filters + }: ResultsFilters & { limit?: number; offset?: number; - tournamentName?: string; } = {}, ) { - let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( - ({ eb, fn }) => [ - "CalendarEvent.id as eventId", - sql`null`.as("tournamentId"), - "CalendarEventResultTeam.placement", - "CalendarEvent.participantCount", - sql`null`.as("setResults"), - sql`null`.as("div"), - sql`null`.as("logoUrl"), - "CalendarEvent.name as eventName", - "CalendarEventResultTeam.id as teamId", - "CalendarEventResultTeam.name as teamName", - fn("iif", [ - "UserResultHighlight.userId", - sql`1`, - sql`0`, - ]).as("isHighlight"), - sql`null`.as("tier"), - withMaxEventStartTime(eb), - jsonArrayFrom( - eb - .selectFrom("CalendarEventResultPlayer") - .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") - .select((eb) => [ - ...commonUserSelect(eb), - "CalendarEventResultPlayer.name", - ]) - .whereRef( - "CalendarEventResultPlayer.teamId", - "=", - "CalendarEventResultTeam.id", - ) - .where((eb) => - eb.or([ - eb("CalendarEventResultPlayer.userId", "is", null), - eb("CalendarEventResultPlayer.userId", "!=", userId), - ]), - ), - ).as("mates"), - ], - ); + const calendarEventResultsQuery = baseCalendarEventResultsQuery( + userId, + filters, + ).select(({ eb, fn }) => [ + "CalendarEvent.id as eventId", + sql`null`.as("tournamentId"), + "CalendarEventResultTeam.placement", + "CalendarEvent.participantCount", + sql`null`.as("setResults"), + sql`null`.as("div"), + sql`null`.as("logoUrl"), + "CalendarEvent.name as eventName", + "CalendarEventResultTeam.id as teamId", + "CalendarEventResultTeam.name as teamName", + fn("iif", ["UserResultHighlight.userId", sql`1`, sql`0`]).as( + "isHighlight", + ), + sql`null`.as("tier"), + withMaxEventStartTime(eb), + jsonArrayFrom( + eb + .selectFrom("CalendarEventResultPlayer") + .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") + .select((eb) => [ + ...commonUserSelect(eb), + "CalendarEventResultPlayer.name", + ]) + .whereRef( + "CalendarEventResultPlayer.teamId", + "=", + "CalendarEventResultTeam.id", + ) + .where((eb) => + eb.or([ + eb("CalendarEventResultPlayer.userId", "is", null), + eb("CalendarEventResultPlayer.userId", "!=", userId), + ]), + ), + ).as("mates"), + ]); - let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( - ({ eb }) => [ - sql`null`.as("eventId"), - "TournamentResult.tournamentId", - "TournamentResult.placement", - "TournamentResult.participantCount", - "TournamentResult.setResults", - "TournamentResult.div", - tournamentLogoOrNull(eb).as("logoUrl"), - "CalendarEvent.name as eventName", - "TournamentTeam.id as teamId", - "TournamentTeam.name as teamName", - "TournamentResult.isHighlight", - "Tournament.tier", - withMaxEventStartTime(eb), - jsonArrayFrom( - eb - .selectFrom("TournamentResult as TournamentResult2") - .innerJoin("User", "User.id", "TournamentResult2.userId") - .select((eb) => [ - ...commonUserSelect(eb), - sql`null`.as("name"), - ]) - .whereRef( - "TournamentResult2.tournamentTeamId", - "=", - "TournamentResult.tournamentTeamId", - ) - .where("TournamentResult2.userId", "!=", userId), - ).as("mates"), - ], - ); - - if (showHighlightsOnly) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - "UserResultHighlight.userId", - "is not", - null, - ); - tournamentResultsQuery = tournamentResultsQuery.where( - "TournamentResult.isHighlight", - "=", - 1, - ); - } - - if (tournamentName) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - tournamentResultsQuery = tournamentResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - } + const tournamentResultsQuery = baseTournamentResultsQuery( + userId, + filters, + ).select(({ eb }) => [ + sql`null`.as("eventId"), + "TournamentResult.tournamentId", + "TournamentResult.placement", + "TournamentResult.participantCount", + "TournamentResult.setResults", + "TournamentResult.div", + tournamentLogoOrNull(eb).as("logoUrl"), + "CalendarEvent.name as eventName", + "TournamentTeam.id as teamId", + "TournamentTeam.name as teamName", + "TournamentResult.isHighlight", + "Tournament.tier", + withMaxEventStartTime(eb), + jsonArrayFrom( + eb + .selectFrom("TournamentResult as TournamentResult2") + .innerJoin("User", "User.id", "TournamentResult2.userId") + .select((eb) => [ + ...commonUserSelect(eb), + sql`null`.as("name"), + ]) + .whereRef( + "TournamentResult2.tournamentTeamId", + "=", + "TournamentResult.tournamentTeamId", + ) + .where("TournamentResult2.userId", "!=", userId), + ).as("mates"), + ]); let query = calendarEventResultsQuery .unionAll(tournamentResultsQuery) @@ -706,40 +875,17 @@ export function findResultsByUserId( export async function countResultsByUserId( userId: number, - { - showHighlightsOnly = false, - tournamentName, - }: { showHighlightsOnly?: boolean; tournamentName?: string } = {}, + filters: ResultsFilters = {}, ) { - let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( - ({ fn }) => [fn.countAll().as("count")], - ); + const calendarEventResultsQuery = baseCalendarEventResultsQuery( + userId, + filters, + ).select(({ fn }) => [fn.countAll().as("count")]); - let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( - ({ fn }) => [fn.countAll().as("count")], - ); - - if (showHighlightsOnly) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - "UserResultHighlight.userId", - "is not", - null, - ); - tournamentResultsQuery = tournamentResultsQuery.where( - "TournamentResult.isHighlight", - "=", - 1, - ); - } - - if (tournamentName) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - tournamentResultsQuery = tournamentResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - } + const tournamentResultsQuery = baseTournamentResultsQuery( + userId, + filters, + ).select(({ fn }) => [fn.countAll().as("count")]); const [calendarEventResults, tournamentResults] = await Promise.all([ calendarEventResultsQuery.executeTakeFirst(), diff --git a/app/features/user-page/UserRepository.test.ts b/app/features/user-page/UserRepository.test.ts index 0f8ba330e..36e52cfdd 100644 --- a/app/features/user-page/UserRepository.test.ts +++ b/app/features/user-page/UserRepository.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; +import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as UserRepository from "./UserRepository.server"; describe("UserRepository", () => { @@ -100,6 +104,192 @@ describe("UserRepository", () => { ).toBe(1); }); + describe("findResultsByUserId filters", () => { + const startTimeOf = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year, 5, 1))); + + const seedResults = async () => { + const user = await UserFactory.create(); + const mate = await UserFactory.create(); + const [firstOpponent, secondOpponent] = await UserFactory.createMany(2); + + const wonEvent = await CalendarEventFactory.create({ + name: "Gamma Open", + authorId: user.id, + startTimes: [startTimeOf(2024)], + }); + await CalendarEventResultFactory.create({ + eventId: wonEvent.id, + participantCount: 8, + results: [ + { + teamName: "Team Gamma", + placement: 1, + players: [ + { userId: user.id, name: null }, + { userId: mate.id, name: null }, + ], + }, + ], + }); + + const lostEvent = await CalendarEventFactory.create({ + name: "Delta Open", + authorId: user.id, + startTimes: [startTimeOf(2022)], + }); + await CalendarEventResultFactory.create({ + eventId: lostEvent.id, + participantCount: 50, + results: [ + { + teamName: "Team Delta", + placement: 5, + players: [ + { userId: user.id, name: null }, + { userId: firstOpponent.id, name: null }, + ], + }, + ], + }); + + await TournamentFactory.createPlayed( + { + name: "Alpha Invitational", + authorId: user.id, + startTimes: [startTimeOf(2023)], + minMembersPerTeam: 1, + }, + { + teamRosters: [[user.id], [secondOpponent.id]], + playedOut: "all", + tier: 1, + }, + ); + + return { userId: user.id, mateUserId: mate.id }; + }; + + const filteredResults = async ( + userId: number, + filters: Parameters[1], + ) => { + const [results, count] = await Promise.all([ + UserRepository.findResultsByUserId(userId, filters), + UserRepository.countResultsByUserId(userId, filters), + ]); + + expect(count).toBe(results.length); + + return results; + }; + + test("returns every result without filters", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, {}); + + expect(results).toHaveLength(3); + }); + + test("filters by result source", async () => { + const { userId } = await seedResults(); + + const tournaments = await filteredResults(userId, { source: "SENDOU" }); + const reported = await filteredResults(userId, { source: "EXTERNAL" }); + + expect(tournaments).toHaveLength(1); + expect(tournaments[0].eventName).toBe("Alpha Invitational"); + expect(reported.map((result) => result.eventName).sort()).toEqual([ + "Delta Open", + "Gamma Open", + ]); + }); + + test("filters by tier, excluding results without one", async () => { + const { userId } = await seedResults(); + + const bestTiers = await filteredResults(userId, { + minTier: 1, + maxTier: 3, + }); + const worstTiers = await filteredResults(userId, { + minTier: 8, + maxTier: 9, + }); + + expect(bestTiers).toHaveLength(1); + expect(bestTiers[0].eventName).toBe("Alpha Invitational"); + expect(worstTiers).toHaveLength(0); + }); + + test("filters by placement", async () => { + const { userId } = await seedResults(); + + const wins = await filteredResults(userId, { maxPlacement: 1 }); + + expect(wins.every((result) => result.placement === 1)).toBe(true); + expect(wins.map((result) => result.eventName)).toContain("Gamma Open"); + expect(wins.map((result) => result.eventName)).not.toContain( + "Delta Open", + ); + }); + + test("filters by year range", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + fromYear: 2023, + toYear: 2024, + }); + + expect(results.map((result) => result.eventName).sort()).toEqual([ + "Alpha Invitational", + "Gamma Open", + ]); + }); + + test("filters by teammate", async () => { + const { userId, mateUserId } = await seedResults(); + + const results = await filteredResults(userId, { mateUserId }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Gamma Open"); + }); + + test("filters by team name", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { teamName: "delta" }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Delta Open"); + }); + + test("filters by minimum participant count", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + minParticipantCount: 16, + }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Delta Open"); + }); + + test("filters by tournament name", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + tournamentName: "alpha", + }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Alpha Invitational"); + }); + }); + describe("userRoles", () => { test("returns empty array for basic user", async () => { await UserFactory.createAdmin(); diff --git a/app/features/user-page/components/ResultsFiltersBar.tsx b/app/features/user-page/components/ResultsFiltersBar.tsx new file mode 100644 index 000000000..3e7b5f455 --- /dev/null +++ b/app/features/user-page/components/ResultsFiltersBar.tsx @@ -0,0 +1,398 @@ +import * as React from "react"; +import type { Key } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { UserSearch } from "~/components/elements/UserSearch"; +import type { FilterBarPill } from "~/components/filter-bar/FilterBar"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { + BEST_TIER_NUMBER, + TIER_NUMBERS, + type TournamentTierNumber, + tierNumberToName, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import { RadioGroupFormField } from "~/form/fields/InputGroupFormField"; +import { useDebounce } from "~/hooks/useDebounce"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, + type ResultPlacementFilter, +} from "../user-page-constants"; +import { userResultsSearchParams } from "../user-page-search-params"; + +const DEFAULT_FILTERS = { + highlightsOnly: true, + tournament: null, + team: null, + mate: null, + minTier: BEST_TIER_NUMBER, + maxTier: WORST_TIER_NUMBER, + maxPlacement: null, + fromYear: null, + toYear: null, + source: "ALL", + minParticipantCount: 0, +} as const; + +export function ResultsFiltersBar() { + const { t } = useTranslation("user"); + const data = useLoaderData(); + const [filters, setFilters] = useSearchParamsTyped(userResultsSearchParams); + + const tierFormatted = () => { + if ( + filters.minTier === DEFAULT_FILTERS.minTier && + filters.maxTier === DEFAULT_FILTERS.maxTier + ) { + return null; + } + + const bestTier = tierNumberToName(filters.minTier); + const worstTier = tierNumberToName(filters.maxTier); + + return bestTier === worstTier ? bestTier : `${bestTier}–${worstTier}`; + }; + + const placementName = (maxPlacement: number) => + maxPlacement === 1 + ? t("results.filter.placement.first") + : t("results.filter.placement.top", { count: maxPlacement }); + + const yearsFormatted = () => { + if (!filters.fromYear && !filters.toYear) return null; + if (filters.fromYear === filters.toYear) return String(filters.fromYear); + + return `${filters.fromYear ?? ""}–${filters.toYear ?? ""}`; + }; + + const highlightsPill: FilterBarPill = { + key: "highlights", + name: t("results.highlights"), + formattedValue: filters.highlightsOnly ? t("results.filter.only") : null, + onRemove: () => setFilters({ highlightsOnly: false }), + onAdd: () => setFilters({ highlightsOnly: true }), + testId: "highlights-filter", + popover: ( + setFilters({ highlightsOnly })} + > + {t("results.filter.highlightsOnly")} + + ), + }; + + const pills: FilterBarPill[] = [ + ...(data.hasHighlightedResults ? [highlightsPill] : []), + { + key: "tournament", + name: t("results.filter.tournament"), + formattedValue: filters.tournament, + onRemove: () => setFilters({ tournament: null }), + testId: "tournament-filter", + popover: ( + setFilters({ tournament })} + /> + ), + }, + { + key: "mate", + name: t("results.filter.mate"), + formattedValue: filters.mate ? (data.mateUsername ?? "?") : null, + onRemove: () => setFilters({ mate: null }), + testId: "mate-filter", + popover: ( + setFilters({ mate: user?.id ?? null })} + /> + ), + }, + { + key: "team", + name: t("results.filter.team"), + formattedValue: filters.team, + onRemove: () => setFilters({ team: null }), + testId: "team-filter", + popover: ( + setFilters({ team })} + /> + ), + }, + { + key: "tier", + name: t("results.filter.tier"), + formattedValue: tierFormatted(), + onRemove: () => + setFilters({ + minTier: DEFAULT_FILTERS.minTier, + maxTier: DEFAULT_FILTERS.maxTier, + }), + testId: "tier-filter", + popover: ( +
+ ({ id: tier }))} + selectedKey={filters.minTier} + onSelectionChange={(key) => { + const minTier = toTierNumber(key); + setFilters({ + minTier, + maxTier: minTier > filters.maxTier ? minTier : filters.maxTier, + }); + }} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + + ({ id: tier }))} + selectedKey={filters.maxTier} + onSelectionChange={(key) => { + const maxTier = toTierNumber(key); + setFilters({ + maxTier, + minTier: maxTier < filters.minTier ? maxTier : filters.minTier, + }); + }} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + +
+ ), + }, + { + key: "placement", + name: t("results.filter.placement"), + formattedValue: filters.maxPlacement + ? placementName(filters.maxPlacement) + : null, + onRemove: () => setFilters({ maxPlacement: null }), + testId: "placement-filter", + popover: ( + ({ + id: placement, + }))} + selectedKey={filters.maxPlacement} + clearable + onSelectionChange={(key) => + setFilters({ + maxPlacement: + key === null ? null : (Number(key) as ResultPlacementFilter), + }) + } + > + {({ id }) => ( + + {placementName(id)} + + )} + + ), + }, + { + key: "years", + name: t("results.filter.years"), + formattedValue: yearsFormatted(), + onRemove: () => setFilters({ fromYear: null, toYear: null }), + testId: "years-filter", + popover: ( +
+ + setFilters({ + fromYear, + toYear: + fromYear && filters.toYear + ? Math.max(fromYear, filters.toYear) + : filters.toYear, + }) + } + /> + + setFilters({ + toYear, + fromYear: + toYear && filters.fromYear + ? Math.min(toYear, filters.fromYear) + : filters.fromYear, + }) + } + /> +
+ ), + }, + { + key: "source", + name: t("results.filter.source"), + formattedValue: + filters.source === DEFAULT_FILTERS.source + ? null + : t(`results.filter.source.${filters.source}`), + onRemove: () => setFilters({ source: DEFAULT_FILTERS.source }), + testId: "source-filter", + popover: ( + ({ + label: t(`results.filter.source.${source}`), + value: source, + }))} + value={filters.source} + onChange={(source) => setFilters({ source })} + onBlur={() => {}} + /> + ), + }, + { + key: "size", + name: t("results.filter.size"), + formattedValue: + filters.minParticipantCount > 0 + ? `${filters.minParticipantCount}+` + : null, + onRemove: () => setFilters({ minParticipantCount: 0 }), + testId: "size-filter", + popover: ( + + ), + }, + ]; + + return ( + setFilters(DEFAULT_FILTERS) + } + /> + ); +} + +function DebouncedNameFilter({ + label, + value, + onChange, +}: { + label: string; + value: string | null; + onChange: (value: string | null) => void; +}) { + const [draft, setDraft] = React.useState(value ?? ""); + + useDebounce( + () => { + if ((value ?? "") === draft.trim()) return; + onChange(draft.trim() || null); + }, + 300, + [draft], + ); + + return ( + + ); +} + +function YearSelect({ + label, + value, + onChange, +}: { + label: string; + value: number | null; + onChange: (value: number | null) => void; +}) { + const years = selectableYears(); + + return ( + ({ id: year }))} + selectedKey={value} + clearable + onSelectionChange={(key) => onChange(key === null ? null : Number(key))} + > + {({ id }) => ( + + {id} + + )} + + ); +} + +const selectableYears = () => { + const currentYear = new Date().getFullYear(); + + const result = []; + for (let year = currentYear; year >= RESULTS_FIRST_YEAR; year--) { + result.push(year); + } + + return result; +}; + +const toTierNumber = (key: Key | null) => Number(key) as TournamentTierNumber; + +const isDefaultFilters = ( + filters: Record, +) => + Object.entries(DEFAULT_FILTERS).every( + ([key, value]) => filters[key as keyof typeof DEFAULT_FILTERS] === value, + ); diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index bd6c14e9a..c892d6612 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -50,16 +50,21 @@ export function UserResultsTable({ {results.map((result, i) => { + // team ids of the two result types are from different tables and can collide + const rowId = result.tournamentId + ? `tournament-${result.teamId}` + : `event-${result.teamId}`; + // We are trying to construct a reasonable label for the checkbox // which shouldn't contain the whole information of the table row as // that can be also accessed when needed. // e.g. "20xx Placing 2nd", "Big House 10 Placing 20th" - const placementCellId = `${id}-${result.teamId}-placement`; - const nameCellId = `${id}-${result.teamId}-name`; + const placementCellId = `${id}-${rowId}-placement`; + const nameCellId = `${id}-${rowId}-name`; const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`; return ( - + {hasHighlightCheckboxes && ( ; export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { - const { all, page, tournament } = userResultsSearchParams.parse(request); + const { + highlightsOnly, + page, + tournament, + team, + mate, + minTier, + maxTier, + maxPlacement, + fromYear, + toYear, + source, + minParticipantCount, + } = userResultsSearchParams.parse(request); const userId = notFoundIfNullish( await UserRepository.findIdByIdentifier(params.identifier!), @@ -20,34 +33,50 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { const hasHighlightedResults = await UserRepository.hasHighlightedResultsByUserId(userId); - let showHighlightsOnly = !all; + const isChoosingHighlights = url.pathname.includes("/results/highlights"); + const canFilter = !isChoosingHighlights && Boolean(getUser()); - if (!hasHighlightedResults) { + /** Logged out visitors are locked to the highlights, if there are any. */ + let showHighlightsOnly = hasHighlightedResults; + + if (canFilter && !highlightsOnly) { showHighlightsOnly = false; } - const isChoosingHighlights = url.pathname.includes("/results/highlights"); if (isChoosingHighlights) { showHighlightsOnly = false; } - const tournamentName = - !isChoosingHighlights && getUser() && tournament !== null - ? tournament - : undefined; + const filters = canFilter + ? { + tournamentName: tournament ?? undefined, + teamName: team ?? undefined, + mateUserId: mate ?? undefined, + minTier, + maxTier, + maxPlacement: maxPlacement ?? undefined, + fromYear: fromYear ?? undefined, + toYear: toYear ?? undefined, + source, + minParticipantCount, + } + : {}; - const [results, totalCount] = await Promise.all([ + const [results, totalCount, mateUsername] = await Promise.all([ UserRepository.findResultsByUserId(userId, { showHighlightsOnly, - tournamentName, + ...filters, ...(isChoosingHighlights ? { limit: HIGHLIGHTS_RESULTS_MAX } : { limit: RESULTS_PER_PAGE, offset: (page - 1) * RESULTS_PER_PAGE }), }), UserRepository.countResultsByUserId(userId, { showHighlightsOnly, - tournamentName, + ...filters, }), + filters.mateUserId + ? UserRepository.findUsernameById(filters.mateUserId) + : null, ]); return { @@ -56,5 +85,6 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { ...paginate({ url, page, pageSize: RESULTS_PER_PAGE, totalCount }), }, hasHighlightedResults, + mateUsername, }; }; diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 72ac79b2e..7ae1ae3d7 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -1,18 +1,13 @@ -import { Search } from "lucide-react"; -import * as React from "react"; import { useTranslation } from "react-i18next"; import { useLoaderData, useMatches } from "react-router"; import { LinkButton } from "~/components/elements/Button"; -import { Input } from "~/components/Input"; import { Pagination } from "~/components/Pagination"; import { useUser } from "~/features/auth/core/user"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; -import { useDebounce } from "~/hooks/useDebounce"; import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import invariant from "~/utils/invariant"; import { userPage, userResultsEditHighlightsPage } from "~/utils/urls"; -import { SendouButton } from "../../../components/elements/Button"; +import { ResultsFiltersBar } from "../components/ResultsFiltersBar"; import { SubPageHeader } from "../components/SubPageHeader"; import { loader } from "../loaders/u.$identifier.results.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; @@ -23,37 +18,13 @@ export { loader }; export default function UserResultsPage() { const user = useUser(); - const { t } = useTranslation("user"); + const { t } = useTranslation(["user", "common"]); const data = useLoaderData(); const [, parentRoute] = useMatches(); invariant(parentRoute); const layoutData = parentRoute.loaderData as UserPageLoaderData; - const [{ all: showAll, tournament }, setParams] = useSearchParamsTyped( - userResultsSearchParams, - ); - - const urlTournamentQuery = tournament ?? ""; - const [tournamentQuery, setTournamentQuery] = - React.useState(urlTournamentQuery); - const [prevUrlTournamentQuery, setPrevUrlTournamentQuery] = - React.useState(urlTournamentQuery); - - if (urlTournamentQuery !== prevUrlTournamentQuery) { - setPrevUrlTournamentQuery(urlTournamentQuery); - setTournamentQuery(urlTournamentQuery); - } - - useDebounce( - () => { - if (urlTournamentQuery === tournamentQuery) return; - setParams({ tournament: tournamentQuery || null }); - }, - 300, - [tournamentQuery], - ); - const pagination = useSearchParamPagination({ definition: userResultsSearchParams, currentPage: data.results.currentPage, @@ -66,43 +37,23 @@ export default function UserResultsPage() { user={layoutData.user} backTo={userPage(layoutData.user)} /> -
-

- {showAll || !data.hasHighlightedResults - ? t("results.title") - : t("results.highlights")} -

+ {user?.id === layoutData.user.id ? (
- {user ? ( - setTournamentQuery(e.target.value)} - placeholder={t("results.filter.placeholder")} - aria-label={t("results.filter.placeholder")} - icon={} - /> - ) : null} - {user?.id === layoutData.user.id ? ( - - {t("results.highlights.choose")} - - ) : null} + + {t("results.highlights.choose")} +
-
- - {data.results.pagesCount > 1 ? : null} - {data.hasHighlightedResults ? ( - setParams({ all: !showAll })} - > - {showAll - ? t("results.button.showHighlights") - : t("results.button.showAll")} - ) : null} + {user ? : null} + {data.results.value.length > 0 ? ( + + ) : ( +
{t("common:noResults")}
+ )} + {data.results.pagesCount > 1 ? : null}
); } diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 4d8af2e42..4d4c6789f 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -21,6 +21,18 @@ export const SPL2_JOIN_ORDER_CUTOFF = 13_589; export const MATCHES_PER_SEASONS_PAGE = 8; export const RESULTS_PER_PAGE = 25; export const HIGHLIGHTS_RESULTS_MAX = 500; + +/** Year of the oldest event that can have results. */ +export const RESULTS_FIRST_YEAR = 2015; + +/** Placement thresholds that results can be filtered by e.g. 3 = top 3 only. */ +export const RESULT_PLACEMENT_FILTERS = [1, 3, 8, 16, 32] as const; + +export type ResultPlacementFilter = (typeof RESULT_PLACEMENT_FILTERS)[number]; + +export const RESULT_SOURCES = ["ALL", "SENDOU", "EXTERNAL"] as const; + +export type ResultSource = (typeof RESULT_SOURCES)[number]; export const BUILD_SORT_IDENTIFIERS = [ "UPDATED_AT", "TOP_500", diff --git a/app/features/user-page/user-page-search-params.test.ts b/app/features/user-page/user-page-search-params.test.ts index 6077cbc6a..df26e71e9 100644 --- a/app/features/user-page/user-page-search-params.test.ts +++ b/app/features/user-page/user-page-search-params.test.ts @@ -1,9 +1,18 @@ import { describe, it } from "vitest"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { assertDecodesToDefault, assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, +} from "./user-page-constants"; import { userArtSearchParams, userBuildsSearchParams, @@ -16,18 +25,31 @@ const startedSeasons = Seasons.allStarted(new Date()); const newestSeason = startedSeasons[0]; const oldestSeason = startedSeasons.at(-1)!; const notStartedSeason = newestSeason + 1000; +const currentYear = new Date().getFullYear(); describe("userResultsSearchParams", () => { it("round-trips", () => { assertRoundTrips(userResultsSearchParams, { - all: [false, true], + highlightsOnly: [false, true], page: [1, 2, 1000], tournament: ["In The Zone", "x", "a".repeat(100)], + team: [null, "Team Olive", "a".repeat(100)], + mate: [null, 1, 9999], + minTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER], + maxTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER], + maxPlacement: [null, ...RESULT_PLACEMENT_FILTERS], + fromYear: [null, RESULTS_FIRST_YEAR, currentYear], + toYear: [null, RESULTS_FIRST_YEAR, currentYear], + source: [...RESULT_SOURCES], + minParticipantCount: [0, 16, 9999], }); }); it("malformed values decode to defaults", () => { - assertDecodesToDefault(userResultsSearchParams, "all", [["1"], ["yes"]]); + assertDecodesToDefault(userResultsSearchParams, "highlightsOnly", [ + ["1"], + ["yes"], + ]); assertDecodesToDefault(userResultsSearchParams, "page", [ ["0"], ["1001"], @@ -38,6 +60,29 @@ describe("userResultsSearchParams", () => { [" "], ["a".repeat(101)], ]); + assertDecodesToDefault(userResultsSearchParams, "team", [ + [""], + ["a".repeat(101)], + ]); + assertDecodesToDefault(userResultsSearchParams, "mate", [["0"], ["abc"]]); + assertDecodesToDefault(userResultsSearchParams, "minTier", [ + ["0"], + ["10"], + ["abc"], + ]); + assertDecodesToDefault(userResultsSearchParams, "maxPlacement", [ + ["2"], + ["abc"], + ]); + assertDecodesToDefault(userResultsSearchParams, "fromYear", [ + [String(RESULTS_FIRST_YEAR - 1)], + [String(currentYear + 1)], + ]); + assertDecodesToDefault(userResultsSearchParams, "source", [["SOMETHING"]]); + assertDecodesToDefault(userResultsSearchParams, "minParticipantCount", [ + ["-1"], + ["10000"], + ]); }); }); diff --git a/app/features/user-page/user-page-search-params.ts b/app/features/user-page/user-page-search-params.ts index 69e21df4e..b5ba279cd 100644 --- a/app/features/user-page/user-page-search-params.ts +++ b/app/features/user-page/user-page-search-params.ts @@ -3,22 +3,81 @@ import { ART_SOURCES } from "~/features/art/art-types"; import { serializedBuildCodec } from "~/features/build-analyzer/analyzer-search-params"; import { EMPTY_BUILD } from "~/features/builds/builds-constants"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { + BEST_TIER_NUMBER, + TIER_NUMBERS, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { numericEnum } from "~/utils/zod"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, +} from "./user-page-constants"; const BUILD_FILTER_TABS = ["ALL", "PUBLIC", "PRIVATE"] as const; +const resultYear = z + .number() + .int() + .min(RESULTS_FIRST_YEAR) + .refine((year) => year <= new Date().getFullYear()); + +const resultsFilterName = z.string().trim().min(1).max(100).nullable(); + export const userResultsSearchParams = SearchParams.define({ - all: SP.param(z.boolean(), { - default: false, + /** Only applies to users who have highlighted results. */ + highlightsOnly: SP.param(z.boolean(), { + default: true, loader: true, resets: ["page"], }), page: SP.page(), - tournament: SP.param(z.string().trim().min(1).max(100).nullable(), { + tournament: SP.param(resultsFilterName, { + loader: true, + resets: ["page"], + }), + team: SP.param(resultsFilterName, { + loader: true, + resets: ["page"], + }), + mate: SP.param(z.number().int().positive().nullable(), { + loader: true, + resets: ["page"], + }), + minTier: SP.param(numericEnum(TIER_NUMBERS), { + default: BEST_TIER_NUMBER, + loader: true, + resets: ["page"], + }), + maxTier: SP.param(numericEnum(TIER_NUMBERS), { + default: WORST_TIER_NUMBER, + loader: true, + resets: ["page"], + }), + maxPlacement: SP.param(numericEnum(RESULT_PLACEMENT_FILTERS).nullable(), { + loader: true, + resets: ["page"], + }), + fromYear: SP.param(resultYear.nullable(), { + loader: true, + resets: ["page"], + }), + toYear: SP.param(resultYear.nullable(), { + loader: true, + resets: ["page"], + }), + source: SP.param(z.enum(RESULT_SOURCES), { + default: "ALL", + loader: true, + resets: ["page"], + }), + minParticipantCount: SP.param(z.number().int().nonnegative().max(9999), { + default: 0, loader: true, resets: ["page"], }), diff --git a/app/features/user-page/user-page.module.css b/app/features/user-page/user-page.module.css index 2da668030..e92c96540 100644 --- a/app/features/user-page/user-page.module.css +++ b/app/features/user-page/user-page.module.css @@ -126,40 +126,13 @@ overflow-x: auto; } -.resultsHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--s-3); -} - .resultsHeaderActions { display: flex; align-items: center; + justify-content: flex-end; gap: var(--s-2); } -.resultsFilterInput { - width: 12rem; - max-width: 100%; -} - -@media screen and (max-width: 599px) { - .resultsHeader { - flex-direction: column; - align-items: stretch; - } - - .resultsHeaderActions { - justify-content: space-between; - } - - .resultsFilterInput { - flex: 1; - width: auto; - } -} - .resultsTableHighlights { border: var(--s-2) solid var(--color-bg-high); padding-inline: 0 !important; diff --git a/app/features/vods/routes/vods.module.css b/app/features/vods/routes/vods.module.css index 297e24158..792282ce5 100644 --- a/app/features/vods/routes/vods.module.css +++ b/app/features/vods/routes/vods.module.css @@ -4,7 +4,3 @@ gap: var(--s-6); justify-content: center; } - -.typeSelect { - width: 220px; -} diff --git a/app/features/vods/routes/vods.tsx b/app/features/vods/routes/vods.tsx index 24dc06e4e..133d4c3f4 100644 --- a/app/features/vods/routes/vods.tsx +++ b/app/features/vods/routes/vods.tsx @@ -1,7 +1,12 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; -import { Label } from "~/components/Label"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { Main } from "~/components/Main"; import { Pagination } from "~/components/Pagination"; import { WeaponSelect } from "~/components/WeaponSelect"; @@ -70,90 +75,105 @@ export default function VodsSearchPage() { } function Filters() { - const { t } = useTranslation(["game-misc", "vods"]); + const { t } = useTranslation(["game-misc", "vods", "weapons"]); const [{ mode, stageId, weapon, type }, setParams] = useSearchParamsTyped(vodsSearchParams); return ( -
-
- - -
-
- - -
- - { - setParams({ weapon: weaponId ?? null }); - }} - clearable - /> - -
- - -
-
+ setParams({ mode: null }), + testId: "vods-mode-filter", + popover: ( + + {modesShort.map((option) => ( + setParams({ mode: value as ModeShort })} + > + {t(`game-misc:MODE_SHORT_${option}`)} + + ))} + + ), + }, + { + key: "stage", + name: t("vods:forms.title.stage"), + formattedValue: + stageId !== null ? t(`game-misc:STAGE_${stageId}`) : null, + onRemove: () => setParams({ stageId: null }), + testId: "vods-stage-filter", + popover: ( + ({ id }))} + selectedKey={stageId} + onSelectionChange={(key) => + setParams({ stageId: key as StageId }) + } + search={{}} + > + {({ id }) => ( + + {t(`game-misc:STAGE_${id}`)} + + )} + + ), + }, + { + key: "weapon", + name: t("vods:forms.title.weapon"), + formattedValue: weapon !== null ? t(`weapons:MAIN_${weapon}`) : null, + onRemove: () => setParams({ weapon: null }), + testId: "vods-weapon-filter", + popover: ( + { + setParams({ weapon: weaponId ?? null }); + }} + clearable + /> + ), + }, + { + key: "type", + name: t("vods:forms.title.type"), + formattedValue: type !== null ? t(`vods:type.${type}`) : null, + onRemove: () => setParams({ type: null }), + testId: "vods-type-filter", + popover: ( + + {videoMatchTypes.map((option) => ( + + setParams({ + type: value as (typeof videoMatchTypes)[number], + }) + } + > + {t(`vods:type.${option}`)} + + ))} + + ), + }, + ]} + /> ); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index f4db1941d..eaff31f90 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -44,7 +44,6 @@ import { userCardEditSearchParams } from "~/features/user-card/user-card-search- import { userArtSearchParams, userBuildsNewSearchParams, - userResultsSearchParams, userSeasonSummaryGraphicSearchParams, userSeasonsSearchParams, } from "~/features/user-page/user-page-search-params"; @@ -273,10 +272,8 @@ export const userEditProfilePage = (user: UserLinkArgs) => `${userPage(user)}/edit`; export const userBuildsPage = (user: UserLinkArgs) => `${userPage(user)}/builds`; -export const userResultsPage = (user: UserLinkArgs, showAll?: boolean) => - userResultsSearchParams.href(`${userPage(user)}/results`, { - all: Boolean(showAll), - }); +export const userResultsPage = (user: UserLinkArgs) => + `${userPage(user)}/results`; export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`; export const userCardEditPage = (args?: { returnTo?: string }) => userCardEditSearchParams.href(USER_CARD_EDIT_PAGE, { @@ -382,12 +379,8 @@ export const weaponBuildPopularPage = (weaponSlug: string) => `${weaponBuildPage(weaponSlug)}/popular`; export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`; -export const calendarPage = (args?: { - filters?: CalendarFilters; - dayMonthYear?: DayMonthYear; -}) => +export const calendarPage = (args?: { dayMonthYear?: DayMonthYear }) => calendarSearchParams.href(CALENDAR_PAGE, { - ...(args?.filters ? { filters: args.filters } : {}), ...(args?.dayMonthYear ? { day: args.dayMonthYear.day, @@ -399,7 +392,7 @@ export const calendarPage = (args?: { export const calendarIcalFeed = (filters?: CalendarFilters) => calendarSearchParams.href(`${SENDOU_INK_BASE_URL}/calendar.ics`, { - ...(filters ? { filters } : {}), + ...(filters ?? {}), }); export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`; diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 687d5f738..be3813309 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -113,14 +113,14 @@ test.describe("Builds", () => { // are all builds with ISM are hidden? await expect(weaponBuilds.ability("ISM")).toHaveCount(1); - await weaponBuilds.deleteFilter(); + await weaponBuilds.deleteFilter("ability"); await expect(weaponBuilds.ability("ISM").nth(1)).toBeVisible(); await weaponBuilds.addFilter("mode"); await weaponBuilds.modeFilterCheckbox("Tower Control").click(); await expect(weaponBuilds.modeBadge("TC")).toHaveCount(3); - await weaponBuilds.deleteFilter(); + await weaponBuilds.deleteFilter("mode"); await expect(weaponBuilds.locators.buildCards.first()).toBeVisible(); await weaponBuilds.addFilter("date"); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 05943393c..f3196928d 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -38,9 +38,7 @@ test.describe("Calendar", () => { const calendar = new CalendarPage(page); await calendar.goto(); - const filters = await calendar.openFilters(); - await filters.form.check("isSendou"); - await filters.apply(); + await calendar.toggleEventTypeFilter("isSendou"); await expect(calendar.locators.tournamentCards).toHaveCount( SENDOU_INK_TOURNAMENTS_COUNT, @@ -77,9 +75,8 @@ test.describe("Calendar", () => { await isNotVisible(calendar.locators.hiddenEventsButtons); - const filters = await calendar.openFilters(); - await filters.form.check("isRanked"); - await filters.applyAndMakeDefault(); + await calendar.toggleEventTypeFilter("isRanked"); + await calendar.saveFiltersAsDefault(); await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible(); @@ -87,6 +84,15 @@ test.describe("Calendar", () => { // remembers selection via user preferences await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible(); + + await calendar.removeEventTypeFilter(); + + // removing the filter sticks instead of falling back to the saved default + await isNotVisible(calendar.locators.hiddenEventsButtons); + + await calendar.reload(); + + await isNotVisible(calendar.locators.hiddenEventsButtons); }); test("navigates view more buttons", async ({ page }) => { diff --git a/e2e/pages/builds/weapon-builds-page.ts b/e2e/pages/builds/weapon-builds-page.ts index 605beb4ff..f91edfcf6 100644 --- a/e2e/pages/builds/weapon-builds-page.ts +++ b/e2e/pages/builds/weapon-builds-page.ts @@ -13,7 +13,6 @@ export class WeaponBuildsPage { this.locators = { buildCards: page.getByTestId("build-card"), addFilterButton: page.getByTestId("add-filter-button"), - deleteFilterButton: page.getByTestId("delete-filter-button"), comparisonSelect: page.getByTestId("comparison-select"), dateSelect: page.getByTestId("date-select"), dateInput: page.getByTestId("date-input"), @@ -41,11 +40,23 @@ export class WeaponBuildsPage { } async addFilter(type: "ability" | "mode" | "date") { + await this.page.keyboard.press("Escape"); + await this.locators.addFilterButton.click(); await this.page.getByTestId(`menu-item-${type}`).click(); + + if (type === "ability") { + await this.page.getByTestId("add-ability-condition").click(); + } } - async deleteFilter() { - await this.locators.deleteFilterButton.click(); + async deleteFilter(type: "ability" | "mode" | "date") { + if (type === "ability") { + await this.page.getByTestId("delete-ability-condition").click(); + return; + } + + await this.page.keyboard.press("Escape"); + await this.page.getByTestId(`${type}-remove`).click(); } } diff --git a/e2e/pages/calendar/calendar-page.ts b/e2e/pages/calendar/calendar-page.ts index e40f81a10..ab8558b0a 100644 --- a/e2e/pages/calendar/calendar-page.ts +++ b/e2e/pages/calendar/calendar-page.ts @@ -1,12 +1,10 @@ import type { Page } from "@playwright/test"; -import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas"; import { calendarPage } from "~/utils/urls"; import { expectIsHydrated, navigate, waitForPOSTResponse, } from "../../helpers/playwright"; -import { createFormHelpers } from "../../helpers/playwright-form"; /** `/calendar` */ export class CalendarPage { @@ -20,7 +18,11 @@ export class CalendarPage { hiddenEventsButtons: page.getByTestId("hidden-events-button"), clockHeaderTimes: page.getByTestId("clock-header-time"), todayHeader: page.getByTestId("today-header"), - filterEventsButton: page.getByTestId("filter-events-button"), + eventTypeFilterPill: page.getByTestId("event-type-filter"), + addFilterButton: page.getByTestId("add-filter-button"), + saveFiltersAsDefaultButton: page.getByTestId( + "save-filters-as-default-button", + ), navigateButtons: page.getByTestId("calendar-navigate-button"), }; } @@ -46,9 +48,42 @@ export class CalendarPage { await expectIsHydrated(this.page); } - async openFilters() { - await this.locators.filterEventsButton.click(); - return new CalendarFiltersDialog(this.page); + /** Toggles one of the switches inside the "Event type" filter pill's popover. */ + async toggleEventTypeFilter(name: "isSendou" | "isRanked") { + await this.page.keyboard.press("Escape"); + await this.openEventTypeFilter(); + await this.page + .getByText( + name === "isSendou" + ? "Only events hosted on sendou.ink" + : "Only ranked events", + ) + .click(); + await this.page.keyboard.press("Escape"); + } + + /** Resets the "Event type" filter pill's filters, hiding the pill. */ + async removeEventTypeFilter() { + await this.page.keyboard.press("Escape"); + await this.page.getByTestId("event-type-filter-remove").click(); + } + + /** The pill is only rendered while its filters differ from the defaults. */ + private async openEventTypeFilter() { + if (await this.locators.eventTypeFilterPill.isVisible()) { + await this.locators.eventTypeFilterPill.click(); + return; + } + + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-event-type-filter").click(); + } + + /** Persists the current filters as the user's default. */ + async saveFiltersAsDefault() { + await waitForPOSTResponse(this.page, () => + this.locators.saveFiltersAsDefaultButton.click(), + ); } /** Shows or hides the events the current filters hide, of the first time slot. */ @@ -64,31 +99,3 @@ export class CalendarPage { await this.locators.navigateButtons.nth(1).click(); } } - -class CalendarFiltersDialog { - private readonly page: Page; - readonly form; - readonly locators; - - constructor(page: Page) { - this.page = page; - this.form = createFormHelpers(page, calendarFiltersFormSchema); - this.locators = { - applyAndMakeDefaultButton: page.getByRole("button", { - name: "Apply & make default", - }), - }; - } - - /** Applies the filters for this visit only, via search params. */ - async apply() { - await this.form.submit(); - } - - /** Applies the filters and saves them as the user's default. */ - async applyAndMakeDefault() { - await waitForPOSTResponse(this.page, () => - this.locators.applyAndMakeDefaultButton.click(), - ); - } -} diff --git a/e2e/pages/lfg/lfg-page.ts b/e2e/pages/lfg/lfg-page.ts index 94c1bb3d2..a02b7ac0a 100644 --- a/e2e/pages/lfg/lfg-page.ts +++ b/e2e/pages/lfg/lfg-page.ts @@ -13,7 +13,9 @@ export class LFGPage { this.page = page; this.locators = { addFilterButton: page.getByTestId("add-filter-button"), - languageFilterSelect: page.getByLabel("Spoken language"), + languageFilterSelect: page.getByLabel("Spoken language", { + exact: true, + }), }; } diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts index 4125938be..fd3150f6f 100644 --- a/e2e/pages/scrims/scrims-page.ts +++ b/e2e/pages/scrims/scrims-page.ts @@ -2,10 +2,12 @@ import type { Page } from "@playwright/test"; import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas"; import { scrimsPage } from "~/utils/urls"; import { + expectIsHydrated, modalClickConfirmButton, navigate, selectUser, submit, + waitForPOSTResponse, } from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; import { AssociationsPage } from "../associations/associations-page"; @@ -38,6 +40,11 @@ export class ScrimsPage { limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"), tournamentPopover: page.getByTestId("tournament-popover-trigger"), canceledLabel: page.getByText("Canceled"), + divsFilterPill: page.getByTestId("divs-filter"), + addFilterButton: page.getByTestId("add-filter-button"), + saveFiltersAsDefaultButton: page.getByTestId( + "save-filters-as-default-button", + ), }; } @@ -45,10 +52,48 @@ export class ScrimsPage { await navigate({ page: this.page, url: scrimsPage() }); } + async reload() { + await this.page.reload(); + await expectIsHydrated(this.page); + } + post(text: string) { return this.page.getByText(text); } + /** Sets both selects of the "Divs" filter pill's popover. */ + async filterByDivs({ max, min }: { max: string; min: string }) { + await this.page.keyboard.press("Escape"); + await this.openDivsFilter(); + await this.page.getByLabel("Max div").selectOption(max); + await this.page.getByLabel("Min div").selectOption(min); + await this.page.keyboard.press("Escape"); + } + + /** Resets the "Divs" filter, hiding the pill. */ + async removeDivsFilter() { + await this.page.keyboard.press("Escape"); + await this.page.getByTestId("divs-filter-remove").click(); + } + + /** Persists the current filters as the user's default. */ + async saveFiltersAsDefault() { + await waitForPOSTResponse(this.page, () => + this.locators.saveFiltersAsDefaultButton.click(), + ); + } + + /** The pill is only rendered while its filter differs from the default. */ + private async openDivsFilter() { + if (await this.locators.divsFilterPill.isVisible()) { + await this.locators.divsFilterPill.click(); + return; + } + + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-divs-filter").click(); + } + async openTab(tab: Tab) { await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click(); } diff --git a/e2e/pages/vods/vods-page.ts b/e2e/pages/vods/vods-page.ts index 8defe882c..c14a4a9e7 100644 --- a/e2e/pages/vods/vods-page.ts +++ b/e2e/pages/vods/vods-page.ts @@ -11,6 +11,7 @@ export class VodsPage { this.page = page; this.locators = { noVodsText: this.page.getByText(/No videos found matching this filter/), + addFilterButton: page.getByTestId("add-filter-button"), }; } @@ -24,6 +25,8 @@ export class VodsPage { } async filterByWeapon(weaponName: string) { + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-vods-weapon-filter").click(); await selectWeapon({ page: this.page, name: weaponName }); } } diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 909c09e5d..062d46aab 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,6 +1,7 @@ import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { serializeLutiDiv } from "~/features/scrims/scrims-utils"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { toDBBoolean } from "~/utils/sql"; @@ -86,6 +87,46 @@ test.describe("Scrims", () => { await expect(scrims.locators.requestButtons).toHaveCount(2); }); + test("filters by div and sets the filter as default", async ({ + page, + factories, + }) => { + await factories.ScrimPostFactory.create({ + users: await createGroup(factories), + maxDiv: serializeLutiDiv("1"), + minDiv: serializeLutiDiv("2"), + }); + + await impersonate(page, NZAP_TEST_ID); + + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); + + await expect(scrims.locators.requestButtons).toHaveCount(1); + + // a div range the post's own range falls outside of + await scrims.filterByDivs({ max: "5", min: "6" }); + + await expect(scrims.locators.requestButtons).toHaveCount(0); + + await scrims.saveFiltersAsDefault(); + await scrims.goto(); + await scrims.openTab("available"); + + // remembers selection via user preferences + await expect(scrims.locators.requestButtons).toHaveCount(0); + + await scrims.removeDivsFilter(); + + // removing the filter sticks instead of falling back to the saved default + await expect(scrims.locators.requestButtons).toHaveCount(1); + + await scrims.reload(); + + await expect(scrims.locators.requestButtons).toHaveCount(1); + }); + test("accepts a request", async ({ page, factories }) => { await createPostWithRequest(factories, { ownerUserId: ADMIN_ID }); diff --git a/locales/da/builds.json b/locales/da/builds.json index 9475bb138..69dbe459d 100644 --- a/locales/da/builds.json +++ b/locales/da/builds.json @@ -21,17 +21,14 @@ "stats.all": "Alle", "stats.public": "", "stats.private": "", - "addFilter": "Tilføj filter", "linkButton.abilityStats": "Egenskabsstatistikker", "linkButton.popularBuilds": "Populære sæt", "noPopularBuilds": "Der er på nuværende tidspunkt ingen populære sæt for det valgte våben.", "emptyAbilitySlot": "Tomt egenskabsfelt", - "filters.type.ability": "Efter egenskab", - "filters.type.mode": "Efter spiltilstand", - "filters.type.date": "Efter dato", - "filters.ability.title": "Egenskabsfilter", - "filters.mode.title": "Spiltilstandsfilter", - "filters.date.title": "Datofilter", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Indeholder", "filters.does.not.have": "Indeholder ikke", "filters.atLeast": "Mindst", diff --git a/locales/da/calendar.json b/locales/da/calendar.json index 0581b40ba..ff0b0f8a4 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Salmon Run begivenhed.", "tag.desc.CARDS": "Tableturf Battle begivenhed.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/da/common.json b/locales/da/common.json index ffe1b03cc..acdcd693e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Bliv medlem", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 91e803569..4f2509fdc 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Testkamp", "vodTypes.MATCHMAKING": "Anarki/X-kamp/Turf-war", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/da/lfg.json b/locales/da/lfg.json index 89a1f2ace..f14ec0ece 100644 --- a/locales/da/lfg.json +++ b/locales/da/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "sidst aktiv", "noPosts": "Ingen opslag passer dette filter", "expiring": "Opslag er ved at udløbe, stadigvæk interresteret?", - "addFilter": "", "filters.Weapon": "Våbenpulje", "filters.Type": "Opslagstype", "filters.Timezone": "Tidszoneforskel", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus tier", "filters.MaxTier": "Max tier", "filters.MinTier": "Min tier", - "filters.suffix": "filter", "filters.orAbove": "Eller over", "new.noMorePosts": "Du kan ikke lave flere opslag", "new.type.header": "Type", diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/da/user.json b/locales/da/user.json index f908c7e83..ad22d513e 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -148,7 +148,6 @@ "sens": "Følsomhed", "usesPronouns": "", "discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ for yderligere information.", - "results.title": "Alle resultater", "results.placing": "Placering", "results.team": "Hold", "results.tournament": "Turnering", @@ -159,9 +158,26 @@ "results.highlights": "Højdepunkter", "results.highlights.choose": "Vælg højdepunkter", "results.highlights.explanation": "Vælg de resultater, som du vil fremhæve", - "results.button.showHighlights": "Vis højdepunkter", - "results.button.showAll": "Vis alt", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maks antal våben nået", "search.info": "", "search.noResults": "Søgningen ’{{query}}’ fandt ingen brugere", diff --git a/locales/de/builds.json b/locales/de/builds.json index 837961634..ef4242623 100644 --- a/locales/de/builds.json +++ b/locales/de/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/de/calendar.json b/locales/de/calendar.json index d091720cb..8f905c6d0 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Es wird Salmon Run gespielt.", "tag.desc.CARDS": "Es wird Revierdecks gespielt.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/de/common.json b/locales/de/common.json index b17680f8a..804686334 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Beitreten", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 1b0d41f4c..933a4ae87 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/X Kampf/Revierkampf", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/de/lfg.json b/locales/de/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/de/lfg.json +++ b/locales/de/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 38bb9b2b7..5b138c980 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/de/user.json b/locales/de/user.json index 842bbdbb7..c7e75ac15 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -148,7 +148,6 @@ "sens": "Empfindlichkeit", "usesPronouns": "", "discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ.", - "results.title": "", "results.placing": "Platzierung", "results.team": "Team", "results.tournament": "Turnier", @@ -159,9 +158,26 @@ "results.highlights": "Highlights", "results.highlights.choose": "Highlights wählen", "results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maximale Zahl an Waffen erreicht", "search.info": "", "search.noResults": "Keine Nutzer gefunden, die '{{query}}' entsprechen", diff --git a/locales/en/builds.json b/locales/en/builds.json index f08407ffe..2fb462035 100644 --- a/locales/en/builds.json +++ b/locales/en/builds.json @@ -21,17 +21,14 @@ "stats.all": "All", "stats.public": "Public", "stats.private": "Private", - "addFilter": "Add filter", "linkButton.abilityStats": "Ability stats", "linkButton.popularBuilds": "Popular builds", "noPopularBuilds": "It seems there are no popular builds for this weapon at this moment.", "emptyAbilitySlot": "Empty ability slot", - "filters.type.ability": "By ability", - "filters.type.mode": "By mode", - "filters.type.date": "By date", - "filters.ability.title": "Ability filter", - "filters.mode.title": "Mode filter", - "filters.date.title": "Date filter", + "filters.abilities": "Abilities", + "filters.mode": "Mode", + "filters.date": "Date", + "filters.addAbility": "Add ability", "filters.has": "Has", "filters.does.not.have": "Doesn't have", "filters.atLeast": "At least", diff --git a/locales/en/calendar.json b/locales/en/calendar.json index 249812045..fa8c84773 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Salmon Run event.", "tag.desc.CARDS": "Tableturf Battle event.", "icalFeed": "iCal", - "filter.button": "Filter", - "filter.heading": "Filter calendar events", "filter.modes": "Modes", "filter.exactModes": "Exact modes", - "filter.exactModesBottom": "Only show events that match all selected modes", "filter.games": "Games", "filter.vs": "Vs.", "filter.vs.4v4": "4v4", @@ -67,11 +64,18 @@ "filter.isSendou": "Only events hosted on sendou.ink", "filter.isRanked": "Only ranked events", "filter.minTeamCount": "Minimum team count", + "filter.minTier": "Highest tier", + "filter.maxTier": "Lowest tier", "filter.orgsIncluded": "Visible organizations", "filter.orgsExcluded": "Hidden organizations", "filter.authorIdsExcluded": "Authors excluded", - "filter.apply": "Apply", - "filter.applyAndDefault": "Apply & make default", + "filterBar.eventType": "Event type", + "filterBar.tier": "Tier", + "filterBar.tags": "Tags", + "filterBar.organizers": "Organizers", + "filterBar.timeAndSize": "Time & size", + "filterBar.sendou": "sendou.ink", + "filterBar.ranked": "Ranked", "forms.draft": "Draft", "forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.", "forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.", diff --git a/locales/en/common.json b/locales/en/common.json index 48fd66587..f48789227 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Join", + "filterBar.addFilter": "Filter", + "filterBar.saveAsDefault": "Save as default", "imageExport.export": "Export image", "imageExport.download": "Download", "imageExport.theme.light": "Light", diff --git a/locales/en/forms.json b/locales/en/forms.json index aed447b43..4e7bc63f2 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "Can not be set if looking for scrim now", "errors.maxAssociationsReached": "You have reached the maximum number of associations", "labels.weekdayTimes": "Weekday times", - "labels.weekendTimes": "Weekend times", "labels.start": "Start", "labels.end": "End", "labels.member": "Member", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "Exact modes", - "bottomTexts.modesExact": "Only show events that match all selected modes", - "labels.games": "Games", - "labels.vs": "Vs.", "labels.startTime": "Start time", - "labels.tagsIncluded": "Tags included", - "labels.tagsExcluded": "Tags excluded", - "labels.onlySendouEvents": "Only events hosted on sendou.ink", - "labels.onlyRankedEvents": "Only ranked events", - "labels.minTeamCount": "Minimum team count", - "labels.orgsIncluded": "Visible organizations", - "labels.orgsExcluded": "Hidden organizations", - "labels.authorIdsExcluded": "Authors excluded", "bottomTexts.authorIdsExcluded": "You can find a user's id on their profile page", - "options.startTime.any": "Any", - "options.startTime.eu": "Europe friendly", - "options.startTime.na": "Americas friendly", - "options.startTime.au": "AU/NZ friendly", "options.game.S1": "Splatoon 1", "options.game.S2": "Splatoon 2", "options.game.S3": "Splatoon 3", diff --git a/locales/en/lfg.json b/locales/en/lfg.json index fc156ef11..360dab290 100644 --- a/locales/en/lfg.json +++ b/locales/en/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "updated", "noPosts": "No posts matching the filter", "expiring": "Post is expiring. Still looking?", - "addFilter": "Add filter", "filters.Weapon": "Weapon pool", "filters.Type": "Post type", "filters.Timezone": "Timezone hour difference", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus tier", "filters.MaxTier": "Max tier", "filters.MinTier": "Min tier", - "filters.suffix": "filter", "filters.orAbove": "or above", "new.noMorePosts": "You can't create any more posts", "new.type.header": "Type", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index f9f8616f3..b2d76223a 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "Start time", "requestModal.at.explanation": "Select a time within the post's time range", "pickupBy": "Pickup by", - "filters.button": "Filters", - "filters.heading": "Scrim Filters", "filters.weekdayTimes": "Weekday times", - "filters.weekdayStart": "Weekday start", - "filters.weekdayEnd": "Weekday end", "filters.weekendTimes": "Weekend times", - "filters.weekendStart": "Weekend start", - "filters.weekendEnd": "Weekend end", - "filters.apply": "Apply", - "filters.applyAndDefault": "Apply & Set as Default", + "filters.divs": "Divs", "filters.showFiltered": "Show filtered ({{count}})", "filters.hideFiltered": "Hide filtered ({{count}})", "filters.showPendingRequests": "Show pending requests ({{count}})", diff --git a/locales/en/user.json b/locales/en/user.json index 1b4775ea1..1d5f9ce56 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "Uses", "discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ for more information.", - "results.title": "All results", "results.placing": "Placing", "results.team": "Team", "results.tournament": "Tournament", @@ -159,9 +158,26 @@ "results.highlights": "Highlights", "results.highlights.choose": "Choose highlights", "results.highlights.explanation": "Select the results you want to highlight", - "results.button.showHighlights": "Show highlights", - "results.button.showAll": "Show all", - "results.filter.placeholder": "Filter by tournament", + "results.filter.only": "Only", + "results.filter.highlightsOnly": "Only highlighted results", + "results.filter.tournament": "Tournament", + "results.filter.team": "Team", + "results.filter.mate": "Teammate", + "results.filter.tier": "Tier", + "results.filter.tier.min": "Highest tier", + "results.filter.tier.max": "Lowest tier", + "results.filter.placement": "Placement", + "results.filter.placement.first": "Winner", + "results.filter.placement.top": "Top {{count}}", + "results.filter.years": "Years", + "results.filter.years.from": "From", + "results.filter.years.to": "To", + "results.filter.source": "Source", + "results.filter.source.ALL": "All", + "results.filter.source.SENDOU": "Hosted on sendou.ink", + "results.filter.source.EXTERNAL": "Reported results", + "results.filter.size": "Size", + "results.filter.size.min": "Minimum teams", "forms.errors.maxWeapons": "Max weapon count reached", "search.info": "Search for users by Discord or Splatoon 3 name", "search.noResults": "No users found matching '{{query}}'", diff --git a/locales/es-ES/builds.json b/locales/es-ES/builds.json index ef543d4ad..d82029361 100644 --- a/locales/es-ES/builds.json +++ b/locales/es-ES/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "Público", "stats.private": "Privado", - "addFilter": "Añadir filtro", "linkButton.abilityStats": "Estadísticas de potenciadores", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.", "emptyAbilitySlot": "Espacio de potenciador vacío", - "filters.type.ability": "Por potenciador", - "filters.type.mode": "Por estilo", - "filters.type.date": "Por fecha", - "filters.ability.title": "Filtro por potenciador", - "filters.mode.title": "Filtro por estilo", - "filters.date.title": "Filtro por fecha", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tiene", "filters.does.not.have": "No tiene", "filters.atLeast": "Al menos", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index ba063531a..7eabeee22 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run", "tag.desc.CARDS": "Evento de Lucha carterritorial", "icalFeed": "iCal", - "filter.button": "Filtrar", - "filter.heading": "Filtrar eventos del calendario", "filter.modes": "Modos", "filter.exactModes": "Modos exactos", - "filter.exactModesBottom": "Mostrar solo eventos que coincidan con todos los modos seleccionados", "filter.games": "Juegos", "filter.vs": "Vs.", "filter.vs.4v4": "4v4", @@ -69,11 +66,18 @@ "filter.isSendou": "Solo eventos alojados en sendou.ink", "filter.isRanked": "Solo eventos clasificados", "filter.minTeamCount": "Mínimo de equipos", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "Organizaciones visibles", "filter.orgsExcluded": "Organizaciones ocultas", "filter.authorIdsExcluded": "Autores excluidos", - "filter.apply": "Aplicar", - "filter.applyAndDefault": "Aplicar y establecer por defecto", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "Borrador", "forms.draftInfo": "Los torneos en borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de que pueda iniciarse cualquier cuadro.", "forms.draftBracketStartBlocked": "El torneo está en modo borrador. Edita el torneo y desactiva la opción de borrador antes de iniciar el cuadro.", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 84b97bc04..db0728568 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Con borde", "actions.noOutline": "Sin borde", "actions.join": "Unirse", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "Exportar imagen", "imageExport.download": "Descargar", "imageExport.theme.light": "Claro", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 8440a5803..bde7f6bdd 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "No se puede establecer si se está buscando scrim ahora", "errors.maxAssociationsReached": "Has alcanzado el número máximo de asociaciones", "labels.weekdayTimes": "Horarios entre semana", - "labels.weekendTimes": "Horarios de fin de semana", "labels.start": "Inicio", "labels.end": "Fin", "labels.member": "Miembro", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Práctica", "vodTypes.MATCHMAKING": "Combate caótico/X/territorial", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "Modos exactos", - "bottomTexts.modesExact": "Mostrar solo eventos que coincidan con todos los modos seleccionados", - "labels.games": "Partidas", - "labels.vs": "Vs.", "labels.startTime": "Hora de inicio", - "labels.tagsIncluded": "Etiquetas incluidas", - "labels.tagsExcluded": "Etiquetas excluidas", - "labels.onlySendouEvents": "Solo eventos organizados en sendou.ink", - "labels.onlyRankedEvents": "Solo eventos competitivos", - "labels.minTeamCount": "Mínimo de equipos", - "labels.orgsIncluded": "Organizaciones visibles", - "labels.orgsExcluded": "Organizaciones ocultas", - "labels.authorIdsExcluded": "Autores excluidos", "bottomTexts.authorIdsExcluded": "Puedes encontrar el ID de un usuario en su página de perfil", - "options.startTime.any": "Cualquiera", - "options.startTime.eu": "Horario europeo", - "options.startTime.na": "Horario americano", - "options.startTime.au": "Horario AU/NZ", "options.game.S1": "Splatoon 1", "options.game.S2": "Splatoon 2", "options.game.S3": "Splatoon 3", diff --git a/locales/es-ES/lfg.json b/locales/es-ES/lfg.json index 39b6e1fd5..889925839 100644 --- a/locales/es-ES/lfg.json +++ b/locales/es-ES/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "hace", "noPosts": "Sin publicaciones que coincidan con el filtro", "expiring": "La publicación caduca. ¿Sigues buscando?", - "addFilter": "Añadir filtro", "filters.Weapon": "Grupo de armas", "filters.Type": "Tipo de publicación", "filters.Timezone": "Diferencia horaria", @@ -16,7 +15,6 @@ "filters.PlusTier": "Nivel Plus", "filters.MaxTier": "Nivel máximo", "filters.MinTier": "Nivel mínimo", - "filters.suffix": "filtro", "filters.orAbove": "o más", "new.noMorePosts": "No puedes crear más publicaciones", "new.type.header": "Tipo", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index afba148f0..c787c0bb6 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "Hora de inicio", "requestModal.at.explanation": "Selecciona una hora dentro del rango de tiempo de la publicación", "pickupBy": "Pickup de", - "filters.button": "Filtros", - "filters.heading": "Filtros de scrims", "filters.weekdayTimes": "Horarios entre semana", - "filters.weekdayStart": "Inicio entre semana", - "filters.weekdayEnd": "Fin entre semana", "filters.weekendTimes": "Horarios de fin de semana", - "filters.weekendStart": "Inicio de fin de semana", - "filters.weekendEnd": "Fin de fin de semana", - "filters.apply": "Aplicar", - "filters.applyAndDefault": "Aplicar y establecer por defecto", + "filters.divs": "", "filters.showFiltered": "Mostrar filtrados ({{count}})", "filters.hideFiltered": "Ocultar filtrados ({{count}})", "filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 18f18f0e8..ccc7945ce 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -148,7 +148,6 @@ "sens": "Sensibilidad", "usesPronouns": "Usa", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", - "results.title": "Todos los resultados", "results.placing": "Lugar", "results.team": "Equipo", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Resaltos", "results.highlights.choose": "Elegir resaltos", "results.highlights.explanation": "Elige los resultados que quieres resaltar", - "results.button.showHighlights": "Mostrar resaltados", - "results.button.showAll": "Mostrar todos", - "results.filter.placeholder": "Filtrar por torneo", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Máxima cantidad de armas", "search.info": "Busca usuarios por su nombre de Discord o de Splatoon 3", "search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'", diff --git a/locales/es-US/builds.json b/locales/es-US/builds.json index e89d6b6cf..a0c6bb373 100644 --- a/locales/es-US/builds.json +++ b/locales/es-US/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "Público", "stats.private": "Privado", - "addFilter": "Añadir filtro", "linkButton.abilityStats": "Estadísticas de potenciadores", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.", "emptyAbilitySlot": "Espacio de potenciador vacío", - "filters.type.ability": "Por potenciador", - "filters.type.mode": "Por estilo", - "filters.type.date": "Por fecha", - "filters.ability.title": "Filtro por potenciador", - "filters.mode.title": "Filtro por estilo", - "filters.date.title": "Filtro por fecha", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tiene", "filters.does.not.have": "No tiene", "filters.atLeast": "Al menos", diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 826032e49..073db546a 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run", "tag.desc.CARDS": "Evento de Combate carterritorial", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b32a8685e..d7e3ad1ac 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Unirse", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index f71b8c436..7ba613fd4 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Práctica", "vodTypes.MATCHMAKING": "Combate caótico/X/territorial", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/es-US/lfg.json b/locales/es-US/lfg.json index 8406b1836..69cba2e42 100644 --- a/locales/es-US/lfg.json +++ b/locales/es-US/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "hace", "noPosts": "No posts matching the filter", "expiring": "La publicación caduca. ¿Sigues buscando?", - "addFilter": "", "filters.Weapon": "Grupo de armas", "filters.Type": "Tipo de publicación", "filters.Timezone": "Diferencia horaria", @@ -16,7 +15,6 @@ "filters.PlusTier": "Nivel Plus", "filters.MaxTier": "Nivel máximo", "filters.MinTier": "Nivel mínimo", - "filters.suffix": "filtro", "filters.orAbove": "o más", "new.noMorePosts": "No puede crear más publicaciones", "new.type.header": "Tipo", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 664981f95..94029ba71 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", - "results.title": "Todos los resultados", "results.placing": "Lugar", "results.team": "Equipo", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Resaltos", "results.highlights.choose": "Elegir resaltos", "results.highlights.explanation": "Elige los resultados que quieres resaltar", - "results.button.showHighlights": "Mostrar resaltos", - "results.button.showAll": "Mostrar todos", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Máxima cantidad de armas", "search.info": "", "search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'", diff --git a/locales/fr-CA/builds.json b/locales/fr-CA/builds.json index 77da3ed25..5bd81a83b 100644 --- a/locales/fr-CA/builds.json +++ b/locales/fr-CA/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tous", "stats.public": "", "stats.private": "", - "addFilter": "Ajouter un filtre", "linkButton.abilityStats": "Statistiques", "linkButton.popularBuilds": "Sets populaires", "noPopularBuilds": "Il semble qu'il n'y ait pas de sets populaires pour cette arme en ce moment.", "emptyAbilitySlot": "Emplacement de bonus vide", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Avec", "filters.does.not.have": "Sans", "filters.atLeast": "Au moins", diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index 9eaea9e94..8fbce9bd7 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Événement Salmon Run.", "tag.desc.CARDS": "Événement Cartes & Territoire.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 45afff4cb..1643a94dd 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Joindre", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 014baff1d..08e46827c 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/Match X/Guerre de Territoire", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/fr-CA/lfg.json b/locales/fr-CA/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/fr-CA/lfg.json +++ b/locales/fr-CA/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index da2712757..07f9ef0c0 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", - "results.title": "", "results.placing": "Placement", "results.team": "Équipe", "results.tournament": "Tournoi", @@ -159,9 +158,26 @@ "results.highlights": "Résultats notables", "results.highlights.choose": "Choisir vos résultats notables", "results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Nombre d'armes maximum atteint", "search.info": "", "search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé", diff --git a/locales/fr-EU/builds.json b/locales/fr-EU/builds.json index 8ed81e18f..29f896f4b 100644 --- a/locales/fr-EU/builds.json +++ b/locales/fr-EU/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tous", "stats.public": "Publique", "stats.private": "Privé", - "addFilter": "Ajouter un filtre", "linkButton.abilityStats": "Statistiques", "linkButton.popularBuilds": "Sets populaires", "noPopularBuilds": "Il semble qu'il n'y ait pas de sets populaires pour cette arme en ce moment.", "emptyAbilitySlot": "Emplacement de bonus vide", - "filters.type.ability": "Par abilité", - "filters.type.mode": "Par mode", - "filters.type.date": "Par date", - "filters.ability.title": "Filtre par abilité", - "filters.mode.title": "Filtre par mode", - "filters.date.title": "Filtre par date", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Avec", "filters.does.not.have": "Sans", "filters.atLeast": "Au moins", diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index 9eaea9e94..8fbce9bd7 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Événement Salmon Run.", "tag.desc.CARDS": "Événement Cartes & Territoire.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index ffaf2ed94..2a974fb35 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Joindre", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 97e9128bc..c30a31c0d 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/Match X/Guerre de Territoire", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/fr-EU/lfg.json b/locales/fr-EU/lfg.json index 1ce3b4432..3e4e8e883 100644 --- a/locales/fr-EU/lfg.json +++ b/locales/fr-EU/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "Mis à jour", "noPosts": "Aucun message correspondant au filtre", "expiring": "Le message expire. Tu cherches toujours ?", - "addFilter": "", "filters.Weapon": "Arme utilisé", "filters.Type": "Type de publication", "filters.Timezone": "Différence horaire entre les heures", @@ -16,7 +15,6 @@ "filters.PlusTier": "Niveau du Plus", "filters.MaxTier": "Niveau max", "filters.MinTier": "Niveau min", - "filters.suffix": "filtre", "filters.orAbove": "ou supérieur", "new.noMorePosts": "Vous ne pouvez pas créer plus de posts", "new.type.header": "Type", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 5d38dcc71..289fff284 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 80a1346f2..36c72b99e 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", - "results.title": "Tout les résultats", "results.placing": "Placement", "results.team": "Équipe", "results.tournament": "Tournoi", @@ -159,9 +158,26 @@ "results.highlights": "Résultats notables", "results.highlights.choose": "Choisir vos résultats notables", "results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant", - "results.button.showHighlights": "Montrer les highlights", - "results.button.showAll": "Tout montrer", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Nombre d'armes maximum atteint", "search.info": "Recherchez avec le pseudo Discord ou Splatoon 3 du compte", "search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé", diff --git a/locales/he/builds.json b/locales/he/builds.json index a46fc2193..1ae4ef17a 100644 --- a/locales/he/builds.json +++ b/locales/he/builds.json @@ -21,17 +21,14 @@ "stats.all": "הכל", "stats.public": "ציבורי", "stats.private": "פרטי", - "addFilter": "הוספת מסנן", "linkButton.abilityStats": "נתונים סטטיסטיים על היכולות", "linkButton.popularBuilds": "ערכות פופולריות", "noPopularBuilds": "נראה שאין ערכות פופולריות לנשק זה כרגע.", "emptyAbilitySlot": "תא עם יכולת ריקה", - "filters.type.ability": "לפי יכולת", - "filters.type.mode": "לפי מוד", - "filters.type.date": "לפי תאריך", - "filters.ability.title": "מסנן לפי יכולת", - "filters.mode.title": "מסנן לפי מוד", - "filters.date.title": "מסנן לפי תאריך", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "יש", "filters.does.not.have": "אין", "filters.atLeast": "לפחות", diff --git a/locales/he/calendar.json b/locales/he/calendar.json index bb9555995..da78a0b78 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "אירוע Salmon Run.", "tag.desc.CARDS": "אירוע Tableturf Battle.", "icalFeed": "iCal", - "filter.button": "מסנן", - "filter.heading": "מסנן אירועי לוח שנה", "filter.modes": "מודים", "filter.exactModes": "מודים ספציפיים", - "filter.exactModesBottom": "הראה רק אירועים שתואמים את כל המודים שנבחרו", "filter.games": "משחקים", "filter.vs": "נגד", "filter.vs.4v4": "4 נגד 4", @@ -69,11 +66,18 @@ "filter.isSendou": "רק אירועים המתארחים ב-sendou.ink", "filter.isRanked": "רק אירועים תחרותיים", "filter.minTeamCount": "מספר צוותים מינימלי", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "ארגונים גלויים", "filter.orgsExcluded": "ארגונים מוסתרים", "filter.authorIdsExcluded": "מחברים לא נכללו", - "filter.apply": "החל", - "filter.applyAndDefault": "החל והפוך לברירת מחדל", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/he/common.json b/locales/he/common.json index e4ed65238..2568baecd 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "הצטרפות", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 7e02a45af..17cf14288 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "משחק ידידות", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/he/lfg.json b/locales/he/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/he/lfg.json +++ b/locales/he/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/he/user.json b/locales/he/user.json index c1c016fdd..a37c9ec43 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -148,7 +148,6 @@ "sens": "רגישות", "usesPronouns": "", "discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות למידע נוסף.", - "results.title": "", "results.placing": "מיקום", "results.team": "צוות", "results.tournament": "טורניר", @@ -159,9 +158,26 @@ "results.highlights": "נקודות שיא", "results.highlights.choose": "בחרו נקודות שיא", "results.highlights.explanation": "בחרו את התוצאות שאתם רוצים להדגיש", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "הגעה לכמות מקסימלית של מספר נשקים", "search.info": "", "search.noResults": "לא נמצאו משתמשים התואמים '{{query}}'", diff --git a/locales/it/builds.json b/locales/it/builds.json index 557e012fa..5e680d581 100644 --- a/locales/it/builds.json +++ b/locales/it/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tutte", "stats.public": "Pubbliche", "stats.private": "Private", - "addFilter": "Aggiungi filtro", "linkButton.abilityStats": "Statistiche abilità", "linkButton.popularBuilds": "Build popolari", "noPopularBuilds": "Sembra che non ci siano build popolari per quest'arma in questo momento", "emptyAbilitySlot": "Slot abilità vuoto", - "filters.type.ability": "Per abilità", - "filters.type.mode": "Per modalità", - "filters.type.date": "Per data", - "filters.ability.title": "Filtro abilità", - "filters.mode.title": "Filtro modalità", - "filters.date.title": "Filtro data", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Ha", "filters.does.not.have": "Non ha", "filters.atLeast": "Almeno", diff --git a/locales/it/calendar.json b/locales/it/calendar.json index 42bf791f7..fb89f677b 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento Salmon Run.", "tag.desc.CARDS": "Evento Splattanza", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/it/common.json b/locales/it/common.json index a517dff84..875b8af3c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Contornato", "actions.noOutline": "Nessun contorno", "actions.join": "Entra", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index b77eef09d..0a6115297 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchiche/Partita X/Mischia Mollusca", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/it/lfg.json b/locales/it/lfg.json index 1e4e3bf66..e82907b8e 100644 --- a/locales/it/lfg.json +++ b/locales/it/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "aggiornato", "noPosts": "Nessun post per questo filtro", "expiring": "Il post sta scadendo. Stai ancora cercando?", - "addFilter": "", "filters.Weapon": "Pool di armi", "filters.Type": "Tipo di post", "filters.Timezone": "Differenza per fuso orario", @@ -16,7 +15,6 @@ "filters.PlusTier": "Tier Plus", "filters.MaxTier": "Tier massimo", "filters.MinTier": "Tier minimo", - "filters.suffix": "filter", "filters.orAbove": "o più", "new.noMorePosts": "Non puoi creare altri posts", "new.type.header": "Tipo", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/it/user.json b/locales/it/user.json index 8bd9658a2..81973904c 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -148,7 +148,6 @@ "sens": "Sens.", "usesPronouns": "", "discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ per ulteriori informazioni.", - "results.title": "Tutti i risultati", "results.placing": "Risultato", "results.team": "Team", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Highlight", "results.highlights.choose": "Scegli i tuoi highlight", "results.highlights.explanation": "Scegli il risultato che vuoi mettere come highlight", - "results.button.showHighlights": "Mostra highlight", - "results.button.showAll": "Mostra tutti", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Massimo numero di armi raggiunto", "search.info": "Cerca utenti tramite nome Discord o Splatoon 3", "search.noResults": "Nessun utente trovato per '{{query}}'", diff --git a/locales/ja/builds.json b/locales/ja/builds.json index 760faaad7..09fc65503 100644 --- a/locales/ja/builds.json +++ b/locales/ja/builds.json @@ -21,17 +21,14 @@ "stats.all": "すべて", "stats.public": "公開", "stats.private": "非公開", - "addFilter": "絞り込みを追加", "linkButton.abilityStats": "ギア統計", "linkButton.popularBuilds": "人気のあるギア構成", "noPopularBuilds": "現在、このブキに対する人気のギア構成はないようです。", "emptyAbilitySlot": "空欄", - "filters.type.ability": "ギアパワーで", - "filters.type.mode": "ルールで", - "filters.type.date": "日付で", - "filters.ability.title": "ギアパワーフィルター", - "filters.mode.title": "ルールフィルター", - "filters.date.title": "日付フィルター", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "含む", "filters.does.not.have": "含まない", "filters.atLeast": "最小", diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index 8a4576b20..2f70e6b11 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -44,11 +44,8 @@ "tag.desc.SR": "サーモンランイベント", "tag.desc.CARDS": "ナワバトラーイベント", "icalFeed": ".ical", - "filter.button": "フィルター", - "filter.heading": "表示するイベントをフィルター", "filter.modes": "ルール", "filter.exactModes": "選択されたルールと一致", - "filter.exactModesBottom": "選択されたルールと一致しているイベントのみ表示します", "filter.games": "ゲーム", "filter.vs": "人数", "filter.vs.4v4": "4対4", @@ -65,11 +62,18 @@ "filter.isSendou": "sendou.inkで開催されているイベントのみ", "filter.isRanked": "ランキングイベントのみ", "filter.minTeamCount": "最低参加チーム数", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "表示するイベント運営組織", "filter.orgsExcluded": "表示しないイベント運営組織", "filter.authorIdsExcluded": "表示しないイベント運営者", - "filter.apply": "フィルターをかける", - "filter.applyAndDefault": "フィルターをかけて基準にする", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "未完成", "forms.draftInfo": "未完成のイベントは表示されず、運営側以外は見えません。大会を開始するには、このオプションをオフにし、公開しなければなりません。", "forms.draftBracketStartBlocked": "このイベントは未完成です。大会を開始する前に、イベントの設定から未完成をオフにしてください。", diff --git a/locales/ja/common.json b/locales/ja/common.json index 2bc3333fb..1790b88d6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -149,6 +149,8 @@ "actions.outlined": "アウトラインあり", "actions.noOutline": "アウトラインなし", "actions.join": "参加する", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 828b9b158..cdb92de0a 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "対抗戦", "vodTypes.MATCHMAKING": "バンカラマッチ/X バトル/ナワバリバトル", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ja/lfg.json b/locales/ja/lfg.json index 75c7c758d..9084a81b3 100644 --- a/locales/ja/lfg.json +++ b/locales/ja/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "最後にログインした時", "noPosts": "フィルターに該当するポーストはありません", "expiring": "ポーストの期限が切れます。まだ探していますか?", - "addFilter": "", "filters.Weapon": "武器プール", "filters.Type": "ポーストの種類", "filters.Timezone": "タイムゾーン(何時間違うか)", @@ -16,7 +15,6 @@ "filters.PlusTier": "+ティア", "filters.MaxTier": "マックスティア", "filters.MinTier": "最小ティア", - "filters.suffix": "フィルター", "filters.orAbove": "より上", "new.noMorePosts": "これ以上ポーストは作れません", "new.type.header": "種類", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index 7fa933268..1eca6c4ef 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -148,7 +148,6 @@ "sens": "感度", "usesPronouns": "", "discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ をご覧ください。", - "results.title": "全ての結果", "results.placing": "順位", "results.team": "チーム", "results.tournament": "トーナメント", @@ -159,9 +158,26 @@ "results.highlights": "主な戦績", "results.highlights.choose": "戦績を選ぶ", "results.highlights.explanation": "戦績として選択したい結果を選ぶ", - "results.button.showHighlights": "ハイライトを表示", - "results.button.showAll": "全て表示", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "最大ブキ数を超えました", "search.info": "", "search.noResults": "該当ユーザーが見つかりません '{{query}}'", diff --git a/locales/ko/builds.json b/locales/ko/builds.json index b8ccbe91d..941a35a2b 100644 --- a/locales/ko/builds.json +++ b/locales/ko/builds.json @@ -21,17 +21,14 @@ "stats.all": "전체", "stats.public": "", "stats.private": "", - "addFilter": "필터 추가", "linkButton.abilityStats": "기어 파워 통계", "linkButton.popularBuilds": "인기 빌드", "noPopularBuilds": "이 무기에는 인기 빌드가 아직 없습니다.", "emptyAbilitySlot": "빈 기어 슬롯", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "포함", "filters.does.not.have": "미포함", "filters.atLeast": "최소", diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index fe8ba6771..aad8656c7 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -42,11 +42,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -63,11 +60,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index c30c56595..5c4c677c8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "참여하기", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index a53c67c77..a5a87aaca 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ko/lfg.json b/locales/ko/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/ko/lfg.json +++ b/locales/ko/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index b862f89c8..350b6310f 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -148,7 +148,6 @@ "sens": "", "usesPronouns": "", "discordExplanation": "", - "results.title": "", "results.placing": "순위", "results.team": "팀", "results.tournament": "대회", @@ -159,9 +158,26 @@ "results.highlights": "", "results.highlights.choose": "", "results.highlights.explanation": "", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/nl/builds.json b/locales/nl/builds.json index 3fd9cdcb3..bd2ec603f 100644 --- a/locales/nl/builds.json +++ b/locales/nl/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index f36b8782e..15dca99f4 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index ba058e330..18a312c5c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 02af876a1..8ac90b831 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/nl/lfg.json b/locales/nl/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/nl/lfg.json +++ b/locales/nl/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 974ecd494..6de20999e 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -148,7 +148,6 @@ "sens": "Gevoeligheid", "usesPronouns": "", "discordExplanation": "", - "results.title": "", "results.placing": "Plaatsing", "results.team": "Team", "results.tournament": "Toernooi", @@ -159,9 +158,26 @@ "results.highlights": "", "results.highlights.choose": "", "results.highlights.explanation": "", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/pl/builds.json b/locales/pl/builds.json index dd1c65a70..63a87a619 100644 --- a/locales/pl/builds.json +++ b/locales/pl/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 6f5f527ac..592196284 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -50,11 +50,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -71,11 +68,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 8e08b880a..8908a59a4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Dołącz", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index f7358e0e8..ecb7f5d79 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/pl/lfg.json b/locales/pl/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/pl/lfg.json +++ b/locales/pl/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 8fa45d203..4c08d66ad 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Nazwa, profilowe oraz połączone konta brane są z konta Discord. Zobacz <1>FAQ by dowiedzieć się więcej.", - "results.title": "", "results.placing": "Placing", "results.team": "Drużyna", "results.tournament": "Turniej", @@ -159,9 +158,26 @@ "results.highlights": "Wyróżnienia", "results.highlights.choose": "Wybierz wyróżnienia", "results.highlights.explanation": "Wybierz wyniki, które chcesz wyróżnić", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maksymalna ilość broni osiągnięta", "search.info": "", "search.noResults": "Nie znaleziono użytkownika o nazwie '{{query}}'", diff --git a/locales/pt-BR/builds.json b/locales/pt-BR/builds.json index 5b3c9a3c0..03362efad 100644 --- a/locales/pt-BR/builds.json +++ b/locales/pt-BR/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "", "stats.private": "", - "addFilter": "Adicionar filtro", "linkButton.abilityStats": "Estatísticas de habilidade", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que não existem builds populares para essa arma nesse momento.", "emptyAbilitySlot": "Espaço (Slot) de habilidade vazio", - "filters.type.ability": "Por habilidade", - "filters.type.mode": "Por modo", - "filters.type.date": "Por data", - "filters.ability.title": "Filtro de habilidade", - "filters.mode.title": "Filtro de modo", - "filters.date.title": "Filtro de data", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tem", "filters.does.not.have": "Não tem", "filters.atLeast": "Pelo menos", diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 9cb764f2c..7002feace 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run.", "tag.desc.CARDS": "Evento de Tableturf Battle.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index a1404b3fa..f729a7418 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Entrar", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index c80072ada..432f0bd1f 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/pt-BR/lfg.json b/locales/pt-BR/lfg.json index c9d84c193..ad1a11a21 100644 --- a/locales/pt-BR/lfg.json +++ b/locales/pt-BR/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "última atividade", "noPosts": "Não há postagens combinando com o filtro.", "expiring": "O prazo de validade da sua postagem está acabando. Você ainda está procurando?", - "addFilter": "", "filters.Weapon": "Pool de armas", "filters.Type": "Tipo de postagem", "filters.Timezone": "Diferença de horas pelo fuso horário", @@ -16,7 +15,6 @@ "filters.PlusTier": "Tier do Plus", "filters.MaxTier": "Tier Máxima", "filters.MinTier": "Tier Mínima", - "filters.suffix": "filtro", "filters.orAbove": "ou acima", "new.noMorePosts": "Você não pode criar mais postagens", "new.type.header": "Tipo", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index d09c981c4..1eb04b5ed 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Nome de usuário, foto de perfil, conta do YouTube, Bluesky e Twitch vêm da sua conta do Discord. Veja o <1>Perguntas Frequentes para mais informações.", - "results.title": "", "results.placing": "Classificação", "results.team": "Time", "results.tournament": "Torneio", @@ -159,9 +158,26 @@ "results.highlights": "Destaques", "results.highlights.choose": "Escolher Destaques", "results.highlights.explanation": "Escolha os resultados que você quer destacar", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Número máximo de armas no perfil atingido.", "search.info": "", "search.noResults": "Nenhum usuário encontrado com o termo '{{query}}'", diff --git a/locales/ru/builds.json b/locales/ru/builds.json index aac59a3dc..7e495c24f 100644 --- a/locales/ru/builds.json +++ b/locales/ru/builds.json @@ -21,17 +21,14 @@ "stats.all": "Все", "stats.public": "Публичные", "stats.private": "Приватные", - "addFilter": "Фильтры", "linkButton.abilityStats": "Статистика свойств", "linkButton.popularBuilds": "Популярные сборки", "noPopularBuilds": "На данный момент для этого оружия нет популярных сборок.", "emptyAbilitySlot": "Пустой слот", - "filters.type.ability": "По свойствам", - "filters.type.mode": "По режимам", - "filters.type.date": "По дате создания", - "filters.ability.title": "Фильтр свойств", - "filters.mode.title": "Фильтр режимов", - "filters.date.title": "Фильтр даты", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Имеет", "filters.does.not.have": "Не имеет", "filters.atLeast": "Минимум", diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index 8a28c7cb8..0f5995c84 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -50,11 +50,8 @@ "tag.desc.SR": "Событие по Salmon Run.", "tag.desc.CARDS": "Событие по \"Карты и район\".", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -71,11 +68,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 0ddd3162e..5184f7c20 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Обводка", "actions.noOutline": "Без обводки", "actions.join": "Присоединиться", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index aff20c7fc..39eb63860 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Скрим", "vodTypes.MATCHMAKING": "Стихийный бой/Бой Х/Бой за район", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ru/lfg.json b/locales/ru/lfg.json index 5dcf62149..985882b6d 100644 --- a/locales/ru/lfg.json +++ b/locales/ru/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "обновлено", "noPosts": "Посты, соответствующие фильтрам, не найдены", "expiring": "Срок действия поста истекает. Ещё ищете?", - "addFilter": "", "filters.Weapon": "Пул оружия", "filters.Type": "Тип поста", "filters.Timezone": "Разница в часовых поясах", @@ -16,7 +15,6 @@ "filters.PlusTier": "Уровень Plus", "filters.MaxTier": "Максимальный уровень", "filters.MinTier": "Минимальный уровень", - "filters.suffix": "фильтр", "filters.orAbove": "или выше", "new.noMorePosts": "Вы не можете создать больше постов", "new.type.header": "Тип", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index cb5d0140d..6105c6527 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -148,7 +148,6 @@ "sens": "Чувствительность", "usesPronouns": "", "discordExplanation": "Имя пользователя, аватар, ссылка на аккаунты YouTube, Bluesky и Twitch берутся из вашего аккаунта в Discord. Посмотрите <1>FAQ для дополнительной информации.", - "results.title": "Все результаты", "results.placing": "Место", "results.team": "Команда", "results.tournament": "Турнир", @@ -159,9 +158,26 @@ "results.highlights": "Избранное", "results.highlights.choose": "Выберите избранное", "results.highlights.explanation": "Выберите ваш избранный результат", - "results.button.showHighlights": "Показать избранные", - "results.button.showAll": "Показать все", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Достигнут максимум", "search.info": "Поиск пользователей по имени Discord или Splatoon 3", "search.noResults": "По запросу '{{query}}' пользователь не найден", diff --git a/locales/zh/builds.json b/locales/zh/builds.json index e1d9a079d..6ee8c1200 100644 --- a/locales/zh/builds.json +++ b/locales/zh/builds.json @@ -21,17 +21,14 @@ "stats.all": "全部", "stats.public": "公开", "stats.private": "私人", - "addFilter": "增加筛选项目", "linkButton.abilityStats": "装备能力数据", "linkButton.popularBuilds": "热门配装", "noPopularBuilds": "目前尚无该武器的热门配装。", "emptyAbilitySlot": "空能力槽", - "filters.type.ability": "装备能力", - "filters.type.mode": "模式", - "filters.type.date": "日期", - "filters.ability.title": "装备能力筛选器", - "filters.mode.title": "模式筛选器", - "filters.date.title": "日期筛选器", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "包含", "filters.does.not.have": "不包含", "filters.atLeast": "至少", diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index 5ab7946b3..635c20b35 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -44,11 +44,8 @@ "tag.desc.SR": "本次赛事为鲑鱼跑赛事。", "tag.desc.CARDS": "本次赛事为占地斗士赛事。", "icalFeed": "iCal", - "filter.button": "筛选", - "filter.heading": "筛选赛事日程", "filter.modes": "模式", "filter.exactModes": "精确筛选", - "filter.exactModesBottom": "仅显示完全符合选定模式的赛事", "filter.games": "游戏", "filter.vs": "对战人数", "filter.vs.4v4": "4v4", @@ -65,11 +62,18 @@ "filter.isSendou": "仅在 sendou.ink 举办的赛事", "filter.isRanked": "仅限排位赛事", "filter.minTeamCount": "参赛队伍数下限", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "显示的组织", "filter.orgsExcluded": "隐藏的组织", "filter.authorIdsExcluded": "排除的创建者", - "filter.apply": "应用", - "filter.applyAndDefault": "应用并设为默认", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "草稿状态", "forms.draftInfo": "处于草稿状态的赛事会被隐藏,仅对举办者可见。在启动任何对战表之前,必须先关闭草稿状态(关闭此开关)以公开赛事。", "forms.draftBracketStartBlocked": "赛事目前处于草稿状态。请先编辑赛事并关闭草稿状态,然后再启动对战表。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3fde9a571..361b0ca63 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -149,6 +149,8 @@ "actions.outlined": "描边", "actions.noOutline": "无描边", "actions.join": "加入", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index fc302b08f..987e1324d 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "如果当前正在寻找对抗战,则无法进行此设置", "errors.maxAssociationsReached": "您已达到群组数量的上限", "labels.weekdayTimes": "工作日", - "labels.weekendTimes": "周末", "labels.start": "开始", "labels.end": "结束", "labels.member": "成员", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "对抗战", "vodTypes.MATCHMAKING": "蛮颓比赛 / X比赛 / 占地对战", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "精确筛选", - "bottomTexts.modesExact": "仅显示完全符合选定模式的赛事", - "labels.games": "游戏", - "labels.vs": "对战人数", "labels.startTime": "开始时间", - "labels.tagsIncluded": "包含的标签", - "labels.tagsExcluded": "排除的标签", - "labels.onlySendouEvents": "仅在 sendou.ink 举办的赛事", - "labels.onlyRankedEvents": "仅限排位赛事", - "labels.minTeamCount": "参赛队伍数下限", - "labels.orgsIncluded": "显示的组织", - "labels.orgsExcluded": "隐藏的组织", - "labels.authorIdsExcluded": "排除的创建者", "bottomTexts.authorIdsExcluded": "您可以在用户的个人资料页找到他们的 ID", - "options.startTime.any": "任意", - "options.startTime.eu": "适合欧洲时间", - "options.startTime.na": "适合美洲时间", - "options.startTime.au": "适合澳洲 / 新加坡时间", "options.game.S1": "斯普拉遁 1", "options.game.S2": "斯普拉遁 2", "options.game.S3": "斯普拉遁 3", diff --git a/locales/zh/lfg.json b/locales/zh/lfg.json index 8aa734118..dacdbde96 100644 --- a/locales/zh/lfg.json +++ b/locales/zh/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "最后活跃", "noPosts": "没有招募信息符合条件", "expiring": "招募信息即将过期。还在招募吗?", - "addFilter": "添加筛选条件", "filters.Weapon": "武器池", "filters.Type": "招募类型", "filters.Timezone": "时差", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus Server 级别", "filters.MaxTier": "SendouQ 段位上限", "filters.MinTier": "SendouQ 段位下限", - "filters.suffix": "筛选条件", "filters.orAbove": "或以上", "new.noMorePosts": "您不能再发表更多招募帖了。", "new.type.header": "类型", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 57164229c..c0c8320ed 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "开始时间", "requestModal.at.explanation": "请在招募帖的时间范围内选择一个时间", "pickupBy": "临时队员:", - "filters.button": "筛选", - "filters.heading": "对抗战筛选", "filters.weekdayTimes": "工作日时间", - "filters.weekdayStart": "工作日开始时间", - "filters.weekdayEnd": "工作日结束时间", "filters.weekendTimes": "周末时间", - "filters.weekendStart": "周末开始时间", - "filters.weekendEnd": "周末结束时间", - "filters.apply": "应用", - "filters.applyAndDefault": "应用并设为默认", + "filters.divs": "", "filters.showFiltered": "显示已过滤内容 ({{count}})", "filters.hideFiltered": "隐藏已过滤内容 ({{count}})", "filters.showPendingRequests": "显示待处理请求 ({{count}})", diff --git a/locales/zh/user.json b/locales/zh/user.json index ec841dc0b..7070ccc9e 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -148,7 +148,6 @@ "sens": "灵敏度", "usesPronouns": "人称代词", "discordExplanation": "您的用户名、头像、YouTube、Bluesky 和 Twitch 账号信息均同步自您的 Discord 账号。详情请参阅 <1>常见问题与解答。", - "results.title": "所有结果", "results.placing": "排名", "results.team": "队伍", "results.tournament": "赛事", @@ -159,9 +158,26 @@ "results.highlights": "高光结果", "results.highlights.choose": "选择高光结果", "results.highlights.explanation": "选择您想要作为高光展示的结果", - "results.button.showHighlights": "显示高光结果", - "results.button.showAll": "显示全部", - "results.filter.placeholder": "按赛事筛选", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "已达到武器数量上限", "search.info": "通过 Discord 或《斯普拉遁 3》玩家名搜索用户", "search.noResults": "未能找到匹配 “{{query}}” 的用户", From 81cb406f1ea030d2798eb8a1c0dd51dea25c0add Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:34:15 +0300 Subject: [PATCH 12/14] Swap tournament-context location to fix HMR crash --- .../bracket-test/routes/bracket-test.tsx | 6 ++-- .../components/ExportDialog.tsx | 2 +- .../routes/to.$id.admin._index.tsx | 2 +- .../routes/to.$id.admin.audit.tsx | 2 +- .../routes/to.$id.admin.brackets.tsx | 2 +- ...d.admin.registration.$tid.browser.test.tsx | 2 +- ...ration.$tid.captain-label.browser.test.tsx | 2 +- .../routes/to.$id.admin.registration.$tid.tsx | 2 +- .../routes/to.$id.admin.seeds.tsx | 2 +- .../routes/to.$id.admin.staff.tsx | 2 +- .../routes/to.$id.admin.stream.tsx | 2 +- .../tournament-admin/routes/to.$id.admin.tsx | 2 +- .../Bracket/Bracket.browser.test.tsx | 5 ++- .../components/Bracket/Match.tsx | 6 ++-- .../components/Bracket/RoundHeader.tsx | 2 +- .../components/Bracket/Swiss.tsx | 6 ++-- .../Bracket/useBracketSpoilerCensor.ts | 2 +- .../components/BracketMapListDialog.tsx | 6 ++-- .../components/TournamentTeamActions.tsx | 2 +- .../routes/to.$id.brackets.finalize.tsx | 2 +- .../routes/to.$id.brackets.tsx | 10 +++--- .../components/LFGGroupCard.tsx | 2 +- .../tournament-lfg/routes/to.$id.looking.tsx | 2 +- .../OrganizerMatchMapListDialog.tsx | 2 +- .../TournamentMatchActionPickBanTab.tsx | 2 +- .../components/TournamentMatchActionTab.tsx | 2 +- .../components/TournamentMatchAdminTab.tsx | 2 +- .../components/TournamentMatchBanner.tsx | 2 +- .../components/TournamentMatchHeader.tsx | 2 +- .../components/TournamentMatchTabs.tsx | 2 +- .../tournament-match/match-page-context.tsx | 2 +- .../routes/to.$id.matches.$mid.tsx | 2 +- .../tournament/components/TeamWithRoster.tsx | 3 +- .../components/TournamentStream.tsx | 2 +- .../tournament/routes/to.$id.info.tsx | 2 +- .../tournament/routes/to.$id.join.tsx | 2 +- .../tournament/routes/to.$id.register.tsx | 2 +- .../tournament/routes/to.$id.results.tsx | 2 +- .../tournament/routes/to.$id.rules.tsx | 2 +- .../tournament/routes/to.$id.streams.tsx | 2 +- .../tournament/routes/to.$id.teams.$tid.tsx | 2 +- .../tournament/routes/to.$id.teams.tsx | 3 +- app/features/tournament/routes/to.$id.tsx | 29 ++-------------- .../tournament/tournament-context.tsx | 34 +++++++++++++++++++ 44 files changed, 94 insertions(+), 82 deletions(-) create mode 100644 app/features/tournament/tournament-context.tsx diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index cd5a0c952..750fbbfec 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -6,7 +6,7 @@ import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import type { Tables } from "~/db/tables"; -import { TournamentOverrideProvider } from "~/features/tournament/routes/to.$id"; +import { TournamentProvider } from "~/features/tournament/tournament-context"; import type { Bracket as BracketType } from "~/features/tournament-bracket/core/Bracket"; import * as Engine from "~/features/tournament-bracket/core/engine"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; @@ -181,7 +181,7 @@ export default function BracketTestLayout() {
- - +
); } diff --git a/app/features/tournament-admin/components/ExportDialog.tsx b/app/features/tournament-admin/components/ExportDialog.tsx index ff7862f20..f7073e65e 100644 --- a/app/features/tournament-admin/components/ExportDialog.tsx +++ b/app/features/tournament-admin/components/ExportDialog.tsx @@ -5,7 +5,7 @@ import { SendouChipRadioGroup, } from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import * as CSV from "~/modules/csv"; import { databaseTimestampToDate } from "~/utils/dates"; diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx index 56abf44fa..e26c97da7 100644 --- a/app/features/tournament-admin/routes/to.$id.admin._index.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -26,7 +26,7 @@ import { type SortState, } from "~/components/SortableTableHeader"; import { Table } from "~/components/Table"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { BracketMeta, Tournament, diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx index 09ce61a9b..3048550d4 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx @@ -5,11 +5,11 @@ import { LocaleTime } from "~/components/LocaleTime"; import { Pagination } from "~/components/Pagination"; import { Table } from "~/components/Table"; import { UserLink } from "~/components/UserLink"; -import { useTournament } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT_AUDIT_LOG_TYPES, type TournamentAuditLogType, } from "~/features/tournament/tournament-constants"; +import { useTournament } from "~/features/tournament/tournament-context"; import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import type { CommonUser } from "~/utils/kysely.server"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx index c4b5c7f67..240ddaf20 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx @@ -8,7 +8,7 @@ import { Redirect } from "~/components/Redirect"; import { SubmitButton } from "~/components/SubmitButton"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { SendouForm } from "~/form/SendouForm"; import { useActionSubmit } from "~/hooks/useActionSubmit"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx index d614fc78c..b0b6032a2 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx @@ -28,7 +28,7 @@ vi.mock("react-router", async () => { }; }); -vi.mock("~/features/tournament/routes/to.$id", () => ({ +vi.mock("~/features/tournament/tournament-context", () => ({ useTournament: () => mockTournament, })); diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx index a38077c91..8ec1599b6 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx @@ -19,7 +19,7 @@ vi.mock("react-router", async () => { }; }); -vi.mock("~/features/tournament/routes/to.$id", () => ({ +vi.mock("~/features/tournament/tournament-context", () => ({ useTournament: () => mockTournament, })); diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx index e396db427..1b932a5ec 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { FormField } from "~/form/FormField"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx index 00b575b9c..6f2cf402a 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx @@ -31,8 +31,8 @@ import { SendouDialog } from "~/components/elements/Dialog"; import { InfoPopover } from "~/components/InfoPopover"; import { Table } from "~/components/Table"; import type { SeedingSnapshot } from "~/db/tables-json"; -import { useTournament } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.staff.tsx b/app/features/tournament-admin/routes/to.$id.admin.staff.tsx index f015670f5..12d745de3 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.staff.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.staff.tsx @@ -5,7 +5,7 @@ import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import type { Tables } from "~/db/tables"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { TOURNAMENT_ORGANIZATION_ROLES } from "~/features/tournament-organization/tournament-organization-constants"; import { SendouForm } from "~/form/SendouForm"; import { tournamentOrganizationEditPage } from "~/utils/urls"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.stream.tsx b/app/features/tournament-admin/routes/to.$id.admin.stream.tsx index 98338ce81..c01271a46 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.stream.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.stream.tsx @@ -1,5 +1,5 @@ import { Redirect } from "~/components/Redirect"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { SendouForm } from "~/form/SendouForm"; import { tournamentAdminPage } from "~/utils/urls"; import { adminStreamFormSchema } from "../tournament-admin-staff-schemas"; diff --git a/app/features/tournament-admin/routes/to.$id.admin.tsx b/app/features/tournament-admin/routes/to.$id.admin.tsx index 11f9934e9..96af0e4a8 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.tsx @@ -22,7 +22,7 @@ import { containerClassName } from "~/components/Main"; import { Redirect } from "~/components/Redirect"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { useHasRole } from "~/modules/permissions/hooks"; import { calendarEventPage, diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index fea605fca..3257dbe50 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -101,8 +101,11 @@ vi.mock("~/features/auth/core/user", () => ({ useUser: () => null, })); -vi.mock("~/features/tournament/routes/to.$id", () => ({ +vi.mock("~/features/tournament/tournament-context", () => ({ useTournament: () => mockTournament, +})); + +vi.mock("~/features/tournament/routes/to.$id", () => ({ useTournamentVods: () => [], useBracketExpanded: () => ({ bracketExpanded: true, diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 391d5f327..28c006e73 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -7,10 +7,8 @@ import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { useUser } from "~/features/auth/core/user"; import { TournamentStream } from "~/features/tournament/components/TournamentStream"; -import { - useTournament, - useTournamentVods, -} from "~/features/tournament/routes/to.$id"; +import { useTournamentVods } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { matchEndedEarly } from "~/features/tournament-bracket/core/engine"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { databaseTimestampToDate } from "~/utils/dates"; diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index 561966615..944169f54 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { differenceInMinutes } from "date-fns"; import { LocaleTime } from "~/components/LocaleTime"; import type { TournamentRoundMaps } from "~/db/tables-json"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { databaseTimestampToDate } from "~/utils/dates"; diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx index fc35e89f7..d8338266b 100644 --- a/app/features/tournament-bracket/components/Bracket/Swiss.tsx +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -2,10 +2,8 @@ import clsx from "clsx"; import { ActionButton } from "~/components/ActionButton"; import { SendouButton } from "~/components/elements/Button"; import { useUser } from "~/features/auth/core/user"; -import { - useBracketExpanded, - useTournament, -} from "~/features/tournament/routes/to.$id"; +import { useBracketExpanded } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as Engine from "~/features/tournament-bracket/core/engine"; import type { MatchData as MatchType } from "~/features/tournament-bracket/core/engine/types"; import { useSearchParam } from "~/modules/search-params/hooks"; diff --git a/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts index 8130ecaa2..f2e99996c 100644 --- a/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts +++ b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts @@ -1,6 +1,6 @@ import { differenceInDays } from "date-fns"; -import { useTournament } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { useTournament } from "~/features/tournament/tournament-context"; import { useSpoilerFree } from "~/hooks/useSpoilerFree"; export type SpoilerCensor = "full" | "score-only" | undefined; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 7c168b76a..56846addf 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -24,11 +24,9 @@ import { Label } from "~/components/Label"; import { LocaleTime } from "~/components/LocaleTime"; import { SubmitButton } from "~/components/SubmitButton"; import type { CustomPickBanFlow, TournamentRoundMaps } from "~/db/tables-json"; -import { - useTournament, - useTournamentPreparedMaps, -} from "~/features/tournament/routes/to.$id"; +import { useTournamentPreparedMaps } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { modesShort } from "~/modules/in-game-lists/modes"; diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx index 1f3c292e3..3d628e419 100644 --- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx +++ b/app/features/tournament-bracket/components/TournamentTeamActions.tsx @@ -8,7 +8,7 @@ import { SendouPopover } from "~/components/elements/Popover"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { useUser } from "~/features/auth/core/user"; import { soundEnabled, soundVolume } from "~/features/chat/chat-utils"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { checkInSchema } from "~/features/tournament/tournament-schemas"; import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament"; import { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx index a1b9398a9..4bd128d38 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx @@ -9,7 +9,7 @@ import { SendouDialog } from "~/components/elements/Dialog"; import { SendouSwitch } from "~/components/elements/Switch"; import { FormMessage } from "~/components/FormMessage"; import { Placement } from "~/components/Placement"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { finalizeTournamentActionSchema, type TournamentBadgeReceivers, diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 0a22cc505..ccfe4077c 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -35,6 +35,10 @@ import { Placeholder } from "~/components/Placeholder"; import { useUser } from "~/features/auth/core/user"; import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { + TournamentProvider, + useTournament, +} from "~/features/tournament/tournament-context"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; @@ -42,9 +46,7 @@ import { useSearchParam } from "~/modules/search-params/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls"; import { - TournamentOverrideProvider, useBracketExpanded, - useTournament, useTournamentPreparedMaps, } from "../../tournament/routes/to.$id"; import { action } from "../actions/to.$id.brackets.server"; @@ -88,9 +90,9 @@ export default function TournamentBracketsPage() { ); return ( - + - + ); } diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 05baef808..0dec0a556 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -13,7 +13,7 @@ import { Image, WeaponImage } from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { UserCard, useUserCardData, diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx index 4b51b44d0..45ce44804 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.tsx +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -21,7 +21,7 @@ import { NoteAvatar } from "~/components/NoteAvatar"; import { Placeholder } from "~/components/Placeholder"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { UserCard, useUserCardData, diff --git a/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx b/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx index c5b7e3ffe..d8c313ff4 100644 --- a/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx +++ b/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { nullFilledArray } from "~/utils/arrays"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { useMatch } from "../match-page-context"; diff --git a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx index 06a876c14..2738a0f41 100644 --- a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx @@ -3,7 +3,7 @@ import { type PickBanMapOption, } from "~/components/match-page/MatchActionPickBanTab"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { useActionSubmit } from "~/hooks/useActionSubmit"; diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index 5b9297121..bedcbafc9 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -7,7 +7,7 @@ import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport"; import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import { isSetOverByScore } from "~/features/tournament-bracket/core/engine"; import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx index a5b41e190..e727eb680 100644 --- a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -16,7 +16,7 @@ import { Label } from "~/components/Label"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { MatchStatus } from "~/features/tournament-bracket/core/engine"; import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { useActionSubmit } from "~/hooks/useActionSubmit"; diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx index f42bf05e6..0cffed821 100644 --- a/app/features/tournament-match/components/TournamentMatchBanner.tsx +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -24,7 +24,7 @@ import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStarted import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer"; import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow"; import type { TournamentRoundMaps } from "~/db/tables-json"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useAutoRerender } from "~/hooks/useAutoRerender"; diff --git a/app/features/tournament-match/components/TournamentMatchHeader.tsx b/app/features/tournament-match/components/TournamentMatchHeader.tsx index f88b5ea0b..00a9d3e80 100644 --- a/app/features/tournament-match/components/TournamentMatchHeader.tsx +++ b/app/features/tournament-match/components/TournamentMatchHeader.tsx @@ -1,7 +1,7 @@ import { ArrowLeft } from "lucide-react"; import { LinkButton } from "~/components/elements/Button"; import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { BracketsPageState } from "~/features/tournament-bracket/routes/to.$id.brackets"; import { tournamentBracketsPage } from "~/utils/urls"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index 62da64b10..0aa16e60a 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -7,7 +7,7 @@ import type { TimelinePickBanEvent, } from "~/components/match-page/MatchTimeline"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; diff --git a/app/features/tournament-match/match-page-context.tsx b/app/features/tournament-match/match-page-context.tsx index 194e62afd..96e8f0241 100644 --- a/app/features/tournament-match/match-page-context.tsx +++ b/app/features/tournament-match/match-page-context.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { resolveRoomPass } from "~/components/match-page/utils"; import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx index b3ade903b..1549bec24 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -2,7 +2,7 @@ import { useLoaderData } from "react-router"; import { containerClassName } from "~/components/Main"; import { MatchPage } from "~/components/match-page/MatchPage"; import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { action } from "../actions/to.$id.matches.$mid.server"; import { TournamentMatchBanner } from "../components/TournamentMatchBanner"; diff --git a/app/features/tournament/components/TeamWithRoster.tsx b/app/features/tournament/components/TeamWithRoster.tsx index ca6faadb8..c9227fc9e 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -4,10 +4,11 @@ import { Avatar } from "~/components/Avatar"; import { ModeImage, StageImage } from "~/components/Image"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { userPage } from "~/utils/urls"; import { accountCreatedInTheLastSixMonths } from "~/utils/users"; -import { useTournament, useTournamentFriendCodes } from "../routes/to.$id"; +import { useTournamentFriendCodes } from "../routes/to.$id"; import styles from "../tournament.module.css"; export function TeamWithRoster({ diff --git a/app/features/tournament/components/TournamentStream.tsx b/app/features/tournament/components/TournamentStream.tsx index 357c36099..c178269da 100644 --- a/app/features/tournament/components/TournamentStream.tsx +++ b/app/features/tournament/components/TournamentStream.tsx @@ -1,10 +1,10 @@ import clsx from "clsx"; import { User } from "lucide-react"; import { Avatar } from "~/components/Avatar"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils"; import { twitchUrl } from "~/utils/urls"; -import { useTournament } from "../routes/to.$id"; import styles from "../tournament.module.css"; export function TournamentStream({ diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx index 385aab5fa..118b7b5e8 100644 --- a/app/features/tournament/routes/to.$id.info.tsx +++ b/app/features/tournament/routes/to.$id.info.tsx @@ -7,6 +7,7 @@ import { containerClassName } from "~/components/Main"; import { Markdown } from "~/components/Markdown"; import { TierPill } from "~/components/TierPill"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { useTournament } from "~/features/tournament/tournament-context"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { removeMarkdown } from "~/utils/strings"; @@ -21,7 +22,6 @@ import { import { parseTournamentLoaderData } from "../core/layout-payload"; import { loader } from "../loaders/to.$id.info.server"; import { bracketProgressionLabel } from "../tournament-utils"; -import { useTournament } from "./to.$id"; import styles from "./to.$id.info.module.css"; export { action, loader }; diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 4133271cb..d05730250 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -6,6 +6,7 @@ import { LinkButton } from "~/components/elements/Button"; import { FriendCodeInput } from "~/components/FriendCodeInput"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/tournament-context"; import invariant from "~/utils/invariant"; import { assertUnreachable } from "~/utils/types"; import { @@ -17,7 +18,6 @@ import { action } from "../actions/to.$id.join.server"; import { loader } from "../loaders/to.$id.join.server"; import styles from "../tournament.module.css"; import { validateCanJoinTeam } from "../tournament-utils"; -import { useTournament } from "./to.$id"; export { action, loader }; diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 4f75452a1..d1df10b9e 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -18,6 +18,7 @@ import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { FormField } from "~/form/FormField"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; @@ -51,7 +52,6 @@ import { type CounterPickValidationStatus, validateCounterPickMapPool, } from "../tournament-utils"; -import { useTournament } from "./to.$id"; export { action, loader }; diff --git a/app/features/tournament/routes/to.$id.results.tsx b/app/features/tournament/routes/to.$id.results.tsx index e5cd17319..c7f89f82f 100644 --- a/app/features/tournament/routes/to.$id.results.tsx +++ b/app/features/tournament/routes/to.$id.results.tsx @@ -16,6 +16,7 @@ import { Flag } from "~/components/Flag"; import { InfoPopover } from "~/components/InfoPopover"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; +import { useTournament } from "~/features/tournament/tournament-context"; import { useSpoilerFree } from "~/hooks/useSpoilerFree"; import { SPR_INFO_URL, @@ -25,7 +26,6 @@ import { import type { TournamentResultsLoaderData } from "../loaders/to.$id.results.server"; import styles from "../tournament.module.css"; import { TOURNAMENT } from "../tournament-constants"; -import { useTournament } from "./to.$id"; export { loader } from "../loaders/to.$id.results.server"; diff --git a/app/features/tournament/routes/to.$id.rules.tsx b/app/features/tournament/routes/to.$id.rules.tsx index 75f1da183..dc5d821e9 100644 --- a/app/features/tournament/routes/to.$id.rules.tsx +++ b/app/features/tournament/routes/to.$id.rules.tsx @@ -8,11 +8,11 @@ import { MapPoolStages } from "~/components/MapPoolSelector"; import { Markdown } from "~/components/Markdown"; import { Section } from "~/components/Section"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { useTournament } from "~/features/tournament/tournament-context"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls"; import { loader } from "../loaders/to.$id.rules.server"; -import { useTournament } from "./to.$id"; import styles from "./to.$id.info.module.css"; export { loader }; diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx index 8cec7d009..4f857265a 100644 --- a/app/features/tournament/routes/to.$id.streams.tsx +++ b/app/features/tournament/routes/to.$id.streams.tsx @@ -1,11 +1,11 @@ import { useTranslation } from "react-i18next"; import { useLoaderData } from "react-router"; import { Redirect } from "~/components/Redirect"; +import { useTournament } from "~/features/tournament/tournament-context"; import { tournamentRegisterPage } from "~/utils/urls"; import { TournamentStream } from "../components/TournamentStream"; import type { TournamentStreamsLoaderData } from "../loaders/to.$id.streams.server"; import styles from "../tournament.module.css"; -import { useTournament } from "./to.$id"; export { loader } from "../loaders/to.$id.streams.server"; diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index eb5e01b39..575c6a67f 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -7,6 +7,7 @@ import { SendouPopover } from "~/components/elements/Popover"; import { ModeImage, StageImage } from "~/components/Image"; import { Placement } from "~/components/Placement"; import { UserLink } from "~/components/UserLink"; +import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; import { metaTags } from "~/utils/remix"; @@ -21,7 +22,6 @@ import { type TournamentTeamLoaderData, } from "../loaders/to.$id.teams.$tid.server"; import styles from "../tournament.module.css"; -import { useTournament } from "./to.$id"; export { loader }; diff --git a/app/features/tournament/routes/to.$id.teams.tsx b/app/features/tournament/routes/to.$id.teams.tsx index 7b987d79f..4a15da028 100644 --- a/app/features/tournament/routes/to.$id.teams.tsx +++ b/app/features/tournament/routes/to.$id.teams.tsx @@ -1,13 +1,14 @@ import { useLoaderData } from "react-router"; import { Pagination } from "~/components/Pagination"; import { Redirect } from "~/components/Redirect"; +import { useTournament } from "~/features/tournament/tournament-context"; import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import { tournamentDivisionsPage, tournamentTeamPage } from "~/utils/urls"; import { TeamWithRoster } from "../components/TeamWithRoster"; import type { TournamentTeamsLoaderData } from "../loaders/to.$id.teams.server"; import { tournamentTeamsSearchParams } from "../tournament-search-params"; import { getBracketProgressionLabel } from "../tournament-utils"; -import { useHasChildTournaments, useTournament } from "./to.$id"; +import { useHasChildTournaments } from "./to.$id"; export { loader } from "../loaders/to.$id.teams.server"; diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 1c5631e08..b381fcde4 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -10,6 +10,7 @@ import { containerClassName, Main } from "~/components/Main"; import { Placeholder } from "~/components/Placeholder"; import { isMatchResultsScopedRevalidation } from "~/features/chat/revalidation-scope"; import { useChatContext } from "~/features/chat/useChatContext"; +import { TournamentProvider } from "~/features/tournament/tournament-context"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { useHydrated } from "~/hooks/useHydrated"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -71,26 +72,6 @@ export const handle: SendouRouteHandle = { }, }; -const TournamentContext = React.createContext(null!); - -/** - * Overrides the tournament of the subtree, used by the views that load bracket match data - * of their own on top of what the layout ships. - */ -export function TournamentOverrideProvider({ - tournament, - children, -}: { - tournament: Tournament; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - export default function TournamentLayoutShell() { const isHydrated = useHydrated(); @@ -135,7 +116,7 @@ export function TournamentLayout() { streamsCount={data.streamsCount} hasChildTournaments={data.hasChildTournaments} /> - + - + ); @@ -173,10 +154,6 @@ type TournamentContext = { vods: NonNullable; }; -export function useTournament() { - return React.useContext(TournamentContext); -} - export function useBracketExpanded() { const { bracketExpanded, setBracketExpanded } = useOutletContext(); diff --git a/app/features/tournament/tournament-context.tsx b/app/features/tournament/tournament-context.tsx new file mode 100644 index 000000000..b2fc00a2d --- /dev/null +++ b/app/features/tournament/tournament-context.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; + +// Lives outside the to.$id route module on purpose: a context created inside a +// route module gets a new identity when HMR re-executes that module, leaving +// consumers in sibling route modules reading the stale context (null). +const TournamentContext = React.createContext(null); + +/** + * Provides the tournament of the subtree. Rendered by the tournament layout, + * and rendered again by views that load bracket match data of their own to + * override the layout's tournament. + */ +export function TournamentProvider({ + tournament, + children, +}: { + tournament: Tournament; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useTournament() { + const tournament = React.useContext(TournamentContext); + if (!tournament) { + throw new Error("useTournament must be used within TournamentProvider"); + } + return tournament; +} From 04460d56d15d839b24ae2e1f97d0f6394d151bc1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:34:30 +0300 Subject: [PATCH 13/14] Self-host font --- app/root.tsx | 17 +++++++------ app/styles/fonts.css | 32 ++++++++++++++++++++++++ app/styles/fonts/lexend-latin-ext.woff2 | Bin 0 -> 34548 bytes app/styles/fonts/lexend-latin.woff2 | Bin 0 -> 39692 bytes 4 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 app/styles/fonts.css create mode 100644 app/styles/fonts/lexend-latin-ext.woff2 create mode 100644 app/styles/fonts/lexend-latin.woff2 diff --git a/app/root.tsx b/app/root.tsx index 0e2edad8a..95e25611b 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -34,6 +34,7 @@ import * as NotificationRepository from "~/features/notifications/NotificationRe import { NOTIFICATIONS } from "~/features/notifications/notifications-contants"; import { resolveSidebarData } from "~/features/sidebar/core/sidebar.server"; import { useDebounce } from "~/hooks/useDebounce"; +import lexendLatinUrl from "~/styles/fonts/lexend-latin.woff2?url"; import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Route } from "./+types/root"; import { Catcher } from "./components/Catcher"; @@ -79,6 +80,7 @@ export const middleware: Route.MiddlewareFunction[] = [ i18nMiddleware, ]; +import "~/styles/fonts.css"; import "~/styles/vars.css"; import "~/styles/normalize.css"; import "~/styles/common.css"; @@ -481,14 +483,13 @@ function HydrationTestIndicator() { function Fonts() { return ( - <> - - - - + ); } diff --git a/app/styles/fonts.css b/app/styles/fonts.css new file mode 100644 index 000000000..bc01b4ffa --- /dev/null +++ b/app/styles/fonts.css @@ -0,0 +1,32 @@ +/* +Lexend variable font, self-hosted. The weight range is capped at 700 to match +`--weight-extra`, so `font-weight: bolder` keeps clamping there as it did when +the four static weights were requested from Google Fonts. + +The vietnamese subset is intentionally omitted: no locale uses it. +*/ + +@font-face { + font-family: "Lexend"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("./fonts/lexend-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, + U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, + U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Lexend"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("./fonts/lexend-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, + U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, + U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} diff --git a/app/styles/fonts/lexend-latin-ext.woff2 b/app/styles/fonts/lexend-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9ca4e6a77da9c4ec45bf62dde7451283b3736733 GIT binary patch literal 34548 zcmV)9K*hgzPew8T0RR910EYAc6951J0YX3k0EU790RR9100000000000000000000 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 z zR0yL~VOWL1S>y&1{TxT8n~I>cQd(iLipz!o#>WOQphXA=Zum$*MDrrooQN`(2vkrt zfd(|A=)f_E0Kz>gps5!F3{_%+HpRvATOl^+ilvIjlNwFdIQX=eI_+-#_2I(-Ja+AH zsA@~eW4Lq$r<*Z`0qjQ+A+F=GxaKE>z#d7uVjR>@OE-jrV%b*0(300eAJ=L+n6tbW zW!<-)s&K&=*1@IkPr3C!Ja&r}n;@xz|j>MB2vA*@mBBh4&IKH%X`J=^0mF6We zZvC|xg$#mpKWb!dtLU1-?GzW6!Zq?NuUehQ?XjU6ylzGbDGW;(z#~D%NgO@cLaWy5 z&$e9#qp=NN2Ma=K=gh2sq^sO?1nml7o)hT%9JmT>O!G#BNjYz>bcnD*io1wEIc2MV zL&-}4^Yu684AZh5x7FzJx~zbEyz!O$x@x~4MwM#V5;%ASL?mPsR5bMJoj9Nd6AQZx z2Nw^&1_2=vF-d2xA|nUYq^Lz%n~Iu-mX4l*v0Fh*b(z(&u(Gk|z>&z(l-!)TSjLqb zcOE<~=Ve7}h08Ubmg1$Mz_d+gV&6CzZYa1rU`M2Qw-z1Z|7inCF? zO%iOD=s#O*CCO+Osbndc&1;`D=`zU3Wmc=dOP-?0B}cAp^5iS9y%!bc?islM=uDoF zLc?_4@UM|dl_^(YkG=M(wBLdJJ@he$99HE>0o7{Msw>!_-)PXNNwXHMj%q99nB#?= z&|XA`PF=d4q;#ski|Q%nv|bxFm6TOf)oc}aNkdah8>FMFrw=yhC3g*>uQ+I=eY!U2`TWIr0+9rfDO4Jr!DO*HTpnK_6sf9-B~o>nhNhObj;@}*fuWJHiK$sk zoXXs?YgVkXb8vEjxp{c`_yq)oghfQf#MMct*Pv08X2nCY3Wps8q-WmITsX#vJzTQx zrxtLZBtFijydBOyNhbaol>%CdNcVT0_Zze3-;2V*@1N5S=NXC({qZ-&g)ToN{HqlT zFPJmoJ-Pjt7pVxyiMbiU>jp*H+%pd#U61mZZR@*u4Lx1+Qj~VYA03ZsOQfGCWg&$~ zSyG9n%KR=1a(_-39~DLQsN8FXUC#f|TeU{2+QVUbayvJBsY=DF6ho{E(_s*)EsBS; z9?$^SJwWdWq z1@7pj;InNY_hJuMztSm}u0v&d7`wC=jqxOkJJ~(b_V*TjBPT8D>~cr3F`=hKw6|dH zu}CP4-3cyl=r0#taoKAhaPiNi6T9Fr*>dIoc^(6Wax##i?##~4*=^Y{)LSU~q|c$A zQig-b%Wx)oEm_4K&kEf<8Du4&u)oIW1#sRn}j!|Pz6X!j0hb-p}AGHrp^=ARR%81bF~czun951 zvf3NWD1hj;0>W}&4RqU(tXt)+H_^kek7|?+%u#(I8qT9}Gp&G$U?W!{fvM9(NPkTKcaU?1Jr_TfJHh+{m%5wDQ$clb*D+Lv3tH$A|PKaXXL>f89t z9!djR>5!7aZ=D4s!i@9j`TO@T#<+Zlep^=KXtBQx zl@kbA%5TcRb%Z5}nV&_C5}`;n-ju!!M4Kv@fI3%t@EFkwe;A5~<8>P!O?D(gq_h12 z^8qU~VmYbBgPu^GsE(s74r(-4NzCzqX2qs)G>d1=S>MYn5bE9d-MwIvw~>{Iy@#8$Jt|ZCJ6ul09LV8y#N3J literal 0 HcmV?d00001 diff --git a/app/styles/fonts/lexend-latin.woff2 b/app/styles/fonts/lexend-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0968c152f1c0d617a90bd843fa3d510c336af085 GIT binary patch literal 39692 zcmV)2K+L~)Pew8T0RR910GkW|6951J0W|;s0GgZt0RR9100000000000000000000 z0000QhGHA>d>o1%KS)+VQi&f1U_Vn-K~#YrCoTYmA}@Xs2nvCTH0(ADghl``f~GtH zHUcCAi3kKB1&9a-i~?KiB5^{QAY0)_jk*m1N-}4yS%1E;umhDp<_=jfA>X0~ERUbkjtWnb@7Y3a%~ZUMC# z`t;T@4J}rf5JZ_VBC}kEd&8s)LkRsBIJNK3MM+nZWjThRn{t{G z4?*Vvc8R*X9|$5VeLG7;k8rSkgQb*`rU}<^j1u3A5Uqh_wgjx|P!WM`W$RD@J7)an z8=jwAkI`d8w}xf0uD|_NksG5Y8?@M1AXZ;{rx#XSc&B$~UKw=}WyhWJ{6G6WZJ%>T z?7uNd6Uqz{5=k_4JP|rHLh@2P6B>(9v{S|J{O8p8zXn-ER|pnZGRc5}CT%*z4`KDr z9x$Um+H80U%>NHi2QURp8sMWj?QCcKw3&0&Au3bo0zjV(<>vPj0SXm=wkCEy><0Sw zo1g#D)LN%_5@KV{PYOb7q;Yrne>m*!0@iElzYL{j9;{vjkn z7EO3lmEqs1{vRjBEM1#24XBq8f4<)5TxAc4E^ujE%OO+DvLp)-45&~0Wy0H4B|Wkd zkW-+P4qhP(bMOIYfo(JX|8iRWQVA@{ifoPpb`Y94nc?HSInJBw?z`_5t0Z^a02E*o z#0~&AV0H(H_RU!@Imv<*@255k1g8%KmXsy~+|JDVHzNC*o)?%;A5ceoe(!%6V}m=j zSbEbEZEERs(UA|9v7o2ax@8#izdG~lx=97HkC4WqtpdQ?d0CFhAt1*A4cD@s&}M*7 znAOeJ1rTs$YMD-BZiNL&m3GOaZr=m4pqx!{lziq)Bn6Se!OY@--QmLW{s1oV070G# zAl@vAH{e??i7%nZcZP4g2Y50d`G$OZM&`W<5*cfADRMy&C~`hXas{y}7kpp)z3HCw zQk@BrPelONcTC4=o z!s4>Rr`~f@phtLa)8&8)*es(DK*bS%By6$t(qIO4LZTd2k~>8v1c76NP#B9KAwkGD zPQPD&i`(tI5cb={HSf3q6KN9o6HtNyV1N0h+U_3i8OLDH;glAR>--?6Rq6#MJ6EpR z)E|jF92gECfc0@8?d1n(e}JO3IEMK=DlsXg2!;IaNX{$*M~kC0bD|tb*|~F3#$1J5 z+g0PPb5Tcv6BU(yTh{8IC7-RhS-uX>@vpi{c`^$otVBC-j zGbaY!AOqu4JDLNB?Jjk1d?|T9tg%#fClgvkepEARLK;{%n?O2ngAeJOvI63NvC<7 z1VWzDko3)I2T+p1gME42j2WC2QOMv4*iw&2^g{HOh4df|8r;--AGhzbFBlz3Sr--2 zU_uuI20;M<00oL;I?={}!z!>#kzhv;Xj?L^g+XJeg7Uavg=C0yn^ALb*4^@b#MwpQT5> zr0;(7jk@>#zvRaDx2`>^yM6DE(~YzD9@pKg?VsN|HNQQbub-aBK5?hJYXSM#z2?dT z^5O$w&mz2Qk-WH6yR?M7eF^(SyNbuzud(rpHC*wQSF!Ob{q*;XqwD*4|KRUm4F2)bL4Nt5c=n)p>jB(8V0#Dr`r+Z>L%4Ma zuN~sY4%xNCYnAXC72%De9(U{>KN&42WbGtCvUGojZaGs|&-DJec=#e<G}m$IzlcXez_|}ZZhrnaD~f?i{`=CQ($+o&k&NEVQJ4M& zbuqi^PJcuYn5CjEpY4|p)x_7T%AmpmAd3Ia_AnFPswjiU%Wf9>cY7W=Xn|rZgElpg z`Ex~{&G1eyL_tE05D+8@IwMFdr=ZW+bfzvIv07q^E|MwhlNTWIedvL6+HA4fLetfm zu1ubE38FDNCiN=wbO#eG^MqhBR>Kb%Zw^p_GvHe#IyD*#xMZ%53z zKWAAgzfZV0taG?h+{1+@^Hk3w%)`XbaOgQ8V{#ANv;PBreJl#$H~pkiv3i3#$+r=H z0+XE|Gd?DR>8KGGW(!vEp?@rik%)Qhb?|wJC#%^oyB)V0Cb};%< zbxAk_`)2-C>os2!HAbDPQ%DvORKQ*sR#fk{+1L71@9pMZ+lzaqcp7@JTZKCCn7a1E zbkx=~)gqd_!&v~&@c^5-mUB6t{mih2LI6xNi)o~q5+0OWx9UP2k>_uzpKD=FE-U0f zKZr9dVLi9f6lD=iTtmH6*o$VY#axURsDeBgys$oSKmjAvkkBCAyp8P>w$ znWtt|W-^t5bfh9psVfax=s>cFLk1GIVa=HMTjtlXJf_F^=#Na)L?BGz6V4$tj(+Q# z-s@jI)ZP6Nuo`B=w%wi^81I@v@vcg*oO`jt2-geu9@%5i z?u&yVERwzhZ~+*3F+js~Obl0uHj@fy&MokAHOXqZsWbzI#FTGQ5XUNWyO(RI%qh5| z@P~}~!RfQkGZ7j?X#G?KFF30E#viWJOqk$M303B`VtK)XBs$`H>}Vbc@;nO;H%A6S zO0y_t`J#@r1&d_YHwsTu{|ot92t@cm(cPH*L4y3AXK8CcmmuS=K+~F}`+L~641h#}vsLHk?NudZW2=w+J zyBl>5!ueqmX+G4jnnqaSRI1xG~L zk^M9qYAa%aQ&Bf|4o{<^%_({}wCi~u_Z&v$cGBC)k>A7@?TZyLLb5E$W!Sq%$=Zxx z%lLbb)Ytg2NbbEZYdK3-e62CNm^QrL^cve7m)YaU_5_N(D9XJ3?a%P&0;wV%tylaO z>c2@T1JwsA6*BR}WiJu(vqs)E6n@pMwOJWQsq3i9rUbsr3@K-jo{y4B&-L{Et_1xW zKIvRc_@&e4t~`*(@FJyE(S%D3Q8_%fhs)5YUnQaev3iZ){Q?GwBkN^w0n*H%A=HM_ zD7Oorj5p4>M;pz+Yf}<&W3M=Kf_?BStoL0CO}-O(&6{H#+sp(Iqb*bih=XB7Ivq&f z=M0arz`0T*Y=MzFJ_!8;CW>|;uu_2BQKis9>V(l)Y0zp!R0){iZ^GBtrC5m}8ShOr zfyFl8Q!Pq{Rwx9t%%4aAr}xyBz@M9``?IO&YnTx8Q@5V&-o*1orx)8h0PZfaPL)2_ z6)A@HBwgeEUbGuAO2WN(w~si0w{#2bkC&TX#pg6|`a0L)b4iF}uUhWELn+$B!jW(@ z8k|ZGkvB%(*Iga?GF-!W@kGdZa189kw+P3wHY&#-MT}YlS&hKg%;z0b3@-MrX?&NS zt+~18@SGEhJ^Kz4%_g3%@~8hNn!}r@MTcKxn%6~G94>~QfJ=u6a1vUe(kHRkNXA>( zQ>N_g^~2LoA?6mZn$y`t=q38)-bvH)&}zX*Y^;*}cW)b}PA`uZynLLWHq{cuZ(jXV?d+nV2xb+}h=9Otm2u?6w*E1-I93G2rJ< zHcuIQLLDv3YLXhJayG_+Jv+owucf(IQ1Aze{XN#Y$X>QcuS_S@Sf26F9`u_)LNDbo zF{%~V0gx=CrP)yHpEi@z-==n;_d@d_&X<9GuWNfDw-#+<#t1KYyKfWkmLT0Su!X|z zcuCkP_9SbIdaMk-bY{pDJz!Yb%YkL zm{=^WEsBj%ugpFoUL1ID$1g2wlan{VRD3^&TTyDgJ4uH{>m0)pbEfhzvc|=jMI2|vg%DJDM`^1-``B3_Q8ub z$L9E7sl4gL;`f{B^tPH;o4WjJoAcj%_+AkvdgQlKm3gzi)_2TOG9Gc%s35@HdoPQy zp9CAszd~97l6jC$opDB;bIz;Bnmi3)O`2osM1>+`So{M= z!i$nqU?^!(_5wo(rEgmplDTXLPYBT~X3Z<%2rUIuDqbhW& z)m4o;O7*t?G_O+F0y^7j7xU7goSX&D&f(R#q4%+ZZp)OyD-Hi`}pRJP5dl z<)1ho!~+R{H^O?>!ZY=ZIk9rfzFTsS?d)K~Ja%ZQ^J&;=o4UzNdVKP>``vVZI^R!x zZ^{Yp4M^JN)NmHB^?YZWX@$|$HPD0^^p{R)pIWq8tCbh6d88Bu)nDtDUfWAMn}GYp zu{_i|vw5r|>)^6^Roa}%?rh9TlmXcjnsTys;zA3h6daT*w>zjbGmAd_IW|c>y)aQ+ z#E47F2@;A)lKIxPG}KTf!m34BtW>XO4Mz4e*);vlc*tpH@pSXAONJJEWNEd}Fozv6 zq8_PH&g#fKmt0opq1G(++zTa$DVfUv14TZySDG`j)|yZ5aC{1Q(_Byp+&96)m^?hj zBf+C)IcWjTRvudgHEPxMXkR^2VVf+~%O{VBl{yR-M?y+g{VwL>UgOQxVX!z7QnE9~ zZ}i$Ns%v#@>>QjRFb^*uzkr~SaHB@F<m6A-RI@f486>Pwt~b5?U>tr7c@ zMwZ?w>`uLV9s(18jg)<0<%7D4nSJ#XH|{*@2_|05yocZW3bqOm5fKp)5fM>kJrNNR z(RbPM@PwV~nx?ZY3`R*Icq0{&*0B3EO}kaI^6*R@gpr_XkJz{}j0RaJlKp+q}z^)w#1Oj)kO6)E+5C{YUfxzYz zPwQ$!TJ^Lsc zqi3c`4MyF`5mUUeF?IOB0iRCsR!jF-q4TR7N3J9pB*Ipv_#M)85+Sp?=Kr`3TfQO_ zCMG5(CMG7US0JAeIjAGjeux{RIw6k&uS^Ox!OnZdT#+|rkxkfRf#)-4z9o@w72&Pm zFHGzuS_=v!@fkBEuy9lBae}{GAi&Mo3YyW%FWY2tl7>0oRjARr0eBtj>hWLO2t0IE zQr>~`o%P70 zk;s^#@eE!AGv!*!nX-eXg_$DlUKwO_=iq~1I;x`lhV2^H!vWvV;0I@XhyYGGTQ-g1 z1AIVzTEd}VUv`#np;C#li#Dj!$-fg*T9kD+8A+gK;(+P&@{RqkW<^+u!R)e(jcL)E zabe!yVr+6(g3+wvFNYvWe2Pzv#%i%TH9^Q``8en%3T0(;6fgm^F$Z%o5A(4A3$X}S zom@Uh;(>va6q;(c)02;nfaj;x@Ut^VFewLaCMqr$CvlQ`ex_)C7VOI&H^ObQN%*Gb zyWo4IU?$iS@2r9RbbLaY*Vma+2Kz@t%9#Nnx6+&%4ad)<$CXfeBFIheNR5m%{7WJa zPEN7vT~vW3^%&S@WumwjHxDd;Gy<$a^m63LO3P{jx-b|poSRV$tfB= z1B}C%LyUzx1ws5Uke=?kfyG4=0i!id1hkIkpryoYOMnKwpuswMsdYfAHwl`O%wGGp z9z;usDzvC#vRR|4sUD=kQ*|UH>IqauVi!tFgduP*1M2o6$X0+2HZ5&Hv!_@MfoI=A z4CqfvIZ9I|b4VryU2}l}njUJr$;?tt?;@-T4sncAoYN`#xWrIPK@T3nA(O+L zjpiDs09HBulxK`;F2}iVqLyk5dB{LEC!bD@DR4ywQj!d3-zt?4RdZq5PQ>_X zfK0-CA#FfEkF6&7bs>Fuuq1bbdM6PQmMC=)nH*11c~Ss$;5+OkUEKEJOI~LlI-FP3 zG>khto-<=c`e+?-O?*a;h;v1IY=xA7i@sg2w$nUxaqYgWKx@Z4hac4C84%L{^jepI%o>A+ouD<`sY4I;;`G3Ow#}^X| zYCL)m#Dx}pBaE-z^u_d(p}WZ^?iSyZzR1HT^-0c?9~<_8rf-NbuWi$M-q?^nxaQHp zXAK8;pgvf|{@|6d>;UGYH{8cIrG|&kgyYA}Css6^*hM?N_3qiXac93wfA+EOi|yer z4=TSrmL(^dT}FwXUB=VtrD?#O%|tE5j>(5^a9THFvhu(Qi}PP~7pl+|#%bVL3d z()m6#J;bR$j4{`-cRw@Pi~oPx;qay=nS+7wh3P*pC~+>C>=ze+oc?f$IG+CELFBqC zn!}zWfS`F~wl`G6bld%6JYQ~kdncz4&nvQ4fB^7SE3TYP{jwSdbXKzgeU~#QgIN+| zdhv-+2u)@hQBpz z{rT2!w*F=7NB7>jaQ}IihbtKoKrA5JJpjnktOnq)WMP(v=Ju1C{yFut-(9fReeYa$ z(Gf@O@sF+c+oDyoZC-fhxg9Pc1ae9?wCw3P&~stXnUNbGe*6Up6eLWz2$7OLrsB%Pod+{d zPk0Fx%pyb-Usm09(N&yodWe^(zdrivCrg@i8IlZ_Yot-~<#;Mjg>qwjr&f_V-U`Bs zQ3nydFgWMDQ_eW+Gy+hg2rZtl$#TepZNgf;6a-1Yx?=&t063K{NZ8Nx>n%h$=6a+s zk$C?EQiMoZKS&jgY=VOwi-iZ6s=dV{$i_*DTX#0+P(Z3nfwm;7_w}~@9&q>p;JN~{ z76344HmR>X#BAOoqTdh-Gi(-weqbIv69YDg;`{qu$QL0*|AbhqtkAbbdT$>AFh5H< zCzw^hJd(2s$$2o^uz02*VW%D$haYm}ms}6n@Xp|LWa|$J=E0Mwr>dET{hrL*Xk zXGg!%LgS|PNVYDq(EtSRfc$kJ0X4s>)u^NDdk1QDO$MZ_f}{Zrvkg~c*;49bLEa7L zh-@}sdqfTiR)jWaGirdgpCrp=99NYw+2}8Ly<)d6Mnx{rnF~r`%w7?2Lknfy1KB|J ziko>$EgSd=r0bAPsHgX`%);yCCpPl8ByLbTFRrF6;C{6~)Z}2?FvXn`-Cth{eT5A>bxUI?MFJ zyf_j6j=)En-2#;^eYJhiP3a_EH0Z3Ix4V?uL@jiAiK~@;3``<7-8!^lMZ@$(&n!HN z4t6%c&=<&Bh#wqs7&iRVIQ*DV`gvY$7lggkzt=c4BCN?^>EIXQyc|GU4TC%BfmSf0Mj3>dk zr?*2?q~aP#`zn&ovW;LfGR0v#6_d=^Xf%r}d;MmGR1qJwo#g`$t*~z10EG*F9gswg z+V^)yZ&F&)zd{8hFY<}#2g?50FsCA4v+S~nAVD$;@@cDI-KDdebx6nV#gp%}IHo6Q zAV&wVeV_r01uP9M76r2o$$6i|C&WuhMrPgdKGg9r_$O4B{_mn^S>`Qtz*uM5Fz-9M(af^;8z+iekaknyfg89V!i68BNQ8 zvgKq;-D$D*#A)GVmP>D2^eLV8I_SgIy@=71*crR<9a7guIiYQ21jT1P5$5Y;vt=8L z>cX9#OJ-U5DDgT`stIsmLz!eM*P3BPV#}z4C9$i)a7}hf6{wkIFWW-#Z=XK&_icv+ zy<*AaTfLwgFSC7)*I_Ts?9~SU(t2^p3VPks^~HrYZ6C##R~o|e5MHY;6#O8Jr4M^U zVJJQ9XFecR77?~9C{YBL3MHD}bEFYXNp@XL23%MB!0@cLT3=p~B^BY#ovSr_b?IZ} znSVOe%;@5D&=YpSjEECOYu!|kio%)>nc~Xui1QU>$UBP%`N8uSPjg&5uXW#q(Y{?T z3JoD$HiUp^_MGRHQjZSk(|6hS>GmP(mW8`QbiqqGwcFj{+pmv{N*k$*=HV+rz_glV+=x6QLvJ* zbSr6lb#o>aBC}&5n)Q05C)v^gYG!S_xi`JAw<|Tg=u}tka=AlJZf;m|QaUsdS+zk8 zi_w9`|K92#=3H9p|*rzcS!z!^%^>l?=& zTc>hAx2g7Im>GKHOC3y$1O8#eK(Nw}d?zu>xAXWt{i&bJ)slCvFs#&FO=*BOm6v|K zJ+BflzbzA{V$WB3G&I8>|TAL+Z%ALx5U2M7A@2Om|E1pUl# znH+y^$P)Y)Dr0^9_f9R+c*KxJy5A$&WZ^rB8aO@MLh&E);TaiZ{yyk>ear3tFC6i5 z4rzcxa~X~X3GAI_H5)hAfnAsu1dPPW)Tb{$u2DR( zpf4~^1@$Apkm<+Tk~<7_O=WKMGHydtmGhCs^2OT?6k7C>mAhzh`~Y3wWqgj&Y|h2T zIb0EwbZ_-x*2ZYfX{YWrSt5ex)qYX=Or=*=XHk`J@9A-rax1?|o6hng@qP7l zVz_5WvRz-Nb*ar(+c(njHTOaB&gPJ;>P~GLKm}p7G;&_eiM_PERNB;ms7TV*CW$A@ zXVmkO=P?J_fIJY!Z*GsW0b8{C)6=PK2t{HY+9s1dtmAfl^v`oyR9in2&6RK4pz;n| zEVg;O5wHmB?NQtfb}tcIyFy&@R#2{+3TWc4G19@X(Iy2&LoD1}n!{zm`e@l;Z=#PD*fGrdTjTm79wuyRNeJ4LhLJ`uboHiF zOwD|p_}}0t9BQfTzaOEbYcSJI_{oHzZ!F#Z-H9BMBL_z456pCP$?E8UDig<|LqamXZGP0>a_^$+P}63f27C zWE{qH6voGJoJVlf2`2P0(Nl}xvrj&?iE$g7Fyx+P_I^*eR_8MQ{|NuY^*KIa>Y6&X z+E{WKneeoL*`;e%WZOra@XkS5C|d{?b@Hkj(qA?=s)nllbMfA*`m2qt#@P3zFF6YT zv9fdQtHj#tW_?)+zbJ&%Dj4^2Zt5l6R{;!uZFcM>u3JmwD9)Uno-5_N{;>a|VK;LA zD@}+|*n+^A#4Ru&WL0#cHZlhS?hWY;^0V-4n%1{R?U*60wBshtJ z)>ys>8{6<|>29yAO_~LTkN>x($#<60O!?N=wEKl3TjO!#qG>$P-o|w`3S<$Z(H5P9 zzW}TFL8Xcw`lz;2|3UVkXwg*`qimn|{@h#if45VlM+{rQINm=)oorQ%MT44=rnZ?{ zpSqPE(arM)&TRRJp{(KBoSc--$WGgx4Qw6j#>7r-eRo9CN(Sw1*x+bs$v-iG=j7TP7C@q zfr-g)82BsS^yn(_h*DTSSQ8(t0+P1ggW6%$7GdsG4*2pW-kz9=Om0E&ME>W_4VPS= zFz41?4L9~ZZ^lTbA=5jiV8%`-Sm40L!s-3}D`N-t4$qWLh`OO2?8R$*bXvO~Z(!!F z&--2oBUQ3W@O<@G8}xJ2HY8NCd=z8tW^CJOxo=1nK!`wIyy~CcgB_a$yF*1a0eo>O z2Q1_Rv$W|})x1bVHP1=V1J9_<6%M1eTK=56hG%uN(|WxTC90{l%;sXH4SH4+2|mk$ zVdGz+&s;Lkkwor^X?>?FYtqt@NJKF=)ihTbmFB|l5p%@T;hgf-W+Sz6Wt^p1aCxG! zf%PjR1=`za#+}JFi^XP5n~ZOwCcjQO{2GJt>LJSOz-&7AO*g4MZG80=cRtJJm6oI$ zOojDo9>c%qx3`Hc8Iw6Utjh)i3hF+#;5j6Xa1%K>+f&PZ z^9t8xZJVe8|E7POdzd>_1156uxgOuF=o`IT)$&+GE?33m@|b#S_Km1-)?5!zI2@R{ zW!quacYEWjv$w+ktlKuH;sE%3=fI?e@3gF&IYDytuhI?Cv(0TY%+Fmt-1TfAqE@Y# zG5@W}zZ?j@`_9;#Zvw@&D8lHHFSV5mW+P`ok#o|UZfEc#@mxS3)>^r0-nZI$AF5`R zuT!}u0L#FYgx}`p9z~WDuW!o%4Damr+Gj?hj^Tb77P&~`VtaOR4xO7lH3cnllaS4F zuT&V*sD)8i z&?WGI*Eu7obH6ocRBGJUQ&}sNUdS!%X7ln6s7$R|f7j=%m);2oe^R|dxQbAZL z7laTJBUFW`LKzd@t_}N8j@8_bHB5|iuwn$n^6^=2&$Nh5Fu@aO7n;=de20sjE-Krt zi}%2;==?(7Gw7wUYKi_~qxMa6b3%A9nj{s#ut3I~md}oyzFJN=^8?POL07hJYB96` z)gwbco5G9Q>JHNMehv@TOxP01#W|ATWRGh&8ij`S=J3s751>1*A$vKqhgux_=u3UwhU?S-URE6Xa`=p)w(6em`E{)?HTG-|~65)KW@gH3Y3OcX=pbI>o9%L51@h<+vo z-EL0#$2dsw--B>t5xRZVbqv^)Wj^mRRx=dZB+u#Mq>IX4W8xiWB)qJHF5oU&fp>`1 z!bDtdmAf?6&HWPN>C$wsF?&4=8|xU#`E=f!7jo0@H%*PPC3@tZ5slb}Yr(Qw?tA^~ zTV7Pkq(l@|tA!DL(yK5r0)Nfoqq*qiLfasKe4KXkyPX7ZD1Jla6XMIxNbBOrQVr1$@ff=I1tt-5eg~@3G2(w~v)m3fwUo z8{FlgE@>5P{=!3-V$Zj|JFZgx>?`CAexCl1pD3(jLN`Ic^Wnbsn;h&jzv2j_f&Jr9 zTb6BolSFmUIjS2BsmfC;0GoM*m-(u0>z8Di&ZQNEkGfcwoTt|v{AE0EiOL9t9e3Mq zd30SF6Krh|D!g*aQx~q_dj7vk4oa-*mZbiz^-YVKz>-DTr@q{liDbYNO%v*>CZ{_q zlu@A$0qrKsPPJsw*?!8~%ff7M`!;Ie<;B7sYr4;)z{wU=7R;UAwp3+Xrhau~aqbP2 zcTKo`Q2=cI<)=S>qR!j$ec|_d+&Q$xx8~!I|KH~wIW`Ple$;gv#JTA7jidhWT+d9t zkXbxD{4J{CZ=lL7??R1B&aFXaG=wBr*(6v;52)J!Mu6`ygp3|3xyu3Oz49}QmIEQ@=2vak5^dbA^crbK`H*np72dErK0F> z$oSY$z4euW0qZM6^|9C_Oxzl6^G6rYo^6I7{T>Xi1sQ@%eAK4*J2cPysK`Zfpvf5j z`Z|hW97N{we_6DNKc7gbOWlc9iP7AsP}dnrFDHnXKHIR%+K>Ye`9QBr943wHKTbO1_d z1J~i?CUul@h#r+Re42Wp%h>ns7qUi1Y>?C`J1n0|H?`VKSzFLDxyJ=}2tH;*5+28~ zpC`Iw7l=8~H(=0p+L89mn`k$2DqvD)x6a;ZU=&FS#;04<0YS^p6DGA(V(yHW}>W ztK4!yLaXCfyJb?TOUA3#Y557aTnJ`IgkdeDXX#qc=DVOZw^n|NH#_I;5AiJ?|i;Llg0|JmuAP|us^ ze!s}s9zO=%D%5OKaQKITWO09Ycr)w93hcU5Qf#pE!xzW9x1NQ-I$6a#Hvi=?o*u3s z{$GjNze+%S?KHe;Q~9jl?oKi3=)Fck%EbU%rFOWe9Fqr*#!WJxMA6OwJGC(MnaYy3o)c6}5IwUhE zm0%tv#Ytr61lkJ+I=(yrz7tqFE-T46{ak_imL|?#9CGa&)qCT}R7%D2b{1W4zXR&t z^dlE?V=-})7w|5cdJ3x5eV)D{mFcEVi8fEItv zwq#*voC}uBnKttp)RhhBqUhB;7^ppvV(Io>)voIQcH#NW__!LX2AC-oA2~WI_Bz0! z67rvdt}I^wG|xJjo#T2hI-c*4G7mG2)9UP9@6wnb!pA2V3YX>C<4N=!+-zjoe-?t$3cs>W)?kjKp*hMUtm9mn@0V_X~N z%d0Ah z4w&EV5BBjrau@1{G%mEen(i&5OW7BzAQ#Jss?@kD1owFja)|@j2e;nXb#&PeA`e-u ziKV4hX_&e6SXe3T5;4|RQ;73xXaBZz-KQ>wCg$WN~LK(Yi_1bbIK3t3xlI8{lZ9 z0rnAoa$$OwFl(ufm|E!IZuAgaSEE&=Elzz)M&qtM*!%PoQCSY+CLB_K-GU`u5smQX zjqUqi%70lFs8I5pm`snsnYZ|6g?I)VgVyg)SB40_u#hQtCYZTmevIf7ZdXQBDX~a3 zpMx>tI;U`8yenjs2;E_kz7%ffO7Y~Hoz0bgmtc=fEsZ3rz+y>oMAz1)8wm!DfP`o; zK%@N`+ROD%TUxb-CW{d_cdOpQb`n3~2Bg>bqj_seIK0xAJxMp`af4sE2%3PoQxXbE zI?WczgiyFsY(_}zBt$LdB}tKVV}PKJtl2ujF6YEAC)f(P^r-genuXQ)KwY0w8)&q$3%9vapSE-CJqzp)b zl?{#wJaA*1$K%`E)(FF_ZTX?X>!VQo-U_nU&jFuz*R+p4sgtm;iw^@MUpLPsbG6u? z(~EA_%W93;_;j13dxxJT)atUOv16^w&4Nv@yL)3B*v0#2s*}yiv3OWH(%3qz@zI9e z{fflMYMQ2==YZMGMhm;u2G?_SgnBztS-rAm_}zb-*W@*=S=;>z1BT(_&;b75DPPjc zC5hAP-#+3T@6u;7I@r}-mG$edX_k_BqYwEEyziy;I5(YMLOjt)f-rR-8TIAN!1##3 zlWid@X%F^z9uuj9AE+Id>S#ObYoD*}&gbtX>t4)*zG5~M5KAh@S`v11gQ{n#J1P0m zKfC?}%1_DPl3=8NQ{$tibhFdsXlyzHg0G}*r2apS{hW%$(moUMGjt#7bKA@eVbJI3 z^Lb*Zp95u|)EBAG4<^4zfW|d#wDcco&Z9vAdSSbz=E+IIa{7X8*f1X7=`tzmC(J2KrxhpLic&1@ey$xOT%r)xxnmQ|Z(4nnqAGW*65nnkA>>Y+3$z%ni!(&5(b=TUl zg+4lXFoa6IMy35K3Z42ZZB3~^5KiF;Ckcf2@wih!T7ZB360@oVZLzpY$D6#$Z!bHo zq+Dt~cgF310|*ak4DR=-??l>FiO$3KoVsy&$N+jK1*t($@~mpw7n#LAdp06BsO?@c z=cfRZvI+5x=(sFq%;9l>Z3SEi%EK|n#U#L% z0PZ%F--$7o9Ok8%5W!kJVz$PDS2PYR*X3Y^#(=Sd$YRBb1W&@ zI{;Yu>Tf^*D)~hwLA*+q7w*Sjg+n77Qk%{{SuZ&E`2?UwAlmk+#K117|eqPQRKhsg)>R(#Kur^0=2Z0HsOkl=!X z=+c|StpY`iA%G*92w4&c0+M(`Q=ut`bq-;1ilBE4QD`2_j*+1bb6_0Ig@MLA3g*LL zSOCLdAqY!Q~HR`X&VX%sWrGziEXB~HW9F`#XJta{SCjM>OVqxgzS zrM4iE*A^giv~B$dV2!v1cqQ7n&7&SDPtP0nXT^mtXkkbt*bg1r+6)s&`TamXr^9{D zpGy_z!4G~;SpLgpQ{pez_P1;NZy9s}QT*N`0?@j$V*v(ATm+5k(`v0Y0-Ny{{1t!0 z9bMwq0Jx$mlZNvuza-LiqHG;-2--F5Y^-7I`(`9xNgqNaor zouei8(<1a)3Up&WAcecxDEXNr-QH8*(zh#ybz5=S$#w^UN4<~dQK>&LMd(AcJs+m^ zMBBS*ztBaSEZmUHM0#9c3Z3pzJ{cNekBeeK)C2sn(3*b8lI*Cuk9!73+*87mS2D7V zUoxWY2|_mU)eX#6r;mqEHpV=1L}hinhebHhZ#^pOt@IkS`b+5{8?+}mpzWisxd(#G ziuzY;#-fept1%z!QHcAwI>J0pM&c}`J-1cEmBY_(&dqG;Yg$;6l(VRqtA4;8(=+?N zKVj{Z&da80lHF`xL?AR~;iXwDOvBSYJL?z!C$2!LRK_W!~$H1r<~ za8|C#8ati!z}KiVDpxwDi~3?l%;H%$yXXA0zq3+Ro~Vt z)^jy~;sIXn(LJv}=}UUUN!~R%Gk>nltL3cm4cYp=b)G$$r|)b!M{nxwy$kR9yYF7Q zugIoGj9-f>z)&z-ur1j0xIA1XPK^uUw&OeTCkQBlh~OeL5vCE=5Oxwvi7w(a;u?~X zw4St|EFweX1i76&M7~jxS8=;?Hl>*IEaeh)2Q5RJPy37Zm`Kf*D*Y*T<9cKH_8eKK@>TNH8e4BAhAQ zB5D#{5#1I&5+lT=;_+gVm@ejttHf%tN$eE+#4X|(;-|#B9&LNY@Bc6MhQu#vk<64l zE4f(}srp6QFP$RYBFmRKWgW5++4HhpvXipUWmn|Ia=ttzU!%Y&o>3f8oLBs(#3{u} zr}BB_bybN9qq3>GRjXCI)yFj}HD|TI>Ne?n^rsATgW3==3>k}zPni;?f6T`$q>!5Xm^|;MsOWDS3m+glg6^;?dvyQWl%g!>V#`&W23DoPNxMVKam2xd` zz2RoN8{9h%w;Egs_rfFa%kW|Nyhq|$>AB%8_ENn{ug}})9q`Wc-u8KXDPR8=I#1)+ z(_q8`3r1}a6qZd35o)+~BVtBRkvr%oe*PdkkGT9%<-bVIU>KIn&Km&$-k$a?1;T`Y znaK2C^<5p)^urBB5G08yXAg$+;4EI#Q*Xpf!)F7g<5i?!@lD3)ihA4p4<#wtjYJ#yD_S zUIz0MZ8u)pxoQeJb+U11>)xw(&Ozo>d#I~~Vkk~X4YLN$a`c_K4t`3nfKS`&ny=QS zJ0_dFvEZ`#Ns<6PK*GQGlW#77(s^@3^R~a841xq?si4@nnGQGYW|QZK{io}Bv{uVw zbOk&@6AosNnsM`;Zl2p0otX=s`S!FHgmM{0@kV{5a`X4Ds{R>lZrg{Kyno2gPyczE z;-}Y+<+EUcj=PK|AcCtLLcyBxv9y0Mtbg6?0IS|ukGFes{;!JeeHB+T8q`58)TT0! zlsPR#QkixStBR-?W889+niK?FMuRvqGJF6G$Ga1=d`xRxZs^u?N{MK-<-)U#YJ&CeDgbEJJ=E=dc2wc&z z8p=xlLxAn+iVRSsfRcabDFW@Q;h|(v-h-Xz1b0AHqgm^O$f)w)Lp140#`W^+r2riMe4hEiFj$PK&HSCly zT_h0a)^m_r$NK2wFw-4e50yEQ^>ECKpv=<>rOgmR!Q3!j@KvXjkcvAi+V{F?e9JrXGOOkFNAdtRy%1|)Vdq8Cu0GPp?icxIBSA^kKIB2Tf48wOz z{nU0lSY0OVzY2rTiO$Qpf(3+^^-4{_^gbA4`IuOi`IgnuWy6OuBt80c=o?Y)U!z1R z9l^g%!Vk-N_5-2j|0bw1^e_c`dNYx&(QNq#%1%~DWj5r#-N2mTT=j%jqs$%Z|LHdy02`%w1&b-W0Bz zpYO-oJ;iG$Ms{9c7Zfla#xwPx#u2WZxmbtQXjOTYJM$mlaTF9;ckXVO1pHlI+yjr3 zaqGh=3GOSpvHjc|Ds}-BV6EF_Oo!N#9 zW#1h>8f*u7cJ(yqPR6G{-n5$9S8@9VNZ{6YJ>#Zh4i&x$wts>Qbnv_Z`Hxdf%8sPb z+J%+9se$Bp*r~Px3n_v5VzY^-L?;+oF(!~Dv_@)e0Rjb<9`^0;j+Kr}p$A21!E*m1_FWXwEOg!o`ye;7vTn~~z7<*VtD>O4g8#|a3P0C}`J zSIPu+z&MvrFP^Vg0xIroQM((M=D2h9xnNDrlcgmuQ5%=HlR*_nSnUcl-5?3>!N3st zV52dIl5%ooyhYTTkdv%#*%O&E31iT5r7#nAAww>Fr@pnW%<(-Y z_BMkNn&sbYVy!o}19v#p>yh8__3Z->&_jq>Idu;{^zQPfKi<8xFSnip^lRm}o_OGY zckp8s;2_Z(=rV6#E|b=P#egSg3y^OtbuyQJqY zE_o$+^4NnR8X|eLRew^Ex+IFiZW*RJGn@`u4u^X7=Xwy%5}+OI80Wb>ujl-YLB(N5_&i30W$*VKHcW&Qo*rNbZ6*Hlx-ySe|3%387; zKHBJdwFbQ$fRmc&`MtC9@&8QEae`wO-mhE!2cNnrJU{&}`CH&+>xD!UK;{^J*=XE; zG*^4;8v3n+J_gsczHbqIY|76Pkv zTe|rU&FF^9vcBeza*P_LrT>^MEMHt2r~Qwy_NozGRfjrt)%?F)W=#0{f5z7Y;{GkK zX3C_XpIHP)4zXYuYJqw+yt?NA?wQ+u6bL01rUV!E441{jkio$vvL{$7_JDb`ROOH| zIxxKf;o_Wf|WA%tN>`q<*NR+SpFfd&6t3#&t$ zJt%sVm(Cs#YUCnKug@HBHe#I=;i57uS86pzuYp$zYGPI z(V>{8aB2rJlg9jvotcoGpOIve%Dv6dxG89 z_0cl!VQ1Z160Ki5UJ0?0OU1TDBkavqlvyt{h!)dU7=LLje=42*SC#fv0S8Uutsey+ z__*EJjSN%65){W}(mQ3M&Oih~wMsfOB1s={rCSA8p*)TgC?)&CnemQs#`=$1UJmD2 z4fakVH*mk0xe}v62rW*d&fl88AHs2Ng{M?dvOCBKxpfO3lXx>FAf0kC@dN#0{yRb4 zKUxbGdz)~HX`d8HEd}qC+JXO8b0*X1lUkyP*Vh{|AjL`Hb7^<|3;9zojmo9owN4d- zy|JQy#kU7Q4eBK!>Q5)VrW)b)l6S>Ud)Nw8nn^rGw=&BXreg|jk7!fJXxqTTWm%h9 zOu?J>UGZ(($woPi?p=7k-Oz_*K~zkEPezDIHN9%#a+g2WG5bmk>bp~Q|M0NV9_ICL zLb=^Y;Tl~lb_!@weM_M>Z?#hk1~Bl*+?dYOJcG3&FouH#ALKLwT6-|Kh~{X5q7;}@ zPJus=JpjFG`(>_9)N|!WQAPh`^{$wc=VBBrPu+!qBQ5)8dy_mk6U3(ACJeo_>daG9 zUZ3LM^^zTHBvW{x3n>tY989M7>K6||hfNG!IH_wiFt(5i3GFSM{l#6G6&KE=ax1ux zsCwM(qE*T-F04k=5(14q_o3dFDsR}*b3 zofv(I+^He8#;u_5ljZwbrU>qCGJo>l-bg-j790XYQCW{Eh+$h%P0_Z}Vslpp`NfG` zUU_~n61B8MD)o`jp~32d&;7=>%NV#n_y|Ci30goH+EZBp$*6cjV^B$T#|un^iF_h` zuBQN|^OR!`9aB{zQN(Rn!&WJqr6beqBFb5%VO0*_qK$A9mM zO*h@Z;v0-Yt{D+r-sw?Un?s=l*N$PhaW~K8!M*`TRQ~GjeS0#Jsk&@B<5b&ot+p<8 z(eOOpXzXUH8MS|Z6{R^mFg(#faGKJ1ws9{PS5VmFpX_i<#WiZrmGH3X; zSSUzsMgz}Wk-|}&6@WPE>WvNkJ1}LlhohTlc4!)w0~N^I|w2_*_GE zL)r2&yZ0c>C_xqbN2#=M2I$y(+_~rPVlILN$Vop6R_UKwYW@boGzS&PSU?ChcU^&G zKWrptRE)ap7-tyRo?Xq5-fwkngkFFCyWu-!%vQtx4mFHfxxdC9*y04Jrnql2BsK(Dz5HKs)e z9w#lHo7%^umc;W@Ny=nce;7DW#oenYJp_NV(5N+F45bB8Ui`vnA52THmMX>jJ-tJ- z+x0(Qb}+xbMYp#%9?fh$2zLDPxfVE&+be5SA5;_6Y;vW&{BBoI zZy?$?+ad&&|I6b#z0es?4~=XUip>v;6WOW3@NgDsd!8C?e^4osZ?ll z#ecoq*4^We^y%oyEAvZU<3FY{@vNy=8qtV;d_jga*~jOD!Jyy$O5TaLQr-s-6D|9H z^)w~pPQAFvcg)ZXf2^xE7)-dJL~i=DG4zd!%>XoDa=QcUn62EWM>cX9^=SBmU0(%< zA&}={zj@wE zpyDhz!!q-r?GOSdfC^Nmr@*tGgGPm>`&)mk;28wp{lJ;=Hs1S>wc6@@>f5XJL;26I z+6^8n-uzN(qVen>27{@&D|&D9d0;mXE1m#b8gJ?qFrdnQ1~gMSz>NEYwI%QyK7!u= zdE?HUNoV{E1+_L2Ard)&w(`q@H>W<-@#ZC6X4Aqag}al_`pGWtyfJH7^`ZzEs{Z ziUP+}k2`)r=%e9y@9V^)GWZ>8%oVr^oJlNXKbqMzfzPC7jEURdgj8}Ti|ReSst1X{ z6&0=_(2UaQTj@+cc%6si@!@URTZ$l$Dnj6J*oSImet(at5fe6Je?` zNVV}8m%%(mVKAl3icaE^1Sa*O5gr(DjZ+!kOmTv4lp;o(O14x#24irh>1XFz-Daj> zHSWp9mReF%G|1!14SEX%YjHUpki(?aYA&s>P+w0`Zo!*7)9Ze5>ai=6I5Z}niPW;t z4Nd0*tvDRR>k4y4jFhGM!RL^KQp?(XdZIANRAVRM91@94Fd@dV$|KTguhH`=l>UAL zhaq#Mz1*Z*n=iMxxj;*EpMN+vgEa3<*`H38tNegUokc7yS)k%#ALKC-mlhT$ih)sr zf?(uZ4Q-Hhura~A<-~L5Q>^}oqt1b%n#D%eE=Ci4fY`W!NKt%`k$_A%!+dpboJUGx4B$?ywG|c$(F_-RLq;6YW|K zeY4T*kh~FiK`d&wdQ@?dR1IvRsYwSsb6g(l6{nIEXx&OD?)7EjLn*+ z!+Ff@9^R^KZT_M6PxqIBczSdjWjI+C39Osj*+(w8xU+djkFucMBA&U;QMgLu^+$_Z zn#51@Axi9i->fw&^)@t=!dY6qU&@x+t?kRyI@!nz<_K5_s_6%^CY!7e9KO5oz;11i&>`I;US7O{+BF)N&)dG{iw zsI8~cOb+uE2@8tzh6=ErmW$ZH%c$935T%Er<(L~QFW+;&A$t%z6*G^(wG4sqkRrfB z4OH{GXnMf7P%9`?E$`|qp36dz2)xcClR9wmJqTiAZcC3_@vtwNhEmyG^W~ry% zrsP<5H9yW8F3E&~5W#*4p_nkEr1krsi9h@s7TYUjwAnOkK2a}dK~Gnh8#2-E5Zl`?wLfmk9K(rEXXjNAHz~O5 zHQ2!f94||GCb(NsH4WCc$4wQ*{=-3?RPX)giR-K`tP ztEd>0aqbAD9mzc1F`CFg;~Z~PiQ5y@<(`xP2RD*Exz#qPGrR`16zbJ17Q;NcRMIL6 znYRCEGZEtBzV?W#bWYpHE-h*H6~AQAp=tJaD5Se`($8M44H%lH3-U0*GL#-!JcrC$ zWn=qp#Tjdl`!wSM1tvf|UlXMRR3~H-N=Irvn64WxAr^`AII-V}Ovg1DLq|G`a$l()Gu(8({z2>5_8s*>}uuajjEKGS+ z$^|2^dM2YJDavT==<<@TMOc<01t;cnqqntb7QI5NB;2RmSHUGczGXbEDRC@sN? z8pfFMX_u&1Sk#T*j5U@%mcSjHS{#40GEdk1z?Q@>=sTI4ye^W!9aJf|#x&6fwn$ za3CmLM}4jV)9pDh#cF1#1VWvZYZmDS3%-YDVOZFua89nwO6+c_(NKF%LrFNJgE4nt z;`c;S%N8tB0&VNj7TWY|H}0>8Jw0D%Sz=tj2<1`(Zn-dw-U1PtKXP3poD@`7z~g{r z@G-c6DPS@B=e0zblg~tAuo6)XMX?fLcLgoB^R@wZ-QY8+8FT9Hgnj;W5K!rWmGNlY zZyBaIMX1SL;6CII=}LrOU(2*tXZ6X4vf*-NTG$A=sy4Z3m3Y z3~5Lp4AT^bJtRpSJJMwi!|>s9y^01z!41g1rc9pYIvBvjBXf1rMI$IdCq)r#8S^yS zWRb?M^caD=XyUe+?u06*4Z)s>)6|pDm)(0Ov1BSUJXtK)!cJjopi#v)1H+qQ23L+m zy=k5$j;pxtiO$aPIs_TI-T5y(6W`b5hTU$jHyDv!#Hlc|tM%=}3grS^a8L-_KKi>$ z(GWHon}tG?OJAlR_`v3-nCqpzkN87YBJvet0u|hXWTj*1lTDG>WsHbc>T}e_9@y6n z8${to$Vt$QE?4DxS873vF_uenyZP1{E*l`6aQ2GL2z-z%At2!25RW9hh6!*AC!T*oNg1c>!59nFaNS75L0k6QGB{np zUC}Mvyb+7QX*lv`b<2ZS^6EXcM}%y+hIbvrVc#dKb(wrFs@Zz-r{@3-I5*#Yt{872 zrK9+kE%e!R8|W#63>yt8vLO5TL5u<1N2b**l?QVSsqD`_lUWD9(Uibusf_G-l*qnc z*BU_T*cNSfvpsw zvdN`ZkZ!|=9AtSBdD4wsg$a`76;ryDkvNWJI6d6Zo1Mlo$r49m*b1f~EIG7EgBK7- zs<+0dhH_MNY{uPQnBtZP$nJi9a1f7}!78!K#Xu8$N;)$LlN z31bLG$etT9YJH2e=d#DdL{~)D!`&2YpxK*ahLH>vP!@M%C5?(n+Y`?V$FsoNrj@Iz z0VA+k%u1zY{;0Ah-O$pJj^5K+Go;oS%x1mD?2Ck5!nXx7&l*dydzj$aa!<0ORcSDi z!8e&Cl_*sbhr^^XDL9lGrZ62(q+3W?!(yLK8N7|H5PF#ZU;t2!r{kE_)CK?Zk2F2>aZJ*5-oGV9KGWJhXJHQi@s6Z8pHk5M0a`)@aGv%dIPZZ*u*6zEWF8 zP()pd|~e#_$>#8Y2ikQeo7P&cD_@>$R9^v**Tm(rIB6YWITHFPp0E zr!r84BC`nay?XSdN*}}FD^kH#N=;V;mV*F@VV%~!n@i;Ym(dI1jA89rYO@TuExJJl0_G{?bfM9?Rf;@!i`vvo^zj^E*knZTB!ZsjT-#4;;$A@OWzU9IabR$3RkJnLn19Xw$5uK=TZPRkt>N@Ba)K(?QPV2D5S7GQxm|z>i7s>yOsMj4+oH|&Cg9{XH+Wy9qT#-r`+RPqrHvX{m?&$nB`GN6DM zg|IA_qnOat8TL^+YN!`HDu@I_jfX(>7%aB9aqK#FVAj?8wdr*GMUPfe?igZ~)L;1H zLvy5|8MfD^tt&*cyjJ?_PBIro^)|mA3`cnI5@gBM0u{GI>-*R8(XN*~r)Hhf{aJ6Qx4AtU4acSnh4O}X1GX1rIALGaw3W7TgW5!DKa%hV94U~Z*R7y zS7|=m#9nZl0{5XV%LS#aeYt1~}p_&F9hB0kw9oo_az z*UUjh=gG;oHgqmyYQ);hRBvugta60lFma_(C8<@CN1Whgk7UUVoD5N~mX`hf`j8Wh zVTtJta_D0UE89sVGGUf^;)CYe0K_x`7a?pSj1TCT z^!l>z!~waT{Qdck|6eh< zf`)$KcGHd{20smE-up~%V9-KP+jfNrJYaLrZ;tzpUg4F7Hf@BSv&gCF^I&z>dR zo1ag-mC8$ChKR!Wz80$ay)8(|Lw^?VI`=*Za2*1p9poEayS-4@E;Y@gx4{Jhu7=%` zD}9eQj)44yaq91|Kk940kqQKVZN2~BU7P#=9=h2NXcyc@tEtE0CqVJF*UWS8OE!L- z@a?_=)AC2nVa8n@bMHhD2lA}QU<`ce)C<2A={-gFz2>VM%^8Rh%uezqXc;lX*!gmLj z+vx!QRj{IKj$t}CKOWKnZU}x6MoKS%A9P9wHK>R>>2L(Nx5my;A(E)9WZW z4Z*K`Cf+rQ&;7~ZpqHXD+!p>x=W;nPN$g4SjY3ui;H6@b3*|x?sITpe`=E<~76_Aq zbU^Ud%?r@@pK>}4oqlOHaA?Qhk^ph=43x)bi9Ji;#8Fx4Dp<;)5K06ja3f~imdaow zO`G!n42}QpkE9N7UUmhpbCIJs$t?d_uQc<@hs!C?V%k}861;6Eh+SmGbw1gEFEgV!y~H)g%wi9 zAk1Z`WT&IBI+Y}et+#sp9phTBNB=NtNP|179rOqZ>R zd6N}F?(<0oj1nmel_fSr6gXEco$7mn!5a;O53?jq&#F3+)r>44^%!N^ucL{$xNHfy z@l%PzkZn@q59K{O(N~}an0XACaT84hX#{e~kg!0Eg~%XaFym;w=bu#$ z5M&ZmNJSBXID#sU5c9-MCMe-*!3FH|*)G-0r-oH1Nm@kKGAO(?D~v63`V^$Wq|#v% zDxC5)2-5tdJz1hjCw3(kRlT@7Ta8WIAchAawWb@Ut^17xK_i;P2pU5*4+#4oJCq7o zz`~u;1jCqS0Aset8EHD(F;h6mqYmwWQjfBk0hBh(Lub0fhw<)$2^`ZysNnnpl&=xO zL4;_g(4;W6W8Wqe=)#-*FM#rxOh&-DSDjGCKXg3?rivo?;D;_5?)Mqw6QNshj4S4rkz_VsdQ6!ve+46XIIDaSlhyA@42*EBr zd%AU=;3aTo3dkU>;B3;`%X%Gd@k=d`H*EBP8RzYhp8ijwW~v)QC; zTvFPQM~Kx`N9Ggr7vL`& zv7^n^7pN@;i7q*7$kZO<=#n+qd_NfU7QI@0J)RyO#|GLnzum#Fpl_AwhlmsglY>-Yj66mm0X006p7Jpe+}?AiOpFT)4iXPftZc&s z;Ws+MYD$pAxsoE9PaO)39Sj8tVZxmLe&~hr{*%Mun@kqP4Ni`uK_X|7ZF_lN-4=$L zv-T3MMh$0qKh%Dto^Bt(e;Ds7Q;$Q69x8}wV)TB>CW}FCX!2rZNPh6*3z1P{@noLi z%t2|7X3x{dUWF?3L3XQIUV*}jp>c&$tyS?GibM)DHQ159P+eP=ZVG_jNu#_%tPKjr zKz25^;Oc{lfByA%@y=aGkjTGYNI`$4Px7;(wC+}j$iF1rRKUPT))VA4`;MR1E)RkAL5#n-}w+fvp-WI>{Bsz*|v6Sj%TW%G!$8UjVV^g0#Q`r;; z-{jSgMZ8xjz2^#I%4T!F;*frhrya)^kLEh2LT0%+auMday6z~jx=C-eT!%CjKnZmq z^5oNTkdrSX8mVVAY`ajY3;6GF{++ zG`c$1EI`{WSJh3^kSLsAeXFxJTIo=Z?RZfyRtU+R$+cIIgGpyXV^EmEu@;nGx7*)V zkO~#I6T{5@wMDSJ7LljbF!+9~7%Sb*JVI|XL*W-Do z)9sJOpv+K+frTM5EjAA^k}pVrZU|f;FXcz1+GH;kMxr-MA-HU@2>s6Fb)iL z0M0yr+ozL~7{E$c!x!}U>@bNcl^L!iiHvO`og%3?#Zc{eQDpnYh@88<8VO(N9@j-w zjF_EmKalC$kV=Hj4)-Cjz@*5(*{d*Q!r1Ue^>~@$n(72Vq&?Xy0NI3gr_aj zj4)kG3W^hjCY=#@J&MJ-?r6{Kt78WN0|z7341T$Ch{VoWgIG?0}U`@86rmhhk`OVH>|%XdE1 z(}L4^ssY+3tLO zJS|}aGNJ7Jd4(Vi1JMhDNbCu#*btTT>f0@@N4c<1rGO^A(-u@aop<8rM3HYmUd(VL zB}HmsSnMn3*Uuq> zl>c!WfqQ6yFu>oVoyG?9n++z0+9?SYT$q8}Q;tA@Ts!HnZ6RAf33Mpjq_-q{AO_8$ zrc>5!yg7?vT6is)1vsIU|nsg{H0Mi{fxOFQ7FVW%f=> zB)F1{(Jh{Or!f}~7G2w0HZ}R}6}1yu$HtgdRHM{;tCUqw)UW{Mv^~Vug5!uNDqeSl zQ;ZMRu%}i5(<8an6M&^8HAstW$uM^zGgi1zrRAwap@9cr?(=tZKnY-cD9nma)&eP< zYo4fm@uKX@@G`b3*51lS4>9UbHLY z%74_QC>MQ%HQ#Hxiy`c3F{VTP(c|6jwmAJbA1bVwbvH{ZNVH_at$0vKxY&UoZ#!>w zzShq87pu^Z4GUU@fb3CMZo$-{Ho_GNH5DRa;(*;|oinNjK&?aI0gsDGDE1Vs?`{tQ z9}HA{9O^gJWpx2>pB=q-PO3N$tMjjc+*8c1KROz@K&pRfAG)F5#COn5UHG?6t2m77 zZ@p$cKOD|9%}3jQ{~rPZL6If!Ly(84lCBGp0;X4* zsS-9&sKT0OSdwWsi2Bm@F=lEfR25jC+vx{mzDKHfnkeTrCWoN3vKT)rSeD}iu1)To zQ;UNu#3IBqJS1REar`XrW1>Tzm=yxp00e+6{zChJ#j8S?`CMT*$pF z!m=gVY{pvmmaJ38`7MahDd-hjT4kW*6)6RDUz*eCKvGtSm|gyvxtkfHT3CxIMvCdP zs|_dWni{snOs68C*rg{zWy)fvDB-%LXIeQBJd&o?vCrkUFug&;L-?5IBTt6M=!{gP zq1sNAC~ZSjG=meUg4%8ohBb+(L!Vf1)F^0RgSk=%^;%!RYVRbM_@_~+>J1wnLmcfD zN_6=UAxXm!H|{^EJ1NAdOu9s&ssmL)D1d#mYR6pq;piC?75= z41mR}$gOvZgW)K2075B|k+pb|VYxB(HcKQ)%<7mZS$qLJ;5p$X>cg_DHMo}H0cD0P zYa1MA4dYs`i5q4-{+%B0bY7sxtTaaw<27Sx%mKxo#x~tc0oK7`5GL|T(HTW_t0Y5= zo1uo4)UilyF}p+~ZMqyHh?326*tXqxdyK-^n>!V>Dj5+fYqCJ9rVf1t zp(r#v2MQQBIBAp;ZUjW+V4k^&=140=QWKSaMmo+^s4WPM$d}N*F%ij-F=esb5(`Fi zFrUxH-BznVM;4(Wb3!q_NI0ejg=V@T=>nZccVTW+HU1J6YMSNfWN8XV=-kz0oMRXs z78s#Jn1zxm%TN$FR`UXs#Rv6J$5T!-6-XC(i0czg*R7j&iKjGs7y~Wx6{rj^0fhEY>UyH%wKXL*yY55;1dcg=+f{sxFA6i7?A3 z6zsxuY)TyWGz{`9N`o&mns6$*Pny8uRpwUMTXhR$YND%SYB|)GA0}LJ{Dh;4?Rd-E7lKlj3<>kcj?UhcPvmOQUrS5J70L%<$F-3H?Q;RnYx*qXhk| zG!zRST-j6uNalQ-uG~;3X@8fmjF72V)8Yvc0hLD$_DEPT)YO<+1S(K*Ra6UlwShv4 zs49cX=DJnzRtodj$1xU4ZDeU`$F*qD?`7+{TRP<}WUL@8tjYG2EZeg4*I~ExJ>QAC5SMl)dx{$h7)mRd?b1*P zg}fxXjMQW0WjFGjF%2vpQb%}z1SCfi#6hqH0e}CaAp!Szd_xLOT!QP4C^-tv;Heiu zbn&$TgKe;v49otO0FZ!%>w8AV`?Bn#Mns6790-Y!u!0B6J1Ha7PsQiPnh7dh)y>{) zIGfME(lk4X9dD#?S!vnka1eqX{QFB=uWa5+1F62d(EI^l$TnMlQ(~6&7yazIDN?Zi zh6Xw_us#;kYt;uw7#oAL_@zAE1E$ts9P>RqI3HT>Y-b+@iq~2X`4&NK(0Ainl{~W)Y1)f&tO}wWQ-V~0UCH#(R4@CL7L;ikx_5q3KvN%=i6*+>R#Fix49ib5u<9wY71WeEWhx;PVVnj+@g(|CRlLm> zoFY&8MvLCQ75Nmex9c1CAo>!Kkpsn}8S)U9b!}J#`>FWK6PhS2i|&r5M_OQ7MPrk|2nIS>+1tNjuW7DgMj2GoWVLb?54oLHCT_bz6%JBz z!ZZ0smUSQcy%fftfVpsToj{y%1o>8h-ogebK5s@&Q3UB!G^qyzjl1n6VXo0og@RMW zZ{lh&)(Mk9!NC_Jei#vj{+NiwxhZO#l3}2_p)n!+z$>fFKRI{d;)V0)QvPOanu^+= zna2@avLP%tT>QK9-2=-8Ve4_%d7z%14osH%{QK}HaYo~`mi?}w_C z-`Q%J)1$5WRDE^Pb_XA! zAw9XUK(7U9@;{Q}ulwrLFdH{GcqeU-qEZZu5r~m-ye*Xi#t^Fwr@TioUwi9QE7p3 z9uW^G{(kFh<14C?7w2hxrAGR9#@P**Rm+j}1HzqM#}xcyZrvM`yxyT&%hfn&!Z|ky z&zCJ;x>_NwuDaG1fiTRwqfCVY3s{_0*&Q=>Ne5NF4DWi^ap0bg#}3#nH!*wN+~=DQ z<>!(mq5KZ#BL;PYu_z8c@Lv$s52&LRV~O$iG}5eVaH;r1?a&R{T|M{g)cYT{9213d zt}4U!YIg^~J#f90bGY;IO%L!Ml;V9ut?c_70Mz-F9kfF!lyiPMEE-&?fhYL5PWBfa z@Dwl_^SO87_nZgvzqUgQa0!Xm!C!#nGYjC@$lR+f_yFn%ilBhA{ebp&c6xVR75p{` z!Slv;5Hi4I37&qthNZdppSs<-7U%x!G|qq0jHvtw7!B>M-x2};f}q@#I@bIVxB5Nz ztpC2|$mi0_{z|_WKzkM})M>Nl3(%yQk{wOHG74+|dI(W`w?)8@H!jBSCm05=hbYvk zB_SNnwk?S^R+2FMw{1I#xUSb}=4qtzWa?=Iyh)n)2?>XAsX%HuOW!4Ht565rpQjHu z1#o?cLQRGzU+8gF#0o?+BXe2`mZNOjZ6x$Qtw-`4NbP#n5*y){oIV&8>zCBGdWPab zOwjafna9@{AE@Kn`oKMAEeAbupX&x9Pc((GK|y~;{#i>9hA@wf+MmUwZAH_p-I!X- zQFcZuT~25^j(Kg>{S=af)isS1PtaZ2!|DefpDS?{vRVVg%eA;!T2T3FPvee07-+UD zp0wjlqtI2F=rk(CD7uTR9fAh;61HF&8u0{cM7Fb&)Z*Di6i;|ni8SB+Owj9i# zjg7HikZro6niE*3l#hTEm#p@Dvpq@3L`&|mKhF&}iu*t=8?>UN97(BcB1Cnel|1Gl z!Y#O#8)xPM+w!j)M~=l;+6!IRv@o5j(+*_fVcvg_R|$mJJstE)_{je5$Jr~Vd^vt)4|8pt^yRe;FFNrf!mmhVB#5?eEE&ABsggZ zg+rpZSH=zanf(@Qtw0hrv2=QwNXUFdlXJIUMU$W9<#haZ>vkd$f3tusXx#Zi|7xs0 z$!i8*==RXeU;o?t+ zljeOV3s|xLg+J0i))0bGx?%H-a65(^TzcTwEh!zeTdhZwsS!i68f>2wf*C)HQu>tZ zD2A@N63-e~(;D@|?sTr+wU&trT?%3ev2=_UwAl#IKWN{uOczDZQ)METUJLpwuXlEj zIfn_yMc9z11}3|MNSS`uTUR>s-;8ZG;YI^)F?y^rHc4#>jmOa>m|P~|GVr;C{{znq zI7FibW1Hq_f2`B=i;r&UCIeAKP9(Jn)3nicz0((P*OG6ahYulfzhxz7f2@olO@tch zbaxs~I75tIs)-lov5lowio60MVto0+>UcIR(i=0HCUHPy$ zjhwE2;X)q#oD>hoKQ;+%_|9rD1pJtwpy$DlgJ8jgs*OZv-zG!;zgpl`+mea+GN1rX zzzIGpq|4Kqu#bAhXxbFPK+(p8+hrr4m6>mP-1^AUL>Ai8nK|BD*)Sow=6f_LI*E#O zT1R^x+M0_uBnU1V5E)Y&&O|<5F6N*dB5KK07(l_JJR+3Ggvs$SLv0Yrz9D0^pK3CP zHp*^Igt7*vIDTwXrO9YjbOS9rG~nh;YrxkA>;9pJl0mRW>BI#84nmiT z6T6X6q^wUUZ5EQR)hyfC87sYL+lCA|jBG4YTlaBu6>E=VNffy=E~Zl@D$j~ibCwgy zg1W*9TTE&qT$mGcID3NQD%_b?pwkc#CfYFiDf6jeD6l#~fhlo(B6xo&X)pkb_=KuB z8P~vV7|t>>SMe-+JX+Ore{6-#&8%fjf>QFs%O%i%XScKbC~G@Zb9737En1P zw5=^{O~di{C|NUZStJp7b?!Qo&G?)0KDU-e3#c@uWk2S)zgyhgE;mUg#oCQVX?=Va zEHv(3e0}E@&I77NwW^8eQQ^blWGo_i-O;3f8SJsfQEg}hgXQvo=L8k-mk{^$cFrlF zV3Z~?C`Xs~pIUd95OI$|0jzD+%&9ewg7BbwuGY5i;WB~+d5i#|*3$~|ky8K$ZL*4gahcKoN5|!b1uuqT&+(xDz&DtXo*5aI;73I+!p1& zdTAX{pmj0v0|iHXcQ5Nssy6x%Bn?x?rX@$?byrQQsm7YPETYs?JkXu5yQzH(QpR?O z)M(Iq+7B6qhbE@hYLrbj&)t_y`c* zemIa%6|+^;clWn~NCzB{>Z0v$njC>6wVakML!@=x5EJQda6B8NIx%B4A*qWYNOlpX zbcfxg&*sPEUVJsk6yMn+awOYX){5Ts!cF4#eeyl%aB5oRNYK++2tlb*KyC;>FP3qg+s zu=gv5TUmudr8jRr!HZgg39Bc8Igt;P+#+ExMjOYYa^~|(uTY;tZj2vi&ksh6G)Wog z9+v3hb?enSDrF$**$`W16EGN|b*O0$e4gCoPE_+_<8cQtT6w6 zAnpXOs@eM9>{lL;ADs25KO8KR*FxaXI>Vbr7d)e4(`|qUN8s4_l=G-r%)^A^J;4(O zekM@cY9bV(rP8aMp?@~R?hy3yUmOO73T+4q`$x4VqM>`@|JIV&HB?N&+S$p@JPP`k zC`8;TO0(4kCP~OJ7z7%HdJjZ+>*MRB?+I8e!3W)+O5V7+$<%d`f;q7rc4nO>te z(r7hj7h!Tcu$uCX4*pGi+)nR30(`l~*>UqHX90(vzunS=2lNd4*lA`6nIV>3oT38B&nu$?$N&5*2N`B;q?XktETA!=lD%%&BG_ z?0u2z4&snodkFzb%-gb6zsNBwar*IIHFr_`+SzD>SU+sULUXEG;5C->5xRQ=M zVb!0vty1O_0PEFn+QFFfmXD(?G}79p_sKsUuAbVU+X~HLP%%XY+^*UGzdUB(_`&1O z2tzQP51;6Xo-@%WT_uwH#tMgK+=YuQf9j>l;$54#8a8!xk4NQi^sTu2Yw$a?KF{ta zb3Z9Lq^P>>D)?f^tq)t}5mQcT%XGqa*^eIDp32t0Z7^EDc8Y~6rL+rbZILurrDkon z9;W1z07rV|}iDrx;<25_#%sMh_vF$E0J1^L; z`dilOIE1xAp|~K^_SnPOH9-5+A#-p^YoQ&{y^lv155w*j^MojWJW;Pyc6PKiD0#H5 zKfZP?s{2Y=Hx@Ttk8U&s66sLsF0(|SPVjnXs=`Z8@%=yb*Dm5cW?phz2HT6=oXK zExRCcO!^n`urjb7Gh_K`^HhzEZN*4|R|1@4%$C1jLSY50<7-%1Fx$5*Lx9F1p1YTImP7Zv zV*x=4;eB1CqqGfaX+{H2Ce;&IWS|#iffjt(+~qhOIA9Y(h3$jrEvR8TEQBJAZp|iB zqhqipgEcDml5mToT>(l;QT()v3b@s1?On4z;+*}daJIxvH%|Kp69idi0i}AgTYPSf zOc>0!5cbt|lk89jBu2Xor*C0f#squ&RI)@~7fzPK;@kj9&`Pr zS*7*zTx%u7vbt#C!{@E>-Z$hH@b|3&imskL?h;gT+@h0+{X-Dk`gN{z%eLq^?dDDK z-xJ`8SD*eye;zpgZu{BBUDY!2SB`z!?B4a}iqqcL#j{VN(23 zK6xpZYmT^0iK``ogB02S(H$m(xIovygLZs+7em zFJxao85~1IXGF@9S7NvH#8i};!?hRc*eDKZ5|umaCR{qpOQH6Qg{^xB(PzWbJAThQ zohK#wp92rC)$3bhn<;qWVD+DXkcfxlF4AL)0zacv9y-Uw`*D%D*59=I*IF%b3T5)P za$f&ZKSOl?dndXP@AN=E%ER|l`rKR9v+X1sq;dT;;5ag1Znr$7+zraa&dLADau=Kd zTi+-r-7l>&laJw@tKZvd0|*}Z$#C)h9_xuSkGA|r?7DZLp&q(CczDgcV@RC*;&>x| z?ef~L{Ume`2AJh5Np==ZRhaMjy&1T`%{{M3EQw|6#8WVaT&Zg+(y7DH_6 zTzW=z-^e`Awsd6~T)?*L`9a(_FlM2kuT~(r3F_R?l$q|6k1yEk?}; zOv_fw%S}vt87V;?T7$L*yV-l<#h?v4a{s zb0i9**gU+bU8{`_IkVkmy|v3t59`rr|7`RnK>A47VmQiSls)R`FgXC^1Mq0TA#&JGiAq3_DS70+@s?We;Ufv9j0)j{?a$Re#;VZlcI6yeMQt8jTPdWm zAjk1?bRD?uHBm>bbnsGTJc1~>s9vd&5flTN*GpOG9Mzu1zgoYCCnOTbI)pyxcY+sm z-AvhJ!xk!{f+xlHTgIO4i=@fKPM)eulM0%h=iaea+|kLzhth@A}L& zhoBjLg#KpO@0xS^Nq@q~k2+V^c#;M4FwsT972Coyf@3&#ES3pbs#yG)kmKH^Lfo*6 z0tQDL45_uXk%Z^vNvZmxo_Ih58m`f`tRt3aWN_9WGcKBCNu@gWo!f!NAQjzV)Zn#8 zrBD13qZJ==u8oQj#|v~#Y@WzBghWHyYNvEoq*~{)c_Sf0QXQ4fJrbg*hJ#I^`na_w zmN|E#E+OZ#+SBCLjWjfx!uEu9bBi9M&R7IP<5G+on{q)QX}C;Bf%$}ka@)-+i*8eb zuv*yZtZ_r47|Q9j0? zi^%?l&hha#%+1E002c5ZTMN@mRKFTr7EoMzL!@b?VD7cPWHSBu$y!Ke+Nj`pCAU@@ z=nHMf)B0%7-X`{F0ZU9clY_`X%@qjb8SMhVofaHom{nEWMX>f<%Lz}pUJ!LJc*9Gx zM5Hu$=vY84S42_Ba3$ju7!`p-kZ1e0Mg9sTsvConG&>3&`Z+DE#%9K~`x5B(hM-NDvd| zALM=Bn)y3IK0~)|_KikSrC)D1kMxcSt`@&!X$zfxMbs3O8s__@J{QWE;2Ohpfs;)o zm~@m^Sl!zU-Ev16I%%=WjfSZQ>mB4NKmc**oYr|arqvq00lE1!rk{7VSja6SW7@w? z-GtoikLgvRj?qYsPLx-}h*;TZ?6E zx%-FtBcF*!K9p#FKzs{*sr@~8V5?2Pdxx_-0D`T=wA)2iY=t%fG0RHQ4?(g;Yclmk>( z3em1SDoI^jrHpEZZeW*Wg=ZTfB_PbuiWI{Ang-hpy2JJ=rRU10g4viXDHZj;sT?6m zJwcQ(&mdLAMy{zYQ8ap&iP{yXx9JhEXJ>;HLb! zqv*u%C_9zdkZfR4wvvVMyi4gYX{}m!x|`-VS_%u0PGl9sMJb_SFLy812P5eJ4&gGR z>^K!gE>%-S5sd_eF%C@MR!Gb@?E-{qZ~1ZG#Wij@zOAfS&6MISb{u+L)Gd}UUoUO$ z{kRo@Zd-c4DjUHVlwOicTd%ePW8&s8sH_#L$jACWf=LJmQhraMF}A@e6`sH?0udF~ zq)X;Ks6yn;TnP!MD_kuoLOly*h(Y=(4Sx$7)?Ap3e{b8i^?zx;-jPn0!inAP?R(kTmW`zycv#N)=)EV!g?E95g;KS>6CkL$d z`;ub;C*T2h3v(n`LY<#Q1q2L;x?Ku|{qk)x(vAm$6VLUbgGxYk9@{J+;GNG3{ zj)N@a27ayuo0{hLq7{jliYdDpMI!^xLlVeIbCNA4U{EFm%OxqSio59qAIp*w#UW?5 z7Q@MkfkrE{h^i-X+Z0NWq65^?H3P5TTV*>1LM~H32`*!T zA=NkZYAB@B%GjvBs#`G@)3hDi)y-IzA_}U*ro~HoG$74)?b>Pkc3u+Whg)ceFzQ39 zdelvv*0mHlqQa}?hCF{VQ8<>MEVL}!V7xa2GpjsXC|I_=qFK|ho&96O9e6-%egT9+8=il|inoIgoo%)hUur1?1}Ov&r~RJj z7yxtEZsj@~mHOwog~OHgtDMug^oyKs6_m*@ z#GmJ_be28&V-}=zbsd`eTPrDL6FIaUAs`=Ql`EvSw6!uc zl2`qiYY^LbVfA8&l_5?7Gnh{@MWu;<3RsfGJ&D3HieLJ3=h)(PI-GWFg^v?o)PXkk zK+3vs9#GoOKXO=>kOX3fwRo|9L{MdZrolWjg`q#{Tz?u&AH=#&5Cbz)*g-6fO`f8b zzLYV;qs=OQ9>*+_tjFbvEJTXT&h*ZDSa&7YrWdbG_kaCX%m~2!_cNPdvWMM{!L&>JNIVfv~ZIZ=pk8} z7|VZku=aC}=h8ls&&v!*0qhbM!cdsQ2YGdYY_JL`)EO&M3d`ez6bK7pD9kAg$~IQj zma}?*gC>{;gN^Jw(672Lj0_%6hwAlZb}>6YaRf+pd8DjlJ&Lp8$WG~lL2~lpgSj#3 z5sIWnsS?Gof&~Z?B;weKaWtb4V}OBn=zEIEVGryq9)A@|nR*okuFA+F zNU$hI0}d{qQ3+4Jb|tMO=DA$6hiYSZc^L<9WH!kxR2(HGg&YBLdtOQq*UGy=t9%8; zGYj>=FqkRhjsA|%`(`oSA*4FM!|70*lw+%0&F1rDlE=$aX8BjrbWPrT`0{hkd4Krx zYNT#@umTo;`CFlj7T9LH5HE#Zr&Rq*E-P|HlxQ(B^0_Wykf8XIvMTB4F121yiOTiz zOp-A+q`%YqI-suUswy>VQ-EsyQ+S0_cVwMZ^%{-UV1sdfm*$mp88Xd~CEIutO)|lt zYoW@&u+OmbH4-V25gvyew#Hg^*IcDuuhhrdi9Q0669rNJ26m@l7j5i+54%5U(>X?M zKKSTUTruGm_js^n!-c_bSev-n=%{IwBs99kGbZYIX|Y2bIfday>Z-eAj@xOM-S+s! zuMRkfBXGx?uE{Jj%{C>>MtEYX{iNaTdrQNI%4=`zW#p=}MMl}IMYCcR`i91)=9bnrn~zzMI20Nmdq-!et2^A&+t)uZI5a#mIyPRm)S@2k75O#!YJXd9 zbyfa|J^{T0xz+*w68Pft`%X{oE3NJ?4pOQ0rtA?|*jKS*CAv3IK6+G6 zITQVgyn(8pJ~V2!SfxjC1NYTxj>de!nuuzpE*X76WGJ|^Etk?^XGFEIr2uMdn7}dn k;|IbTL$R`XFfhh^=~RK0)f(a0*>6_q9n!Q4a$R%?0BoipmjD0& literal 0 HcmV?d00001 From b73aac0d53c7ea89fe6ef0ec27d4da9ef60258a2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:00:02 +0300 Subject: [PATCH 14/14] Fix team page crash --- .../tournament-bracket/core/Bracket.test.ts | 109 ++++++++++++++++++ .../core/Bracket/SingleEliminationBracket.ts | 28 ++++- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index ef00fbbfc..2d3f74937 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -820,6 +820,115 @@ describe("single elimination standings - third place match", () => { }); }); +describe("single elimination standings - byes in later rounds", () => { + // Brackets created before the current engine paired the padded seeding + // naturally, so the byes ended up next to each other and could fill both + // sides of a first round match. The current engine spreads byes with + // `space_between`, which makes this impossible to create today, but such + // brackets are still stored (tournament 1252's playoffs is one). A first + // round match that is a bye on both sides leaves the second round match it + // feeds with a single opponent, so that match is won against a bye. The + // semifinal won that way produces no loser, leaving only one team for the + // third place match, which can therefore never be played. + const legacyByeBracketData = (): BracketData => { + const stageId = 0; + const thirdPlaceRoundId = 3; + + const match = ( + id: number, + roundId: number, + number: number, + opponent1: number | null, + opponent2: number | null, + winnerSide: MatchData["winnerSide"], + ): MatchData => ({ + id, + stageId, + groupId: roundId === thirdPlaceRoundId ? 1 : 0, + roundId, + number, + opponent1: opponent1 === null ? null : { id: opponent1 }, + opponent2: opponent2 === null ? null : { id: opponent2 }, + winnerSide, + }); + + return { + stage: [ + { + id: stageId, + type: "single_elimination", + settings: { consolationFinal: true }, + number: 1, + }, + ], + group: [ + { id: 0, stageId, number: 1 }, + { id: 1, stageId, number: 2 }, + ], + round: [ + { id: 0, stageId, groupId: 0, number: 1 }, + { id: 1, stageId, groupId: 0, number: 2 }, + { id: 2, stageId, groupId: 0, number: 3 }, + { id: thirdPlaceRoundId, stageId, groupId: 1, number: 1 }, + ], + match: [ + match(0, 0, 1, 1, 2, "opponent1"), + match(1, 0, 2, 3, 4, "opponent1"), + match(2, 0, 3, 5, 6, "opponent1"), + // six teams in an eight team bracket, both byes landed here + match(3, 0, 4, null, null, null), + match(4, 1, 1, 1, 3, "opponent1"), + // won against a bye + match(5, 1, 2, 5, null, "opponent1"), + match(6, 2, 1, 1, 5, "opponent1"), + // only one semifinal produced a loser + match(7, thirdPlaceRoundId, 1, 3, null, null), + ], + }; + }; + + const legacyByeTournament = () => + testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "SE", + requiresCheckIn: false, + settings: {}, + sources: [], + }, + ], + }, + }, + data: legacyByeBracketData(), + }); + + it("places every team when a match is won against a bye", () => { + const tournament = legacyByeTournament(); + + const standings = tournament.bracketByIdx(0)!.standings; + + expect(standings.map((s) => [s.team.id, s.placement])).toEqual([ + [1, 1], + [5, 2], + [3, 3], + [2, 4], + [4, 4], + [6, 4], + ]); + }); + + it("gives third place to the only semifinal loser when the third place match is a bye", () => { + const tournament = legacyByeTournament(); + + const standings = tournament.bracketByIdx(0)!.standings; + + expect(standings.find((s) => s.team.id === 3)?.placement).toBe(3); + }); +}); + describe("single elimination standings - projected ties", () => { // Two semifinal losers tie for 3rd (no consolation final). Reports only one // semifinal so the other is still in progress, mirroring the projected diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 1fb579169..841abacfc 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -2,6 +2,7 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; import type { BracketData, + MatchData, RoundData, } from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; @@ -86,6 +87,9 @@ export class SingleEliminationBracket extends Bracket { continue; } + // BYE + if (!match.opponent1 || !match.opponent2) continue; + const loser = match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1; invariant(loser?.id, "Loser id not found"); @@ -139,12 +143,7 @@ export class SingleEliminationBracket extends Bracket { const thirdPlaceMatch = this.hasThirdPlaceMatch() ? this.data.match.find((m) => m.groupId !== matches[0].groupId) : undefined; - const thirdPlaceMatchWinner = - thirdPlaceMatch?.winnerSide === "opponent1" - ? thirdPlaceMatch.opponent1 - : thirdPlaceMatch?.winnerSide === "opponent2" - ? thirdPlaceMatch.opponent2 - : undefined; + const thirdPlaceMatchWinner = winnerOfThirdPlaceMatch(thirdPlaceMatch); const resultWithThirdPlaceTiebroken = result .flatMap((standing) => { @@ -220,3 +219,20 @@ export class SingleEliminationBracket extends Bracket { }; } } + +/** + * A third place match with only one opponent is decided by a BYE: the semifinal + * on the other side was itself won against a BYE, so it produced no loser and + * the lone semifinal loser takes third place without playing. + */ +function winnerOfThirdPlaceMatch(match: MatchData | undefined) { + if (!match) return undefined; + + if (match.opponent1 && !match.opponent2) return match.opponent1; + if (!match.opponent1 && match.opponent2) return match.opponent2; + + if (match.winnerSide === "opponent1") return match.opponent1; + if (match.winnerSide === "opponent2") return match.opponent2; + + return undefined; +}