Merge main and re-apply spelling fixes

This commit is contained in:
pedro 2026-08-06 22:12:02 +02:00
commit af0533a62e
310 changed files with 9073 additions and 4292 deletions

View File

@ -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<HTMLInputElement>,
"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 && (
<input name={name} type="hidden" value={parsedDate.getTime() ?? ""} />
)}
<input
{...inputProps}
type="datetime-local"
disabled={!isHydrated || inputProps.disabled}
// This is important, because SSR will likely have a date in the wrong
// timezone. We can only fill in a value once hydration is over.
value={isHydrated ? valueString : ""}
min={min ? dateToYearMonthDayHourMinuteString(min) : undefined}
max={max ? dateToYearMonthDayHourMinuteString(max) : undefined}
onChange={(e) => {
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"
/>
</>
);
}

View File

@ -5,6 +5,13 @@
width: fit-content;
}
.clickable {
cursor: pointer;
border: none;
padding: 0;
background: none;
}
.badge {
position: absolute;
bottom: 15%;

View File

@ -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 (
<div className={clsx(styles.wrapper, className)}>
<Wrapper
type={onClick ? "button" : undefined}
className={clsx(styles.wrapper, className, {
[styles.clickable]: onClick,
})}
onClick={onClick}
tabIndex={onClick ? -1 : undefined}
>
{children}
{sentiment ? (
<span
@ -56,6 +70,6 @@ export function NoteAvatar({
{BADGE_ICON[sentiment]}
</span>
) : null}
</div>
</Wrapper>
);
}

View File

@ -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<string | null>(props.initialMode ?? null);
const [weapon, setWeapon] = useState<string | null>(
props.initialWeapon ?? null,
);
// unlike the other pills this one seeds a value when added from the menu
const [rank, setRank] = useState<string | null>(props.initialRank ?? null);
return (
<FilterBar
pills={[
{
key: "mode",
name: "Mode",
formattedValue: mode,
onRemove: () => setMode(null),
popover: (
<div>
{MODES.map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
>
Set {value}
</button>
))}
</div>
),
},
{
key: "weapon",
name: "Weapon",
formattedValue: weapon,
onRemove: () => setWeapon(null),
popover: (
<button type="button" onClick={() => setWeapon("Splattershot")}>
Set Splattershot
</button>
),
},
{
key: "rank",
name: "Rank",
formattedValue: rank,
onAdd: () => setRank("S+"),
onRemove: () => setRank(null),
popover: (
<button type="button" onClick={() => setRank("X")}>
Set X
</button>
),
},
]}
onReset={
mode !== null || weapon !== null || rank !== null
? () => {
setMode(null);
setWeapon(null);
setRank(null);
}
: undefined
}
actions={<SendouButton>Save as default</SendouButton>}
/>
);
}
describe("FilterBar", () => {
test("renders a set pill with its name and formatted value", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
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(<TestFilterBar initialMode="SZ" />);
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(<TestFilterBar />);
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(<TestFilterBar />);
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(<TestFilterBar initialWeapon="Splattershot" />);
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(<TestFilterBar />);
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(<TestFilterBar initialMode="SZ" />);
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(<TestFilterBar initialMode="SZ" />);
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(
<TestFilterBar
initialMode="SZ"
initialWeapon="Splattershot"
initialRank="X"
/>,
);
await expect
.element(screen.getByRole("button", { name: "Filter", exact: true }))
.not.toBeInTheDocument();
});
});

View File

@ -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);
}

View File

@ -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<ReadonlySet<string>>(
new Set(),
);
const [openPillKey, setOpenPillKey] = React.useState<string | null>(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 (
<div className={styles.bar}>
{pills.filter(isVisible).map((pill) => (
<FilterPill
key={pill.key}
pill={pill}
isOpen={openPillKey === pill.key}
onOpenChange={(isOpen) => setOpenPillKey(isOpen ? pill.key : null)}
onRemove={pill.onRemove ? () => removePill(pill) : undefined}
/>
))}
{hiddenPills.length > 0 ? (
<AddFilterMenu pills={hiddenPills} onAdd={addPill} />
) : null}
{onReset || actions ? (
<div className={styles.actions}>
{onReset ? (
<SendouButton icon={<RotateCcw />} onPress={resetPills}>
{t("actions.reset")}
</SendouButton>
) : null}
{actions}
</div>
) : null}
</div>
);
}
function FilterPill({
pill,
isOpen,
onOpenChange,
onRemove,
}: {
pill: FilterBarPill;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onRemove?: () => void;
}) {
return (
<div className={styles.pill}>
<SendouPopover
isOpen={isOpen}
onOpenChange={onOpenChange}
popoverClassName={clsx(styles.popover, pill.popoverClassName)}
trigger={
<Button
className={styles.trigger}
data-active={pill.formattedValue !== null}
data-testid={pill.testId}
>
{pill.icon ? (
<span className={styles.icon}>{pill.icon}</span>
) : null}
<span>{pill.name}</span>
{pill.formattedValue !== null ? (
<span className={styles.value}>{pill.formattedValue}</span>
) : null}
<ChevronDown className={styles.chevron} />
</Button>
}
>
{pill.popover}
</SendouPopover>
{onRemove ? (
<Button
className={styles.removeButton}
aria-label={`Remove ${pill.name} filter`}
onPress={onRemove}
data-testid={pill.testId ? `${pill.testId}-remove` : undefined}
>
<X />
</Button>
) : null}
</div>
);
}
function AddFilterMenu({
pills,
onAdd,
}: {
pills: FilterBarPill[];
onAdd: (pill: FilterBarPill) => void;
}) {
const { t } = useTranslation();
return (
<SendouMenu
trigger={
<div className={styles.pill}>
<Button className={styles.trigger} data-testid="add-filter-button">
<Plus className={styles.plus} />
<span>{t("filterBar.addFilter")}</span>
</Button>
</div>
}
>
{pills.map((pill) => (
<SendouMenuItem
key={pill.key}
icon={pill.icon}
onAction={() => onAdd(pill)}
data-testid={pill.testId ? `menu-item-${pill.testId}` : undefined}
>
{pill.name}
</SendouMenuItem>
))}
</SendouMenu>
);
}

View File

@ -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;

View File

@ -715,6 +715,8 @@ export interface TournamentTeamMember {
isStayAsSub: Generated<DBBoolean>;
/** Set when the member was added to the roster after registration closed. */
isSub: Generated<DBBoolean>;
/** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */
isOrganizerAdded: Generated<DBBoolean>;
// denormalized from TournamentTeam.isLooking
isLooking: Generated<DBBoolean>;
}

View File

@ -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) {

View File

@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => {
userId,
newTeamId: team.id,
previousTeamIdToDelete,
isOrganizerAdded: true,
});
if (previousTeamPickupChat) {

View File

@ -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,
});
};

View File

@ -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 */

View File

@ -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() {
</SendouSwitch>
</div>
</div>
<TournamentOverrideProvider
<TournamentProvider
tournament={mockTournament as unknown as TournamentClass}
>
<Outlet
@ -194,7 +194,7 @@ export default function BracketTestLayout() {
bracket: mockBracket,
}}
/>
</TournamentOverrideProvider>
</TournamentProvider>
</Main>
);
}

View File

@ -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<typeof buildFiltersSchema>;
export const buildsDateFilterSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((value) => isValidDate(new Date(value)));

View File

@ -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"],
]);
});
});

View File

