mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-24 12:06:54 -05:00
Unified filters (#3313)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -51,6 +51,12 @@ const NUMBER_TO_TIER = {
|
||||
export type TournamentTier = keyof typeof TIER_TO_NUMBER;
|
||||
export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier];
|
||||
|
||||
/** Every tier number, from the best tier (X) to the worst (C). */
|
||||
export const TIER_NUMBERS = Object.values(TIER_TO_NUMBER);
|
||||
|
||||
export const BEST_TIER_NUMBER = TIER_TO_NUMBER.X;
|
||||
export const WORST_TIER_NUMBER = TIER_TO_NUMBER.C;
|
||||
|
||||
export function calculateAdjustedScore(
|
||||
rawScore: number,
|
||||
teamCount: number,
|
||||
|
||||
@@ -6,7 +6,15 @@ import { db } from "~/db/sql";
|
||||
import type { DB, Tables, TablesInsertable } from "~/db/tables";
|
||||
import type { CustomTheme, UserPreferences } from "~/db/tables-json";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import type { BuildSort } from "~/features/user-page/user-page-constants";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
type TournamentTierNumber,
|
||||
WORST_TIER_NUMBER,
|
||||
} from "~/features/tournament/core/tiering";
|
||||
import type {
|
||||
BuildSort,
|
||||
ResultSource,
|
||||
} from "~/features/user-page/user-page-constants";
|
||||
import { userRoles } from "~/modules/permissions/mapper.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
@@ -392,6 +400,16 @@ export function findByFriendCode(friendCode: string) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findUsernameById(id: number) {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.select("User.username")
|
||||
.where("User.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return user?.username ?? null;
|
||||
}
|
||||
|
||||
export async function findLeanById(id: number) {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
@@ -525,16 +543,61 @@ export async function findChatUsersByUserIds(userIds: number[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const withMaxEventStartTime = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
|
||||
return eb
|
||||
export interface ResultsFilters {
|
||||
showHighlightsOnly?: boolean;
|
||||
tournamentName?: string;
|
||||
teamName?: string;
|
||||
mateUserId?: number;
|
||||
minTier?: TournamentTierNumber;
|
||||
maxTier?: TournamentTierNumber;
|
||||
maxPlacement?: number;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
source?: ResultSource;
|
||||
minParticipantCount?: number;
|
||||
}
|
||||
|
||||
const withMaxEventStartTime = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
|
||||
eb
|
||||
.selectFrom("CalendarEventDate")
|
||||
.select(({ fn }) => [fn.max("CalendarEventDate.startsAt").as("startsAt")])
|
||||
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id")
|
||||
.as("startsAt");
|
||||
};
|
||||
|
||||
const baseCalendarEventResultsQuery = (userId: number) =>
|
||||
db
|
||||
const maxEventStartTimeExpr = sql<number>`(select max(${sql.ref("CalendarEventDate.startsAt")}) from ${sql.table("CalendarEventDate")} where ${sql.ref("CalendarEventDate.eventId")} = ${sql.ref("CalendarEvent.id")})`;
|
||||
|
||||
const maxEventStartTimeAtLeastExpr = (year: number) =>
|
||||
sql<boolean>`${maxEventStartTimeExpr} >= ${yearStartsAt(year)}`;
|
||||
|
||||
const maxEventStartTimeAtMostExpr = (year: number) =>
|
||||
sql<boolean>`${maxEventStartTimeExpr} <= ${yearEndsAt(year)}`;
|
||||
|
||||
const NEVER_MATCHES = sql<boolean>`0`;
|
||||
|
||||
const isTierFiltered = ({
|
||||
minTier = BEST_TIER_NUMBER,
|
||||
maxTier = WORST_TIER_NUMBER,
|
||||
}: ResultsFilters) =>
|
||||
minTier !== BEST_TIER_NUMBER || maxTier !== WORST_TIER_NUMBER;
|
||||
|
||||
/** Results reported on a calendar event have no tier, so filtering by tier excludes them. */
|
||||
const includesCalendarEventResults = (filters: ResultsFilters) =>
|
||||
filters.source !== "SENDOU" && !isTierFiltered(filters);
|
||||
|
||||
const includesTournamentResults = (filters: ResultsFilters) =>
|
||||
filters.source !== "EXTERNAL";
|
||||
|
||||
const yearStartsAt = (year: number) =>
|
||||
dateToDatabaseTimestamp(new Date(Date.UTC(year, 0, 1)));
|
||||
|
||||
const yearEndsAt = (year: number) =>
|
||||
dateToDatabaseTimestamp(new Date(Date.UTC(year + 1, 0, 1))) - 1;
|
||||
|
||||
const baseCalendarEventResultsQuery = (
|
||||
userId: number,
|
||||
filters: ResultsFilters,
|
||||
) => {
|
||||
let query = db
|
||||
.selectFrom("CalendarEventResultPlayer")
|
||||
.innerJoin(
|
||||
"CalendarEventResultTeam",
|
||||
@@ -553,8 +616,71 @@ const baseCalendarEventResultsQuery = (userId: number) =>
|
||||
)
|
||||
.where("CalendarEventResultPlayer.userId", "=", userId);
|
||||
|
||||
const baseTournamentResultsQuery = (userId: number) =>
|
||||
db
|
||||
if (!includesCalendarEventResults(filters)) {
|
||||
return query.where(NEVER_MATCHES);
|
||||
}
|
||||
|
||||
if (filters.showHighlightsOnly) {
|
||||
query = query.where("UserResultHighlight.userId", "is not", null);
|
||||
}
|
||||
|
||||
if (filters.tournamentName) {
|
||||
query = query.where(
|
||||
nameLikeExpr("CalendarEvent.name", filters.tournamentName),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.teamName) {
|
||||
query = query.where(
|
||||
nameLikeExpr("CalendarEventResultTeam.name", filters.teamName),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.mateUserId) {
|
||||
const mateUserId = filters.mateUserId;
|
||||
query = query.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("CalendarEventResultPlayer as MatePlayer")
|
||||
.select("MatePlayer.userId")
|
||||
.whereRef("MatePlayer.teamId", "=", "CalendarEventResultTeam.id")
|
||||
.where("MatePlayer.userId", "=", mateUserId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.maxPlacement) {
|
||||
query = query.where(
|
||||
"CalendarEventResultTeam.placement",
|
||||
"<=",
|
||||
filters.maxPlacement,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.minParticipantCount) {
|
||||
query = query.where(
|
||||
"CalendarEvent.participantCount",
|
||||
">=",
|
||||
filters.minParticipantCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.fromYear) {
|
||||
query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear));
|
||||
}
|
||||
|
||||
if (filters.toYear) {
|
||||
query = query.where(maxEventStartTimeAtMostExpr(filters.toYear));
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const baseTournamentResultsQuery = (
|
||||
userId: number,
|
||||
filters: ResultsFilters,
|
||||
) => {
|
||||
let query = db
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
@@ -569,124 +695,167 @@ const baseTournamentResultsQuery = (userId: number) =>
|
||||
.innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId")
|
||||
.where("TournamentResult.userId", "=", userId);
|
||||
|
||||
if (!includesTournamentResults(filters)) {
|
||||
return query.where(NEVER_MATCHES);
|
||||
}
|
||||
|
||||
if (filters.showHighlightsOnly) {
|
||||
query = query.where("TournamentResult.isHighlight", "=", 1);
|
||||
}
|
||||
|
||||
if (filters.tournamentName) {
|
||||
query = query.where(
|
||||
nameLikeExpr("CalendarEvent.name", filters.tournamentName),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.teamName) {
|
||||
query = query.where(nameLikeExpr("TournamentTeam.name", filters.teamName));
|
||||
}
|
||||
|
||||
if (filters.mateUserId) {
|
||||
const mateUserId = filters.mateUserId;
|
||||
query = query.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("TournamentResult as MateResult")
|
||||
.select("MateResult.userId")
|
||||
.whereRef(
|
||||
"MateResult.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.where("MateResult.userId", "=", mateUserId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isTierFiltered(filters)) {
|
||||
query = query
|
||||
.where("Tournament.tier", ">=", filters.minTier ?? BEST_TIER_NUMBER)
|
||||
.where("Tournament.tier", "<=", filters.maxTier ?? WORST_TIER_NUMBER);
|
||||
}
|
||||
|
||||
if (filters.maxPlacement) {
|
||||
query = query.where(
|
||||
"TournamentResult.placement",
|
||||
"<=",
|
||||
filters.maxPlacement,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.minParticipantCount) {
|
||||
query = query.where(
|
||||
"TournamentResult.participantCount",
|
||||
">=",
|
||||
filters.minParticipantCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.fromYear) {
|
||||
query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear));
|
||||
}
|
||||
|
||||
if (filters.toYear) {
|
||||
query = query.where(maxEventStartTimeAtMostExpr(filters.toYear));
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const escapeLikePattern = (value: string) =>
|
||||
value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
|
||||
const tournamentNameLikeExpr = (tournamentName: string) => {
|
||||
const pattern = `%${escapeLikePattern(tournamentName)}%`;
|
||||
return sql<boolean>`${sql.ref("CalendarEvent.name")} like ${pattern} escape '\\'`;
|
||||
const nameLikeExpr = (column: string, name: string) => {
|
||||
const pattern = `%${escapeLikePattern(name)}%`;
|
||||
return sql<boolean>`${sql.ref(column)} like ${pattern} escape '\\'`;
|
||||
};
|
||||
|
||||
export function findResultsByUserId(
|
||||
userId: number,
|
||||
{
|
||||
showHighlightsOnly = false,
|
||||
limit,
|
||||
offset,
|
||||
tournamentName,
|
||||
}: {
|
||||
showHighlightsOnly?: boolean;
|
||||
...filters
|
||||
}: ResultsFilters & {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
tournamentName?: string;
|
||||
} = {},
|
||||
) {
|
||||
let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select(
|
||||
({ eb, fn }) => [
|
||||
"CalendarEvent.id as eventId",
|
||||
sql<number>`null`.as("tournamentId"),
|
||||
"CalendarEventResultTeam.placement",
|
||||
"CalendarEvent.participantCount",
|
||||
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
|
||||
sql<string | null>`null`.as("div"),
|
||||
sql<string | null>`null`.as("logoUrl"),
|
||||
"CalendarEvent.name as eventName",
|
||||
"CalendarEventResultTeam.id as teamId",
|
||||
"CalendarEventResultTeam.name as teamName",
|
||||
fn<number | null>("iif", [
|
||||
"UserResultHighlight.userId",
|
||||
sql`1`,
|
||||
sql`0`,
|
||||
]).as("isHighlight"),
|
||||
sql<number | null>`null`.as("tier"),
|
||||
withMaxEventStartTime(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventResultPlayer")
|
||||
.leftJoin("User", "User.id", "CalendarEventResultPlayer.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"CalendarEventResultPlayer.name",
|
||||
])
|
||||
.whereRef(
|
||||
"CalendarEventResultPlayer.teamId",
|
||||
"=",
|
||||
"CalendarEventResultTeam.id",
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("CalendarEventResultPlayer.userId", "is", null),
|
||||
eb("CalendarEventResultPlayer.userId", "!=", userId),
|
||||
]),
|
||||
),
|
||||
).as("mates"),
|
||||
],
|
||||
);
|
||||
const calendarEventResultsQuery = baseCalendarEventResultsQuery(
|
||||
userId,
|
||||
filters,
|
||||
).select(({ eb, fn }) => [
|
||||
"CalendarEvent.id as eventId",
|
||||
sql<number>`null`.as("tournamentId"),
|
||||
"CalendarEventResultTeam.placement",
|
||||
"CalendarEvent.participantCount",
|
||||
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
|
||||
sql<string | null>`null`.as("div"),
|
||||
sql<string | null>`null`.as("logoUrl"),
|
||||
"CalendarEvent.name as eventName",
|
||||
"CalendarEventResultTeam.id as teamId",
|
||||
"CalendarEventResultTeam.name as teamName",
|
||||
fn<number | null>("iif", ["UserResultHighlight.userId", sql`1`, sql`0`]).as(
|
||||
"isHighlight",
|
||||
),
|
||||
sql<number | null>`null`.as("tier"),
|
||||
withMaxEventStartTime(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventResultPlayer")
|
||||
.leftJoin("User", "User.id", "CalendarEventResultPlayer.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"CalendarEventResultPlayer.name",
|
||||
])
|
||||
.whereRef(
|
||||
"CalendarEventResultPlayer.teamId",
|
||||
"=",
|
||||
"CalendarEventResultTeam.id",
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("CalendarEventResultPlayer.userId", "is", null),
|
||||
eb("CalendarEventResultPlayer.userId", "!=", userId),
|
||||
]),
|
||||
),
|
||||
).as("mates"),
|
||||
]);
|
||||
|
||||
let tournamentResultsQuery = baseTournamentResultsQuery(userId).select(
|
||||
({ eb }) => [
|
||||
sql<number>`null`.as("eventId"),
|
||||
"TournamentResult.tournamentId",
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount",
|
||||
"TournamentResult.setResults",
|
||||
"TournamentResult.div",
|
||||
tournamentLogoOrNull(eb).as("logoUrl"),
|
||||
"CalendarEvent.name as eventName",
|
||||
"TournamentTeam.id as teamId",
|
||||
"TournamentTeam.name as teamName",
|
||||
"TournamentResult.isHighlight",
|
||||
"Tournament.tier",
|
||||
withMaxEventStartTime(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult as TournamentResult2")
|
||||
.innerJoin("User", "User.id", "TournamentResult2.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
sql<string | null>`null`.as("name"),
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentResult2.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.where("TournamentResult2.userId", "!=", userId),
|
||||
).as("mates"),
|
||||
],
|
||||
);
|
||||
|
||||
if (showHighlightsOnly) {
|
||||
calendarEventResultsQuery = calendarEventResultsQuery.where(
|
||||
"UserResultHighlight.userId",
|
||||
"is not",
|
||||
null,
|
||||
);
|
||||
tournamentResultsQuery = tournamentResultsQuery.where(
|
||||
"TournamentResult.isHighlight",
|
||||
"=",
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
if (tournamentName) {
|
||||
calendarEventResultsQuery = calendarEventResultsQuery.where(
|
||||
tournamentNameLikeExpr(tournamentName),
|
||||
);
|
||||
tournamentResultsQuery = tournamentResultsQuery.where(
|
||||
tournamentNameLikeExpr(tournamentName),
|
||||
);
|
||||
}
|
||||
const tournamentResultsQuery = baseTournamentResultsQuery(
|
||||
userId,
|
||||
filters,
|
||||
).select(({ eb }) => [
|
||||
sql<number>`null`.as("eventId"),
|
||||
"TournamentResult.tournamentId",
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount",
|
||||
"TournamentResult.setResults",
|
||||
"TournamentResult.div",
|
||||
tournamentLogoOrNull(eb).as("logoUrl"),
|
||||
"CalendarEvent.name as eventName",
|
||||
"TournamentTeam.id as teamId",
|
||||
"TournamentTeam.name as teamName",
|
||||
"TournamentResult.isHighlight",
|
||||
"Tournament.tier",
|
||||
withMaxEventStartTime(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult as TournamentResult2")
|
||||
.innerJoin("User", "User.id", "TournamentResult2.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
sql<string | null>`null`.as("name"),
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentResult2.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.where("TournamentResult2.userId", "!=", userId),
|
||||
).as("mates"),
|
||||
]);
|
||||
|
||||
let query = calendarEventResultsQuery
|
||||
.unionAll(tournamentResultsQuery)
|
||||
@@ -706,40 +875,17 @@ export function findResultsByUserId(
|
||||
|
||||
export async function countResultsByUserId(
|
||||
userId: number,
|
||||
{
|
||||
showHighlightsOnly = false,
|
||||
tournamentName,
|
||||
}: { showHighlightsOnly?: boolean; tournamentName?: string } = {},
|
||||
filters: ResultsFilters = {},
|
||||
) {
|
||||
let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select(
|
||||
({ fn }) => [fn.countAll<number>().as("count")],
|
||||
);
|
||||
const calendarEventResultsQuery = baseCalendarEventResultsQuery(
|
||||
userId,
|
||||
filters,
|
||||
).select(({ fn }) => [fn.countAll<number>().as("count")]);
|
||||
|
||||
let tournamentResultsQuery = baseTournamentResultsQuery(userId).select(
|
||||
({ fn }) => [fn.countAll<number>().as("count")],
|
||||
);
|
||||
|
||||
if (showHighlightsOnly) {
|
||||
calendarEventResultsQuery = calendarEventResultsQuery.where(
|
||||
"UserResultHighlight.userId",
|
||||
"is not",
|
||||
null,
|
||||
);
|
||||
tournamentResultsQuery = tournamentResultsQuery.where(
|
||||
"TournamentResult.isHighlight",
|
||||
"=",
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
if (tournamentName) {
|
||||
calendarEventResultsQuery = calendarEventResultsQuery.where(
|
||||
tournamentNameLikeExpr(tournamentName),
|
||||
);
|
||||
tournamentResultsQuery = tournamentResultsQuery.where(
|
||||
tournamentNameLikeExpr(tournamentName),
|
||||
);
|
||||
}
|
||||
const tournamentResultsQuery = baseTournamentResultsQuery(
|
||||
userId,
|
||||
filters,
|
||||
).select(({ fn }) => [fn.countAll<number>().as("count")]);
|
||||
|
||||
const [calendarEventResults, tournamentResults] = await Promise.all([
|
||||
calendarEventResultsQuery.executeTakeFirst(),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
|
||||
import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as UserRepository from "./UserRepository.server";
|
||||
|
||||
describe("UserRepository", () => {
|
||||
@@ -100,6 +104,192 @@ describe("UserRepository", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
describe("findResultsByUserId filters", () => {
|
||||
const startTimeOf = (year: number) =>
|
||||
dateToDatabaseTimestamp(new Date(Date.UTC(year, 5, 1)));
|
||||
|
||||
const seedResults = async () => {
|
||||
const user = await UserFactory.create();
|
||||
const mate = await UserFactory.create();
|
||||
const [firstOpponent, secondOpponent] = await UserFactory.createMany(2);
|
||||
|
||||
const wonEvent = await CalendarEventFactory.create({
|
||||
name: "Gamma Open",
|
||||
authorId: user.id,
|
||||
startTimes: [startTimeOf(2024)],
|
||||
});
|
||||
await CalendarEventResultFactory.create({
|
||||
eventId: wonEvent.id,
|
||||
participantCount: 8,
|
||||
results: [
|
||||
{
|
||||
teamName: "Team Gamma",
|
||||
placement: 1,
|
||||
players: [
|
||||
{ userId: user.id, name: null },
|
||||
{ userId: mate.id, name: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lostEvent = await CalendarEventFactory.create({
|
||||
name: "Delta Open",
|
||||
authorId: user.id,
|
||||
startTimes: [startTimeOf(2022)],
|
||||
});
|
||||
await CalendarEventResultFactory.create({
|
||||
eventId: lostEvent.id,
|
||||
participantCount: 50,
|
||||
results: [
|
||||
{
|
||||
teamName: "Team Delta",
|
||||
placement: 5,
|
||||
players: [
|
||||
{ userId: user.id, name: null },
|
||||
{ userId: firstOpponent.id, name: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await TournamentFactory.createPlayed(
|
||||
{
|
||||
name: "Alpha Invitational",
|
||||
authorId: user.id,
|
||||
startTimes: [startTimeOf(2023)],
|
||||
minMembersPerTeam: 1,
|
||||
},
|
||||
{
|
||||
teamRosters: [[user.id], [secondOpponent.id]],
|
||||
playedOut: "all",
|
||||
tier: 1,
|
||||
},
|
||||
);
|
||||
|
||||
return { userId: user.id, mateUserId: mate.id };
|
||||
};
|
||||
|
||||
const filteredResults = async (
|
||||
userId: number,
|
||||
filters: Parameters<typeof UserRepository.countResultsByUserId>[1],
|
||||
) => {
|
||||
const [results, count] = await Promise.all([
|
||||
UserRepository.findResultsByUserId(userId, filters),
|
||||
UserRepository.countResultsByUserId(userId, filters),
|
||||
]);
|
||||
|
||||
expect(count).toBe(results.length);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
test("returns every result without filters", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, {});
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters by result source", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const tournaments = await filteredResults(userId, { source: "SENDOU" });
|
||||
const reported = await filteredResults(userId, { source: "EXTERNAL" });
|
||||
|
||||
expect(tournaments).toHaveLength(1);
|
||||
expect(tournaments[0].eventName).toBe("Alpha Invitational");
|
||||
expect(reported.map((result) => result.eventName).sort()).toEqual([
|
||||
"Delta Open",
|
||||
"Gamma Open",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters by tier, excluding results without one", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const bestTiers = await filteredResults(userId, {
|
||||
minTier: 1,
|
||||
maxTier: 3,
|
||||
});
|
||||
const worstTiers = await filteredResults(userId, {
|
||||
minTier: 8,
|
||||
maxTier: 9,
|
||||
});
|
||||
|
||||
expect(bestTiers).toHaveLength(1);
|
||||
expect(bestTiers[0].eventName).toBe("Alpha Invitational");
|
||||
expect(worstTiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("filters by placement", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const wins = await filteredResults(userId, { maxPlacement: 1 });
|
||||
|
||||
expect(wins.every((result) => result.placement === 1)).toBe(true);
|
||||
expect(wins.map((result) => result.eventName)).toContain("Gamma Open");
|
||||
expect(wins.map((result) => result.eventName)).not.toContain(
|
||||
"Delta Open",
|
||||
);
|
||||
});
|
||||
|
||||
test("filters by year range", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, {
|
||||
fromYear: 2023,
|
||||
toYear: 2024,
|
||||
});
|
||||
|
||||
expect(results.map((result) => result.eventName).sort()).toEqual([
|
||||
"Alpha Invitational",
|
||||
"Gamma Open",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters by teammate", async () => {
|
||||
const { userId, mateUserId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, { mateUserId });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].eventName).toBe("Gamma Open");
|
||||
});
|
||||
|
||||
test("filters by team name", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, { teamName: "delta" });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].eventName).toBe("Delta Open");
|
||||
});
|
||||
|
||||
test("filters by minimum participant count", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, {
|
||||
minParticipantCount: 16,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].eventName).toBe("Delta Open");
|
||||
});
|
||||
|
||||
test("filters by tournament name", async () => {
|
||||
const { userId } = await seedResults();
|
||||
|
||||
const results = await filteredResults(userId, {
|
||||
tournamentName: "alpha",
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].eventName).toBe("Alpha Invitational");
|
||||
});
|
||||
});
|
||||
|
||||
describe("userRoles", () => {
|
||||
test("returns empty array for basic user", async () => {
|
||||
await UserFactory.createAdmin();
|
||||
|
||||
398
app/features/user-page/components/ResultsFiltersBar.tsx
Normal file
398
app/features/user-page/components/ResultsFiltersBar.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import * as React from "react";
|
||||
import type { Key } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import type { FilterBarPill } from "~/components/filter-bar/FilterBar";
|
||||
import { FilterBar } from "~/components/filter-bar/FilterBar";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
TIER_NUMBERS,
|
||||
type TournamentTierNumber,
|
||||
tierNumberToName,
|
||||
WORST_TIER_NUMBER,
|
||||
} from "~/features/tournament/core/tiering";
|
||||
import { RadioGroupFormField } from "~/form/fields/InputGroupFormField";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server";
|
||||
import {
|
||||
RESULT_PLACEMENT_FILTERS,
|
||||
RESULT_SOURCES,
|
||||
RESULTS_FIRST_YEAR,
|
||||
type ResultPlacementFilter,
|
||||
} from "../user-page-constants";
|
||||
import { userResultsSearchParams } from "../user-page-search-params";
|
||||
|
||||
const DEFAULT_FILTERS = {
|
||||
highlightsOnly: true,
|
||||
tournament: null,
|
||||
team: null,
|
||||
mate: null,
|
||||
minTier: BEST_TIER_NUMBER,
|
||||
maxTier: WORST_TIER_NUMBER,
|
||||
maxPlacement: null,
|
||||
fromYear: null,
|
||||
toYear: null,
|
||||
source: "ALL",
|
||||
minParticipantCount: 0,
|
||||
} as const;
|
||||
|
||||
export function ResultsFiltersBar() {
|
||||
const { t } = useTranslation("user");
|
||||
const data = useLoaderData<UserResultsLoaderData>();
|
||||
const [filters, setFilters] = useSearchParamsTyped(userResultsSearchParams);
|
||||
|
||||
const tierFormatted = () => {
|
||||
if (
|
||||
filters.minTier === DEFAULT_FILTERS.minTier &&
|
||||
filters.maxTier === DEFAULT_FILTERS.maxTier
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bestTier = tierNumberToName(filters.minTier);
|
||||
const worstTier = tierNumberToName(filters.maxTier);
|
||||
|
||||
return bestTier === worstTier ? bestTier : `${bestTier}–${worstTier}`;
|
||||
};
|
||||
|
||||
const placementName = (maxPlacement: number) =>
|
||||
maxPlacement === 1
|
||||
? t("results.filter.placement.first")
|
||||
: t("results.filter.placement.top", { count: maxPlacement });
|
||||
|
||||
const yearsFormatted = () => {
|
||||
if (!filters.fromYear && !filters.toYear) return null;
|
||||
if (filters.fromYear === filters.toYear) return String(filters.fromYear);
|
||||
|
||||
return `${filters.fromYear ?? ""}–${filters.toYear ?? ""}`;
|
||||
};
|
||||
|
||||
const highlightsPill: FilterBarPill = {
|
||||
key: "highlights",
|
||||
name: t("results.highlights"),
|
||||
formattedValue: filters.highlightsOnly ? t("results.filter.only") : null,
|
||||
onRemove: () => setFilters({ highlightsOnly: false }),
|
||||
onAdd: () => setFilters({ highlightsOnly: true }),
|
||||
testId: "highlights-filter",
|
||||
popover: (
|
||||
<SendouSwitch
|
||||
isSelected={filters.highlightsOnly}
|
||||
onChange={(highlightsOnly) => setFilters({ highlightsOnly })}
|
||||
>
|
||||
{t("results.filter.highlightsOnly")}
|
||||
</SendouSwitch>
|
||||
),
|
||||
};
|
||||
|
||||
const pills: FilterBarPill[] = [
|
||||
...(data.hasHighlightedResults ? [highlightsPill] : []),
|
||||
{
|
||||
key: "tournament",
|
||||
name: t("results.filter.tournament"),
|
||||
formattedValue: filters.tournament,
|
||||
onRemove: () => setFilters({ tournament: null }),
|
||||
testId: "tournament-filter",
|
||||
popover: (
|
||||
<DebouncedNameFilter
|
||||
label={t("results.filter.tournament")}
|
||||
value={filters.tournament}
|
||||
onChange={(tournament) => setFilters({ tournament })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mate",
|
||||
name: t("results.filter.mate"),
|
||||
formattedValue: filters.mate ? (data.mateUsername ?? "?") : null,
|
||||
onRemove: () => setFilters({ mate: null }),
|
||||
testId: "mate-filter",
|
||||
popover: (
|
||||
<UserSearch
|
||||
label={t("results.filter.mate")}
|
||||
initialUserId={filters.mate ?? undefined}
|
||||
onChange={(user) => setFilters({ mate: user?.id ?? null })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "team",
|
||||
name: t("results.filter.team"),
|
||||
formattedValue: filters.team,
|
||||
onRemove: () => setFilters({ team: null }),
|
||||
testId: "team-filter",
|
||||
popover: (
|
||||
<DebouncedNameFilter
|
||||
label={t("results.filter.team")}
|
||||
value={filters.team}
|
||||
onChange={(team) => setFilters({ team })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tier",
|
||||
name: t("results.filter.tier"),
|
||||
formattedValue: tierFormatted(),
|
||||
onRemove: () =>
|
||||
setFilters({
|
||||
minTier: DEFAULT_FILTERS.minTier,
|
||||
maxTier: DEFAULT_FILTERS.maxTier,
|
||||
}),
|
||||
testId: "tier-filter",
|
||||
popover: (
|
||||
<div className="stack md">
|
||||
<SendouSelect
|
||||
label={t("results.filter.tier.min")}
|
||||
items={TIER_NUMBERS.map((tier) => ({ id: tier }))}
|
||||
selectedKey={filters.minTier}
|
||||
onSelectionChange={(key) => {
|
||||
const minTier = toTierNumber(key);
|
||||
setFilters({
|
||||
minTier,
|
||||
maxTier: minTier > filters.maxTier ? minTier : filters.maxTier,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ id }) => (
|
||||
<SendouSelectItem key={id} id={id}>
|
||||
{tierNumberToName(id)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
<SendouSelect
|
||||
label={t("results.filter.tier.max")}
|
||||
items={TIER_NUMBERS.map((tier) => ({ id: tier }))}
|
||||
selectedKey={filters.maxTier}
|
||||
onSelectionChange={(key) => {
|
||||
const maxTier = toTierNumber(key);
|
||||
setFilters({
|
||||
maxTier,
|
||||
minTier: maxTier < filters.minTier ? maxTier : filters.minTier,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ id }) => (
|
||||
<SendouSelectItem key={id} id={id}>
|
||||
{tierNumberToName(id)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "placement",
|
||||
name: t("results.filter.placement"),
|
||||
formattedValue: filters.maxPlacement
|
||||
? placementName(filters.maxPlacement)
|
||||
: null,
|
||||
onRemove: () => setFilters({ maxPlacement: null }),
|
||||
testId: "placement-filter",
|
||||
popover: (
|
||||
<SendouSelect
|
||||
label={t("results.filter.placement")}
|
||||
items={RESULT_PLACEMENT_FILTERS.map((placement) => ({
|
||||
id: placement,
|
||||
}))}
|
||||
selectedKey={filters.maxPlacement}
|
||||
clearable
|
||||
onSelectionChange={(key) =>
|
||||
setFilters({
|
||||
maxPlacement:
|
||||
key === null ? null : (Number(key) as ResultPlacementFilter),
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ id }) => (
|
||||
<SendouSelectItem key={id} id={id}>
|
||||
{placementName(id)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "years",
|
||||
name: t("results.filter.years"),
|
||||
formattedValue: yearsFormatted(),
|
||||
onRemove: () => setFilters({ fromYear: null, toYear: null }),
|
||||
testId: "years-filter",
|
||||
popover: (
|
||||
<div className="stack md">
|
||||
<YearSelect
|
||||
label={t("results.filter.years.from")}
|
||||
value={filters.fromYear}
|
||||
onChange={(fromYear) =>
|
||||
setFilters({
|
||||
fromYear,
|
||||
toYear:
|
||||
fromYear && filters.toYear
|
||||
? Math.max(fromYear, filters.toYear)
|
||||
: filters.toYear,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YearSelect
|
||||
label={t("results.filter.years.to")}
|
||||
value={filters.toYear}
|
||||
onChange={(toYear) =>
|
||||
setFilters({
|
||||
toYear,
|
||||
fromYear:
|
||||
toYear && filters.fromYear
|
||||
? Math.min(toYear, filters.fromYear)
|
||||
: filters.fromYear,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
name: t("results.filter.source"),
|
||||
formattedValue:
|
||||
filters.source === DEFAULT_FILTERS.source
|
||||
? null
|
||||
: t(`results.filter.source.${filters.source}`),
|
||||
onRemove: () => setFilters({ source: DEFAULT_FILTERS.source }),
|
||||
testId: "source-filter",
|
||||
popover: (
|
||||
<RadioGroupFormField
|
||||
name="source"
|
||||
label={t("results.filter.source")}
|
||||
items={RESULT_SOURCES.map((source) => ({
|
||||
label: t(`results.filter.source.${source}`),
|
||||
value: source,
|
||||
}))}
|
||||
value={filters.source}
|
||||
onChange={(source) => setFilters({ source })}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
name: t("results.filter.size"),
|
||||
formattedValue:
|
||||
filters.minParticipantCount > 0
|
||||
? `${filters.minParticipantCount}+`
|
||||
: null,
|
||||
onRemove: () => setFilters({ minParticipantCount: 0 }),
|
||||
testId: "size-filter",
|
||||
popover: (
|
||||
<label className="stack xs mb-0">
|
||||
{t("results.filter.size.min")}
|
||||
<input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={0}
|
||||
value={
|
||||
filters.minParticipantCount > 0 ? filters.minParticipantCount : ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
minParticipantCount: Math.max(0, Number(e.target.value) || 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
pills={pills}
|
||||
onReset={
|
||||
isDefaultFilters(filters)
|
||||
? undefined
|
||||
: () => setFilters(DEFAULT_FILTERS)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DebouncedNameFilter({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState(value ?? "");
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if ((value ?? "") === draft.trim()) return;
|
||||
onChange(draft.trim() || null);
|
||||
},
|
||||
300,
|
||||
[draft],
|
||||
);
|
||||
|
||||
return (
|
||||
<label className="stack xs mb-0">
|
||||
{label}
|
||||
<input
|
||||
className="w-full"
|
||||
value={draft}
|
||||
maxLength={100}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function YearSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}) {
|
||||
const years = selectableYears();
|
||||
|
||||
return (
|
||||
<SendouSelect
|
||||
label={label}
|
||||
items={years.map((year) => ({ id: year }))}
|
||||
selectedKey={value}
|
||||
clearable
|
||||
onSelectionChange={(key) => onChange(key === null ? null : Number(key))}
|
||||
>
|
||||
{({ id }) => (
|
||||
<SendouSelectItem key={id} id={id}>
|
||||
{id}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
);
|
||||
}
|
||||
|
||||
const selectableYears = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const result = [];
|
||||
for (let year = currentYear; year >= RESULTS_FIRST_YEAR; year--) {
|
||||
result.push(year);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const toTierNumber = (key: Key | null) => Number(key) as TournamentTierNumber;
|
||||
|
||||
const isDefaultFilters = (
|
||||
filters: Record<keyof typeof DEFAULT_FILTERS, unknown>,
|
||||
) =>
|
||||
Object.entries(DEFAULT_FILTERS).every(
|
||||
([key, value]) => filters[key as keyof typeof DEFAULT_FILTERS] === value,
|
||||
);
|
||||
@@ -50,16 +50,21 @@ export function UserResultsTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result, i) => {
|
||||
// team ids of the two result types are from different tables and can collide
|
||||
const rowId = result.tournamentId
|
||||
? `tournament-${result.teamId}`
|
||||
: `event-${result.teamId}`;
|
||||
|
||||
// We are trying to construct a reasonable label for the checkbox
|
||||
// which shouldn't contain the whole information of the table row as
|
||||
// that can be also accessed when needed.
|
||||
// e.g. "20xx Placing 2nd", "Big House 10 Placing 20th"
|
||||
const placementCellId = `${id}-${result.teamId}-placement`;
|
||||
const nameCellId = `${id}-${result.teamId}-name`;
|
||||
const placementCellId = `${id}-${rowId}-placement`;
|
||||
const nameCellId = `${id}-${rowId}-name`;
|
||||
const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`;
|
||||
|
||||
return (
|
||||
<tr key={result.teamId}>
|
||||
<tr key={rowId}>
|
||||
{hasHighlightCheckboxes && (
|
||||
<td>
|
||||
<input
|
||||
|
||||
@@ -12,7 +12,20 @@ import { userResultsSearchParams } from "../user-page-search-params";
|
||||
export type UserResultsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params, request, url }: LoaderFunctionArgs) => {
|
||||
const { all, page, tournament } = userResultsSearchParams.parse(request);
|
||||
const {
|
||||
highlightsOnly,
|
||||
page,
|
||||
tournament,
|
||||
team,
|
||||
mate,
|
||||
minTier,
|
||||
maxTier,
|
||||
maxPlacement,
|
||||
fromYear,
|
||||
toYear,
|
||||
source,
|
||||
minParticipantCount,
|
||||
} = userResultsSearchParams.parse(request);
|
||||
|
||||
const userId = notFoundIfNullish(
|
||||
await UserRepository.findIdByIdentifier(params.identifier!),
|
||||
@@ -20,34 +33,50 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => {
|
||||
const hasHighlightedResults =
|
||||
await UserRepository.hasHighlightedResultsByUserId(userId);
|
||||
|
||||
let showHighlightsOnly = !all;
|
||||
const isChoosingHighlights = url.pathname.includes("/results/highlights");
|
||||
const canFilter = !isChoosingHighlights && Boolean(getUser());
|
||||
|
||||
if (!hasHighlightedResults) {
|
||||
/** Logged out visitors are locked to the highlights, if there are any. */
|
||||
let showHighlightsOnly = hasHighlightedResults;
|
||||
|
||||
if (canFilter && !highlightsOnly) {
|
||||
showHighlightsOnly = false;
|
||||
}
|
||||
|
||||
const isChoosingHighlights = url.pathname.includes("/results/highlights");
|
||||
if (isChoosingHighlights) {
|
||||
showHighlightsOnly = false;
|
||||
}
|
||||
|
||||
const tournamentName =
|
||||
!isChoosingHighlights && getUser() && tournament !== null
|
||||
? tournament
|
||||
: undefined;
|
||||
const filters = canFilter
|
||||
? {
|
||||
tournamentName: tournament ?? undefined,
|
||||
teamName: team ?? undefined,
|
||||
mateUserId: mate ?? undefined,
|
||||
minTier,
|
||||
maxTier,
|
||||
maxPlacement: maxPlacement ?? undefined,
|
||||
fromYear: fromYear ?? undefined,
|
||||
toYear: toYear ?? undefined,
|
||||
source,
|
||||
minParticipantCount,
|
||||
}
|
||||
: {};
|
||||
|
||||
const [results, totalCount] = await Promise.all([
|
||||
const [results, totalCount, mateUsername] = await Promise.all([
|
||||
UserRepository.findResultsByUserId(userId, {
|
||||
showHighlightsOnly,
|
||||
tournamentName,
|
||||
...filters,
|
||||
...(isChoosingHighlights
|
||||
? { limit: HIGHLIGHTS_RESULTS_MAX }
|
||||
: { limit: RESULTS_PER_PAGE, offset: (page - 1) * RESULTS_PER_PAGE }),
|
||||
}),
|
||||
UserRepository.countResultsByUserId(userId, {
|
||||
showHighlightsOnly,
|
||||
tournamentName,
|
||||
...filters,
|
||||
}),
|
||||
filters.mateUserId
|
||||
? UserRepository.findUsernameById(filters.mateUserId)
|
||||
: null,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -56,5 +85,6 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => {
|
||||
...paginate({ url, page, pageSize: RESULTS_PER_PAGE, totalCount }),
|
||||
},
|
||||
hasHighlightedResults,
|
||||
mateUsername,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { userPage, userResultsEditHighlightsPage } from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { ResultsFiltersBar } from "../components/ResultsFiltersBar";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { loader } from "../loaders/u.$identifier.results.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
@@ -23,37 +18,13 @@ export { loader };
|
||||
|
||||
export default function UserResultsPage() {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation("user");
|
||||
const { t } = useTranslation(["user", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as UserPageLoaderData;
|
||||
|
||||
const [{ all: showAll, tournament }, setParams] = useSearchParamsTyped(
|
||||
userResultsSearchParams,
|
||||
);
|
||||
|
||||
const urlTournamentQuery = tournament ?? "";
|
||||
const [tournamentQuery, setTournamentQuery] =
|
||||
React.useState(urlTournamentQuery);
|
||||
const [prevUrlTournamentQuery, setPrevUrlTournamentQuery] =
|
||||
React.useState(urlTournamentQuery);
|
||||
|
||||
if (urlTournamentQuery !== prevUrlTournamentQuery) {
|
||||
setPrevUrlTournamentQuery(urlTournamentQuery);
|
||||
setTournamentQuery(urlTournamentQuery);
|
||||
}
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (urlTournamentQuery === tournamentQuery) return;
|
||||
setParams({ tournament: tournamentQuery || null });
|
||||
},
|
||||
300,
|
||||
[tournamentQuery],
|
||||
);
|
||||
|
||||
const pagination = useSearchParamPagination({
|
||||
definition: userResultsSearchParams,
|
||||
currentPage: data.results.currentPage,
|
||||
@@ -66,43 +37,23 @@ export default function UserResultsPage() {
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 className="text-lg">
|
||||
{showAll || !data.hasHighlightedResults
|
||||
? t("results.title")
|
||||
: t("results.highlights")}
|
||||
</h2>
|
||||
{user?.id === layoutData.user.id ? (
|
||||
<div className={styles.resultsHeaderActions}>
|
||||
{user ? (
|
||||
<Input
|
||||
className={styles.resultsFilterInput}
|
||||
value={tournamentQuery}
|
||||
onChange={(e) => setTournamentQuery(e.target.value)}
|
||||
placeholder={t("results.filter.placeholder")}
|
||||
aria-label={t("results.filter.placeholder")}
|
||||
icon={<Search />}
|
||||
/>
|
||||
) : null}
|
||||
{user?.id === layoutData.user.id ? (
|
||||
<LinkButton to={userResultsEditHighlightsPage(user)} size="small">
|
||||
{t("results.highlights.choose")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<LinkButton to={userResultsEditHighlightsPage(user)} size="small">
|
||||
{t("results.highlights.choose")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<UserResultsTable id="user-results-table" results={data.results.value} />
|
||||
{data.results.pagesCount > 1 ? <Pagination {...pagination} /> : null}
|
||||
{data.hasHighlightedResults ? (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
onPress={() => setParams({ all: !showAll })}
|
||||
>
|
||||
{showAll
|
||||
? t("results.button.showHighlights")
|
||||
: t("results.button.showAll")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{user ? <ResultsFiltersBar /> : null}
|
||||
{data.results.value.length > 0 ? (
|
||||
<UserResultsTable
|
||||
id="user-results-table"
|
||||
results={data.results.value}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-lighter text-sm">{t("common:noResults")}</div>
|
||||
)}
|
||||
{data.results.pagesCount > 1 ? <Pagination {...pagination} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ export const SPL2_JOIN_ORDER_CUTOFF = 13_589;
|
||||
export const MATCHES_PER_SEASONS_PAGE = 8;
|
||||
export const RESULTS_PER_PAGE = 25;
|
||||
export const HIGHLIGHTS_RESULTS_MAX = 500;
|
||||
|
||||
/** Year of the oldest event that can have results. */
|
||||
export const RESULTS_FIRST_YEAR = 2015;
|
||||
|
||||
/** Placement thresholds that results can be filtered by e.g. 3 = top 3 only. */
|
||||
export const RESULT_PLACEMENT_FILTERS = [1, 3, 8, 16, 32] as const;
|
||||
|
||||
export type ResultPlacementFilter = (typeof RESULT_PLACEMENT_FILTERS)[number];
|
||||
|
||||
export const RESULT_SOURCES = ["ALL", "SENDOU", "EXTERNAL"] as const;
|
||||
|
||||
export type ResultSource = (typeof RESULT_SOURCES)[number];
|
||||
export const BUILD_SORT_IDENTIFIERS = [
|
||||
"UPDATED_AT",
|
||||
"TOP_500",
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, it } from "vitest";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
WORST_TIER_NUMBER,
|
||||
} from "~/features/tournament/core/tiering";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/search-params/search-params-test-utils";
|
||||
import {
|
||||
RESULT_PLACEMENT_FILTERS,
|
||||
RESULT_SOURCES,
|
||||
RESULTS_FIRST_YEAR,
|
||||
} from "./user-page-constants";
|
||||
import {
|
||||
userArtSearchParams,
|
||||
userBuildsSearchParams,
|
||||
@@ -16,18 +25,31 @@ const startedSeasons = Seasons.allStarted(new Date());
|
||||
const newestSeason = startedSeasons[0];
|
||||
const oldestSeason = startedSeasons.at(-1)!;
|
||||
const notStartedSeason = newestSeason + 1000;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
describe("userResultsSearchParams", () => {
|
||||
it("round-trips", () => {
|
||||
assertRoundTrips(userResultsSearchParams, {
|
||||
all: [false, true],
|
||||
highlightsOnly: [false, true],
|
||||
page: [1, 2, 1000],
|
||||
tournament: ["In The Zone", "x", "a".repeat(100)],
|
||||
team: [null, "Team Olive", "a".repeat(100)],
|
||||
mate: [null, 1, 9999],
|
||||
minTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER],
|
||||
maxTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER],
|
||||
maxPlacement: [null, ...RESULT_PLACEMENT_FILTERS],
|
||||
fromYear: [null, RESULTS_FIRST_YEAR, currentYear],
|
||||
toYear: [null, RESULTS_FIRST_YEAR, currentYear],
|
||||
source: [...RESULT_SOURCES],
|
||||
minParticipantCount: [0, 16, 9999],
|
||||
});
|
||||
});
|
||||
|
||||
it("malformed values decode to defaults", () => {
|
||||
assertDecodesToDefault(userResultsSearchParams, "all", [["1"], ["yes"]]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "highlightsOnly", [
|
||||
["1"],
|
||||
["yes"],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "page", [
|
||||
["0"],
|
||||
["1001"],
|
||||
@@ -38,6 +60,29 @@ describe("userResultsSearchParams", () => {
|
||||
[" "],
|
||||
["a".repeat(101)],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "team", [
|
||||
[""],
|
||||
["a".repeat(101)],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "mate", [["0"], ["abc"]]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "minTier", [
|
||||
["0"],
|
||||
["10"],
|
||||
["abc"],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "maxPlacement", [
|
||||
["2"],
|
||||
["abc"],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "fromYear", [
|
||||
[String(RESULTS_FIRST_YEAR - 1)],
|
||||
[String(currentYear + 1)],
|
||||
]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "source", [["SOMETHING"]]);
|
||||
assertDecodesToDefault(userResultsSearchParams, "minParticipantCount", [
|
||||
["-1"],
|
||||
["10000"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,22 +3,81 @@ import { ART_SOURCES } from "~/features/art/art-types";
|
||||
import { serializedBuildCodec } from "~/features/build-analyzer/analyzer-search-params";
|
||||
import { EMPTY_BUILD } from "~/features/builds/builds-constants";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
TIER_NUMBERS,
|
||||
WORST_TIER_NUMBER,
|
||||
} from "~/features/tournament/core/tiering";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import {
|
||||
RESULT_PLACEMENT_FILTERS,
|
||||
RESULT_SOURCES,
|
||||
RESULTS_FIRST_YEAR,
|
||||
} from "./user-page-constants";
|
||||
|
||||
const BUILD_FILTER_TABS = ["ALL", "PUBLIC", "PRIVATE"] as const;
|
||||
|
||||
const resultYear = z
|
||||
.number()
|
||||
.int()
|
||||
.min(RESULTS_FIRST_YEAR)
|
||||
.refine((year) => year <= new Date().getFullYear());
|
||||
|
||||
const resultsFilterName = z.string().trim().min(1).max(100).nullable();
|
||||
|
||||
export const userResultsSearchParams = SearchParams.define({
|
||||
all: SP.param(z.boolean(), {
|
||||
default: false,
|
||||
/** Only applies to users who have highlighted results. */
|
||||
highlightsOnly: SP.param(z.boolean(), {
|
||||
default: true,
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
page: SP.page(),
|
||||
tournament: SP.param(z.string().trim().min(1).max(100).nullable(), {
|
||||
tournament: SP.param(resultsFilterName, {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
team: SP.param(resultsFilterName, {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
mate: SP.param(z.number().int().positive().nullable(), {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
minTier: SP.param(numericEnum(TIER_NUMBERS), {
|
||||
default: BEST_TIER_NUMBER,
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
maxTier: SP.param(numericEnum(TIER_NUMBERS), {
|
||||
default: WORST_TIER_NUMBER,
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
maxPlacement: SP.param(numericEnum(RESULT_PLACEMENT_FILTERS).nullable(), {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
fromYear: SP.param(resultYear.nullable(), {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
toYear: SP.param(resultYear.nullable(), {
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
source: SP.param(z.enum(RESULT_SOURCES), {
|
||||
default: "ALL",
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
minParticipantCount: SP.param(z.number().int().nonnegative().max(9999), {
|
||||
default: 0,
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
|
||||
@@ -126,40 +126,13 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.resultsHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.resultsFilterInput {
|
||||
width: 12rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 599px) {
|
||||
.resultsHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.resultsHeaderActions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.resultsFilterInput {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.resultsTableHighlights {
|
||||
border: var(--s-2) solid var(--color-bg-high);
|
||||
padding-inline: 0 !important;
|
||||
|
||||
@@ -4,7 +4,3 @@
|
||||
gap: var(--s-6);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.typeSelect {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Label } from "~/components/Label";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import { FilterBar } from "~/components/filter-bar/FilterBar";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
@@ -70,90 +75,105 @@ export default function VodsSearchPage() {
|
||||
}
|
||||
|
||||
function Filters() {
|
||||
const { t } = useTranslation(["game-misc", "vods"]);
|
||||
const { t } = useTranslation(["game-misc", "vods", "weapons"]);
|
||||
|
||||
const [{ mode, stageId, weapon, type }, setParams] =
|
||||
useSearchParamsTyped(vodsSearchParams);
|
||||
|
||||
return (
|
||||
<div className="stack sm horizontal flex-wrap">
|
||||
<div>
|
||||
<Label>{t("vods:forms.title.mode")}</Label>
|
||||
<select
|
||||
name="mode"
|
||||
value={mode ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({ mode: (e.target.value || null) as ModeShort | null })
|
||||
}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{modesShort.map((mode) => {
|
||||
return (
|
||||
<option key={mode} value={mode}>
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("vods:forms.title.stage")}</Label>
|
||||
<select
|
||||
name="stage"
|
||||
value={stageId ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
stageId:
|
||||
e.target.value === ""
|
||||
? null
|
||||
: (Number(e.target.value) as StageId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{stageIds.map((stageId) => {
|
||||
return (
|
||||
<option key={stageId} value={stageId}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<WeaponSelect
|
||||
label={t("vods:forms.title.weapon")}
|
||||
value={weapon}
|
||||
onChange={(weaponId) => {
|
||||
setParams({ weapon: weaponId ?? null });
|
||||
}}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label>{t("vods:forms.title.type")}</Label>
|
||||
<select
|
||||
name="type"
|
||||
className={styles.typeSelect}
|
||||
value={type ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
type: (e.target.value || null) as
|
||||
| (typeof videoMatchTypes)[number]
|
||||
| null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{videoMatchTypes.map((type) => {
|
||||
return (
|
||||
<option key={type} value={type}>
|
||||
{t(`vods:type.${type}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<FilterBar
|
||||
pills={[
|
||||
{
|
||||
key: "mode",
|
||||
name: t("vods:forms.title.mode"),
|
||||
formattedValue:
|
||||
mode !== null ? t(`game-misc:MODE_SHORT_${mode}`) : null,
|
||||
onRemove: () => setParams({ mode: null }),
|
||||
testId: "vods-mode-filter",
|
||||
popover: (
|
||||
<SendouChipRadioGroup wrap>
|
||||
{modesShort.map((option) => (
|
||||
<SendouChipRadio
|
||||
key={option}
|
||||
name="vods-mode"
|
||||
value={option}
|
||||
checked={mode === option}
|
||||
onChange={(value) => setParams({ mode: value as ModeShort })}
|
||||
>
|
||||
{t(`game-misc:MODE_SHORT_${option}`)}
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "stage",
|
||||
name: t("vods:forms.title.stage"),
|
||||
formattedValue:
|
||||
stageId !== null ? t(`game-misc:STAGE_${stageId}`) : null,
|
||||
onRemove: () => setParams({ stageId: null }),
|
||||
testId: "vods-stage-filter",
|
||||
popover: (
|
||||
<SendouSelect
|
||||
aria-label={t("vods:forms.title.stage")}
|
||||
items={stageIds.map((id) => ({ id }))}
|
||||
selectedKey={stageId}
|
||||
onSelectionChange={(key) =>
|
||||
setParams({ stageId: key as StageId })
|
||||
}
|
||||
search={{}}
|
||||
>
|
||||
{({ id }) => (
|
||||
<SendouSelectItem key={id} id={id}>
|
||||
{t(`game-misc:STAGE_${id}`)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "weapon",
|
||||
name: t("vods:forms.title.weapon"),
|
||||
formattedValue: weapon !== null ? t(`weapons:MAIN_${weapon}`) : null,
|
||||
onRemove: () => setParams({ weapon: null }),
|
||||
testId: "vods-weapon-filter",
|
||||
popover: (
|
||||
<WeaponSelect
|
||||
value={weapon}
|
||||
onChange={(weaponId) => {
|
||||
setParams({ weapon: weaponId ?? null });
|
||||
}}
|
||||
clearable
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
name: t("vods:forms.title.type"),
|
||||
formattedValue: type !== null ? t(`vods:type.${type}`) : null,
|
||||
onRemove: () => setParams({ type: null }),
|
||||
testId: "vods-type-filter",
|
||||
popover: (
|
||||
<SendouChipRadioGroup wrap>
|
||||
{videoMatchTypes.map((option) => (
|
||||
<SendouChipRadio
|
||||
key={option}
|
||||
name="vods-type"
|
||||
value={option}
|
||||
checked={type === option}
|
||||
onChange={(value) =>
|
||||
setParams({
|
||||
type: value as (typeof videoMatchTypes)[number],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`vods:type.${option}`)}
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import { userCardEditSearchParams } from "~/features/user-card/user-card-search-
|
||||
import {
|
||||
userArtSearchParams,
|
||||
userBuildsNewSearchParams,
|
||||
userResultsSearchParams,
|
||||
userSeasonSummaryGraphicSearchParams,
|
||||
userSeasonsSearchParams,
|
||||
} from "~/features/user-page/user-page-search-params";
|
||||
@@ -273,10 +272,8 @@ export const userEditProfilePage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/edit`;
|
||||
export const userBuildsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/builds`;
|
||||
export const userResultsPage = (user: UserLinkArgs, showAll?: boolean) =>
|
||||
userResultsSearchParams.href(`${userPage(user)}/results`, {
|
||||
all: Boolean(showAll),
|
||||
});
|
||||
export const userResultsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/results`;
|
||||
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
|
||||
export const userCardEditPage = (args?: { returnTo?: string }) =>
|
||||
userCardEditSearchParams.href(USER_CARD_EDIT_PAGE, {
|
||||
@@ -382,12 +379,8 @@ export const weaponBuildPopularPage = (weaponSlug: string) =>
|
||||
`${weaponBuildPage(weaponSlug)}/popular`;
|
||||
export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`;
|
||||
|
||||
export const calendarPage = (args?: {
|
||||
filters?: CalendarFilters;
|
||||
dayMonthYear?: DayMonthYear;
|
||||
}) =>
|
||||
export const calendarPage = (args?: { dayMonthYear?: DayMonthYear }) =>
|
||||
calendarSearchParams.href(CALENDAR_PAGE, {
|
||||
...(args?.filters ? { filters: args.filters } : {}),
|
||||
...(args?.dayMonthYear
|
||||
? {
|
||||
day: args.dayMonthYear.day,
|
||||
@@ -399,7 +392,7 @@ export const calendarPage = (args?: {
|
||||
|
||||
export const calendarIcalFeed = (filters?: CalendarFilters) =>
|
||||
calendarSearchParams.href(`${SENDOU_INK_BASE_URL}/calendar.ics`, {
|
||||
...(filters ? { filters } : {}),
|
||||
...(filters ?? {}),
|
||||
});
|
||||
|
||||
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
|
||||
|
||||
@@ -113,14 +113,14 @@ test.describe("Builds", () => {
|
||||
// are all builds with ISM are hidden?
|
||||
await expect(weaponBuilds.ability("ISM")).toHaveCount(1);
|
||||
|
||||
await weaponBuilds.deleteFilter();
|
||||
await weaponBuilds.deleteFilter("ability");
|
||||
|
||||
await expect(weaponBuilds.ability("ISM").nth(1)).toBeVisible();
|
||||
|
||||
await weaponBuilds.addFilter("mode");
|
||||
await weaponBuilds.modeFilterCheckbox("Tower Control").click();
|
||||
await expect(weaponBuilds.modeBadge("TC")).toHaveCount(3);
|
||||
await weaponBuilds.deleteFilter();
|
||||
await weaponBuilds.deleteFilter("mode");
|
||||
await expect(weaponBuilds.locators.buildCards.first()).toBeVisible();
|
||||
|
||||
await weaponBuilds.addFilter("date");
|
||||
|
||||
@@ -38,9 +38,7 @@ test.describe("Calendar", () => {
|
||||
const calendar = new CalendarPage(page);
|
||||
await calendar.goto();
|
||||
|
||||
const filters = await calendar.openFilters();
|
||||
await filters.form.check("isSendou");
|
||||
await filters.apply();
|
||||
await calendar.toggleEventTypeFilter("isSendou");
|
||||
|
||||
await expect(calendar.locators.tournamentCards).toHaveCount(
|
||||
SENDOU_INK_TOURNAMENTS_COUNT,
|
||||
@@ -77,9 +75,8 @@ test.describe("Calendar", () => {
|
||||
|
||||
await isNotVisible(calendar.locators.hiddenEventsButtons);
|
||||
|
||||
const filters = await calendar.openFilters();
|
||||
await filters.form.check("isRanked");
|
||||
await filters.applyAndMakeDefault();
|
||||
await calendar.toggleEventTypeFilter("isRanked");
|
||||
await calendar.saveFiltersAsDefault();
|
||||
|
||||
await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible();
|
||||
|
||||
@@ -87,6 +84,15 @@ test.describe("Calendar", () => {
|
||||
|
||||
// remembers selection via user preferences
|
||||
await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible();
|
||||
|
||||
await calendar.removeEventTypeFilter();
|
||||
|
||||
// removing the filter sticks instead of falling back to the saved default
|
||||
await isNotVisible(calendar.locators.hiddenEventsButtons);
|
||||
|
||||
await calendar.reload();
|
||||
|
||||
await isNotVisible(calendar.locators.hiddenEventsButtons);
|
||||
});
|
||||
|
||||
test("navigates view more buttons", async ({ page }) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ export class WeaponBuildsPage {
|
||||
this.locators = {
|
||||
buildCards: page.getByTestId("build-card"),
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
deleteFilterButton: page.getByTestId("delete-filter-button"),
|
||||
comparisonSelect: page.getByTestId("comparison-select"),
|
||||
dateSelect: page.getByTestId("date-select"),
|
||||
dateInput: page.getByTestId("date-input"),
|
||||
@@ -41,11 +40,23 @@ export class WeaponBuildsPage {
|
||||
}
|
||||
|
||||
async addFilter(type: "ability" | "mode" | "date") {
|
||||
await this.page.keyboard.press("Escape");
|
||||
|
||||
await this.locators.addFilterButton.click();
|
||||
await this.page.getByTestId(`menu-item-${type}`).click();
|
||||
|
||||
if (type === "ability") {
|
||||
await this.page.getByTestId("add-ability-condition").click();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFilter() {
|
||||
await this.locators.deleteFilterButton.click();
|
||||
async deleteFilter(type: "ability" | "mode" | "date") {
|
||||
if (type === "ability") {
|
||||
await this.page.getByTestId("delete-ability-condition").click();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.page.getByTestId(`${type}-remove`).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
|
||||
import { calendarPage } from "~/utils/urls";
|
||||
import {
|
||||
expectIsHydrated,
|
||||
navigate,
|
||||
waitForPOSTResponse,
|
||||
} from "../../helpers/playwright";
|
||||
import { createFormHelpers } from "../../helpers/playwright-form";
|
||||
|
||||
/** `/calendar` */
|
||||
export class CalendarPage {
|
||||
@@ -20,7 +18,11 @@ export class CalendarPage {
|
||||
hiddenEventsButtons: page.getByTestId("hidden-events-button"),
|
||||
clockHeaderTimes: page.getByTestId("clock-header-time"),
|
||||
todayHeader: page.getByTestId("today-header"),
|
||||
filterEventsButton: page.getByTestId("filter-events-button"),
|
||||
eventTypeFilterPill: page.getByTestId("event-type-filter"),
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
saveFiltersAsDefaultButton: page.getByTestId(
|
||||
"save-filters-as-default-button",
|
||||
),
|
||||
navigateButtons: page.getByTestId("calendar-navigate-button"),
|
||||
};
|
||||
}
|
||||
@@ -46,9 +48,42 @@ export class CalendarPage {
|
||||
await expectIsHydrated(this.page);
|
||||
}
|
||||
|
||||
async openFilters() {
|
||||
await this.locators.filterEventsButton.click();
|
||||
return new CalendarFiltersDialog(this.page);
|
||||
/** Toggles one of the switches inside the "Event type" filter pill's popover. */
|
||||
async toggleEventTypeFilter(name: "isSendou" | "isRanked") {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.openEventTypeFilter();
|
||||
await this.page
|
||||
.getByText(
|
||||
name === "isSendou"
|
||||
? "Only events hosted on sendou.ink"
|
||||
: "Only ranked events",
|
||||
)
|
||||
.click();
|
||||
await this.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/** Resets the "Event type" filter pill's filters, hiding the pill. */
|
||||
async removeEventTypeFilter() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.page.getByTestId("event-type-filter-remove").click();
|
||||
}
|
||||
|
||||
/** The pill is only rendered while its filters differ from the defaults. */
|
||||
private async openEventTypeFilter() {
|
||||
if (await this.locators.eventTypeFilterPill.isVisible()) {
|
||||
await this.locators.eventTypeFilterPill.click();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.locators.addFilterButton.click();
|
||||
await this.page.getByTestId("menu-item-event-type-filter").click();
|
||||
}
|
||||
|
||||
/** Persists the current filters as the user's default. */
|
||||
async saveFiltersAsDefault() {
|
||||
await waitForPOSTResponse(this.page, () =>
|
||||
this.locators.saveFiltersAsDefaultButton.click(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows or hides the events the current filters hide, of the first time slot. */
|
||||
@@ -64,31 +99,3 @@ export class CalendarPage {
|
||||
await this.locators.navigateButtons.nth(1).click();
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarFiltersDialog {
|
||||
private readonly page: Page;
|
||||
readonly form;
|
||||
readonly locators;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.form = createFormHelpers(page, calendarFiltersFormSchema);
|
||||
this.locators = {
|
||||
applyAndMakeDefaultButton: page.getByRole("button", {
|
||||
name: "Apply & make default",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies the filters for this visit only, via search params. */
|
||||
async apply() {
|
||||
await this.form.submit();
|
||||
}
|
||||
|
||||
/** Applies the filters and saves them as the user's default. */
|
||||
async applyAndMakeDefault() {
|
||||
await waitForPOSTResponse(this.page, () =>
|
||||
this.locators.applyAndMakeDefaultButton.click(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ export class LFGPage {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
languageFilterSelect: page.getByLabel("Spoken language"),
|
||||
languageFilterSelect: page.getByLabel("Spoken language", {
|
||||
exact: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { Page } from "@playwright/test";
|
||||
import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas";
|
||||
import { scrimsPage } from "~/utils/urls";
|
||||
import {
|
||||
expectIsHydrated,
|
||||
modalClickConfirmButton,
|
||||
navigate,
|
||||
selectUser,
|
||||
submit,
|
||||
waitForPOSTResponse,
|
||||
} from "../../helpers/playwright";
|
||||
import { createFormHelpers } from "../../helpers/playwright-form";
|
||||
import { AssociationsPage } from "../associations/associations-page";
|
||||
@@ -38,6 +40,11 @@ export class ScrimsPage {
|
||||
limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"),
|
||||
tournamentPopover: page.getByTestId("tournament-popover-trigger"),
|
||||
canceledLabel: page.getByText("Canceled"),
|
||||
divsFilterPill: page.getByTestId("divs-filter"),
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
saveFiltersAsDefaultButton: page.getByTestId(
|
||||
"save-filters-as-default-button",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,10 +52,48 @@ export class ScrimsPage {
|
||||
await navigate({ page: this.page, url: scrimsPage() });
|
||||
}
|
||||
|
||||
async reload() {
|
||||
await this.page.reload();
|
||||
await expectIsHydrated(this.page);
|
||||
}
|
||||
|
||||
post(text: string) {
|
||||
return this.page.getByText(text);
|
||||
}
|
||||
|
||||
/** Sets both selects of the "Divs" filter pill's popover. */
|
||||
async filterByDivs({ max, min }: { max: string; min: string }) {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.openDivsFilter();
|
||||
await this.page.getByLabel("Max div").selectOption(max);
|
||||
await this.page.getByLabel("Min div").selectOption(min);
|
||||
await this.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/** Resets the "Divs" filter, hiding the pill. */
|
||||
async removeDivsFilter() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.page.getByTestId("divs-filter-remove").click();
|
||||
}
|
||||
|
||||
/** Persists the current filters as the user's default. */
|
||||
async saveFiltersAsDefault() {
|
||||
await waitForPOSTResponse(this.page, () =>
|
||||
this.locators.saveFiltersAsDefaultButton.click(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The pill is only rendered while its filter differs from the default. */
|
||||
private async openDivsFilter() {
|
||||
if (await this.locators.divsFilterPill.isVisible()) {
|
||||
await this.locators.divsFilterPill.click();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.locators.addFilterButton.click();
|
||||
await this.page.getByTestId("menu-item-divs-filter").click();
|
||||
}
|
||||
|
||||
async openTab(tab: Tab) {
|
||||
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class VodsPage {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
noVodsText: this.page.getByText(/No videos found matching this filter/),
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +25,8 @@ export class VodsPage {
|
||||
}
|
||||
|
||||
async filterByWeapon(weaponName: string) {
|
||||
await this.locators.addFilterButton.click();
|
||||
await this.page.getByTestId("menu-item-vods-weapon-filter").click();
|
||||
await selectWeapon({ page: this.page, name: weaponName });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { serializeLutiDiv } from "~/features/scrims/scrims-utils";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
@@ -86,6 +87,46 @@ test.describe("Scrims", () => {
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("filters by div and sets the filter as default", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.ScrimPostFactory.create({
|
||||
users: await createGroup(factories),
|
||||
maxDiv: serializeLutiDiv("1"),
|
||||
minDiv: serializeLutiDiv("2"),
|
||||
});
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const scrims = new ScrimsPage(page);
|
||||
await scrims.goto();
|
||||
await scrims.openTab("available");
|
||||
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(1);
|
||||
|
||||
// a div range the post's own range falls outside of
|
||||
await scrims.filterByDivs({ max: "5", min: "6" });
|
||||
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(0);
|
||||
|
||||
await scrims.saveFiltersAsDefault();
|
||||
await scrims.goto();
|
||||
await scrims.openTab("available");
|
||||
|
||||
// remembers selection via user preferences
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(0);
|
||||
|
||||
await scrims.removeDivsFilter();
|
||||
|
||||
// removing the filter sticks instead of falling back to the saved default
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(1);
|
||||
|
||||
await scrims.reload();
|
||||
|
||||
await expect(scrims.locators.requestButtons).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("accepts a request", async ({ page, factories }) => {
|
||||
await createPostWithRequest(factories, { ownerUserId: ADMIN_ID });
|
||||
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
"stats.all": "Alle",
|
||||
"stats.public": "",
|
||||
"stats.private": "",
|
||||
"addFilter": "Tilføj filter",
|
||||
"linkButton.abilityStats": "Egenskabsstatistikker",
|
||||
"linkButton.popularBuilds": "Populære sæt",
|
||||
"noPopularBuilds": "Der er på nuværende tidspunkt ingen populære sæt for det valgte våben.",
|
||||
"emptyAbilitySlot": "Tomt egenskabsfelt",
|
||||
"filters.type.ability": "Efter egenskab",
|
||||
"filters.type.mode": "Efter spiltilstand",
|
||||
"filters.type.date": "Efter dato",
|
||||
"filters.ability.title": "Egenskabsfilter",
|
||||
"filters.mode.title": "Spiltilstandsfilter",
|
||||
"filters.date.title": "Datofilter",
|
||||
"filters.abilities": "",
|
||||
"filters.mode": "",
|
||||
"filters.date": "",
|
||||
"filters.addAbility": "",
|
||||
"filters.has": "Indeholder",
|
||||
"filters.does.not.have": "Indeholder ikke",
|
||||
"filters.atLeast": "Mindst",
|
||||
|
||||
@@ -46,11 +46,8 @@
|
||||
"tag.desc.SR": "Salmon Run begivenhed.",
|
||||
"tag.desc.CARDS": "Tableturf Battle begivenhed.",
|
||||
"icalFeed": "",
|
||||
"filter.button": "",
|
||||
"filter.heading": "",
|
||||
"filter.modes": "",
|
||||
"filter.exactModes": "",
|
||||
"filter.exactModesBottom": "",
|
||||
"filter.games": "",
|
||||
"filter.vs": "",
|
||||
"filter.vs.4v4": "",
|
||||
@@ -67,11 +64,18 @@
|
||||
"filter.isSendou": "",
|
||||
"filter.isRanked": "",
|
||||
"filter.minTeamCount": "",
|
||||
"filter.minTier": "",
|
||||
"filter.maxTier": "",
|
||||
"filter.orgsIncluded": "",
|
||||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": "",
|
||||
"filterBar.eventType": "",
|
||||
"filterBar.tier": "",
|
||||
"filterBar.tags": "",
|
||||
"filterBar.organizers": "",
|
||||
"filterBar.timeAndSize": "",
|
||||
"filterBar.sendou": "",
|
||||
"filterBar.ranked": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": "",
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Bliv medlem",
|
||||
"filterBar.addFilter": "",
|
||||
"filterBar.saveAsDefault": "",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"errors.canNotSetIfLookingNow": "",
|
||||
"errors.maxAssociationsReached": "",
|
||||
"labels.weekdayTimes": "",
|
||||
"labels.weekendTimes": "",
|
||||
"labels.start": "",
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
@@ -187,24 +186,8 @@
|
||||
"vodTypes.SCRIM": "Testkamp",
|
||||
"vodTypes.MATCHMAKING": "Anarki/X-kamp/Turf-war",
|
||||
"vodTypes.SENDOUQ": "SendouQ",
|
||||
"labels.modesExact": "",
|
||||
"bottomTexts.modesExact": "",
|
||||
"labels.games": "",
|
||||
"labels.vs": "",
|
||||
"labels.startTime": "",
|
||||
"labels.tagsIncluded": "",
|
||||
"labels.tagsExcluded": "",
|
||||
"labels.onlySendouEvents": "",
|
||||
"labels.onlyRankedEvents": "",
|
||||
"labels.minTeamCount": "",
|
||||
"labels.orgsIncluded": "",
|
||||
"labels.orgsExcluded": "",
|
||||
"labels.authorIdsExcluded": "",
|
||||
"bottomTexts.authorIdsExcluded": "",
|
||||
"options.startTime.any": "",
|
||||
"options.startTime.eu": "",
|
||||
"options.startTime.na": "",
|
||||
"options.startTime.au": "",
|
||||
"options.game.S1": "",
|
||||
"options.game.S2": "",
|
||||
"options.game.S3": "",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"post.lastActive": "sidst aktiv",
|
||||
"noPosts": "Ingen opslag passer dette filter",
|
||||
"expiring": "Opslag er ved at udløbe, stadigvæk interresteret?",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Våbenpulje",
|
||||
"filters.Type": "Opslagstype",
|
||||
"filters.Timezone": "Tidszoneforskel",
|
||||
@@ -16,7 +15,6 @@
|
||||
"filters.PlusTier": "Plus tier",
|
||||
"filters.MaxTier": "Max tier",
|
||||
"filters.MinTier": "Min tier",
|
||||
"filters.suffix": "filter",
|
||||
"filters.orAbove": "Eller over",
|
||||
"new.noMorePosts": "Du kan ikke lave flere opslag",
|
||||
"new.type.header": "Type",
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
"requestModal.at.label": "",
|
||||
"requestModal.at.explanation": "",
|
||||
"pickupBy": "",
|
||||
"filters.button": "",
|
||||
"filters.heading": "",
|
||||
"filters.weekdayTimes": "",
|
||||
"filters.weekdayStart": "",
|
||||
"filters.weekdayEnd": "",
|
||||
"filters.weekendTimes": "",
|
||||
"filters.weekendStart": "",
|
||||
"filters.weekendEnd": "",
|
||||
"filters.apply": "",
|
||||
"filters.applyAndDefault": "",
|
||||
"filters.divs": "",
|
||||
"filters.showFiltered": "",
|
||||
"filters.hideFiltered": "",
|
||||
"filters.showPendingRequests": "",
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"sens": "Følsomhed",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ</1> for yderligere information.",
|
||||
"results.title": "Alle resultater",
|
||||
"results.placing": "Placering",
|
||||
"results.team": "Hold",
|
||||
"results.tournament": "Turnering",
|
||||
@@ -159,9 +158,26 @@
|
||||
"results.highlights": "Højdepunkter",
|
||||
"results.highlights.choose": "Vælg højdepunkter",
|
||||
"results.highlights.explanation": "Vælg de resultater, som du vil fremhæve",
|
||||
"results.button.showHighlights": "Vis højdepunkter",
|
||||
"results.button.showAll": "Vis alt",
|
||||
"results.filter.placeholder": "",
|
||||
"results.filter.only": "",
|
||||
"results.filter.highlightsOnly": "",
|
||||
"results.filter.tournament": "",
|
||||
"results.filter.team": "",
|
||||
"results.filter.mate": "",
|
||||
"results.filter.tier": "",
|
||||
"results.filter.tier.min": "",
|
||||
"results.filter.tier.max": "",
|
||||
"results.filter.placement": "",
|
||||
"results.filter.placement.first": "",
|
||||
"results.filter.placement.top": "",
|
||||
"results.filter.years": "",
|
||||
"results.filter.years.from": "",
|
||||
"results.filter.years.to": "",
|
||||
"results.filter.source": "",
|
||||
"results.filter.source.ALL": "",
|
||||
"results.filter.source.SENDOU": "",
|
||||
"results.filter.source.EXTERNAL": "",
|
||||
"results.filter.size": "",
|
||||
"results.filter.size.min": "",
|
||||
"forms.errors.maxWeapons": "Maks antal våben nået",
|
||||
"search.info": "",
|
||||
"search.noResults": "Søgningen ’{{query}}’ fandt ingen brugere",
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
"stats.all": "",
|
||||
"stats.public": "",
|
||||
"stats.private": "",
|
||||
"addFilter": "",
|
||||
"linkButton.abilityStats": "",
|
||||
"linkButton.popularBuilds": "",
|
||||
"noPopularBuilds": "",
|
||||
"emptyAbilitySlot": "",
|
||||
"filters.type.ability": "",
|
||||
"filters.type.mode": "",
|
||||
"filters.type.date": "",
|
||||
"filters.ability.title": "",
|
||||
"filters.mode.title": "",
|
||||
"filters.date.title": "",
|
||||
"filters.abilities": "",
|
||||
"filters.mode": "",
|
||||
"filters.date": "",
|
||||
"filters.addAbility": "",
|
||||
"filters.has": "",
|
||||
"filters.does.not.have": "",
|
||||
"filters.atLeast": "",
|
||||
|
||||
@@ -46,11 +46,8 @@
|
||||
"tag.desc.SR": "Es wird Salmon Run gespielt.",
|
||||
"tag.desc.CARDS": "Es wird Revierdecks gespielt.",
|
||||
"icalFeed": "",
|
||||
"filter.button": "",
|
||||
"filter.heading": "",
|
||||
"filter.modes": "",
|
||||
"filter.exactModes": "",
|
||||
"filter.exactModesBottom": "",
|
||||
"filter.games": "",
|
||||
"filter.vs": "",
|
||||
"filter.vs.4v4": "",
|
||||
@@ -67,11 +64,18 @@
|
||||
"filter.isSendou": "",
|
||||
"filter.isRanked": "",
|
||||
"filter.minTeamCount": "",
|
||||
"filter.minTier": "",
|
||||
"filter.maxTier": "",
|
||||
"filter.orgsIncluded": "",
|
||||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": "",
|
||||
"filterBar.eventType": "",
|
||||
"filterBar.tier": "",
|
||||
"filterBar.tags": "",
|
||||
"filterBar.organizers": "",
|
||||
"filterBar.timeAndSize": "",
|
||||
"filterBar.sendou": "",
|
||||
"filterBar.ranked": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": "",
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Beitreten",
|
||||
"filterBar.addFilter": "",
|
||||
"filterBar.saveAsDefault": "",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"errors.canNotSetIfLookingNow": "",
|
||||
"errors.maxAssociationsReached": "",
|
||||
"labels.weekdayTimes": "",
|
||||
"labels.weekendTimes": "",
|
||||
"labels.start": "",
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
@@ -187,24 +186,8 @@
|
||||
"vodTypes.SCRIM": "Scrim",
|
||||
"vodTypes.MATCHMAKING": "Anarchie/X Kampf/Revierkampf",
|
||||
"vodTypes.SENDOUQ": "",
|
||||
"labels.modesExact": "",
|
||||
"bottomTexts.modesExact": "",
|
||||
"labels.games": "",
|
||||
"labels.vs": "",
|
||||
"labels.startTime": "",
|
||||
"labels.tagsIncluded": "",
|
||||
"labels.tagsExcluded": "",
|
||||
"labels.onlySendouEvents": "",
|
||||
"labels.onlyRankedEvents": "",
|
||||
"labels.minTeamCount": "",
|
||||
"labels.orgsIncluded": "",
|
||||
"labels.orgsExcluded": "",
|
||||
"labels.authorIdsExcluded": "",
|
||||
"bottomTexts.authorIdsExcluded": "",
|
||||
"options.startTime.any": "",
|
||||
"options.startTime.eu": "",
|
||||
"options.startTime.na": "",
|
||||
"options.startTime.au": "",
|
||||
"options.game.S1": "",
|
||||
"options.game.S2": "",
|
||||
"options.game.S3": "",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
@@ -16,7 +15,6 @@
|
||||
"filters.PlusTier": "",
|
||||
"filters.MaxTier": "",
|
||||
"filters.MinTier": "",
|
||||
"filters.suffix": "",
|
||||
"filters.orAbove": "",
|
||||
"new.noMorePosts": "",
|
||||
"new.type.header": "",
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
"requestModal.at.label": "",
|
||||
"requestModal.at.explanation": "",
|
||||
"pickupBy": "",
|
||||
"filters.button": "",
|
||||
"filters.heading": "",
|
||||
"filters.weekdayTimes": "",
|
||||
"filters.weekdayStart": "",
|
||||
"filters.weekdayEnd": "",
|
||||
"filters.weekendTimes": "",
|
||||
"filters.weekendStart": "",
|
||||
"filters.weekendEnd": "",
|
||||
"filters.apply": "",
|
||||
"filters.applyAndDefault": "",
|
||||
"filters.divs": "",
|
||||
"filters.showFiltered": "",
|
||||
"filters.hideFiltered": "",
|
||||
"filters.showPendingRequests": "",
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"sens": "Empfindlichkeit",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ</1>.",
|
||||
"results.title": "",
|
||||
"results.placing": "Platzierung",
|
||||
"results.team": "Team",
|
||||
"results.tournament": "Turnier",
|
||||
@@ -159,9 +158,26 @@
|
||||
"results.highlights": "Highlights",
|
||||
"results.highlights.choose": "Highlights wählen",
|
||||
"results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest",
|
||||
"results.button.showHighlights": "",
|
||||
"results.button.showAll": "",
|
||||
"results.filter.placeholder": "",
|
||||
"results.filter.only": "",
|
||||
"results.filter.highlightsOnly": "",
|
||||
"results.filter.tournament": "",
|
||||
"results.filter.team": "",
|
||||
"results.filter.mate": "",
|
||||
"results.filter.tier": "",
|
||||
"results.filter.tier.min": "",
|
||||
"results.filter.tier.max": "",
|
||||
"results.filter.placement": "",
|
||||
"results.filter.placement.first": "",
|
||||
"results.filter.placement.top": "",
|
||||
"results.filter.years": "",
|
||||
"results.filter.years.from": "",
|
||||
"results.filter.years.to": "",
|
||||
"results.filter.source": "",
|
||||
"results.filter.source.ALL": "",
|
||||
"results.filter.source.SENDOU": "",
|
||||
"results.filter.source.EXTERNAL": "",
|
||||
"results.filter.size": "",
|
||||
"results.filter.size.min": "",
|
||||
"forms.errors.maxWeapons": "Maximale Zahl an Waffen erreicht",
|
||||
"search.info": "",
|
||||
"search.noResults": "Keine Nutzer gefunden, die '{{query}}' entsprechen",
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
"stats.all": "All",
|
||||
"stats.public": "Public",
|
||||
"stats.private": "Private",
|
||||
"addFilter": "Add filter",
|
||||
"linkButton.abilityStats": "Ability stats",
|
||||
"linkButton.popularBuilds": "Popular builds",
|
||||
"noPopularBuilds": "It seems there are no popular builds for this weapon at this moment.",
|
||||
"emptyAbilitySlot": "Empty ability slot",
|
||||
"filters.type.ability": "By ability",
|
||||
"filters.type.mode": "By mode",
|
||||
"filters.type.date": "By date",
|
||||
"filters.ability.title": "Ability filter",
|
||||
"filters.mode.title": "Mode filter",
|
||||
"filters.date.title": "Date filter",
|
||||
"filters.abilities": "Abilities",
|
||||
"filters.mode": "Mode",
|
||||
"filters.date": "Date",
|
||||
"filters.addAbility": "Add ability",
|
||||
"filters.has": "Has",
|
||||
"filters.does.not.have": "Doesn't have",
|
||||
"filters.atLeast": "At least",
|
||||
|
||||
@@ -46,11 +46,8 @@
|
||||
"tag.desc.SR": "Salmon Run event.",
|
||||
"tag.desc.CARDS": "Tableturf Battle event.",
|
||||
"icalFeed": "iCal",
|
||||
"filter.button": "Filter",
|
||||
"filter.heading": "Filter calendar events",
|
||||
"filter.modes": "Modes",
|
||||
"filter.exactModes": "Exact modes",
|
||||
"filter.exactModesBottom": "Only show events that match all selected modes",
|
||||
"filter.games": "Games",
|
||||
"filter.vs": "Vs.",
|
||||
"filter.vs.4v4": "4v4",
|
||||
@@ -67,11 +64,18 @@
|
||||
"filter.isSendou": "Only events hosted on sendou.ink",
|
||||
"filter.isRanked": "Only ranked events",
|
||||
"filter.minTeamCount": "Minimum team count",
|
||||
"filter.minTier": "Highest tier",
|
||||
"filter.maxTier": "Lowest tier",
|
||||
"filter.orgsIncluded": "Visible organizations",
|
||||
"filter.orgsExcluded": "Hidden organizations",
|
||||
"filter.authorIdsExcluded": "Authors excluded",
|
||||
"filter.apply": "Apply",
|
||||
"filter.applyAndDefault": "Apply & make default",
|
||||
"filterBar.eventType": "Event type",
|
||||
"filterBar.tier": "Tier",
|
||||
"filterBar.tags": "Tags",
|
||||
"filterBar.organizers": "Organizers",
|
||||
"filterBar.timeAndSize": "Time & size",
|
||||
"filterBar.sendou": "sendou.ink",
|
||||
"filterBar.ranked": "Ranked",
|
||||
"forms.draft": "Draft",
|
||||
"forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.",
|
||||
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.",
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"actions.outlined": "Outlined",
|
||||
"actions.noOutline": "No outline",
|
||||
"actions.join": "Join",
|
||||
"filterBar.addFilter": "Filter",
|
||||
"filterBar.saveAsDefault": "Save as default",
|
||||
"imageExport.export": "Export image",
|
||||
"imageExport.download": "Download",
|
||||
"imageExport.theme.light": "Light",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"errors.canNotSetIfLookingNow": "Can not be set if looking for scrim now",
|
||||
"errors.maxAssociationsReached": "You have reached the maximum number of associations",
|
||||
"labels.weekdayTimes": "Weekday times",
|
||||
"labels.weekendTimes": "Weekend times",
|
||||
"labels.start": "Start",
|
||||
"labels.end": "End",
|
||||
"labels.member": "Member",
|
||||
@@ -187,24 +186,8 @@
|
||||
"vodTypes.SCRIM": "Scrim",
|
||||
"vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War",
|
||||
"vodTypes.SENDOUQ": "SendouQ",
|
||||
"labels.modesExact": "Exact modes",
|
||||
"bottomTexts.modesExact": "Only show events that match all selected modes",
|
||||
"labels.games": "Games",
|
||||
"labels.vs": "Vs.",
|
||||
"labels.startTime": "Start time",
|
||||
"labels.tagsIncluded": "Tags included",
|
||||
"labels.tagsExcluded": "Tags excluded",
|
||||
"labels.onlySendouEvents": "Only events hosted on sendou.ink",
|
||||
"labels.onlyRankedEvents": "Only ranked events",
|
||||
"labels.minTeamCount": "Minimum team count",
|
||||
"labels.orgsIncluded": "Visible organizations",
|
||||
"labels.orgsExcluded": "Hidden organizations",
|
||||
"labels.authorIdsExcluded": "Authors excluded",
|
||||
"bottomTexts.authorIdsExcluded": "You can find a user's id on their profile page",
|
||||
"options.startTime.any": "Any",
|
||||
"options.startTime.eu": "Europe friendly",
|
||||
"options.startTime.na": "Americas friendly",
|
||||
"options.startTime.au": "AU/NZ friendly",
|
||||
"options.game.S1": "Splatoon 1",
|
||||
"options.game.S2": "Splatoon 2",
|
||||
"options.game.S3": "Splatoon 3",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"post.lastActive": "updated",
|
||||
"noPosts": "No posts matching the filter",
|
||||
"expiring": "Post is expiring. Still looking?",
|
||||
"addFilter": "Add filter",
|
||||
"filters.Weapon": "Weapon pool",
|
||||
"filters.Type": "Post type",
|
||||
"filters.Timezone": "Timezone hour difference",
|
||||
@@ -16,7 +15,6 @@
|
||||
"filters.PlusTier": "Plus tier",
|
||||
"filters.MaxTier": "Max tier",
|
||||
"filters.MinTier": "Min tier",
|
||||
"filters.suffix": "filter",
|
||||
"filters.orAbove": "or above",
|
||||
"new.noMorePosts": "You can't create any more posts",
|
||||
"new.type.header": "Type",
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
"requestModal.at.label": "Start time",
|
||||
"requestModal.at.explanation": "Select a time within the post's time range",
|
||||
"pickupBy": "Pickup by",
|
||||
"filters.button": "Filters",
|
||||
"filters.heading": "Scrim Filters",
|
||||
"filters.weekdayTimes": "Weekday times",
|
||||
"filters.weekdayStart": "Weekday start",
|
||||
"filters.weekdayEnd": "Weekday end",
|
||||
"filters.weekendTimes": "Weekend times",
|
||||
"filters.weekendStart": "Weekend start",
|
||||
"filters.weekendEnd": "Weekend end",
|
||||
"filters.apply": "Apply",
|
||||
"filters.applyAndDefault": "Apply & Set as Default",
|
||||
"filters.divs": "Divs",
|
||||
"filters.showFiltered": "Show filtered ({{count}})",
|
||||
"filters.hideFiltered": "Hide filtered ({{count}})",
|
||||
"filters.showPendingRequests": "Show pending requests ({{count}})",
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "Uses",
|
||||
"discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
|
||||
"results.title": "All results",
|
||||
"results.placing": "Placing",
|
||||
"results.team": "Team",
|
||||
"results.tournament": "Tournament",
|
||||
@@ -159,9 +158,26 @@
|
||||
"results.highlights": "Highlights",
|
||||
"results.highlights.choose": "Choose highlights",
|
||||
"results.highlights.explanation": "Select the results you want to highlight",
|
||||
"results.button.showHighlights": "Show highlights",
|
||||
"results.button.showAll": "Show all",
|
||||
"results.filter.placeholder": "Filter by tournament",
|
||||
"results.filter.only": "Only",
|
||||
"results.filter.highlightsOnly": "Only highlighted results",
|
||||
"results.filter.tournament": "Tournament",
|
||||
"results.filter.team": "Team",
|
||||
"results.filter.mate": "Teammate",
|
||||
"results.filter.tier": "Tier",
|
||||
"results.filter.tier.min": "Highest tier",
|
||||
"results.filter.tier.max": "Lowest tier",
|
||||
"results.filter.placement": "Placement",
|
||||
"results.filter.placement.first": "Winner",
|
||||
"results.filter.placement.top": "Top {{count}}",
|
||||
"results.filter.years": "Years",
|
||||
"results.filter.years.from": "From",
|
||||
"results.filter.years.to": "To",
|
||||
"results.filter.source": "Source",
|
||||
"results.filter.source.ALL": "All",
|
||||
"results.filter.source.SENDOU": "Hosted on sendou.ink",
|
||||
"results.filter.source.EXTERNAL": "Reported results",
|
||||
"results.filter.size": "Size",
|
||||
"results.filter.size.min": "Minimum teams",
|
||||
"forms.errors.maxWeapons": "Max weapon count reached",
|
||||
"search.info": "Search for users by Discord or Splatoon 3 name",
|
||||
"search.noResults": "No users found matching '{{query}}'",
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
"stats.all": "Todas",
|
||||
"stats.public": "Público",
|
||||
"stats.private": "Privado",
|
||||
"addFilter": "Añadir filtro",
|
||||
"linkButton.abilityStats": "Estadísticas de potenciadores",
|
||||
"linkButton.popularBuilds": "Builds populares",
|
||||
"noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.",
|
||||
"emptyAbilitySlot": "Espacio de potenciador vacío",
|
||||
"filters.type.ability": "Por potenciador",
|
||||
"filters.type.mode": "Por estilo",
|
||||
"filters.type.date": "Por fecha",
|
||||
"filters.ability.title": "Filtro por potenciador",
|
||||
"filters.mode.title": "Filtro por estilo",
|
||||
"filters.date.title": "Filtro por fecha",
|
||||
"filters.abilities": "",
|
||||
"filters.mode": "",
|
||||
"filters.date": "",
|
||||
"filters.addAbility": "",
|
||||
"filters.has": "Tiene",
|
||||
"filters.does.not.have": "No tiene",
|
||||
"filters.atLeast": "Al menos",
|
||||
|
||||
@@ -48,11 +48,8 @@
|
||||
"tag.desc.SR": "Evento de Salmon Run",
|
||||
"tag.desc.CARDS": "Evento de Lucha carterritorial",
|
||||
"icalFeed": "iCal",
|
||||
"filter.button": "Filtrar",
|
||||
"filter.heading": "Filtrar eventos del calendario",
|
||||
"filter.modes": "Modos",
|
||||
"filter.exactModes": "Modos exactos",
|
||||
"filter.exactModesBottom": "Mostrar solo eventos que coincidan con todos los modos seleccionados",
|
||||
"filter.games": "Juegos",
|
||||
"filter.vs": "Vs.",
|
||||
"filter.vs.4v4": "4v4",
|
||||
@@ -69,11 +66,18 @@
|
||||
"filter.isSendou": "Solo eventos alojados en sendou.ink",
|
||||
"filter.isRanked": "Solo eventos clasificados",
|
||||
"filter.minTeamCount": "Mínimo de equipos",
|
||||
"filter.minTier": "",
|
||||
"filter.maxTier": "",
|
||||
"filter.orgsIncluded": "Organizaciones visibles",
|
||||
"filter.orgsExcluded": "Organizaciones ocultas",
|
||||
"filter.authorIdsExcluded": "Autores excluidos",
|
||||
"filter.apply": "Aplicar",
|
||||
"filter.applyAndDefault": "Aplicar y establecer por defecto",
|
||||
"filterBar.eventType": "",
|
||||
"filterBar.tier": "",
|
||||
"filterBar.tags": "",
|
||||
"filterBar.organizers": "",
|
||||
"filterBar.timeAndSize": "",
|
||||
"filterBar.sendou": "",
|
||||
"filterBar.ranked": "",
|
||||
"forms.draft": "Borrador",
|
||||
"forms.draftInfo": "Los torneos en borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de que pueda iniciarse cualquier cuadro.",
|
||||
"forms.draftBracketStartBlocked": "El torneo está en modo borrador. Edita el torneo y desactiva la opción de borrador antes de iniciar el cuadro.",
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"actions.outlined": "Con borde",
|
||||
"actions.noOutline": "Sin borde",
|
||||
"actions.join": "Unirse",
|
||||
"filterBar.addFilter": "",
|
||||
"filterBar.saveAsDefault": "",
|
||||
"imageExport.export": "Exportar imagen",
|
||||
"imageExport.download": "Descargar",
|
||||
"imageExport.theme.light": "Claro",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"errors.canNotSetIfLookingNow": "No se puede establecer si se está buscando scrim ahora",
|
||||
"errors.maxAssociationsReached": "Has alcanzado el número máximo de asociaciones",
|
||||
"labels.weekdayTimes": "Horarios entre semana",
|
||||
"labels.weekendTimes": "Horarios de fin de semana",
|
||||
"labels.start": "Inicio",
|
||||
"labels.end": "Fin",
|
||||
"labels.member": "Miembro",
|
||||
@@ -187,24 +186,8 @@
|
||||
"vodTypes.SCRIM": "Práctica",
|
||||
"vodTypes.MATCHMAKING": "Combate caótico/X/territorial",
|
||||
"vodTypes.SENDOUQ": "SendouQ",
|
||||
"labels.modesExact": "Modos exactos",
|
||||
"bottomTexts.modesExact": "Mostrar solo eventos que coincidan con todos los modos seleccionados",
|
||||
"labels.games": "Partidas",
|
||||
"labels.vs": "Vs.",
|
||||
"labels.startTime": "Hora de inicio",
|
||||
"labels.tagsIncluded": "Etiquetas incluidas",
|
||||
"labels.tagsExcluded": "Etiquetas excluidas",
|
||||
"labels.onlySendouEvents": "Solo eventos organizados en sendou.ink",
|
||||
"labels.onlyRankedEvents": "Solo eventos competitivos",
|
||||
"labels.minTeamCount": "Mínimo de equipos",
|
||||
"labels.orgsIncluded": "Organizaciones visibles",
|
||||
"labels.orgsExcluded": "Organizaciones ocultas",
|
||||
"labels.authorIdsExcluded": "Autores excluidos",
|
||||
"bottomTexts.authorIdsExcluded": "Puedes encontrar el ID de un usuario en su página de perfil",
|
||||
"options.startTime.any": "Cualquiera",
|
||||
"options.startTime.eu": "Horario europeo",
|
||||
"options.startTime.na": "Horario americano",
|
||||
"options.startTime.au": "Horario AU/NZ",
|
||||
"options.game.S1": "Splatoon 1",
|
||||
"options.game.S2": "Splatoon 2",
|
||||
"options.game.S3": "Splatoon 3",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"post.lastActive": "hace",
|
||||
"noPosts": "Sin publicaciones que coincidan con el filtro",
|
||||
"expiring": "La publicación caduca. ¿Sigues buscando?",
|
||||
"addFilter": "Añadir filtro",
|
||||
"filters.Weapon": "Grupo de armas",
|
||||
"filters.Type": "Tipo de publicación",
|
||||
"filters.Timezone": "Diferencia horaria",
|
||||
@@ -16,7 +15,6 @@
|
||||
"filters.PlusTier": "Nivel Plus",
|
||||
"filters.MaxTier": "Nivel máximo",
|
||||
"filters.MinTier": "Nivel mínimo",
|
||||
"filters.suffix": "filtro",
|
||||
"filters.orAbove": "o más",
|
||||
"new.noMorePosts": "No puedes crear más publicaciones",
|
||||
"new.type.header": "Tipo",
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
"requestModal.at.label": "Hora de inicio",
|
||||
"requestModal.at.explanation": "Selecciona una hora dentro del rango de tiempo de la publicación",
|
||||
"pickupBy": "Pickup de",
|
||||
"filters.button": "Filtros",
|
||||
"filters.heading": "Filtros de scrims",
|
||||
"filters.weekdayTimes": "Horarios entre semana",
|
||||
"filters.weekdayStart": "Inicio entre semana",
|
||||
"filters.weekdayEnd": "Fin entre semana",
|
||||
"filters.weekendTimes": "Horarios de fin de semana",
|
||||
"filters.weekendStart": "Inicio de fin de semana",
|
||||
"filters.weekendEnd": "Fin de fin de semana",
|
||||
"filters.apply": "Aplicar",
|
||||
"filters.applyAndDefault": "Aplicar y establecer por defecto",
|
||||
"filters.divs": "",
|
||||
"filters.showFiltered": "Mostrar filtrados ({{count}})",
|
||||
"filters.hideFiltered": "Ocultar filtrados ({{count}})",
|
||||
"filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})",
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"sens": "Sensibilidad",
|
||||
"usesPronouns": "Usa",
|
||||
"discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
|
||||
"results.title": "Todos los resultados",
|
||||
"results.placing": "Lugar",
|
||||
"results.team": "Equipo",
|
||||
"results.tournament": "Torneo",
|
||||
@@ -159,9 +158,26 @@
|
||||
"results.highlights": "Resaltos",
|
||||
"results.highlights.choose": "Elegir resaltos",
|
||||
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
|
||||
"results.button.showHighlights": "Mostrar resaltados",
|
||||
"results.button.showAll": "Mostrar todos",
|
||||
"results.filter.placeholder": "Filtrar por torneo",
|
||||
"results.filter.only": "",
|
||||
"results.filter.highlightsOnly": "",
|
||||
"results.filter.tournament": "",
|
||||
"results.filter.team": "",
|
||||
"results.filter.mate": "",
|
||||
"results.filter.tier": "",
|
||||
"results.filter.tier.min": "",
|
||||
"results.filter.tier.max": "",
|
||||
"results.filter.placement": "",
|
||||
"results.filter.placement.first": "",
|
||||
"results.filter.placement.top": "",
|
||||
"results.filter.years": "",
|
||||
"results.filter.years.from": "",
|
||||
"results.filter.years.to": "",
|
||||
"results.filter.source": "",
|
||||
"results.filter.source.ALL": "",
|
||||
"results.filter.source.SENDOU": "",
|
||||
"results.filter.source.EXTERNAL": "",
|
||||
"results.filter.size": "",
|
||||
"results.filter.size.min": "",
|
||||
"forms.errors.maxWeapons": "Máxima cantidad de armas",
|
||||
"search.info": "Busca usuarios por su nombre de Discord o de Splatoon 3",
|
||||
"search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'",
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
"stats.all": "Todas",
|
||||
"stats.public": "Público",
|
||||
"stats.private": "Privado",
|
||||
"addFilter": "Añadir filtro",
|
||||
"linkButton.abilityStats": "Estadísticas de potenciadores",
|
||||
"linkButton.popularBuilds": "Builds populares",
|
||||
"noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.",
|
||||
"emptyAbilitySlot": "Espacio de potenciador vacío",
|
||||
"filters.type.ability": "Por potenciador",
|
||||
"filters.type.mode": "Por estilo",
|
||||
"filters.type.date": "Por fecha",
|
||||
"filters.ability.title": "Filtro por potenciador",
|
||||
"filters.mode.title": "Filtro por estilo",
|
||||
"filters.date.title": "Filtro por fecha",
|
||||
"filters.abilities": "",
|
||||
"filters.mode": "",
|
||||
"filters.date": "",
|
||||
"filters.addAbility": "",
|
||||
"filters.has": "Tiene",
|
||||
"filters.does.not.have": "No tiene",
|
||||
"filters.atLeast": "Al menos",
|
||||
|
||||
@@ -48,11 +48,8 @@
|
||||
"tag.desc.SR": "Evento de Salmon Run",
|
||||
"tag.desc.CARDS": "Evento de Combate carterritorial",
|
||||
"icalFeed": "",
|
||||
"filter.button": "",
|
||||
"filter.heading": "",
|
||||
"filter.modes": "",
|
||||
"filter.exactModes": "",
|
||||
"filter.exactModesBottom": "",
|
||||
"filter.games": "",
|
||||
"filter.vs": "",
|
||||
"filter.vs.4v4": "",
|
||||
@@ -69,11 +66,18 @@
|
||||
"filter.isSendou": "",
|
||||
"filter.isRanked": "",
|
||||
"filter.minTeamCount": "",
|
||||
"filter.minTier": "",
|
||||
"filter.maxTier": "",
|
||||
"filter.orgsIncluded": "",
|
||||
"filter.orgsExcluded": "",
|
||||
"filter.authorIdsExcluded": "",
|
||||
"filter.apply": "",
|
||||
"filter.applyAndDefault": "",
|
||||
"filterBar.eventType": "",
|
||||
"filterBar.tier": "",
|
||||
"filterBar.tags": "",
|
||||
"filterBar.organizers": "",
|
||||
"filterBar.timeAndSize": "",
|
||||
"filterBar.sendou": "",
|
||||
"filterBar.ranked": "",
|
||||
"forms.draft": "",
|
||||
"forms.draftInfo": "",
|
||||
"forms.draftBracketStartBlocked": "",
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Unirse",
|
||||
"filterBar.addFilter": "",
|
||||
"filterBar.saveAsDefault": "",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"errors.canNotSetIfLookingNow": "",
|
||||
"errors.maxAssociationsReached": "",
|
||||
"labels.weekdayTimes": "",
|
||||
"labels.weekendTimes": "",
|
||||
"labels.start": "",
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
@@ -187,24 +186,8 @@
|
||||
"vodTypes.SCRIM": "Práctica",
|
||||
"vodTypes.MATCHMAKING": "Combate caótico/X/territorial",
|
||||
"vodTypes.SENDOUQ": "SendouQ",
|
||||
"labels.modesExact": "",
|
||||
"bottomTexts.modesExact": "",
|
||||
"labels.games": "",
|
||||
"labels.vs": "",
|
||||
"labels.startTime": "",
|
||||
"labels.tagsIncluded": "",
|
||||
"labels.tagsExcluded": "",
|
||||
"labels.onlySendouEvents": "",
|
||||
"labels.onlyRankedEvents": "",
|
||||
"labels.minTeamCount": "",
|
||||
"labels.orgsIncluded": "",
|
||||
"labels.orgsExcluded": "",
|
||||
"labels.authorIdsExcluded": "",
|
||||
"bottomTexts.authorIdsExcluded": "",
|
||||
"options.startTime.any": "",
|
||||
"options.startTime.eu": "",
|
||||
"options.startTime.na": "",
|
||||
"options.startTime.au": "",
|
||||
"options.game.S1": "",
|
||||
"options.game.S2": "",
|
||||
"options.game.S3": "",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"post.lastActive": "hace",
|
||||
"noPosts": "No posts matching the filter",
|
||||
"expiring": "La publicación caduca. ¿Sigues buscando?",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Grupo de armas",
|
||||
"filters.Type": "Tipo de publicación",
|
||||
"filters.Timezone": "Diferencia horaria",
|
||||
@@ -16,7 +15,6 @@
|
||||
"filters.PlusTier": "Nivel Plus",
|
||||
"filters.MaxTier": "Nivel máximo",
|
||||
"filters.MinTier": "Nivel mínimo",
|
||||
"filters.suffix": "filtro",
|
||||
"filters.orAbove": "o más",
|
||||
"new.noMorePosts": "No puede crear más publicaciones",
|
||||
"new.type.header": "Tipo",
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
"requestModal.at.label": "",
|
||||
"requestModal.at.explanation": "",
|
||||
"pickupBy": "",
|
||||
"filters.button": "",
|
||||
"filters.heading": "",
|
||||
"filters.weekdayTimes": "",
|
||||
"filters.weekdayStart": "",
|
||||
"filters.weekdayEnd": "",
|
||||
"filters.weekendTimes": "",
|
||||
"filters.weekendStart": "",
|
||||
"filters.weekendEnd": "",
|
||||
"filters.apply": "",
|
||||
"filters.applyAndDefault": "",
|
||||
"filters.divs": "",
|
||||
"filters.showFiltered": "",
|
||||
"filters.hideFiltered": "",
|
||||
"filters.showPendingRequests": "",
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
|
||||
"results.title": "Todos los resultados",
|
||||
"results.placing": "Lugar",
|
||||
"results.team": "Equipo",
|
||||
"results.tournament": "Torneo",
|
||||
@@ -159,9 +158,26 @@
|
||||
"results.highlights": "Resaltos",
|
||||
"results.highlights.choose": "Elegir resaltos",
|
||||
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
|
||||
"results.button.showHighlights": "Mostrar resaltos",
|
||||
"results.button.showAll": "Mostrar todos",
|
||||
"results.filter.placeholder": "",
|
||||
"results.filter.only": "",
|
||||
"results.filter.highlightsOnly": "",
|
||||
"results.filter.tournament": "",
|
||||
"results.filter.team": "",
|
||||
"results.filter.mate": "",
|
||||
"results.filter.tier": "",
|
||||
"results.filter.tier.min": "",
|
||||
"results.filter.tier.max": "",
|
||||
"results.filter.placement": "",
|
||||
"results.filter.placement.first": "",
|
||||
"results.filter.placement.top": "",
|
||||
"results.filter.years": "",
|
||||
"results.filter.years.from": "",
|
||||
"results.filter.years.to": "",
|
||||
"results.filter.source": "",
|
||||
"results.filter.source.ALL": "",
|
||||
"results.filter.source.SENDOU": "",
|
||||
"results.filter.source.EXTERNAL": "",
|
||||
"results.filter.size": "",
|
||||
"results.filter.size.min": "",
|
||||
"forms.errors.maxWeapons": "Máxima cantidad de armas",
|
||||
"search.info": "",
|
||||
"search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user