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/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/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/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/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/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.$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/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/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/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/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..59f483124 --- /dev/null +++ b/app/features/calendar/calendar-progression-form.test.ts @@ -0,0 +1,288 @@ +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 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], + { ...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], + sources: [{ bracketIdx: "0", placements: "not placements" }], + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(["progression", 1, "sources"]); + 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], + sources: [{ bracketIdx: "10", placements: "1,2" }], + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + 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], + sources: [{ bracketIdx: "00", placements: "1,2" }], + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + 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], + sources: [{ bracketIdx: "1", placements: "1,2" }], + }; + + const issues = validationIssues(formValues); + + expect(issues).toHaveLength(1); + 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 new file mode 100644 index 000000000..d11930abe --- /dev/null +++ b/app/features/calendar/calendar-progression-form.ts @@ -0,0 +1,419 @@ +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 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"; + sources: ProgressionSourceFormValue[]; +} + +// 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 progressionSourceFieldset = fieldset({ + fields: z.object({ + bracketIdx: selectDynamic({ + label: "labels.sourceBracket", + initialValue: "0", + }), + placements: textFieldOptional({ + label: "labels.placements", + placeholder: "placeholders.placements", + maxLength: 100, + }), + }), +}); + +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, + 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", sources: [newProgressionSource()] }], + }; +} + +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", 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. */ +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: entry.sources.map((source) => ({ + bracketId: source.bracketIdx, + placements: sourceBracketHasEarlyAdvance(brackets, source) + ? "" + : (source.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", + sources: bracket.sources?.length + ? bracket.sources.map((source) => ({ + bracketIdx: source.bracketId, + placements: source.placements, + })) + : [newProgressionSource()], + })), + }; +} + +/** 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[], + source: ProgressionSourceFormValue, +) { + const sourceBracket = brackets[Number(source.bracketIdx)]; + 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; + + 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; + } + } + } + + 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": + 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 "MIXED_POSITIVE_NEGATIVE_PLACEMENTS": + case "DUPLICATE_SOURCE_BRACKET": + case "EMPTY_PLACEMENTS_ON_NON_SWISS": + case "MERGED_STARTING_BRACKETS": + return [["progression", error.bracketIdx, "sources"]]; + 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/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/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..b6a2e5fca --- /dev/null +++ b/app/features/calendar/components/BracketProgressionFormFields.tsx @@ -0,0 +1,397 @@ +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, + newProgressionSource, + type ProgressionFormValue, + type ProgressionSourceFormValue, + 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, setItemField } = renderContext; + const entry = values as unknown as ProgressionFormValue; + const brackets = (formValues.brackets ?? []) as BracketFormValue[]; + const sources = entry.sources ?? []; + + const isFirstBracket = index === 0; + + // 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 ( +
+ {brackets[index]?.name ? ( +
{brackets[index].name}
+ ) : null} + + {!isFirstBracket && entry.source === "BRACKET" ? ( + + {(sourceRenderContext: ArrayItemRenderContext) => ( + + )} + + ) : ( + + {isInvitational + ? t("forms:progression.addedByOrganizer") + : t("forms:progression.joinFromSignUp")} + + )} +
+ ); +} + +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 ( + +
+ 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) => ({ + ...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/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/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.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/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/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}
(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-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/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 25ae7bcca..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 @@ -1037,6 +1146,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..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) => { @@ -161,13 +160,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)); @@ -215,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; +} 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.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 6c71bcbb7..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"; @@ -55,6 +57,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, @@ -87,9 +90,9 @@ export default function TournamentBracketsPage() { ); return ( - + - + ); } @@ -154,44 +157,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 +755,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-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 46419e48a..a48602c24 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -387,7 +387,6 @@ function lfgMembersAgg( return commonUserMembersAgg(eb, { languages: eb.ref("User.languages"), vc: eb.ref("User.vc"), - pronouns: eb.ref("User.pronouns"), role: eb.ref("TournamentTeamMember.role"), isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), weapons: matchProfileWeapons(eb), diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index cecb05c5d..0dec0a556 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -11,10 +11,9 @@ import { SendouPopover } from "~/components/elements/Popover"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, WeaponImage } from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; -import type { Pronouns } from "~/db/tables-json"; 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, @@ -40,7 +39,6 @@ export type LFGGroupMember = { customUrl: string | null; languages: UnifiedLanguageCode[]; vc: "YES" | "NO" | "LISTEN_ONLY" | null; - pronouns: Pronouns | null; role: "OWNER" | "MANAGER" | "REGULAR"; isStayAsSub: boolean; weapons: Array<{ @@ -218,11 +216,6 @@ function LFGGroupMemberRow({ {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; -} 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/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/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/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/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/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 d1ccaa412..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 }; @@ -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/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; +} 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/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({ )}
- +
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/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/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/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/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 000000000..9ca4e6a77 Binary files /dev/null and b/app/styles/fonts/lexend-latin-ext.woff2 differ diff --git a/app/styles/fonts/lexend-latin.woff2 b/app/styles/fonts/lexend-latin.woff2 new file mode 100644 index 000000000..0968c152f Binary files /dev/null and b/app/styles/fonts/lexend-latin.woff2 differ 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/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/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"] }); 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 9fd2b4220..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 }) => { @@ -197,7 +203,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/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-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts index cb7b42e1f..d348a5d81 100644 --- a/e2e/pages/calendar/calendar-new-event-page.ts +++ b/e2e/pages/calendar/calendar-new-event-page.ts @@ -19,12 +19,17 @@ 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" }), + // 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" }), }; @@ -79,10 +84,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 +105,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, @@ -115,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/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/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/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-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/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/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/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..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"; @@ -254,10 +256,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"); @@ -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/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/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 f08b8e1ef..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": "", @@ -390,6 +373,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/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/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/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 c434875c7..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": "", @@ -390,6 +373,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/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/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/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 ddda34772..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", @@ -390,6 +373,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/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/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/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/analyzer.json b/locales/es-ES/analyzer.json index 28f68af0f..339462a17 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 102d1e53d..f801ef6a0 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/builds.json b/locales/es-ES/builds.json index 4ce429452..79436f42f 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 en este 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 39317dd05..7eabeee22 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", @@ -46,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", @@ -67,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 fd9faec36..3c34ac18d 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 f52471b67..bde7f6bdd 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", @@ -137,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", @@ -184,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", @@ -387,6 +373,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", @@ -415,6 +433,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/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/tournament.json b/locales/es-ES/tournament.json index 1819ab70f..a60d9181b 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 cuadro 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 379a9ddf0..5639c9603 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": "Destacados", "results.highlights.choose": "Elegir resaltos", "results.highlights.explanation": "Elige los resultados que quieres resaltar", - "results.button.showHighlights": "Mostrar destacados", - "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}}'", @@ -202,8 +218,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 +244,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/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 8eab014de..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": "", @@ -390,6 +373,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/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/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/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 2ca03cbbb..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": "", @@ -390,6 +373,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-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/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-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 6186e704a..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": "", @@ -390,6 +373,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/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/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/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 558621c19..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": "", @@ -390,6 +373,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/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/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/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 518567893..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": "", @@ -390,6 +373,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/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/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/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 981446638..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": "", @@ -390,6 +373,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/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/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/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 995518535..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": "", @@ -390,6 +373,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/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/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/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 e472c6826..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": "", @@ -390,6 +373,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/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/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/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 533f8438c..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": "", @@ -390,6 +373,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/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/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/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 b511bd3ac..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": "", @@ -390,6 +373,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/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/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/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 e21e62855..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": "", @@ -390,6 +373,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/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/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/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 62bfec027..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", @@ -390,6 +373,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": "", 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/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": "本局结束后", 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}}” 的用户", 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/package.json b/package.json index 7a6d09cfa..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", @@ -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/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 0dab7d949..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: @@ -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 @@ -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) @@ -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 @@ -185,14 +185,14 @@ 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 '@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 @@ -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': @@ -494,59 +491,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] @@ -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 @@ -5034,39 +5031,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': {} @@ -5774,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 @@ -5805,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 @@ -5833,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) @@ -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: 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());