@ -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,
}),
});

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<BuildFilter>) => void;
remove: () => void;
}) {
const { t } = useTranslation(["builds"]);
return (
<section>
<div className="stack horizontal justify-between mx-2">
<div className="text-xs font-bold">
{t(`builds:filters.${filter.type}.title`)}{" "}
{nthOfSame > 1 ? nthOfSame : ""}
</div>
<div>
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
onPress={remove}
aria-label="Delete filter"
data-testid="delete-filter-button"
/>
</div>
</div>
{filter.type === "ability" ? (
<AbilityFilter filter={filter} onChange={onChange} />
) : null}
{filter.type === "mode" ? (
<ModeFilter filter={filter} onChange={onChange} number={number} />
) : null}
{filter.type === "date" ? (
<DateFilter filter={filter} onChange={onChange} />
) : null}
</section>
);
}
function AbilityFilter({
filter,
onChange,
}: {
filter: AbilityBuildFilter;
onChange: (filter: Partial<BuildFilter>) => void;
}) {
const { t } = useTranslation(["analyzer", "game-misc", "builds"]);
const abilityObject = abilities.find((a) => a.name === filter.ability)!;
return (
<div className={styles.filter}>
<div className={styles.abilityContainer}>
<Ability ability={filter.ability} size="TINY" />
</div>
<select
value={filter.ability}
onChange={(e) =>
onChange({
ability: e.target.value as AbilityType,
value:
abilities.find((a) => a.name === e.target.value)!.type ===
"STACKABLE"
? 0
: true,
})
}
>
{abilities.map((ability) => {
return (
<option key={ability.name} value={ability.name}>
{t(`game-misc:ABILITY_${ability.name}`)}
</option>
);
})}
</select>
{abilityObject.type !== "STACKABLE" ? (
<select
value={!filter.value ? "false" : "true"}
onChange={(e) => onChange({ value: e.target.value === "true" })}
>
<option value="true">{t("builds:filters.has")}</option>
<option value="false">{t("builds:filters.does.not.have")}</option>
</select>
) : null}
{abilityObject.type === "STACKABLE" ? (
<select
value={filter.comparison}
onChange={(e) =>
onChange({
comparison: e.target.value as AbilityBuildFilter["comparison"],
})
}
data-testid="comparison-select"
>
<option value="AT_LEAST">{t("builds:filters.atLeast")}</option>
<option value="AT_MOST">{t("builds:filters.atMost")}</option>
</select>
) : null}
{abilityObject.type === "STACKABLE" ? (
<div className="stack horizontal sm items-center">
<select
className={styles.apSelect}
value={typeof filter.value === "number" ? filter.value : "0"}
onChange={(e) => onChange({ value: Number(e.target.value) })}
>
{possibleApValues().map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<div className="text-sm">{t("analyzer:abilityPoints.short")}</div>
</div>
) : null}
</div>
);
}
function ModeFilter({
filter,
onChange,
number,
}: {
filter: ModeBuildFilter;
onChange: (filter: Partial<BuildFilter>) => void;
number: number;
}) {
const { t } = useTranslation(["game-misc"]);
const inputId = (mode: ModeShort) => `${number}-${mode}`;
return (
<div className={clsx(styles.filter, styles.filterMode)}>
{modesShort.map((mode) => {
return (
<div
key={mode}
className="stack horizontal xs items-center font-sm font-semi-bold"
>
<input
type="radio"
name={`mode-${number}`}
id={inputId(mode)}
value={mode}
checked={filter.mode === mode}
onChange={() => onChange({ mode })}
/>
<label htmlFor={inputId(mode)} className="stack horizontal xs mb-0">
<ModeImage mode={mode} size={18} />
{t(`game-misc:MODE_LONG_${mode}`)}
</label>
</div>
);
})}
</div>
);
}
function DateFilter({
filter,
onChange,
}: {
filter: DateBuildFilter;
onChange: (filter: Partial<DateBuildFilter>) => 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 (
<div className={clsx(styles.filter, styles.filterDate)}>
<label className="mb-0">{t("builds:filters.date.since")}</label>
<select
className={styles.dateSelect}
value={selectValue()}
data-testid="date-select"
onChange={(e) =>
onChange({
date:
e.target.value === "CUSTOM"
? dateToYYYYMMDD(oneMonthAgoOnSaturday)
: e.target.value,
})
}
>
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
const date = new Date(dateString);
return (
<option key={patch} value={dateString}>
{patch} ({patchDateFormatter.format(date) ?? ""})
</option>
);
})}
<option value="CUSTOM">{t("builds:filters.date.custom")}</option>
</select>
{selectValue() === "CUSTOM" ? (
<input
type="date"
value={dateToYYYYMMDD(customDate)}
onChange={(e) => onChange({ date: e.target.value })}
max={dateToYYYYMMDD(new Date())}
data-testid="date-input"
/>
) : null}
</div>
);
}

View File

