mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-09 15:13:53 -05:00
Merge main and re-apply spelling fixes
This commit is contained in:
commit
af0533a62e
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,13 @@
|
|||
width: fit-content;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
bottom: 15%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
197
app/components/filter-bar/FilterBar.browser.test.tsx
Normal file
197
app/components/filter-bar/FilterBar.browser.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
139
app/components/filter-bar/FilterBar.module.css
Normal file
139
app/components/filter-bar/FilterBar.module.css
Normal 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);
|
||||
}
|
||||
183
app/components/filter-bar/FilterBar.tsx
Normal file
183
app/components/filter-bar/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
|||
userId,
|
||||
newTeamId: team.id,
|
||||
previousTeamIdToDelete,
|
||||
isOrganizerAdded: true,
|
||||
});
|
||||
|
||||
if (previousTeamPickupChat) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
288
app/features/calendar/calendar-progression-form.test.ts
Normal file
288
app/features/calendar/calendar-progression-form.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
419
app/features/calendar/calendar-progression-form.ts
Normal file
419
app/features/calendar/calendar-progression-form.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
541
app/features/calendar/components/FiltersBar.tsx
Normal file
541
app/features/calendar/components/FiltersBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.filter {
|
||||
padding: var(--s-1-5) var(--s-2);
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
.topRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.post {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -157,7 +157,6 @@ function groupWithTeamAndMembers(
|
|||
"GroupMember.role",
|
||||
"GroupMember.note",
|
||||
"User.inGameName",
|
||||
"User.pronouns",
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.noScreen",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ vi.mock("react-router", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("~/features/tournament/routes/to.$id", () => ({
|
||||
vi.mock("~/features/tournament/tournament-context", () => ({
|
||||
useTournament: () => mockTournament,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ vi.mock("react-router", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("~/features/tournament/routes/to.$id", () => ({
|
||||
vi.mock("~/features/tournament/tournament-context", () => ({
|
||||
useTournament: () => mockTournament,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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") ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user