@ -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<T extends PartialBuild>({
filters,
abilities,
mode,
date,
count,
builds,
}: {
filters: BuildFiltersFromSearchParams;
}: BuildFilters & {
count: number;
builds: T[];
}) {
@ -38,7 +39,7 @@ export function filterBuilds<T extends PartialBuild>({
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<T extends PartialBuild>({
function buildMatchesFilters<T extends PartialBuild>({
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);
}

View File

@ -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);

View File

@ -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,
};
};

View File

@ -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;
}

View File

@ -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<typeof loader> }) {
export default function WeaponsBuildsPage() {
const data = useLoaderData<typeof loader>();
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<BuildFilter>) => {
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 (
<Main className="stack lg">
<div className={styles.buildsButtons}>
<SendouMenu
trigger={
<SendouButton
variant="outlined"
size="small"
icon={<Funnel />}
isDisabled={filters.length >= MAX_BUILD_FILTERS}
data-testid="add-filter-button"
>
{t("builds:addFilter")}
</SendouButton>
}
>
<SendouMenuItem
icon={<FlaskConical />}
isDisabled={filters.length >= MAX_BUILD_FILTERS}
onAction={() => handleFilterAdd("ability")}
data-testid="menu-item-ability"
>
{t("builds:filters.type.ability")}
</SendouMenuItem>
<SendouMenuItem
icon={<MapIcon />}
onAction={() => handleFilterAdd("mode")}
data-testid="menu-item-mode"
>
{t("builds:filters.type.mode")}
</SendouMenuItem>
<SendouMenuItem
icon={<Calendar />}
isDisabled={filters.some((filter) => filter.type === "date")}
onAction={() => handleFilterAdd("date")}
data-testid="menu-item-date"
>
{t("builds:filters.type.date")}
</SendouMenuItem>
</SendouMenu>
<Filters />
<div className={styles.buildsButtonsLink}>
<LinkButton
to={weaponBuildStatsPage(data.slug)}
@ -218,20 +136,6 @@ export default function WeaponsBuildsPage() {
</LinkButton>
</div>
</div>
{filters.length > 0 ? (
<div className="stack md">
{filters.map((filter, i) => (
<FilterSection
key={i}
number={i + 1}
filter={filter}
onChange={(newFilter) => handleFilterChange(i, newFilter)}
remove={() => handleFilterDelete(i)}
nthOfSame={nthOfSameFilter(i)}
/>
))}
</div>
) : null}
<BuildCards data={data} />
{data.limit < BUILDS_PAGE_MAX_BUILDS && data.hasMoreBuilds ? (
<LinkButton
@ -246,3 +150,332 @@ export default function WeaponsBuildsPage() {
</Main>
);
}
function Filters() {
const { t } = useTranslation(["builds", "game-misc"]);
const [{ abilities: abilityConditions, mode, date }, setParams] =
useSearchParamsTyped(buildsSearchParams);
return (
<FilterBar
pills={[
{
key: "abilities",
name: t("builds:filters.abilities"),
icon: <FlaskConical />,
formattedValue:
abilityConditions.length > 0
? formatAbilityConditions(abilityConditions)
: null,
onRemove: () => setParams({ abilities: [] }),
testId: "ability",
popover: (
<AbilityConditionsPopover
conditions={abilityConditions}
onChange={(newConditions, opts) =>
setParams({ abilities: newConditions }, opts)
}
/>
),
},
{
key: "mode",
name: t("builds:filters.mode"),
icon: <MapIcon />,
formattedValue:
mode !== null ? t(`game-misc:MODE_SHORT_${mode}`) : null,
onAdd: () => setParams({ mode: "SZ" }),
onRemove: () => setParams({ mode: null }),
testId: "mode",
popover: (
<div className="stack sm">
{modesShort.map((option) => (
<div
key={option}
className="stack horizontal xs items-center font-sm font-semi-bold"
>
<input
type="radio"
name="builds-mode"
id={`builds-mode-${option}`}
value={option}
checked={mode === option}
onChange={() => setParams({ mode: option })}
/>
<label
htmlFor={`builds-mode-${option}`}
className="stack horizontal xs mb-0"
>
<ModeImage mode={option} size={18} />
{t(`game-misc:MODE_LONG_${option}`)}
</label>
</div>
))}
</div>
),
},
{
key: "date",
name: t("builds:filters.date"),
icon: <Calendar />,
formattedValue: date !== null ? <FormattedDate date={date} /> : null,
onAdd: () => setParams({ date: RECENT_PATCHES[0].date }),
onRemove: () => setParams({ date: null }),
testId: "date",
popover: (
<DatePopover
date={date}
onChange={(newDate) => 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 (
<div className={styles.abilityConditions}>
{conditions.map((condition, i) => (
<AbilityConditionRow
key={i}
condition={condition}
onChange={(newCondition) =>
onChange(
conditions.map((c, index) => (index === i ? newCondition : c)),
)
}
remove={() => onChange(conditions.filter((_, index) => index !== i))}
/>
))}
<SendouButton
className="self-start"
size="small"
variant="minimal"
isDisabled={conditions.length >= MAX_BUILD_FILTERS}
onPress={addCondition}
data-testid="add-ability-condition"
>
{t("builds:filters.addAbility")}
</SendouButton>
</div>
);
}
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 (
<div className={styles.abilityConditionRow}>
<Ability ability={condition.ability} size="TINY" />
<select
value={condition.ability}
onChange={(e) => {
const newAbility = e.target.value as AbilityType;
const stackable =
abilities.find((a) => a.name === newAbility)!.type === "STACKABLE";
onChange({
...condition,
ability: newAbility,
value: stackable ? 0 : true,
});
}}
>
{abilities.map((ability) => {
return (
<option key={ability.name} value={ability.name}>
{t(`game-misc:ABILITY_${ability.name}`)}
</option>
);
})}
</select>
<SendouButton
icon={<X />}
size="miniscule"
variant="minimal-destructive"
onPress={remove}
aria-label="Delete ability condition"
data-testid="delete-ability-condition"
/>
<div className={styles.abilityConditionValueRow}>
{abilityObject.type === "STACKABLE" ? (
<>
<select
value={condition.comparison}
onChange={(e) =>
onChange({
...condition,
comparison: e.target.value as AbilityCondition["comparison"],
})
}
data-testid="comparison-select"
>
<option value="AT_LEAST">{t("builds:filters.atLeast")}</option>
<option value="AT_MOST">{t("builds:filters.atMost")}</option>
</select>
<select
className={styles.abilityConditionApSelect}
value={
typeof condition.value === "number" ? condition.value : "0"
}
onChange={(e) =>
onChange({ ...condition, value: Number(e.target.value) })
}
>
{possibleApValues().map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<div className="text-sm">{t("analyzer:abilityPoints.short")}</div>
</>
) : (
<select
value={!condition.value ? "false" : "true"}
onChange={(e) =>
onChange({ ...condition, value: e.target.value === "true" })
}
>
<option value="true">{t("builds:filters.has")}</option>
<option value="false">{t("builds:filters.does.not.have")}</option>
</select>
)}
</div>
</div>
);
}
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 (
<div className="stack sm">
<label className="mb-0">{t("builds:filters.date.since")}</label>
<select
className="w-full"
value={selectValue() ?? "CUSTOM"}
data-testid="date-select"
onChange={(e) =>
onChange(
e.target.value === "CUSTOM"
? dateToYYYYMMDD(oneMonthAgoOnSaturday)
: e.target.value,
)
}
>
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
const patchDate = new Date(dateString);
return (
<option key={patch} value={dateString}>
{patch} ({patchDateFormatter.format(patchDate) ?? ""})
</option>
);
})}
<option value="CUSTOM">{t("builds:filters.date.custom")}</option>
</select>
{selectValue() === "CUSTOM" ? (
<input
className="w-full"
type="date"
value={dateToYYYYMMDD(customDate)}
onChange={(e) => onChange(e.target.value)}
max={dateToYYYYMMDD(new Date())}
data-testid="date-input"
/>
) : null}
</div>
);
}

View File

@ -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<typeof formValuesToInputBrackets>[0];
progression: Parameters<typeof formValuesToInputBrackets>[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,

View File

@ -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

View File

@ -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<typeof validateBracketProgressionFormValues>[0];
progression: Parameters<typeof validateBracketProgressionFormValues>[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",
);
});
});

View File

@ -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<Array<string | number>> {
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);
}
}

View File

@ -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({

View File

@ -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", () => {

View File

@ -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 }),

View File

@ -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;
}

View File

@ -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 (
<>
<FormField
name="brackets"
// removing a bracket shifts the idxs of the brackets after it, so
// brackets before a started one must stay to keep started brackets'
// idxs (and disabledBracketIdxs) stable, matching the server's guard
canRemoveItem={(_, idx) =>
idx !== 0 &&
disabledBracketIdxs.every((disabledIdx) => disabledIdx < idx)
}
onValueChange={handleBracketsChanged}
>
{(renderContext: ArrayItemRenderContext) => (
<BracketFields
renderContext={renderContext}
isDisabled={disabledBracketIdxs.includes(renderContext.index)}
/>
)}
</FormField>
{brackets.length > 1 ? (
<FormField name="progression" canRemoveItem={() => false}>
{(renderContext: ArrayItemRenderContext) => (
<ProgressionEntryFields
renderContext={renderContext}
isInvitational={isInvitational}
isDisabled={disabledBracketIdxs.includes(renderContext.index)}
isSourceLocked={
isTournamentInProgress ||
disabledBracketIdxs.includes(renderContext.index)
}
/>
)}
</FormField>
) : 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 (
<div className="stack md items-start">
<FormField name={`${itemName}.name`} disabled={isDisabled} />
<FormField name={`${itemName}.type`} disabled={isDisabled} />
{bracket.type === "single_elimination" ? (
<FormField name={`${itemName}.thirdPlaceMatch`} disabled={isDisabled} />
) : null}
{bracket.type === "round_robin" ? (
<FormField
name={`${itemName}.teamsPerGroup`}
disabled={isDisabled}
options={(!isFollowUp && bracket.hasAbDivisions
? TOURNAMENT.RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS
: TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS
).map((count) => ({ value: String(count), label: String(count) }))}
/>
) : null}
{bracket.type === "round_robin" && !isFollowUp ? (
<FormField
name={`${itemName}.hasAbDivisions`}
disabled={isDisabled}
onValueChange={(isSelected) => {
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" ? (
<>
<FormField name={`${itemName}.groupCount`} disabled={isDisabled} />
<FormField
name={`${itemName}.roundCount`}
disabled={isDisabled}
onValueChange={(newRoundCount) => {
if (!bracket.earlyAdvance) return;
if (
!Swiss.isValidAdvanceThreshold({
roundCount: Number(newRoundCount),
advanceThreshold: Number(bracket.advanceThreshold),
})
) {
setItemField("advanceThreshold", DEFAULT_ADVANCE_THRESHOLD);
}
}}
/>
<FormField name={`${itemName}.earlyAdvance`} disabled={isDisabled} />
</>
) : null}
{bracket.type === "swiss" && bracket.earlyAdvance ? (
<div>
<FormField
name={`${itemName}.advanceThreshold`}
disabled={isDisabled}
options={Swiss.validAdvanceThresholdOptions({
roundCount: Number(bracket.roundCount),
}).map((threshold) => ({
value: String(threshold),
label: String(threshold),
}))}
/>
<FormMessage type="info">
{t("forms:bottomTexts.advanceThresholdMaxLosses", {
maxLosses:
Swiss.eliminationThreshold({
roundCount: Number(bracket.roundCount),
advanceThreshold: Number(bracket.advanceThreshold),
}) - 1,
})}
</FormMessage>
</div>
) : null}
{isFollowUp ? (
<>
<FormField name={`${itemName}.startTime`} disabled={isDisabled} />
<FormField
name={`${itemName}.requiresCheckIn`}
disabled={isDisabled}
/>
</>
) : null}
</div>
);
}
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 (
<div className="stack md items-start">
{brackets[index]?.name ? (
<div className="text-sm font-semi-bold">{brackets[index].name}</div>
) : null}
<FormField
name={`${itemName}.source`}
disabled={isFirstBracket || isSourceLocked}
/>
{!isFirstBracket && entry.source === "BRACKET" ? (
<FormField
name={`${itemName}.sources`}
disabled={isDisabled}
onValueChange={handleSourcesChanged}
>
{(sourceRenderContext: ArrayItemRenderContext) => (
<SourceFields
renderContext={sourceRenderContext}
destinationBracketIdx={index}
isDisabled={isDisabled}
/>
)}
</FormField>
) : (
<FormMessage type="info">
{isInvitational
? t("forms:progression.addedByOrganizer")
: t("forms:progression.joinFromSignUp")}
</FormMessage>
)}
</div>
);
}
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 (
<div className="stack md items-start">
<FormField
name={`${itemName}.bracketIdx`}
options={bracketOptions}
disabled={isDisabled}
/>
{!sourceBracketHasEarlyAdvance(brackets, source) ? (
<FormField
name={`${itemName}.placements`}
disabled={isDisabled}
labelPopover={<PlacementsSyntaxPopover />}
/>
) : null}
</div>
);
}
function PlacementsSyntaxPopover() {
return (
<InfoPopover tiny>
<div>
Which teams of the source bracket move to this bracket. Examples:
</div>
<div className={styles.syntaxExample}>
<code className={styles.syntaxCode}>1,2,3</code>
<span className={styles.syntaxExplanation}>Places 1, 2 and 3</span>
</div>
<div className={styles.syntaxExample}>
<code className={styles.syntaxCode}>1-4</code>
<span className={styles.syntaxExplanation}>Places 1 to 4</span>
</div>
<div className={styles.syntaxExample}>
<code className={styles.syntaxCode}>5+</code>
<span className={styles.syntaxExplanation}>
Place 5 and every place after
</span>
</div>
<div className={styles.syntaxExample}>
<code className={styles.syntaxCode}>-1,-2</code>
<span className={styles.syntaxExplanation}>
Teams eliminated in (losers) rounds 1 & 2 (elimination brackets only)
</span>
</div>
</InfoPopover>
);
}
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;
}

View File

@ -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;
}

View File

@ -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<Progression.InputBracket[]>(
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 (
<div className="stack lg items-start">
<div className="stack lg">
{brackets.map((bracket, i) => (
<TournamentFormatBracketSelector
key={bracket.id}
bracket={bracket}
brackets={brackets}
onChange={(newBracket) => {
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}
/>
))}
</div>
<SendouButton
icon={<Plus />}
size="small"
variant="outlined"
onPress={handleAddBracket}
isDisabled={brackets.length >= TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT}
data-testid="add-bracket-button"
>
Add bracket
</SendouButton>
{Progression.isError(validated) ? (
<ErrorMessage error={validated} />
) : null}
</div>
);
}
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<Progression.InputBracket>) => {
const defaultSettings = newProps.type
? defaultBracketSettings(newProps.type)
: undefined;
onChange({
...bracket,
...newProps,
settings: newProps.settings ?? defaultSettings ?? bracket.settings,
});
};
return (
<div className="stack horizontal md items-center">
<div>
<div className={styles.count}>Bracket #{count}</div>
{onDelete ? (
<SendouButton
size="small"
variant="minimal-destructive"
onPress={onDelete}
className="mx-auto"
data-testid="delete-bracket-button"
>
Delete
</SendouButton>
) : null}
</div>
<div className={styles.divider} />
<div className="stack md items-start">
<div>
<Label htmlFor={createId("name")}>Bracket's name</Label>
<Input
id={createId("name")}
value={bracket.name}
onChange={(e) => updateBracket({ name: e.target.value })}
maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH}
readOnly={bracket.disabled}
/>
</div>
{bracket.sources ? (
<div>
<Label htmlFor={createId("startTime")}>Start time</Label>
<DateInput
id={createId("startTime")}
defaultValue={bracket.startTime ?? undefined}
onChange={(newDate) =>
updateBracket({ startTime: newDate ?? undefined })
}
readOnly={bracket.disabled}
/>
<FormMessage type="info">
If missing, bracket can be started when the previous brackets have
finished
</FormMessage>
</div>
) : null}
{bracket.sources ? (
<div>
<Label htmlFor={createId("checkIn")}>Check-in required</Label>
<SendouSwitch
id={createId("checkIn")}
isSelected={bracket.requiresCheckIn}
onChange={(isSelected) =>
updateBracket({ requiresCheckIn: isSelected })
}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Check-in starts 1 hour before start time or right after the
previous bracket finishes if no start time is set
</FormMessage>
</div>
) : null}
<div>
<Label htmlFor={createId("format")}>Format</Label>
<select
value={bracket.type}
onChange={(e) =>
updateBracket({
type: e.target.value as Progression.InputBracket["type"],
})
}
className="w-max"
name="format"
id={createId("format")}
disabled={bracket.disabled}
>
<option value="single_elimination">Single-elimination</option>
<option value="double_elimination">Double-elimination</option>
<option value="round_robin">Round robin</option>
<option value="swiss">Swiss</option>
</select>
</div>
{bracket.type === "single_elimination" ? (
<div>
<Label htmlFor={createId("thirdPlaceMatch")}>
Third place match
</Label>
<SendouSwitch
id={createId("thirdPlaceMatch")}
isSelected={Boolean(
bracket.settings.thirdPlaceMatch ??
TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH,
)}
onChange={(isSelected) =>
updateBracket({
settings: {
...bracket.settings,
thirdPlaceMatch: isSelected,
},
})
}
isDisabled={bracket.disabled}
/>
</div>
) : null}
{bracket.type === "round_robin" ? (
<div>
<Label htmlFor="teamsPerGroup">Max participants per group</Label>
<select
value={
bracket.settings.teamsPerGroup ??
TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP
}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
teamsPerGroup: Number(e.target.value),
},
})
}
className="w-max"
name="teamsPerGroup"
id="teamsPerGroup"
disabled={bracket.disabled}
>
{(bracket.settings.hasAbDivisions
? TOURNAMENT.RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS
: TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS
).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<FormMessage type="info">
Participants are distributed equally, so groups may have fewer
than selected
</FormMessage>
</div>
) : null}
{bracket.type === "round_robin" && !bracket.sources ? (
<div>
<Label htmlFor={createId("abDivisions")}>A/B divisions</Label>
<SendouSwitch
id={createId("abDivisions")}
isSelected={Boolean(bracket.settings.hasAbDivisions)}
onChange={(isSelected) => {
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}
/>
<FormMessage type="info">
Teams split into A and B pools; every A plays every B once
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissGroupCount">Groups count</Label>
<select
value={
bracket.settings.groupCount ??
TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT
}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
groupCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
disabled={bracket.disabled}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissRoundCount">Round count</Label>
<select
value={
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT
}
onChange={(e) => {
const newRoundCount = Number(e.target.value);
const currentAdvanceThreshold =
bracket.settings.advanceThreshold;
updateBracket({
settings: {
...bracket.settings,
roundCount: newRoundCount,
advanceThreshold:
currentAdvanceThreshold &&
!Swiss.isValidAdvanceThreshold({
roundCount: newRoundCount,
advanceThreshold: currentAdvanceThreshold,
})
? 3
: currentAdvanceThreshold,
},
});
}}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor={createId("earlyAdvance")}>
Early advance/elimination
</Label>
<SendouSwitch
id={createId("earlyAdvance")}
isSelected={Boolean(bracket.settings.advanceThreshold)}
onChange={(isSelected) =>
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: isSelected ? 3 : undefined,
},
})
}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Teams stop playing once they reach required wins or exceed maximum
losses
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" && bracket.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("advanceThreshold")}>
Wins needed to advance
</Label>
<select
value={bracket.settings.advanceThreshold}
onChange={(e) => {
const newThreshold = Number(e.target.value);
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: newThreshold,
},
});
}}
className="w-max"
name="advanceThreshold"
id={createId("advanceThreshold")}
disabled={bracket.disabled}
>
{Swiss.validAdvanceThresholdOptions({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}).map((threshold) => (
<option key={threshold} value={threshold}>
{threshold}
</option>
))}
</select>
<FormMessage type="info">
Maximum losses allowed:{" "}
{Swiss.eliminationThreshold({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
advanceThreshold: bracket.settings.advanceThreshold,
}) - 1}
</FormMessage>
</div>
) : null}
<div>
<div className="stack horizontal sm">
<Label htmlFor={createId("source")}>Source</Label>{" "}
</div>
{!isFirstBracket ? (
<div className="stack sm horizontal mt-1 mb-2">
<SendouSwitch
id={createId("follow-up-bracket")}
isSelected={Boolean(bracket.sources)}
onChange={(isSelected) =>
updateBracket({
sources: isSelected ? [] : undefined,
requiresCheckIn: false,
startTime: undefined,
})
}
isDisabled={bracket.disabled || isTournamentInProgress}
data-testid="follow-up-bracket-switch"
/>
<Label htmlFor={createId("follow-up-bracket")} spaced={false}>
Is follow-up bracket
</Label>
</div>
) : null}
{!bracket.sources ? (
<FormMessage type="info">
{isInvitationalTournament
? "Participants added by the organizer"
: "Participants join from sign-up"}
</FormMessage>
) : (
<SourcesSelector
brackets={brackets.filter(
(bracket2) => bracket.id !== bracket2.id && bracket2.name,
)}
source={bracket.sources?.[0] ?? null}
onChange={(source) => updateBracket({ sources: [source] })}
/>
)}
</div>
</div>
</div>
);
}
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 (
<div>
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
</div>
{!inputBracket?.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
) : null}
</div>
{!inputBracket?.settings.advanceThreshold ? (
<FormMessage type="info">
Use N+ for Nth place and every placement after
</FormMessage>
) : null}
</div>
);
}
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 (
<FormMessage type="error">
Problems with the bracket progression
{bracketIdxsArr ? (
<> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")})</>
) : null}
:{" "}
{t(`tournament:progression.error.${error.type}`, {
max: TOURNAMENT.PLACEMENT_MAX,
})}
</FormMessage>
);
}

View File

@ -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<CalendarLoaderData>();
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<CalendarFilters>) => {
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<Exclude<CalendarFilters["preferredStartTime"], "ANY">>}`,
),
);
}
if (filters.minTeamCount > 0) {
parts.push(`${filters.minTeamCount}+`);
}
return parts.length > 0 ? parts.join(" · ") : null;
};
return (
<FilterBar
pills={[
{
key: "modes",
name: t("calendar:filter.modes"),
formattedValue: modesFormatted(),
onRemove: () =>
writeFilters({ modes: defaults.modes, modesExact: false }),
testId: "modes-filter",
popover: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="modes"
label={t("calendar:filter.modes")}
items={[
{ label: t("forms:modes.TW"), value: "TW" },
{ label: t("forms:modes.SZ"), value: "SZ" },
{ label: t("forms:modes.TC"), value: "TC" },
{ label: t("forms:modes.RM"), value: "RM" },
{ label: "Salmon Run", value: "SR" },
{ label: t("forms:modes.CB"), value: "CB" },
{ label: "Tricolor", value: "TB" },
]}
value={filters.modes}
onChange={(modes) =>
modes.length > 0 ? writeFilters({ modes }) : undefined
}
minLength={1}
onBlur={() => {}}
/>
<SendouSwitch
isSelected={filters.modesExact}
onChange={(modesExact) => writeFilters({ modesExact })}
>
{t("calendar:filter.exactModes")}
</SendouSwitch>
</div>
),
},
{
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: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="games"
label={t("calendar:filter.games")}
items={gamesShort.map((game) => ({
label: t(`forms:options.game.${game}`),
value: game,
}))}
value={filters.games}
onChange={(games) =>
games.length > 0 ? writeFilters({ games }) : undefined
}
minLength={1}
onBlur={() => {}}
/>
<CheckboxGroupFormField
name="preferredVersus"
label={t("calendar:filter.vs")}
items={versusShort.map((versus) => ({
label: versus,
value: versus,
}))}
value={filters.preferredVersus}
onChange={(preferredVersus) =>
preferredVersus.length > 0
? writeFilters({ preferredVersus })
: undefined
}
minLength={1}
onBlur={() => {}}
/>
<SendouSwitch
isSelected={filters.isSendou}
onChange={(isSendou) => writeFilters({ isSendou })}
>
{t("calendar:filter.isSendou")}
</SendouSwitch>
<SendouSwitch
isSelected={filters.isRanked}
onChange={(isRanked) => writeFilters({ isRanked })}
>
{t("calendar:filter.isRanked")}
</SendouSwitch>
</div>
),
},
{
key: "tier",
name: t("calendar:filterBar.tier"),
formattedValue: tierFormatted(),
onRemove: () =>
writeFilters({
minTier: defaults.minTier,
maxTier: defaults.maxTier,
}),
testId: "tier-filter",
popover: (
<div className="stack md">
<TierSelect
label={t("calendar:filter.minTier")}
value={filters.minTier}
onChange={(minTier) =>
writeFilters({
minTier,
maxTier: Math.max(minTier, filters.maxTier),
})
}
/>
<TierSelect
label={t("calendar:filter.maxTier")}
value={filters.maxTier}
onChange={(maxTier) =>
writeFilters({
maxTier,
minTier: Math.min(maxTier, filters.minTier),
})
}
/>
</div>
),
},
{
key: "tags",
name: t("calendar:filterBar.tags"),
formattedValue: tagsFormatted(),
onRemove: () => writeFilters({ tagsIncluded: [], tagsExcluded: [] }),
testId: "tags-filter",
popover: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="tagsIncluded"
label={t("calendar:filter.tagsIncluded")}
items={tagItems}
value={filters.tagsIncluded}
onChange={(tagsIncluded) =>
writeFilters({
tagsIncluded,
tagsExcluded: filters.tagsExcluded.filter(
(tag) => !tagsIncluded.includes(tag),
),
})
}
minLength={0}
onBlur={() => {}}
/>
<CheckboxGroupFormField
name="tagsExcluded"
label={t("calendar:filter.tagsExcluded")}
items={tagItems}
value={filters.tagsExcluded}
onChange={(tagsExcluded) =>
writeFilters({
tagsExcluded,
tagsIncluded: filters.tagsIncluded.filter(
(tag) => !tagsExcluded.includes(tag),
),
})
}
minLength={0}
onBlur={() => {}}
/>
</div>
),
},
{
key: "organizers",
name: t("calendar:filterBar.organizers"),
formattedValue: organizersFormatted(),
onRemove: () =>
writeFilters({
orgsIncluded: [],
orgsExcluded: [],
authorIdsExcluded: [],
}),
testId: "organizers-filter",
popover: (
<div className="stack md">
<OrgListEditor
label={t("calendar:filter.orgsIncluded")}
values={filters.orgsIncluded}
onChange={(orgsIncluded) => writeFilters({ orgsIncluded })}
disabled={filters.orgsExcluded.length > 0}
/>
<OrgListEditor
label={t("calendar:filter.orgsExcluded")}
values={filters.orgsExcluded}
onChange={(orgsExcluded) => writeFilters({ orgsExcluded })}
disabled={filters.orgsIncluded.length > 0}
/>
<ExcludedAuthorsEditor
label={t("calendar:filter.authorIdsExcluded")}
values={filters.authorIdsExcluded}
onChange={(authorIdsExcluded) =>
writeFilters({ authorIdsExcluded })
}
/>
</div>
),
},
{
key: "timeAndSize",
name: t("calendar:filterBar.timeAndSize"),
formattedValue: timeAndSizeFormatted(),
onRemove: () =>
writeFilters({ preferredStartTime: "ANY", minTeamCount: 0 }),
testId: "time-and-size-filter",
popover: (
<div className="stack md">
<RadioGroupFormField
name="preferredStartTime"
label={t("calendar:filter.startTime")}
items={[
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
{ label: t("calendar:filter.startTime.na"), value: "NA" },
{ label: t("calendar:filter.startTime.au"), value: "AU" },
]}
value={filters.preferredStartTime}
onChange={(preferredStartTime) =>
writeFilters({ preferredStartTime })
}
onBlur={() => {}}
/>
<label className="stack xs mb-0">
{t("calendar:filter.minTeamCount")}
<input
className="w-full"
type="number"
min={0}
value={filters.minTeamCount > 0 ? filters.minTeamCount : ""}
onChange={(e) =>
writeFilters({
minTeamCount: Math.max(0, Number(e.target.value) || 0),
})
}
/>
</label>
</div>
),
},
]}
onReset={
!CalendarEvent.isDefaultFilters(filters)
? () => writeFilters(defaults)
: undefined
}
actions={
user && data.canSaveAsDefault ? (
<SendouButton
icon={<Star />}
isDisabled={persistFetcher.state !== "idle"}
onPress={() =>
persistFetcher.submit(filters, {
method: "post",
encType: "application/json",
})
}
data-testid="save-filters-as-default-button"
>
{t("common:filterBar.saveAsDefault")}
</SendouButton>
) : null
}
/>
);
}
function TierSelect({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<SendouSelect
label={label}
items={TIER_NUMBERS.map((tier) => ({ id: tier }))}
selectedKey={value}
onSelectionChange={(key) => onChange(Number(key))}
>
{({ id }) => (
<SendouSelectItem key={id} id={id}>
{tierNumberToName(id)}
</SendouSelectItem>
)}
</SendouSelect>
);
}
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 (
<div className="stack xs">
<span className="text-sm font-semi-bold">{label}</span>
{values.map((org) => (
<div key={org} className="stack horizontal xs items-center">
<span className="text-sm">{org}</span>
<SendouButton
icon={<X />}
variant="minimal-destructive"
size="miniscule"
aria-label={`Remove ${org}`}
onPress={() => onChange(values.filter((value) => value !== org))}
/>
</div>
))}
{values.length < 10 ? (
<div className="stack horizontal xs items-center">
<input
className="w-full"
value={draft}
maxLength={100}
disabled={disabled}
aria-label={label}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addDraft();
}
}}
/>
<SendouButton
variant="minimal"
size="small"
isDisabled={disabled || draft.trim().length === 0}
onPress={addDraft}
>
{t("common:actions.add")}
</SendouButton>
</div>
) : null}
</div>
);
}
function ExcludedAuthorsEditor({
label,
values,
onChange,
}: {
label: string;
values: number[];
onChange: (values: number[]) => void;
}) {
return (
<div className="stack xs">
<span className="text-sm font-semi-bold">{label}</span>
{values.map((userId) => (
<div key={userId} className="stack horizontal xs items-center">
<UserSearch initialUserId={userId} isDisabled />
<SendouButton
icon={<X />}
variant="minimal-destructive"
size="miniscule"
aria-label="Remove excluded author"
onPress={() => onChange(values.filter((value) => value !== userId))}
/>
</div>
))}
{values.length < 10 ? (
<UserSearch
key={values.length}
onChange={(user) => {
if (user && !values.includes(user.id)) {
onChange([...values, user.id]);
}
}}
/>
) : null}
</div>
);
}

View File

@ -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<typeof calendarFiltersFormSchema>;
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
const { t } = useTranslation(["calendar"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
size="small"
icon={<Funnel />}
onPress={() => setIsOpen(true)}
data-testid="filter-events-button"
>
{t("calendar:filter.button")}
</SendouButton>
<SendouDialog
heading={t("calendar:filter.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
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 (
<SendouForm
schema={calendarFiltersFormSchema}
defaultValues={filters as unknown as FormValues}
onApply={handleApply}
submitButtonText={t("calendar:filter.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="modes" />
<FormField name="modesExact" />
<FormField name="games" />
<FormField name="preferredVersus" />
<FormField name="preferredStartTime" />
<FormField name="tagsIncluded" />
<FormField name="tagsExcluded" />
<FormField name="isSendou" />
<FormField name="isRanked" />
<FormField name="minTeamCount" />
<FormField name="orgsIncluded" />
<FormField name="orgsExcluded" />
<FormField name="authorIdsExcluded" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["calendar"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
return (
<SendouButton
variant="outlined"
onPress={() => submitToServer(values as CalendarFilters)}
isDisabled={fetcherState !== "idle"}
>
{t("calendar:filter.applyAndDefault")}
</SendouButton>
);
}

View File

@ -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 = [
{

View File

@ -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) {

View File

@ -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;

View File

@ -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,
]);
}

View File

@ -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 {

View File

@ -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 (
<div className="stack md w-full">
<Divider smallText className="mt-4">
Tournament format
</Divider>
<FormField name="bracketProgression">
{({ onChange, error }: CustomFieldRenderProps) => (
<>
<BracketProgressionSelector
initialBrackets={initialBrackets}
isInvitationalTournament={Boolean(values.isInvitational)}
onChange={onChange}
isTournamentInProgress={false}
/>
{error ? (
<FormMessage
id={errorMessageId("bracketProgression")}
type="error"
>
{t(error as never)}
</FormMessage>
) : null}
</>
)}
</FormField>
<BracketProgressionFormFields
isInvitational={Boolean(values.isInvitational)}
/>
</div>
);
}

View File

@ -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}
>
<div className={styles.buttonsContainer}>
<div
className={clsx(styles.columnsWidthContainer, styles.buttonsContainer)}
>
<div className={styles.navigateButtonsContainer}>
<NavigateButton
icon={<ChevronLeft />}
daysInterval={previous}
filters={data.filters}
>
<NavigateButton icon={<ChevronLeft />} daysInterval={previous}>
{t("common:actions.previous")}
</NavigateButton>
<NavigateButton
icon={<ChevronRight />}
daysInterval={next}
filters={data.filters}
>
<NavigateButton icon={<ChevronRight />} daysInterval={next}>
{t("common:actions.next")}
</NavigateButton>
<CalendarDatePicker
dayMonthYear={current}
filters={data.filters}
key={JSON.stringify(current)}
/>
</div>
@ -108,12 +97,11 @@ export default function CalendarPage() {
}
url={calendarIcalFeed(data.filters)}
/>
<FiltersDialog
key={CalendarEvent.filtersToString(data.filters)}
filters={data.filters}
/>
</div>
</div>
<div className={styles.columnsWidthContainer}>
<FiltersBar />
</div>
<div
key={`${shown[0].year}-${shown[0].month}-${shown[0].day}`}
ref={scrollTodayToCenter}
@ -145,13 +133,13 @@ function NavigateButton({
icon,
children,
daysInterval,
filters,
}: {
icon: SendouButtonProps["icon"];
children: React.ReactNode;
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const dayHref = useCalendarDayHref();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
@ -159,7 +147,7 @@ function NavigateButton({
return (
<Link
to={calendarPage({ filters, dayMonthYear: lowestDate })}
to={dayHref(lowestDate)}
className={clsx(styles.navigateButton, styles.navigateArrowButton)}
data-testid="calendar-navigate-button"
>
@ -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;

View File

@ -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<string | null>("SZ");
const [stage, setStage] = useState<string | null>(null);
return (
<Section>
<SectionTitle id={id}>Filter Bar</SectionTitle>
<FilterBar
pills={[
{
key: "mode",
name: "Mode",
formattedValue: mode,
onRemove: () => setMode(null),
popover: (
<SendouChipRadioGroup wrap>
{SHOWCASE_MODES.map((value) => (
<SendouChipRadio
key={value}
name="filter-bar-showcase-mode"
value={value}
checked={mode === value}
onChange={setMode}
>
{value}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
),
},
{
key: "stage",
name: "Stage",
formattedValue: stage,
onRemove: () => setStage(null),
popover: (
<SendouChipRadioGroup orientation="vertical">
{SHOWCASE_STAGES.map((value) => (
<SendouChipRadio
key={value}
name="filter-bar-showcase-stage"
value={value}
checked={stage === value}
onChange={setStage}
>
{value}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
),
},
]}
actions={
mode !== null || stage !== null ? (
<SendouButton
icon={<RotateCcw />}
onPress={() => {
setMode(null);
setStage(null);
}}
>
Reset
</SendouButton>
) : null
}
/>
</Section>
);
}
function ToastSection({ id }: { id: string }) {
return (
<Section>

View File

@ -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<LFGFilter["_tag"], LFGFilter> = {
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 (
<SendouMenu
trigger={
<SendouButton
variant="outlined"
size="small"
icon={<Filter />}
data-testid="add-filter-button"
>
{t("lfg:addFilter")}
</SendouButton>
}
>
{Object.entries(defaultFilters).map(([tag, defaultFilter]) => (
<SendouMenuItem
key={tag}
isDisabled={filters.some((filter) => filter._tag === tag)}
onAction={() => addFilter(defaultFilter)}
>
{t(`lfg:filters.${tag as LFGFilter["_tag"]}`)}
</SendouMenuItem>
))}
</SendouMenu>
);
}

View File

@ -1,5 +0,0 @@
.filter {
padding: var(--s-1-5) var(--s-2);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
}

View File

@ -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 (
<div className="stack md">
{filters.map((filter) => (
<Filter
key={filter._tag}
filter={filter}
changeFilter={changeFilter}
removeFilter={() => removeFilterByTag(filter._tag)}
/>
))}
</div>
);
}
function Filter({
filter,
changeFilter,
removeFilter,
}: {
filter: LFGFilter;
changeFilter: (newFilter: LFGFilter) => void;
removeFilter: () => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<div className="stack horizontal justify-between">
<Label htmlFor={`${filter._tag.toLowerCase()}-filter`}>
{t(`lfg:filters.${filter._tag}`)} {t("lfg:filters.suffix")}
</Label>
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
onPress={removeFilter}
aria-label="Delete filter"
/>
</div>
<div className={styles.filter}>
{filter._tag === "Weapon" && (
<WeaponFilterFields
value={filter.weaponSplIds}
changeFilter={changeFilter}
/>
)}
{filter._tag === "Type" && (
<TypeFilterFields value={filter.type} changeFilter={changeFilter} />
)}
{filter._tag === "Timezone" && (
<TimezoneFilterFields
value={filter.maxHourDifference}
changeFilter={changeFilter}
/>
)}
{filter._tag === "Language" && (
<LanguageFilterFields
value={filter.language}
changeFilter={changeFilter}
/>
)}
{filter._tag === "PlusTier" && (
<PlusTierFilterFields
value={filter.tier}
changeFilter={changeFilter}
/>
)}
{filter._tag === "MaxTier" && (
<TierFilterFields
_tag="MaxTier"
value={filter.tier}
changeFilter={changeFilter}
/>
)}
{filter._tag === "MinTier" && (
<TierFilterFields
_tag="MinTier"
value={filter.tier}
changeFilter={changeFilter}
/>
)}
</div>
</div>
);
}
function WeaponFilterFields({
value,
changeFilter,
}: {
value: MainWeaponId[];
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div className="stack horizontal sm flex-wrap">
<WeaponSelect
disabledWeaponIds={value}
onChange={(weaponId) =>
changeFilter({
_tag: "Weapon",
weaponSplIds:
value.length >= 10
? [...value.slice(1, 10), weaponId]
: [...value, weaponId],
})
}
key={value.join("-")}
/>
{value.map((weapon) => (
<SendouButton
key={weapon}
variant="minimal"
onPress={() =>
changeFilter({
_tag: "Weapon",
weaponSplIds: value.filter((weaponId) => weaponId !== weapon),
})
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</SendouButton>
))}
</div>
);
}
function TypeFilterFields({
value,
changeFilter,
}: {
value: Tables["LFGPost"]["type"];
changeFilter: (newFilter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<select
id="type-filter"
className="w-max"
value={value}
onChange={(e) =>
changeFilter({
_tag: "Type",
type: e.target.value as Tables["LFGPost"]["type"],
})
}
>
{LFG.types.map((type) => (
<option key={type} value={type}>
{t(`lfg:types.${type}`)}
</option>
))}
</select>
</div>
);
}
function TimezoneFilterFields({
value,
changeFilter,
}: {
value: number;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<input
id="timezone-filter"
type="number"
value={value}
min={0}
max={12}
onChange={(e) => {
changeFilter({
_tag: "Timezone",
maxHourDifference: Number(e.target.value),
});
}}
/>
</div>
);
}
function LanguageFilterFields({
value,
changeFilter,
}: {
value: string;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<select
id="language-filter"
className="w-max"
value={value}
onChange={(e) =>
changeFilter({
_tag: "Language",
language: e.target.value as UnifiedLanguageCode,
})
}
>
{languagesUnified.map((language) => (
<option key={language.code} value={language.code}>
{language.name}
</option>
))}
</select>
</div>
);
}
function PlusTierFilterFields({
value,
changeFilter,
}: {
value: number;
changeFilter: (newFilter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<select
id="plustier-filter"
value={value}
onChange={(e) =>
changeFilter({ _tag: "PlusTier", tier: Number(e.target.value) })
}
className="w-max"
>
<option value="1">+1</option>
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
</select>
</div>
);
}
function TierFilterFields({
_tag,
value,
changeFilter,
}: {
_tag: "MaxTier" | "MinTier";
value: TierName;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<select
id={`${_tag.toLowerCase()}-filter`}
value={value}
onChange={(e) =>
changeFilter({ _tag, tier: e.target.value as TierName })
}
className="w-max"
>
{TIERS.map((tier) => (
<option key={tier.name} value={tier.name}>
{R.capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
</div>
);
}

View File

@ -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(),
);

View File

@ -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 = (

View File

@ -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,
};

View File

@ -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", () => {

View File

@ -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<LFGFilter[]>((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({

View File

@ -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;
}

View File

@ -1,8 +1,3 @@
.topRow {
display: flex;
justify-content: flex-end;
}
.post {
scroll-margin-top: 6rem;
}

View File

@ -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<typeof loader>();
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<LFGLoaderData["posts"]>) => {
if (post.author.id !== user?.id) return false;
@ -78,25 +84,7 @@ export default function LFGPage() {
return (
<Main className="stack xl">
<div className={styles.topRow}>
<LFGAddFilterButton
addFilter={(newFilter) => setFilters([...filters, newFilter])}
filters={filters}
/>
</div>
<LFGFilters
filters={filters}
changeFilter={(newFilter) =>
setFilters(
filters.map((filter) =>
filter._tag === newFilter._tag ? newFilter : filter,
),
)
}
removeFilterByTag={(tag) =>
setFilters(filters.filter((filter) => filter._tag !== tag))
}
/>
<Filters />
{filteredPosts.map((post) => (
<div
key={post.id}
@ -116,6 +104,231 @@ export default function LFGPage() {
);
}
function Filters() {
const { t } = useTranslation(["lfg"]);
const [
{ weapons, type, timezone, language, plusTier, minTier, maxTier },
setParams,
] = useSearchParamsTyped(lfgSearchParams);
return (
<FilterBar
pills={[
{
key: "weapons",
name: t("lfg:filters.Weapon"),
formattedValue:
weapons.length > 0 ? (
<span className="stack horizontal xs">
{weapons.map((weaponSplId) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={18}
variant="badge"
/>
))}
</span>
) : null,
onRemove: () => setParams({ weapons: [] }),
popover: (
<WeaponsPopover
weapons={weapons}
onChange={(newWeapons) => 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: (
<select
aria-label={t("lfg:filters.Type")}
className="w-full"
value={type ?? "PLAYER_FOR_TEAM"}
onChange={(e) =>
setParams({ type: e.target.value as typeof type })
}
>
{LFG.types.map((option) => (
<option key={option} value={option}>
{t(`lfg:types.${option}`)}
</option>
))}
</select>
),
},
{
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: (
<select
aria-label={t("lfg:filters.Language")}
className="w-full"
value={language ?? "en"}
onChange={(e) =>
setParams({ language: e.target.value as typeof language })
}
>
{languagesUnified.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
),
},
{
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: (
<select
aria-label={t("lfg:filters.PlusTier")}
className="w-full"
value={plusTier ?? 3}
onChange={(e) => setParams({ plusTier: Number(e.target.value) })}
>
<option value="1">+1</option>
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
</select>
),
},
{
key: "timezone",
name: t("lfg:filters.Timezone"),
formattedValue: timezone !== null ? `±${timezone}h` : null,
onAdd: () => setParams({ timezone: 3 }),
onRemove: () => setParams({ timezone: null }),
popover: (
<input
aria-label={t("lfg:filters.Timezone")}
className="w-full"
type="number"
value={timezone ?? 3}
min={0}
max={12}
onChange={(e) => 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: (
<TierSelect
label={t("lfg:filters.MinTier")}
value={minTier ?? "GOLD"}
onChange={(tier) => 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: (
<TierSelect
label={t("lfg:filters.MaxTier")}
value={maxTier ?? "PLATINUM"}
onChange={(tier) => setParams({ maxTier: tier })}
/>
),
},
]}
/>
);
}
function WeaponsPopover({
weapons,
onChange,
}: {
weapons: MainWeaponId[];
onChange: (weapons: MainWeaponId[]) => void;
}) {
return (
<div className="stack sm">
<WeaponSelect
disabledWeaponIds={weapons}
onChange={(weaponId) =>
onChange(
weapons.length >= LFG.MAX_WEAPON_FILTERS
? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId]
: [...weapons, weaponId],
)
}
key={weapons.join("-")}
/>
{weapons.length > 0 ? (
<div className="stack horizontal sm flex-wrap">
{weapons.map((weapon) => (
<SendouButton
key={weapon}
variant="minimal"
onPress={() =>
onChange(weapons.filter((weaponId) => weaponId !== weapon))
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</SendouButton>
))}
</div>
) : null}
</div>
);
}
function TierSelect({
label,
value,
onChange,
}: {
label: string;
value: (typeof TIERS)[number]["name"];
onChange: (tier: (typeof TIERS)[number]["name"]) => void;
}) {
return (
<select
aria-label={label}
className="w-full"
value={value}
onChange={(e) => onChange(e.target.value as typeof value)}
>
{TIERS.map((tier) => (
<option key={tier.name} value={tier.name}>
{R.capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
);
}
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);

View File

@ -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<typeof scrimsFiltersFormSchema>;
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
const { t } = useTranslation(["scrims"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
variant="outlined"
size="small"
icon={<Funnel />}
onPress={() => setIsOpen(true)}
data-testid="filter-scrims-button"
>
{t("scrims:filters.button")}
</SendouButton>
<SendouDialog
heading={t("scrims:filters.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
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 (
<SendouForm
schema={scrimsFiltersFormSchema}
defaultValues={defaultValues}
onApply={handleApply}
submitButtonText={t("scrims:filters.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="weekdayTimes" />
<FormField name="weekendTimes" />
<FormField name="divs" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["scrims"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
const handlePress = () => {
submitToServer({
_action: "PERSIST_SCRIM_FILTERS",
filters: formValuesToFilters(values as FormValues),
});
};
return (
<SendouButton
variant="outlined"
onPress={handlePress}
isDisabled={fetcherState !== "idle"}
>
{t("scrims:filters.applyAndDefault")}
</SendouButton>
);
}

View File

@ -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(),
),
};
};

View File

@ -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 (
<Main className="stack lg">
<div className="stack horizontal justify-between items-center">
<div className="stack horizontal sm">
<LinkButton
size="small"
to={associationsPage()}
className={clsx({ invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
{user ? (
<ScrimFiltersDialog
key={JSON.stringify(data.filters)}
filters={data.filters}
/>
) : null}
</div>
<div className="stack horizontal sm items-center flex-wrap">
<LinkButton
size="small"
to={associationsPage()}
className={clsx({ invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
<Filters />
</div>
<SendouTabs
key={pendingRequestPostId}
@ -187,6 +188,182 @@ export default function ScrimsPage() {
);
}
function Filters() {
const { t } = useTranslation(["scrims", "forms", "common"]);
const data = useLoaderData<typeof loader>();
const [, setParams] = useSearchParamsTyped(scrimsSearchParams);
const persistFilters = useActionSubmit(scrimsActionSchema, {
encType: "application/json",
});
const filters = data.filters;
const writeFilters = (partial: Partial<ScrimFilters>) => {
setParams({ ...filters, ...partial, useDefaults: false });
};
return (
<FilterBar
pills={[
{
key: "weekdayTimes",
name: t("scrims:filters.weekdayTimes"),
formattedValue: filters.weekdayTimes
? `${filters.weekdayTimes.start}${filters.weekdayTimes.end}`
: null,
onRemove: () => writeFilters({ weekdayTimes: null }),
testId: "weekday-times-filter",
popover: (
<TimeRangePopover
name="weekdayTimes"
value={filters.weekdayTimes}
onChange={(timeRange) =>
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: (
<TimeRangePopover
name="weekendTimes"
value={filters.weekendTimes}
onChange={(timeRange) =>
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: (
<DivsPopover
value={filters.divs}
onChange={(divs) => writeFilters({ divs })}
/>
),
},
]}
onReset={
!Scrim.filtersAreDefault(filters)
? () =>
writeFilters({
weekdayTimes: null,
weekendTimes: null,
divs: null,
})
: undefined
}
actions={
data.canSaveAsDefault ? (
<SendouButton
icon={<Star />}
isDisabled={persistFilters.state !== "idle"}
onPress={() =>
persistFilters.submit("PERSIST_SCRIM_FILTERS", { filters })
}
data-testid="save-filters-as-default-button"
>
{t("common:filterBar.saveAsDefault")}
</SendouButton>
) : 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 (
<TimeRangeFormField
name={name}
value={draft}
onChange={handleChange}
startLabel={t("forms:labels.start")}
endLabel={t("forms:labels.end")}
/>
);
}
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 (
<DualSelectFormField
name="divs"
fields={[
{ label: t("forms:labels.scrimMaxDiv"), items: divItems },
{ label: t("forms:labels.scrimMinDiv"), items: divItems },
]}
value={draft}
onChange={handleChange}
onBlur={() => {}}
/>
);
}
function ScrimsDaySeparatedCards({
posts,
filters,

View File

@ -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<T extends { min: string | null; max: string | null }>(
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,

View File

@ -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,
});
});
});

View File

@ -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,
}),

View File

@ -157,7 +157,6 @@ function groupWithTeamAndMembers(
"GroupMember.role",
"GroupMember.note",
"User.inGameName",
"User.pronouns",
"User.vc",
"User.languages",
"User.noScreen",

View File

@ -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"),

View File

@ -32,7 +32,6 @@ function createMember(overrides: Partial<SQGroupMember> = {}): 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,

View File

@ -291,11 +291,6 @@ function GroupMember({
</span>
</span>
</UserCard>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxs">
{member.pronouns.subject}/{member.pronouns.object}
</span>
) : null}
</div>
<div
className={clsx(

View File

@ -5,7 +5,7 @@ import {
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useTournament } from "~/features/tournament/tournament-context";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import * as CSV from "~/modules/csv";
import { databaseTimestampToDate } from "~/utils/dates";

View File

@ -26,7 +26,7 @@ import {
type SortState,
} from "~/components/SortableTableHeader";
import { Table } from "~/components/Table";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useTournament } from "~/features/tournament/tournament-context";
import type {
BracketMeta,
Tournament,

View File

@ -5,11 +5,11 @@ import { LocaleTime } from "~/components/LocaleTime";
import { Pagination } from "~/components/Pagination";
import { Table } from "~/components/Table";
import { UserLink } from "~/components/UserLink";
import { useTournament } from "~/features/tournament/routes/to.$id";
import {
TOURNAMENT_AUDIT_LOG_TYPES,
type TournamentAuditLogType,
} from "~/features/tournament/tournament-constants";
import { useTournament } from "~/features/tournament/tournament-context";
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import type { CommonUser } from "~/utils/kysely.server";

View File

@ -8,10 +8,18 @@ import { Redirect } from "~/components/Redirect";
import { SubmitButton } from "~/components/SubmitButton";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useTournament } from "~/features/tournament/tournament-context";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { SendouForm } from "~/form/SendouForm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import invariant from "~/utils/invariant";
import { tournamentAdminPage } from "~/utils/urls";
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
import {
bracketProgressionFormSchema,
formValuesToInputBrackets,
progressionToFormValues,
} from "../../calendar/calendar-progression-form";
import { BracketProgressionFormFields } from "../../calendar/components/BracketProgressionFormFields";
import { adminBracketsActionSchema } from "../tournament-admin-schemas";
export { action } from "../actions/to.$id.admin.brackets.server";
@ -127,45 +135,53 @@ function BracketReset() {
function BracketProgressionEdit() {
const tournament = useTournament();
const fetcher = useFetcher();
const [bracketProgression, setBracketProgression] = React.useState<
Progression.ParsedBracket[] | null
>(tournament.ctx.settings.bracketProgression);
const { submit } = useActionSubmit(adminBracketsActionSchema);
const disabledBracketIdxs = tournament.bracketsMeta
.filter((bracket) => !bracket.preview)
.map((bracket) => bracket.idx);
return (
<fetcher.Form method="post">
{bracketProgression ? (
<input
type="hidden"
name="bracketProgression"
value={JSON.stringify(bracketProgression)}
/>
) : null}
<BracketProgressionSelector
initialBrackets={Progression.validatedBracketsToInputFormat(
tournament.ctx.settings.bracketProgression,
).map((bracket, idx) => ({
...bracket,
disabled: disabledBracketIdxs.includes(idx),
}))}
isInvitationalTournament={tournament.isInvitational}
onChange={setBracketProgression}
<SendouForm
schema={bracketProgressionFormSchema}
defaultValues={progressionToFormValues(
tournament.ctx.settings.bracketProgression,
)}
submitButtonText="Save changes"
fullWidth
onApply={(values) => {
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,
});
}}
>
<BracketProgressionFormFields
isInvitational={tournament.isInvitational}
disabledBracketIdxs={disabledBracketIdxs}
isTournamentInProgress
/>
<div className="stack md horizontal justify-center mt-6">
<SubmitButton
schema={adminBracketsActionSchema}
_action="UPDATE_TOURNAMENT_PROGRESSION"
isDisabled={!bracketProgression}
>
Save changes
</SubmitButton>
</div>
</fetcher.Form>
</SendouForm>
);
}

View File

@ -28,7 +28,7 @@ vi.mock("react-router", async () => {
};
});
vi.mock("~/features/tournament/routes/to.$id", () => ({
vi.mock("~/features/tournament/tournament-context", () => ({
useTournament: () => mockTournament,
}));

View File

@ -19,7 +19,7 @@ vi.mock("react-router", async () => {
};
});
vi.mock("~/features/tournament/routes/to.$id", () => ({
vi.mock("~/features/tournament/tournament-context", () => ({
useTournament: () => mockTournament,
}));

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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;
}

View File

@ -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]);
});
});

View File

@ -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<number>();
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<number>();
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<number, Set<number>>();
const startingAncestors = (bracketIdx: number): Set<number> => {
const cached = cache.get(bracketIdx);
if (cached) return cached;
const sources = brackets[bracketIdx]?.sources;
const result = new Set<number>();
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<number>();
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>,
): 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<number, Map<number, number>>,
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<number, number> {
const result = new Map<number, number>();
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<number> = new Set(),
): Map<number, number> {
const result = new Map<number, number>();
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<number, number>,
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<number, number>,
placementsB: Map<number, number>,
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<number, number>,
placementsB: Map<number, number>,
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;
}

View File

@ -364,9 +364,14 @@ export class Tournament {
}
private resolveTeamsFromSources(
sources: NonNullable<Progression.ParsedBracket["sources"]>,
unsortedSources: NonNullable<Progression.ParsedBracket["sources"]>,
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");

View File

@ -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,

View File

@ -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,

View File

@ -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 (
<TournamentOverrideProvider tournament={tournament}>
<TournamentProvider tournament={tournament}>
<TournamentBracketsView />
</TournamentOverrideProvider>
</TournamentProvider>
);
}
@ -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)
</div>
) : null}

View File

@ -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),

View File

@ -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({
<span className={styles.name}>{member.username}</span>
</span>
</UserCard>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxs">
{member.pronouns.subject}/{member.pronouns.object}
</span>
) : null}
</div>
<div className="ml-auto stack horizontal sm items-center">
{showActions || (!showActions && member.role === "OWNER") ? (

View File

@ -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;
}

View File

@ -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,

View File

@ -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";

View File

@ -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";

Some files were not shown because too many files have changed in this diff Show More