Unified filters (#3313)

This commit is contained in:
Kalle
2026-08-06 17:37:08 +03:00
committed by GitHub
parent 0b005ce30a
commit 0c50b45cdb
177 changed files with 4494 additions and 2886 deletions

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { describe, expect, test } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { SendouButton } from "../elements/Button";
import { FilterBar } from "./FilterBar";
const MODES = ["SZ", "TC", "RM"];
function TestFilterBar(props: {
initialMode?: string | null;
initialWeapon?: string | null;
initialRank?: string | null;
}) {
const [mode, setMode] = useState<string | null>(props.initialMode ?? null);
const [weapon, setWeapon] = useState<string | null>(
props.initialWeapon ?? null,
);
// unlike the other pills this one seeds a value when added from the menu
const [rank, setRank] = useState<string | null>(props.initialRank ?? null);
return (
<FilterBar
pills={[
{
key: "mode",
name: "Mode",
formattedValue: mode,
onRemove: () => setMode(null),
popover: (
<div>
{MODES.map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
>
Set {value}
</button>
))}
</div>
),
},
{
key: "weapon",
name: "Weapon",
formattedValue: weapon,
onRemove: () => setWeapon(null),
popover: (
<button type="button" onClick={() => setWeapon("Splattershot")}>
Set Splattershot
</button>
),
},
{
key: "rank",
name: "Rank",
formattedValue: rank,
onAdd: () => setRank("S+"),
onRemove: () => setRank(null),
popover: (
<button type="button" onClick={() => setRank("X")}>
Set X
</button>
),
},
]}
onReset={
mode !== null || weapon !== null || rank !== null
? () => {
setMode(null);
setWeapon(null);
setRank(null);
}
: undefined
}
actions={<SendouButton>Save as default</SendouButton>}
/>
);
}
describe("FilterBar", () => {
test("renders a set pill with its name and formatted value", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
await expect
.element(screen.getByRole("button", { name: /Mode.*SZ/ }))
.toBeVisible();
});
test("updates the pill value instantly when changed in the popover", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
await screen.getByRole("button", { name: "Mode SZ" }).click();
await screen.getByRole("button", { name: "Set TC" }).click();
await expect
.element(screen.getByRole("button", { name: "Mode TC" }))
.toBeVisible();
});
test("hides a pill at its default value behind the add filter menu", async () => {
const screen = await render(<TestFilterBar />);
await expect
.element(screen.getByRole("button", { name: /Mode/ }))
.not.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: /Weapon/ }))
.not.toBeInTheDocument();
await screen.getByRole("button", { name: "Filter" }).click();
await expect
.element(screen.getByRole("menuitem", { name: "Weapon" }))
.toBeVisible();
});
test("adding a pill opens its popover and keeps the pill visible while unset", async () => {
const screen = await render(<TestFilterBar />);
await screen.getByRole("button", { name: "Filter" }).click();
await screen.getByRole("menuitem", { name: "Weapon" }).click();
await expect
.element(screen.getByRole("button", { name: "Set Splattershot" }))
.toBeVisible();
await screen.getByRole("button", { name: "Set Splattershot" }).click();
await expect
.element(screen.getByRole("button", { name: /Weapon.*Splattershot/ }))
.toBeVisible();
});
test("removing a pill hides it again", async () => {
const screen = await render(<TestFilterBar initialWeapon="Splattershot" />);
await screen.getByRole("button", { name: "Remove Weapon filter" }).click();
await expect
.element(screen.getByRole("button", { name: /Weapon/ }))
.not.toBeInTheDocument();
});
test("adding a pill seeds its starting value via onAdd", async () => {
const screen = await render(<TestFilterBar />);
await screen.getByRole("button", { name: "Filter" }).click();
await screen.getByRole("menuitem", { name: "Rank" }).click();
await expect
.element(screen.getByRole("button", { name: /Rank.*S\+/ }))
.toBeVisible();
});
test("renders the reset button and the actions slot", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
await expect
.element(screen.getByRole("button", { name: "Reset" }))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Save as default" }))
.toBeVisible();
});
test("resetting hides an added pill that was left unset", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
await screen.getByRole("button", { name: "Filter", exact: true }).click();
await screen.getByRole("menuitem", { name: "Weapon" }).click();
// adding a pill opens its popover, which blocks the reset button beneath it
await userEvent.keyboard("{Escape}");
await screen.getByRole("button", { name: "Reset" }).click();
await expect
.element(screen.getByRole("button", { name: /Weapon/ }))
.not.toBeInTheDocument();
});
test("hides the add filter menu when every pill is visible", async () => {
const screen = await render(
<TestFilterBar
initialMode="SZ"
initialWeapon="Splattershot"
initialRank="X"
/>,
);
await expect
.element(screen.getByRole("button", { name: "Filter", exact: true }))
.not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,139 @@
.popover {
min-width: 14rem;
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}
.pill {
display: inline-flex;
align-items: center;
height: var(--selector-size);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
transition: background-color 0.15s;
&:has(.trigger[data-hovered]) {
background-color: var(--color-bg-high);
}
}
.trigger {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: 100%;
padding: 0 var(--s-2);
border: none;
border-radius: inherit;
background-color: transparent;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
cursor: pointer;
&[data-focus-visible] {
outline: var(--focus-ring);
outline-offset: 2px;
}
.pill:has(.removeButton) & {
padding-right: var(--s-1);
}
}
.removeButton {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 var(--s-1-5);
border: none;
border-radius: inherit;
background-color: transparent;
color: var(--color-text-high);
cursor: pointer;
& > svg {
width: 14px;
height: 14px;
}
&[data-hovered] {
color: var(--color-error);
}
&[data-focus-visible] {
outline: var(--focus-ring);
outline-offset: 2px;
}
}
.actions {
display: contents;
}
.actions button {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: var(--selector-size);
padding: 0 var(--s-2);
border: var(--border-style-high);
border-radius: var(--radius-selector);
background-color: transparent;
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
&[data-hovered] {
background-color: var(--color-bg-high);
color: var(--color-text);
}
&[data-focus-visible] {
outline: var(--focus-ring);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
& svg {
width: 14px;
min-width: 14px;
max-width: 14px;
height: 14px;
margin-inline-end: 0;
}
}
.icon {
display: inline-flex;
& > svg {
width: 14px;
height: 14px;
}
}
.value {
color: var(--color-text-accent);
}
.chevron,
.plus {
width: 14px;
height: 14px;
color: var(--color-text-high);
}

View File

@@ -0,0 +1,183 @@
import clsx from "clsx";
import { ChevronDown, Plus, RotateCcw, X } from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouButton } from "../elements/Button";
import { SendouMenu, SendouMenuItem } from "../elements/Menu";
import { SendouPopover } from "../elements/Popover";
import styles from "./FilterBar.module.css";
export interface FilterBarPill {
key: string;
/** Translated filter name shown on the pill and in the add filter menu. */
name: string;
/** Translated current value shown on the pill. Null when the filter is at its default. */
formattedValue: React.ReactNode | null;
/** Popover content. Inputs inside write search params directly (instant apply). */
popover: React.ReactNode;
/** Resets the pill's param(s) to defaults. Renders the remove button. */
onRemove?: () => void;
/** Writes a starting value when the pill is added from the menu. */
onAdd?: () => void;
icon?: React.ReactNode;
popoverClassName?: string;
testId?: string;
}
export function FilterBar({
pills,
onReset,
actions,
}: {
pills: FilterBarPill[];
/** Resets every pill's param(s) to defaults. Renders the reset button. */
onReset?: () => void;
actions?: React.ReactNode;
}) {
const { t } = useTranslation();
const [justAddedKeys, setJustAddedKeys] = React.useState<ReadonlySet<string>>(
new Set(),
);
const [openPillKey, setOpenPillKey] = React.useState<string | null>(null);
const isVisible = (pill: FilterBarPill) =>
pill.formattedValue !== null || justAddedKeys.has(pill.key);
const hiddenPills = pills.filter((pill) => !isVisible(pill));
const addPill = (pill: FilterBarPill) => {
setJustAddedKeys((prev) => new Set(prev).add(pill.key));
setOpenPillKey(pill.key);
pill.onAdd?.();
};
const removePill = (pill: FilterBarPill) => {
setJustAddedKeys((prev) => {
const next = new Set(prev);
next.delete(pill.key);
return next;
});
if (openPillKey === pill.key) {
setOpenPillKey(null);
}
pill.onRemove?.();
};
const resetPills = () => {
setJustAddedKeys(new Set());
setOpenPillKey(null);
onReset?.();
};
return (
<div className={styles.bar}>
{pills.filter(isVisible).map((pill) => (
<FilterPill
key={pill.key}
pill={pill}
isOpen={openPillKey === pill.key}
onOpenChange={(isOpen) => setOpenPillKey(isOpen ? pill.key : null)}
onRemove={pill.onRemove ? () => removePill(pill) : undefined}
/>
))}
{hiddenPills.length > 0 ? (
<AddFilterMenu pills={hiddenPills} onAdd={addPill} />
) : null}
{onReset || actions ? (
<div className={styles.actions}>
{onReset ? (
<SendouButton icon={<RotateCcw />} onPress={resetPills}>
{t("actions.reset")}
</SendouButton>
) : null}
{actions}
</div>
) : null}
</div>
);
}
function FilterPill({
pill,
isOpen,
onOpenChange,
onRemove,
}: {
pill: FilterBarPill;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onRemove?: () => void;
}) {
return (
<div className={styles.pill}>
<SendouPopover
isOpen={isOpen}
onOpenChange={onOpenChange}
popoverClassName={clsx(styles.popover, pill.popoverClassName)}
trigger={
<Button
className={styles.trigger}
data-active={pill.formattedValue !== null}
data-testid={pill.testId}
>
{pill.icon ? (
<span className={styles.icon}>{pill.icon}</span>
) : null}
<span>{pill.name}</span>
{pill.formattedValue !== null ? (
<span className={styles.value}>{pill.formattedValue}</span>
) : null}
<ChevronDown className={styles.chevron} />
</Button>
}
>
{pill.popover}
</SendouPopover>
{onRemove ? (
<Button
className={styles.removeButton}
aria-label={`Remove ${pill.name} filter`}
onPress={onRemove}
data-testid={pill.testId ? `${pill.testId}-remove` : undefined}
>
<X />
</Button>
) : null}
</div>
);
}
function AddFilterMenu({
pills,
onAdd,
}: {
pills: FilterBarPill[];
onAdd: (pill: FilterBarPill) => void;
}) {
const { t } = useTranslation();
return (
<SendouMenu
trigger={
<div className={styles.pill}>
<Button className={styles.trigger} data-testid="add-filter-button">
<Plus className={styles.plus} />
<span>{t("filterBar.addFilter")}</span>
</Button>
</div>
}
>
{pills.map((pill) => (
<SendouMenuItem
key={pill.key}
icon={pill.icon}
onAction={() => onAdd(pill)}
data-testid={pill.testId ? `menu-item-${pill.testId}` : undefined}
>
{pill.name}
</SendouMenuItem>
))}
</SendouMenu>
);
}

View File

@@ -1,10 +1,10 @@
import { z } from "zod";
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
import { ability, modeShort } from "~/utils/zod";
import { isValidDate } from "~/utils/dates";
import { ability } from "~/utils/zod";
import { MAX_BUILD_FILTERS } from "./builds-constants";
const abilityFilterSchema = z.object({
type: z.literal("ability"),
const abilityConditionSchema = z.object({
ability: z.string().toUpperCase().pipe(ability),
value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]),
comparison: z
@@ -14,18 +14,11 @@ const abilityFilterSchema = z.object({
.optional(),
});
const modeFilterSchema = z.object({
type: z.literal("mode"),
mode: z.string().toUpperCase().pipe(modeShort),
});
const dateFilterSchema = z.object({
type: z.literal("date"),
date: z.iso.date(),
});
export const buildFiltersSchema = z
.array(z.union([abilityFilterSchema, modeFilterSchema, dateFilterSchema]))
export const abilityConditionsSchema = z
.array(abilityConditionSchema)
.max(MAX_BUILD_FILTERS);
export type BuildFiltersFromSearchParams = z.infer<typeof buildFiltersSchema>;
export const buildsDateFilterSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((value) => isValidDate(new Date(value)));

View File

@@ -9,15 +9,17 @@ describe("buildsSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(buildsSearchParams, {
limit: [24, 48, 1, 240],
f: [
abilities: [
[],
[
{ type: "ability", ability: "ISM", comparison: "AT_LEAST", value: 3 },
{ type: "mode", mode: "SZ" },
{ type: "date", date: "2026-01-28" },
{ ability: "ISM", comparison: "AT_LEAST", value: 3 },
{ ability: "SSU", comparison: "AT_MOST", value: 12 },
],
[{ type: "ability", ability: "LDE", value: true }],
[{ ability: "LDE", value: true }],
[{ ability: "CB", value: false }],
],
mode: [null, "SZ", "TW"],
date: [null, "2026-01-28"],
});
});
@@ -28,11 +30,17 @@ describe("buildsSearchParams", () => {
["241"],
["abc"],
]);
assertDecodesToDefault(buildsSearchParams, "f", [
assertDecodesToDefault(buildsSearchParams, "abilities", [
["not-json"],
['[{"type":"ability"}]'],
['{"type":"mode","mode":"SZ"}'],
['[{"type":"mode","mode":"XX"}]'],
['[{"ability":"XXX","value":true}]'],
['{"ability":"ISM","value":3}'],
['[{"ability":"ISM","value":100,"comparison":"AT_LEAST"}]'],
]);
assertDecodesToDefault(buildsSearchParams, "mode", [["XX"], ["zz"]]);
assertDecodesToDefault(buildsSearchParams, "date", [
["not-a-date"],
["2026-13-99"],
["2026-1-1"],
]);
});
});

View File

@@ -1,20 +1,32 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { modeShort } from "~/utils/zod";
import {
BUILDS_PAGE_BATCH_SIZE,
BUILDS_PAGE_MAX_BUILDS,
} from "./builds-constants";
import { buildFiltersSchema } from "./builds-schemas";
import {
abilityConditionsSchema,
buildsDateFilterSchema,
} from "./builds-schemas";
export const buildsSearchParams = SearchParams.define({
limit: SP.param(z.number().int().min(1).max(BUILDS_PAGE_MAX_BUILDS), {
default: BUILDS_PAGE_BATCH_SIZE,
loader: true,
}),
f: SP.json(buildFiltersSchema, {
abilities: SP.json(abilityConditionsSchema, {
default: [],
resets: ["limit"],
loader: true,
}),
mode: SP.param(modeShort.nullable(), {
resets: ["limit"],
loader: true,
}),
date: SP.param(buildsDateFilterSchema.nullable(), {
resets: ["limit"],
loader: true,
}),
});

View File

@@ -1,34 +1,13 @@
import type {
Ability,
MainWeaponId,
ModeShort,
} from "~/modules/in-game-lists/types";
import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types";
export interface BuildWeaponWithTop500Info {
weaponSplId: MainWeaponId;
isTop500: number;
}
export type AbilityBuildFilter = {
type: "ability";
export interface AbilityCondition {
ability: Ability;
/** Ability points value or "has"/"doesn't have" */
value: number | boolean;
comparison?: "AT_LEAST" | "AT_MOST";
};
export type ModeBuildFilter = {
type: "mode";
mode: ModeShort;
};
export type DateBuildFilter = {
type: "date";
/** YYYY-MM-DD */
date: string;
};
export type BuildFilter =
| AbilityBuildFilter
| ModeBuildFilter
| DateBuildFilter;
}

View File

@@ -1,38 +0,0 @@
.filter {
display: flex;
flex-direction: column;
padding: var(--s-3);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
gap: var(--s-2);
}
.filterMode {
gap: var(--s-6);
flex-wrap: wrap;
}
.filterDate {
display: flex;
align-items: center;
}
@container (width >= 560px) {
.filter {
flex-direction: row;
}
}
.abilityContainer {
display: flex;
width: 32px;
align-items: center;
}
.apSelect {
width: 75px;
}
.dateSelect {
width: 275px;
}

View File

@@ -1,259 +0,0 @@
import clsx from "clsx";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage } from "~/components/Image";
import { possibleApValues } from "~/features/build-analyzer/analyzer-constants";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { abilities } from "~/modules/in-game-lists/abilities";
import { modesShort } from "~/modules/in-game-lists/modes";
import type {
Ability as AbilityType,
ModeShort,
} from "~/modules/in-game-lists/types";
import { dateToYYYYMMDD, isValidDate } from "~/utils/dates";
import { RECENT_PATCHES } from "../builds-constants";
import type {
AbilityBuildFilter,
BuildFilter,
DateBuildFilter,
ModeBuildFilter,
} from "../builds-types";
import styles from "./FilterSection.module.css";
export function FilterSection({
number,
nthOfSame,
filter,
onChange,
remove,
}: {
number: number;
nthOfSame: number;
filter: BuildFilter;
onChange: (filter: Partial<BuildFilter>) => void;
remove: () => void;
}) {
const { t } = useTranslation(["builds"]);
return (
<section>
<div className="stack horizontal justify-between mx-2">
<div className="text-xs font-bold">
{t(`builds:filters.${filter.type}.title`)}{" "}
{nthOfSame > 1 ? nthOfSame : ""}
</div>
<div>
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
onPress={remove}
aria-label="Delete filter"
data-testid="delete-filter-button"
/>
</div>
</div>
{filter.type === "ability" ? (
<AbilityFilter filter={filter} onChange={onChange} />
) : null}
{filter.type === "mode" ? (
<ModeFilter filter={filter} onChange={onChange} number={number} />
) : null}
{filter.type === "date" ? (
<DateFilter filter={filter} onChange={onChange} />
) : null}
</section>
);
}
function AbilityFilter({
filter,
onChange,
}: {
filter: AbilityBuildFilter;
onChange: (filter: Partial<BuildFilter>) => void;
}) {
const { t } = useTranslation(["analyzer", "game-misc", "builds"]);
const abilityObject = abilities.find((a) => a.name === filter.ability)!;
return (
<div className={styles.filter}>
<div className={styles.abilityContainer}>
<Ability ability={filter.ability} size="TINY" />
</div>
<select
value={filter.ability}
onChange={(e) =>
onChange({
ability: e.target.value as AbilityType,
value:
abilities.find((a) => a.name === e.target.value)!.type ===
"STACKABLE"
? 0
: true,
})
}
>
{abilities.map((ability) => {
return (
<option key={ability.name} value={ability.name}>
{t(`game-misc:ABILITY_${ability.name}`)}
</option>
);
})}
</select>
{abilityObject.type !== "STACKABLE" ? (
<select
value={!filter.value ? "false" : "true"}
onChange={(e) => onChange({ value: e.target.value === "true" })}
>
<option value="true">{t("builds:filters.has")}</option>
<option value="false">{t("builds:filters.does.not.have")}</option>
</select>
) : null}
{abilityObject.type === "STACKABLE" ? (
<select
value={filter.comparison}
onChange={(e) =>
onChange({
comparison: e.target.value as AbilityBuildFilter["comparison"],
})
}
data-testid="comparison-select"
>
<option value="AT_LEAST">{t("builds:filters.atLeast")}</option>
<option value="AT_MOST">{t("builds:filters.atMost")}</option>
</select>
) : null}
{abilityObject.type === "STACKABLE" ? (
<div className="stack horizontal sm items-center">
<select
className={styles.apSelect}
value={typeof filter.value === "number" ? filter.value : "0"}
onChange={(e) => onChange({ value: Number(e.target.value) })}
>
{possibleApValues().map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<div className="text-sm">{t("analyzer:abilityPoints.short")}</div>
</div>
) : null}
</div>
);
}
function ModeFilter({
filter,
onChange,
number,
}: {
filter: ModeBuildFilter;
onChange: (filter: Partial<BuildFilter>) => void;
number: number;
}) {
const { t } = useTranslation(["game-misc"]);
const inputId = (mode: ModeShort) => `${number}-${mode}`;
return (
<div className={clsx(styles.filter, styles.filterMode)}>
{modesShort.map((mode) => {
return (
<div
key={mode}
className="stack horizontal xs items-center font-sm font-semi-bold"
>
<input
type="radio"
name={`mode-${number}`}
id={inputId(mode)}
value={mode}
checked={filter.mode === mode}
onChange={() => onChange({ mode })}
/>
<label htmlFor={inputId(mode)} className="stack horizontal xs mb-0">
<ModeImage mode={mode} size={18} />
{t(`game-misc:MODE_LONG_${mode}`)}
</label>
</div>
);
})}
</div>
);
}
function DateFilter({
filter,
onChange,
}: {
filter: DateBuildFilter;
onChange: (filter: Partial<DateBuildFilter>) => void;
}) {
const { t } = useTranslation(["builds"]);
const { formatter: patchDateFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
year: "numeric",
});
const selectValue = () =>
RECENT_PATCHES.some(({ date }) => date === filter.date)
? filter.date
: "CUSTOM";
// on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays)
const oneMonthAgoOnSaturday = new Date();
oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30);
oneMonthAgoOnSaturday.setUTCDate(
oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6,
);
const customDate = isValidDate(new Date(filter.date))
? new Date(filter.date)
: oneMonthAgoOnSaturday;
return (
<div className={clsx(styles.filter, styles.filterDate)}>
<label className="mb-0">{t("builds:filters.date.since")}</label>
<select
className={styles.dateSelect}
value={selectValue()}
data-testid="date-select"
onChange={(e) =>
onChange({
date:
e.target.value === "CUSTOM"
? dateToYYYYMMDD(oneMonthAgoOnSaturday)
: e.target.value,
})
}
>
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
const date = new Date(dateString);
return (
<option key={patch} value={dateString}>
{patch} ({patchDateFormatter.format(date) ?? ""})
</option>
);
})}
<option value="CUSTOM">{t("builds:filters.date.custom")}</option>
</select>
{selectValue() === "CUSTOM" ? (
<input
type="date"
value={dateToYYYYMMDD(customDate)}
onChange={(e) => onChange({ date: e.target.value })}
max={dateToYYYYMMDD(new Date())}
data-testid="date-input"
/>
) : null}
</div>
);
}

View File

@@ -5,13 +5,7 @@ import type {
ModeShort,
} from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
import type { BuildFiltersFromSearchParams } from "../builds-schemas";
import type {
AbilityBuildFilter,
DateBuildFilter,
ModeBuildFilter,
} from "../builds-types";
import type { AbilityCondition } from "../builds-types";
type PartialBuild = {
abilities: BuildAbilitiesTuple;
@@ -19,17 +13,24 @@ type PartialBuild = {
updatedAt: Tables["Build"]["updatedAt"];
};
interface BuildFilters {
abilities: AbilityCondition[];
mode: ModeShort | null;
date: string | null;
}
/**
* Filters an array of builds based on the provided filter criteria and returns up to a specified count of matching builds.
*
* Filters are applied on "AND" basis, meaning all filters must match for a build to be included in the result.
*/
export function filterBuilds<T extends PartialBuild>({
filters,
abilities,
mode,
date,
count,
builds,
}: {
filters: BuildFiltersFromSearchParams;
}: BuildFilters & {
count: number;
builds: T[];
}) {
@@ -38,7 +39,7 @@ export function filterBuilds<T extends PartialBuild>({
for (const build of builds) {
if (result.length === count) break;
if (buildMatchesFilters({ build, filters })) {
if (buildMatchesFilters({ build, abilities, mode, date })) {
result.push(build);
}
}
@@ -48,42 +49,38 @@ export function filterBuilds<T extends PartialBuild>({
function buildMatchesFilters<T extends PartialBuild>({
build,
filters,
}: {
build: T;
filters: BuildFiltersFromSearchParams;
}) {
for (const filter of filters) {
if (filter.type === "ability") {
if (!matchesAbilityFilter({ build, filter })) return false;
} else if (filter.type === "mode") {
if (!matchesModeFilter({ build, filter })) return false;
} else if (filter.type === "date") {
if (!matchesDateFilter({ build, filter })) return false;
} else {
assertUnreachable(filter);
}
abilities,
mode,
date,
}: BuildFilters & { build: T }) {
for (const condition of abilities) {
if (!matchesAbilityCondition({ build, condition })) return false;
}
if (mode !== null && !matchesModeFilter({ build, mode })) return false;
if (date !== null && !matchesDateFilter({ build, date })) return false;
return true;
}
function matchesAbilityFilter({
function matchesAbilityCondition({
build,
filter,
condition,
}: {
build: PartialBuild;
filter: AbilityBuildFilter;
condition: AbilityCondition;
}) {
if (typeof filter.value === "boolean") {
const hasAbility = build.abilities.flat().includes(filter.ability);
if (filter.value && !hasAbility) return false;
if (!filter.value && hasAbility) return false;
} else if (typeof filter.value === "number") {
if (typeof condition.value === "boolean") {
const hasAbility = build.abilities.flat().includes(condition.ability);
if (condition.value && !hasAbility) return false;
if (!condition.value && hasAbility) return false;
} else if (typeof condition.value === "number") {
const abilityPoints = buildToAbilityPoints(build.abilities);
const ap = abilityPoints.get(filter.ability) ?? 0;
if (filter.comparison === "AT_LEAST" && ap < filter.value) return false;
if (filter.comparison === "AT_MOST" && ap > filter.value) return false;
const ap = abilityPoints.get(condition.ability) ?? 0;
if (condition.comparison === "AT_LEAST" && ap < condition.value)
return false;
if (condition.comparison === "AT_MOST" && ap > condition.value)
return false;
}
return true;
@@ -91,24 +88,22 @@ function matchesAbilityFilter({
function matchesModeFilter({
build,
filter,
mode,
}: {
build: PartialBuild;
filter: ModeBuildFilter;
mode: ModeShort;
}) {
if (!build.modes) return false;
return build.modes.includes(filter.mode);
return build.modes.includes(mode);
}
function matchesDateFilter({
build,
filter,
date,
}: {
build: PartialBuild;
filter: DateBuildFilter;
date: string;
}) {
const date = new Date(filter.date);
return date < databaseTimestampToDate(build.updatedAt);
return new Date(date) < databaseTimestampToDate(build.updatedAt);
}

View File

@@ -33,6 +33,8 @@ const createBuild = ({
};
};
const noFilters = { abilities: [], mode: null, date: null };
describe("Filter builds", () => {
test("returns correct build back based on abilities (AT_LEAST)", () => {
const filtered = filterBuilds({
@@ -41,9 +43,9 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }),
],
count: 2,
filters: [
...noFilters,
abilities: [
{
type: "ability",
ability: "ISM",
value: 10,
comparison: "AT_LEAST",
@@ -62,9 +64,9 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }),
],
count: 2,
filters: [
...noFilters,
abilities: [
{
type: "ability",
ability: "ISM",
value: 6,
comparison: "AT_MOST",
@@ -83,9 +85,9 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }),
],
count: 2,
filters: [
...noFilters,
abilities: [
{
type: "ability",
ability: "T",
value: true,
},
@@ -103,9 +105,9 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }),
],
count: 2,
filters: [
...noFilters,
abilities: [
{
type: "ability",
ability: "T",
value: false,
},
@@ -130,45 +132,8 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: [] }),
],
count: 3,
filters: [
{
type: "mode",
mode: "SZ",
},
],
});
expect(filtered.length).toBe(1);
expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]);
});
test("filters based on many modes", () => {
const filtered = filterBuilds({
builds: [
createBuild({
headAbilities: ["ISS", "ISM", "ISM", "ISM"],
modes: ["SZ", "TC"],
}),
createBuild({
headAbilities: ["ISM", "ISM", "ISM", "ISM"],
modes: ["SZ"],
}),
createBuild({
headAbilities: ["ISM", "ISM", "ISM", "ISM"],
modes: ["TC"],
}),
],
count: 3,
filters: [
{
type: "mode",
mode: "SZ",
},
{
type: "mode",
mode: "TC",
},
],
...noFilters,
mode: "SZ",
});
expect(filtered.length).toBe(1);
@@ -188,19 +153,15 @@ describe("Filter builds", () => {
}),
],
count: 2,
filters: [
{
type: "date",
date: "2022-01-01",
},
],
...noFilters,
date: "2022-01-01",
});
expect(filtered.length).toBe(1);
expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]);
});
test("combines filters of same type", () => {
test("combines multiple ability conditions", () => {
const filtered = filterBuilds({
builds: [
createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }),
@@ -208,14 +169,13 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }),
],
count: 2,
filters: [
...noFilters,
abilities: [
{
type: "ability",
ability: "T",
value: true,
},
{
type: "ability",
ability: "ISM",
value: 9,
comparison: "AT_LEAST",
@@ -247,13 +207,10 @@ describe("Filter builds", () => {
}),
],
count: 2,
filters: [
...noFilters,
date: "2022-01-01",
abilities: [
{
type: "date",
date: "2022-01-01",
},
{
type: "ability",
ability: "ISM",
value: 9,
comparison: "AT_LEAST",
@@ -273,7 +230,7 @@ describe("Filter builds", () => {
createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }),
],
count: 2,
filters: [],
...noFilters,
});
expect(filtered.length).toBe(2);

View File

@@ -18,13 +18,14 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
}
const { limit, f: filters } = buildsSearchParams.parse(url);
const { limit, abilities, mode, date } = buildsSearchParams.parse(url);
const weaponName = t(`weapons:MAIN_${weaponId}`);
const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" }));
const hasActiveFilters = filters.length > 0;
const hasActiveFilters =
abilities.length > 0 || mode !== null || date !== null;
const builds = await BuildRepository.findAllByWeaponId(weaponId, {
limit: hasActiveFilters ? BUILDS_PAGE_MAX_BUILDS : limit + 1,
@@ -34,7 +35,9 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => {
const filteredBuilds = hasActiveFilters
? filterBuilds({
builds,
filters,
abilities,
mode,
date,
count: limit + 1,
})
: builds;
@@ -55,6 +58,5 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => {
limit,
hasMoreBuilds,
slug,
filters,
};
};

View File

@@ -22,3 +22,30 @@
flex-direction: row;
}
}
.abilityConditions {
display: flex;
flex-direction: column;
gap: var(--s-3);
width: 100%;
min-width: 14rem;
}
.abilityConditionRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--s-1-5);
}
.abilityConditionValueRow {
display: grid;
grid-column: 2 / -1;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--s-1-5);
}
.abilityConditionApSelect {
min-width: 4.5rem;
}

View File

@@ -3,17 +3,25 @@ import {
ChartColumnBig,
Flame,
FlaskConical,
Funnel,
Map as MapIcon,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { Ability } from "~/components/Ability";
import { BuildCard } from "~/components/BuildCard";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { ModeImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { possibleApValues } from "~/features/build-analyzer/analyzer-constants";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { abilities } from "~/modules/in-game-lists/abilities";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { Ability as AbilityType } from "~/modules/in-game-lists/types";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { dateToYYYYMMDD, isValidDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -31,8 +39,7 @@ import {
RECENT_PATCHES,
} from "../builds-constants";
import { buildsSearchParams } from "../builds-search-params";
import type { AbilityBuildFilter, BuildFilter } from "../builds-types";
import { FilterSection } from "../components/FilterSection";
import type { AbilityCondition } from "../builds-types";
import { loader } from "../loaders/builds.$slug.server";
@@ -95,110 +102,21 @@ export function BuildCards({ data }: { data: SerializeFrom<typeof loader> }) {
export default function WeaponsBuildsPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "builds"]);
const [{ f: filters }, setParams] = useSearchParamsTyped(buildsSearchParams);
const syncSearchParams = (
newFilters: BuildFilter[],
opts?: { loader?: boolean },
) => {
setParams({ f: newFilters }, opts);
};
const handleFilterAdd = (type: BuildFilter["type"]) => {
const newFilter: BuildFilter =
type === "ability"
? {
type: "ability",
ability: "ISM",
comparison: "AT_LEAST",
value: 0,
}
: type === "date"
? {
type: "date",
date: RECENT_PATCHES[0].date,
}
: {
type: "mode",
mode: "SZ",
};
// a fresh "at least 0" ability filter matches every build, so no need to refetch
syncSearchParams(
[...filters, newFilter],
type === "ability" ? { loader: false } : undefined,
);
};
const handleFilterChange = (i: number, newFilter: Partial<BuildFilter>) => {
const newFilters = filters.map((f, index) =>
index === i
? ({
...(f as AbilityBuildFilter),
...(newFilter as AbilityBuildFilter),
} as BuildFilter)
: f,
);
syncSearchParams(newFilters);
};
const handleFilterDelete = (i: number) => {
syncSearchParams(filters.filter((_, index) => index !== i));
};
const [{ abilities: abilityConditions, mode, date }] =
useSearchParamsTyped(buildsSearchParams);
const loadMoreLink = () =>
buildsSearchParams.href("", {
limit: data.limit + BUILDS_PAGE_BATCH_SIZE,
f: filters,
abilities: abilityConditions,
mode,
date,
});
const nthOfSameFilter = (index: number) => {
const type = filters[index].type;
return filters.slice(0, index).filter((f) => f.type === type).length + 1;
};
return (
<Main className="stack lg">
<div className={styles.buildsButtons}>
<SendouMenu
trigger={
<SendouButton
variant="outlined"
size="small"
icon={<Funnel />}
isDisabled={filters.length >= MAX_BUILD_FILTERS}
data-testid="add-filter-button"
>
{t("builds:addFilter")}
</SendouButton>
}
>
<SendouMenuItem
icon={<FlaskConical />}
isDisabled={filters.length >= MAX_BUILD_FILTERS}
onAction={() => handleFilterAdd("ability")}
data-testid="menu-item-ability"
>
{t("builds:filters.type.ability")}
</SendouMenuItem>
<SendouMenuItem
icon={<MapIcon />}
onAction={() => handleFilterAdd("mode")}
data-testid="menu-item-mode"
>
{t("builds:filters.type.mode")}
</SendouMenuItem>
<SendouMenuItem
icon={<Calendar />}
isDisabled={filters.some((filter) => filter.type === "date")}
onAction={() => handleFilterAdd("date")}
data-testid="menu-item-date"
>
{t("builds:filters.type.date")}
</SendouMenuItem>
</SendouMenu>
<Filters />
<div className={styles.buildsButtonsLink}>
<LinkButton
to={weaponBuildStatsPage(data.slug)}
@@ -218,20 +136,6 @@ export default function WeaponsBuildsPage() {
</LinkButton>
</div>
</div>
{filters.length > 0 ? (
<div className="stack md">
{filters.map((filter, i) => (
<FilterSection
key={i}
number={i + 1}
filter={filter}
onChange={(newFilter) => handleFilterChange(i, newFilter)}
remove={() => handleFilterDelete(i)}
nthOfSame={nthOfSameFilter(i)}
/>
))}
</div>
) : null}
<BuildCards data={data} />
{data.limit < BUILDS_PAGE_MAX_BUILDS && data.hasMoreBuilds ? (
<LinkButton
@@ -246,3 +150,332 @@ export default function WeaponsBuildsPage() {
</Main>
);
}
function Filters() {
const { t } = useTranslation(["builds", "game-misc"]);
const [{ abilities: abilityConditions, mode, date }, setParams] =
useSearchParamsTyped(buildsSearchParams);
return (
<FilterBar
pills={[
{
key: "abilities",
name: t("builds:filters.abilities"),
icon: <FlaskConical />,
formattedValue:
abilityConditions.length > 0
? formatAbilityConditions(abilityConditions)
: null,
onRemove: () => setParams({ abilities: [] }),
testId: "ability",
popover: (
<AbilityConditionsPopover
conditions={abilityConditions}
onChange={(newConditions, opts) =>
setParams({ abilities: newConditions }, opts)
}
/>
),
},
{
key: "mode",
name: t("builds:filters.mode"),
icon: <MapIcon />,
formattedValue:
mode !== null ? t(`game-misc:MODE_SHORT_${mode}`) : null,
onAdd: () => setParams({ mode: "SZ" }),
onRemove: () => setParams({ mode: null }),
testId: "mode",
popover: (
<div className="stack sm">
{modesShort.map((option) => (
<div
key={option}
className="stack horizontal xs items-center font-sm font-semi-bold"
>
<input
type="radio"
name="builds-mode"
id={`builds-mode-${option}`}
value={option}
checked={mode === option}
onChange={() => setParams({ mode: option })}
/>
<label
htmlFor={`builds-mode-${option}`}
className="stack horizontal xs mb-0"
>
<ModeImage mode={option} size={18} />
{t(`game-misc:MODE_LONG_${option}`)}
</label>
</div>
))}
</div>
),
},
{
key: "date",
name: t("builds:filters.date"),
icon: <Calendar />,
formattedValue: date !== null ? <FormattedDate date={date} /> : null,
onAdd: () => setParams({ date: RECENT_PATCHES[0].date }),
onRemove: () => setParams({ date: null }),
testId: "date",
popover: (
<DatePopover
date={date}
onChange={(newDate) => setParams({ date: newDate })}
/>
),
},
]}
/>
);
}
function formatAbilityConditions(conditions: AbilityCondition[]) {
const label = abilityConditionLabel(conditions[0]);
return conditions.length > 1 ? `${label} +${conditions.length - 1}` : label;
}
function abilityConditionLabel(condition: AbilityCondition) {
if (condition.value === true) return condition.ability;
if (condition.value === false) return `${condition.ability}`;
return `${condition.ability} ${
condition.comparison === "AT_MOST" ? "≤" : "≥"
} ${condition.value}`;
}
function AbilityConditionsPopover({
conditions,
onChange,
}: {
conditions: AbilityCondition[];
onChange: (
conditions: AbilityCondition[],
opts?: { loader: boolean },
) => void;
}) {
const { t } = useTranslation(["builds"]);
const addCondition = () => {
const newCondition: AbilityCondition = {
ability: "ISM",
comparison: "AT_LEAST",
value: 0,
};
// a fresh "at least 0" ability condition matches every build, so no need to refetch
onChange([...conditions, newCondition], { loader: false });
};
return (
<div className={styles.abilityConditions}>
{conditions.map((condition, i) => (
<AbilityConditionRow
key={i}
condition={condition}
onChange={(newCondition) =>
onChange(
conditions.map((c, index) => (index === i ? newCondition : c)),
)
}
remove={() => onChange(conditions.filter((_, index) => index !== i))}
/>
))}
<SendouButton
className="self-start"
size="small"
variant="minimal"
isDisabled={conditions.length >= MAX_BUILD_FILTERS}
onPress={addCondition}
data-testid="add-ability-condition"
>
{t("builds:filters.addAbility")}
</SendouButton>
</div>
);
}
function AbilityConditionRow({
condition,
onChange,
remove,
}: {
condition: AbilityCondition;
onChange: (condition: AbilityCondition) => void;
remove: () => void;
}) {
const { t } = useTranslation(["analyzer", "game-misc", "builds"]);
const abilityObject = abilities.find((a) => a.name === condition.ability)!;
return (
<div className={styles.abilityConditionRow}>
<Ability ability={condition.ability} size="TINY" />
<select
value={condition.ability}
onChange={(e) => {
const newAbility = e.target.value as AbilityType;
const stackable =
abilities.find((a) => a.name === newAbility)!.type === "STACKABLE";
onChange({
...condition,
ability: newAbility,
value: stackable ? 0 : true,
});
}}
>
{abilities.map((ability) => {
return (
<option key={ability.name} value={ability.name}>
{t(`game-misc:ABILITY_${ability.name}`)}
</option>
);
})}
</select>
<SendouButton
icon={<X />}
size="miniscule"
variant="minimal-destructive"
onPress={remove}
aria-label="Delete ability condition"
data-testid="delete-ability-condition"
/>
<div className={styles.abilityConditionValueRow}>
{abilityObject.type === "STACKABLE" ? (
<>
<select
value={condition.comparison}
onChange={(e) =>
onChange({
...condition,
comparison: e.target.value as AbilityCondition["comparison"],
})
}
data-testid="comparison-select"
>
<option value="AT_LEAST">{t("builds:filters.atLeast")}</option>
<option value="AT_MOST">{t("builds:filters.atMost")}</option>
</select>
<select
className={styles.abilityConditionApSelect}
value={
typeof condition.value === "number" ? condition.value : "0"
}
onChange={(e) =>
onChange({ ...condition, value: Number(e.target.value) })
}
>
{possibleApValues().map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<div className="text-sm">{t("analyzer:abilityPoints.short")}</div>
</>
) : (
<select
value={!condition.value ? "false" : "true"}
onChange={(e) =>
onChange({ ...condition, value: e.target.value === "true" })
}
>
<option value="true">{t("builds:filters.has")}</option>
<option value="false">{t("builds:filters.does.not.have")}</option>
</select>
)}
</div>
</div>
);
}
function FormattedDate({ date }: { date: string }) {
const { formatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
year: "numeric",
});
const patch = RECENT_PATCHES.find(
({ date: patchDate }) => patchDate === date,
);
if (patch) return <>{patch.patch}</>;
return <>{formatter.format(new Date(date))}</>;
}
function DatePopover({
date,
onChange,
}: {
date: string | null;
onChange: (date: string) => void;
}) {
const { t } = useTranslation(["builds"]);
const { formatter: patchDateFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
year: "numeric",
});
const selectValue = () =>
RECENT_PATCHES.some(({ date: patchDate }) => patchDate === date)
? date
: "CUSTOM";
// on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays)
const oneMonthAgoOnSaturday = new Date();
oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30);
oneMonthAgoOnSaturday.setUTCDate(
oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6,
);
const customDate =
date !== null && isValidDate(new Date(date))
? new Date(date)
: oneMonthAgoOnSaturday;
return (
<div className="stack sm">
<label className="mb-0">{t("builds:filters.date.since")}</label>
<select
className="w-full"
value={selectValue() ?? "CUSTOM"}
data-testid="date-select"
onChange={(e) =>
onChange(
e.target.value === "CUSTOM"
? dateToYYYYMMDD(oneMonthAgoOnSaturday)
: e.target.value,
)
}
>
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
const patchDate = new Date(dateString);
return (
<option key={patch} value={dateString}>
{patch} ({patchDateFormatter.format(patchDate) ?? ""})
</option>
);
})}
<option value="CUSTOM">{t("builds:filters.date.custom")}</option>
</select>
{selectValue() === "CUSTOM" ? (
<input
className="w-full"
type="date"
value={dateToYYYYMMDD(customDate)}
onChange={(e) => onChange(e.target.value)}
max={dateToYYYYMMDD(new Date())}
data-testid="date-input"
/>
) : null}
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { z } from "zod";
import type { CalendarEventTag } from "~/features/calendar/calendar-types";
import {
BEST_TIER_NUMBER,
WORST_TIER_NUMBER,
} from "~/features/tournament/core/tiering";
import {
TOURNAMENT,
TOURNAMENT_STAGE_TYPES,
@@ -8,16 +12,10 @@ import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-sta
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
array,
checkboxGroup,
customField,
fieldset,
numberField,
numberFieldOptional,
radioGroup,
textField,
textFieldOptional,
toggle,
userSearchOptional,
} from "~/form/fields";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
@@ -33,6 +31,10 @@ const calendarEventTagSchema = z
.string()
.refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag));
export const calendarFilterTagsArr = z
.array(calendarEventTagSchema)
.max(CALENDAR_EVENT.TAGS.length);
const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10);
const calendarFiltersIdsArr = z.array(id).max(10);
const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3);
@@ -45,11 +47,16 @@ const modeArr = z
.array(modeShortWithSpecial)
.min(1)
.max(modesShortWithSpecial.length);
const tierNumber = z.coerce
.number()
.int()
.min(BEST_TIER_NUMBER)
.max(WORST_TIER_NUMBER);
export const calendarFiltersSearchParamsSchema = z.object({
preferredStartTime: preferredStartTime.catch("ANY"),
tagsIncluded: z.array(calendarEventTagSchema).catch([]),
tagsExcluded: z.array(calendarEventTagSchema).catch([]),
tagsIncluded: calendarFilterTagsArr.catch([]),
tagsExcluded: calendarFilterTagsArr.catch([]),
isSendou: z.boolean().catch(false),
isRanked: z.boolean().catch(false),
orgsIncluded: calendarFiltersPlainStringArr.catch([]),
@@ -60,6 +67,8 @@ export const calendarFiltersSearchParamsSchema = z.object({
modes: modeArr.catch([...modesShortWithSpecial]),
modesExact: z.boolean().catch(false),
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
minTier: tierNumber.catch(BEST_TIER_NUMBER),
maxTier: tierNumber.catch(WORST_TIER_NUMBER),
});
const TAGS_TO_OMIT: CalendarEventTag[] = [
@@ -72,110 +81,10 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [
"TRIOS",
];
const filterTags = CALENDAR_EVENT.TAGS.filter(
export const calendarFilterTags = CALENDAR_EVENT.TAGS.filter(
(tag) => !TAGS_TO_OMIT.includes(tag),
);
const tagItems = filterTags.map((tag) => ({
label: `options.tag.${tag}` as const,
value: tag,
}));
export const calendarFiltersFormSchema = z
.object({
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
{ label: "modes.TC", value: "TC" },
{ label: "modes.RM", value: "RM" },
{ label: "modes.CB", value: "CB" },
{ label: () => "Salmon Run", value: "SR" },
{ label: () => "Tricolor", value: "TB" },
],
minLength: 1,
}),
modesExact: toggle({
label: "labels.modesExact",
bottomText: "bottomTexts.modesExact",
}),
games: checkboxGroup({
label: "labels.games",
items: [
{ label: "options.game.S1", value: "S1" },
{ label: "options.game.S2", value: "S2" },
{ label: "options.game.S3", value: "S3" },
],
minLength: 1,
}),
preferredVersus: checkboxGroup({
label: "labels.vs",
items: [
{ label: () => "4v4", value: "4v4" },
{ label: () => "3v3", value: "3v3" },
{ label: () => "2v2", value: "2v2" },
{ label: () => "1v1", value: "1v1" },
],
minLength: 1,
}),
preferredStartTime: radioGroup({
label: "labels.startTime",
items: [
{ label: "options.startTime.any", value: "ANY" },
{ label: "options.startTime.eu", value: "EU" },
{ label: "options.startTime.na", value: "NA" },
{ label: "options.startTime.au", value: "AU" },
],
}),
tagsIncluded: checkboxGroup({
label: "labels.tagsIncluded",
items: tagItems,
}),
tagsExcluded: checkboxGroup({
label: "labels.tagsExcluded",
items: tagItems,
}),
isSendou: toggle({ label: "labels.onlySendouEvents" }),
isRanked: toggle({ label: "labels.onlyRankedEvents" }),
minTeamCount: numberFieldOptional({
label: "labels.minTeamCount",
}),
orgsIncluded: array({
label: "labels.orgsIncluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
orgsExcluded: array({
label: "labels.orgsExcluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
authorIdsExcluded: array({
label: "labels.authorIdsExcluded",
field: userSearchOptional({}),
max: 10,
}),
})
.superRefine((filters, ctx) => {
if (
filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag))
) {
ctx.addIssue({
path: ["tagsExcluded"],
message: "Can't include and exclude the same tag",
code: z.ZodIssueCode.custom,
});
}
if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) {
ctx.addIssue({
path: ["orgsExcluded"],
message: "Can't both include and exclude organizations",
code: z.ZodIssueCode.custom,
});
}
});
const reportedPlayerSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("USER"), id: id.nullable() }),
z.object({

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
@@ -13,24 +13,26 @@ import * as CalendarEvent from "./core/CalendarEvent";
describe("calendarSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(calendarSearchParams, {
filters: [
CalendarEvent.defaultFilters(),
{
preferredStartTime: "EU",
tagsIncluded: ["ART"],
tagsExcluded: ["MONEY"],
isSendou: true,
isRanked: true,
orgsIncluded: ["Splat Org"],
orgsExcluded: [],
authorIdsExcluded: [1, 274],
games: ["S3"],
preferredVersus: ["4v4"],
modes: ["SZ", "TC"],
modesExact: true,
minTeamCount: 16,
},
modes: [CalendarEvent.defaultFilters().modes, ["SZ", "TC"], ["TB"]],
modesExact: [false, true],
games: [CalendarEvent.defaultFilters().games, ["S3"], ["S1", "S2"]],
preferredVersus: [
CalendarEvent.defaultFilters().preferredVersus,
["4v4"],
["1v1", "2v2"],
],
preferredStartTime: ["ANY", "EU", "NA", "AU"],
tagsIncluded: [[], ["ART"], ["ART", "MONEY"]],
tagsExcluded: [[], ["MONEY"]],
isSendou: [false, true],
isRanked: [false, true],
minTeamCount: [0, 16],
minTier: [1, 3, 9],
maxTier: [1, 5, 9],
orgsIncluded: [[], ["Splat Org"], ["A", "B"]],
orgsExcluded: [[], ["Bad Org"]],
authorIdsExcluded: [[], [1, 274]],
useDefaults: [true, false],
day: [null, 1, 15, 31],
month: [null, 0, 11],
year: [null, 2015, 2026, 2100],
@@ -38,12 +40,26 @@ describe("calendarSearchParams", () => {
});
it("decodes garbage to defaults", () => {
assertDecodesToDefault(calendarSearchParams, "filters", [
["not-json"],
["[1,2,3]"],
['"foo"'],
['{"preferredStartTime":"XX"}'],
assertDecodesToDefault(calendarSearchParams, "preferredStartTime", [
["XX"],
["eu"],
]);
assertDecodesToDefault(calendarSearchParams, "modesExact", [
["1"],
["yes"],
]);
assertDecodesToDefault(calendarSearchParams, "minTeamCount", [
["-1"],
["abc"],
["1.5"],
]);
assertDecodesToDefault(calendarSearchParams, "minTier", [
["0"],
["10"],
["abc"],
]);
assertDecodesToDefault(calendarSearchParams, "maxTier", [["0"], ["10"]]);
assertDecodesToDefault(calendarSearchParams, "games", [["BAD"]]);
assertDecodesToDefault(calendarSearchParams, "day", [
["0"],
["32"],
@@ -57,21 +73,6 @@ describe("calendarSearchParams", () => {
["nope"],
]);
});
it("keeps valid fields when part of the filters blob is invalid", () => {
const parsed = calendarSearchParams.parse(
new URL(
`http://localhost/calendar?filters=${encodeURIComponent(
JSON.stringify({ isSendou: true, games: ["BAD"] }),
)}`,
),
);
expect(parsed.filters).toEqual({
...CalendarEvent.defaultFilters(),
isSendou: true,
});
});
});
describe("calendarEventsSearchParams", () => {

View File

@@ -1,9 +1,18 @@
import { z } from "zod";
import {
BEST_TIER_NUMBER,
WORST_TIER_NUMBER,
} from "~/features/tournament/core/tiering";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { dayMonthYear } from "~/utils/zod";
import { calendarFiltersSearchParamsSchema } from "./calendar-schemas";
import * as CalendarEvent from "./core/CalendarEvent";
import {
dayMonthYear,
gamesShortSchema,
modeShortWithSpecial,
} from "~/utils/zod";
import { calendarFilterTagsArr } from "./calendar-schemas";
export const VIEW_FILTERS = [
"registered",
@@ -14,11 +23,60 @@ export const VIEW_FILTERS = [
] as const;
export type ViewFilter = (typeof VIEW_FILTERS)[number];
const tierNumber = z
.number()
.int()
.min(BEST_TIER_NUMBER)
.max(WORST_TIER_NUMBER);
export const calendarSearchParams = SearchParams.define({
filters: SP.json(calendarFiltersSearchParamsSchema, {
default: CalendarEvent.defaultFilters(),
modes: SP.param(
z.array(modeShortWithSpecial).min(1).max(modesShortWithSpecial.length),
{ default: [...modesShortWithSpecial], loader: true },
),
modesExact: SP.param(z.boolean(), { default: false, loader: true }),
games: SP.param(z.array(gamesShortSchema).min(1).max(gamesShort.length), {
default: [...gamesShort],
loader: true,
}),
preferredVersus: SP.param(
z.array(z.enum(versusShort)).min(1).max(versusShort.length),
{ default: [...versusShort], loader: true },
),
preferredStartTime: SP.param(z.enum(["ANY", "EU", "NA", "AU"]), {
default: "ANY",
loader: true,
}),
tagsIncluded: SP.param(calendarFilterTagsArr, {
default: [],
loader: true,
}),
tagsExcluded: SP.param(calendarFilterTagsArr, {
default: [],
loader: true,
}),
isSendou: SP.param(z.boolean(), { default: false, loader: true }),
isRanked: SP.param(z.boolean(), { default: false, loader: true }),
minTeamCount: SP.param(z.number().int().nonnegative(), {
default: 0,
loader: true,
}),
minTier: SP.param(tierNumber, { default: BEST_TIER_NUMBER, loader: true }),
maxTier: SP.param(tierNumber, { default: WORST_TIER_NUMBER, loader: true }),
orgsIncluded: SP.param(z.array(z.string().max(100)).max(10), {
default: [],
loader: true,
}),
orgsExcluded: SP.param(z.array(z.string().max(100)).max(10), {
default: [],
loader: true,
}),
authorIdsExcluded: SP.param(z.array(z.number().int().positive()).max(10), {
default: [],
loader: true,
}),
/** False once the user has edited the filters, making the URL win over their saved defaults. */
useDefaults: SP.param(z.boolean(), { default: true, loader: true }),
day: SP.param(dayMonthYear.shape.day.nullable(), { loader: true }),
month: SP.param(dayMonthYear.shape.month.nullable(), { loader: true }),
year: SP.param(dayMonthYear.shape.year.nullable(), { loader: true }),

View File

@@ -0,0 +1,541 @@
import { Star, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { SendouSwitch } from "~/components/elements/Switch";
import { UserSearch } from "~/components/elements/UserSearch";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { useUser } from "~/features/auth/core/user";
import { calendarFilterTags } from "~/features/calendar/calendar-schemas";
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import {
TIER_NUMBERS,
tierNumberToName,
} from "~/features/tournament/core/tiering";
import {
CheckboxGroupFormField,
RadioGroupFormField,
} from "~/form/fields/InputGroupFormField";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import * as CalendarEvent from "../core/CalendarEvent";
import type { CalendarLoaderData } from "../loaders/calendar.server";
export function FiltersBar() {
const { t } = useTranslation(["calendar", "common", "forms"]);
const user = useUser();
const data = useLoaderData<CalendarLoaderData>();
const [, setParams] = useSearchParamsTyped(calendarSearchParams);
const persistFetcher = useFetcher();
const filters = data.filters;
const defaults = CalendarEvent.defaultFilters();
const tagItems = calendarFilterTags.map((tag) => ({
label: t(`forms:options.tag.${tag}`),
value: tag,
}));
const writeFilters = (partial: Partial<CalendarFilters>) => {
setParams({ ...filters, ...partial, useDefaults: false });
};
const modesFormatted = () => {
const parts = [];
if (filters.modes.length < modesShortWithSpecial.length) {
parts.push(filters.modes.join(", "));
}
if (filters.modesExact) {
parts.push(t("calendar:filter.exactModes"));
}
return parts.length > 0 ? parts.join(" · ") : null;
};
const eventTypeFormatted = () => {
const parts = [];
if (filters.games.length < gamesShort.length) {
parts.push(filters.games.join(", "));
}
if (filters.preferredVersus.length < versusShort.length) {
parts.push(filters.preferredVersus.join(", "));
}
if (filters.isSendou) {
parts.push(t("calendar:filterBar.sendou"));
}
if (filters.isRanked) {
parts.push(t("calendar:filterBar.ranked"));
}
return parts.length > 0 ? parts.join(" · ") : null;
};
const tierFormatted = () => {
if (
filters.minTier === defaults.minTier &&
filters.maxTier === defaults.maxTier
) {
return null;
}
const bestTier = tierNumberToName(filters.minTier);
const worstTier = tierNumberToName(filters.maxTier);
return bestTier === worstTier ? bestTier : `${bestTier}${worstTier}`;
};
const tagsFormatted = () => {
const parts = [];
if (filters.tagsIncluded.length > 0) {
parts.push(`+${filters.tagsIncluded.length}`);
}
if (filters.tagsExcluded.length > 0) {
parts.push(`${filters.tagsExcluded.length}`);
}
return parts.length > 0 ? parts.join(" · ") : null;
};
const organizersFormatted = () => {
const parts = [];
if (filters.orgsIncluded.length > 0) {
parts.push(`+${filters.orgsIncluded.length}`);
}
const excludedCount =
filters.orgsExcluded.length + filters.authorIdsExcluded.length;
if (excludedCount > 0) {
parts.push(`${excludedCount}`);
}
return parts.length > 0 ? parts.join(" · ") : null;
};
const timeAndSizeFormatted = () => {
const parts = [];
if (filters.preferredStartTime !== "ANY") {
parts.push(
t(
`calendar:filter.startTime.${filters.preferredStartTime.toLowerCase() as Lowercase<Exclude<CalendarFilters["preferredStartTime"], "ANY">>}`,
),
);
}
if (filters.minTeamCount > 0) {
parts.push(`${filters.minTeamCount}+`);
}
return parts.length > 0 ? parts.join(" · ") : null;
};
return (
<FilterBar
pills={[
{
key: "modes",
name: t("calendar:filter.modes"),
formattedValue: modesFormatted(),
onRemove: () =>
writeFilters({ modes: defaults.modes, modesExact: false }),
testId: "modes-filter",
popover: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="modes"
label={t("calendar:filter.modes")}
items={[
{ label: t("forms:modes.TW"), value: "TW" },
{ label: t("forms:modes.SZ"), value: "SZ" },
{ label: t("forms:modes.TC"), value: "TC" },
{ label: t("forms:modes.RM"), value: "RM" },
{ label: "Salmon Run", value: "SR" },
{ label: t("forms:modes.CB"), value: "CB" },
{ label: "Tricolor", value: "TB" },
]}
value={filters.modes}
onChange={(modes) =>
modes.length > 0 ? writeFilters({ modes }) : undefined
}
minLength={1}
onBlur={() => {}}
/>
<SendouSwitch
isSelected={filters.modesExact}
onChange={(modesExact) => writeFilters({ modesExact })}
>
{t("calendar:filter.exactModes")}
</SendouSwitch>
</div>
),
},
{
key: "eventType",
name: t("calendar:filterBar.eventType"),
formattedValue: eventTypeFormatted(),
onRemove: () =>
writeFilters({
games: defaults.games,
preferredVersus: defaults.preferredVersus,
isSendou: false,
isRanked: false,
}),
testId: "event-type-filter",
popover: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="games"
label={t("calendar:filter.games")}
items={gamesShort.map((game) => ({
label: t(`forms:options.game.${game}`),
value: game,
}))}
value={filters.games}
onChange={(games) =>
games.length > 0 ? writeFilters({ games }) : undefined
}
minLength={1}
onBlur={() => {}}
/>
<CheckboxGroupFormField
name="preferredVersus"
label={t("calendar:filter.vs")}
items={versusShort.map((versus) => ({
label: versus,
value: versus,
}))}
value={filters.preferredVersus}
onChange={(preferredVersus) =>
preferredVersus.length > 0
? writeFilters({ preferredVersus })
: undefined
}
minLength={1}
onBlur={() => {}}
/>
<SendouSwitch
isSelected={filters.isSendou}
onChange={(isSendou) => writeFilters({ isSendou })}
>
{t("calendar:filter.isSendou")}
</SendouSwitch>
<SendouSwitch
isSelected={filters.isRanked}
onChange={(isRanked) => writeFilters({ isRanked })}
>
{t("calendar:filter.isRanked")}
</SendouSwitch>
</div>
),
},
{
key: "tier",
name: t("calendar:filterBar.tier"),
formattedValue: tierFormatted(),
onRemove: () =>
writeFilters({
minTier: defaults.minTier,
maxTier: defaults.maxTier,
}),
testId: "tier-filter",
popover: (
<div className="stack md">
<TierSelect
label={t("calendar:filter.minTier")}
value={filters.minTier}
onChange={(minTier) =>
writeFilters({
minTier,
maxTier: Math.max(minTier, filters.maxTier),
})
}
/>
<TierSelect
label={t("calendar:filter.maxTier")}
value={filters.maxTier}
onChange={(maxTier) =>
writeFilters({
maxTier,
minTier: Math.min(maxTier, filters.minTier),
})
}
/>
</div>
),
},
{
key: "tags",
name: t("calendar:filterBar.tags"),
formattedValue: tagsFormatted(),
onRemove: () => writeFilters({ tagsIncluded: [], tagsExcluded: [] }),
testId: "tags-filter",
popover: (
<div className="stack md items-start">
<CheckboxGroupFormField
name="tagsIncluded"
label={t("calendar:filter.tagsIncluded")}
items={tagItems}
value={filters.tagsIncluded}
onChange={(tagsIncluded) =>
writeFilters({
tagsIncluded,
tagsExcluded: filters.tagsExcluded.filter(
(tag) => !tagsIncluded.includes(tag),
),
})
}
minLength={0}
onBlur={() => {}}
/>
<CheckboxGroupFormField
name="tagsExcluded"
label={t("calendar:filter.tagsExcluded")}
items={tagItems}
value={filters.tagsExcluded}
onChange={(tagsExcluded) =>
writeFilters({
tagsExcluded,
tagsIncluded: filters.tagsIncluded.filter(
(tag) => !tagsExcluded.includes(tag),
),
})
}
minLength={0}
onBlur={() => {}}
/>
</div>
),
},
{
key: "organizers",
name: t("calendar:filterBar.organizers"),
formattedValue: organizersFormatted(),
onRemove: () =>
writeFilters({
orgsIncluded: [],
orgsExcluded: [],
authorIdsExcluded: [],
}),
testId: "organizers-filter",
popover: (
<div className="stack md">
<OrgListEditor
label={t("calendar:filter.orgsIncluded")}
values={filters.orgsIncluded}
onChange={(orgsIncluded) => writeFilters({ orgsIncluded })}
disabled={filters.orgsExcluded.length > 0}
/>
<OrgListEditor
label={t("calendar:filter.orgsExcluded")}
values={filters.orgsExcluded}
onChange={(orgsExcluded) => writeFilters({ orgsExcluded })}
disabled={filters.orgsIncluded.length > 0}
/>
<ExcludedAuthorsEditor
label={t("calendar:filter.authorIdsExcluded")}
values={filters.authorIdsExcluded}
onChange={(authorIdsExcluded) =>
writeFilters({ authorIdsExcluded })
}
/>
</div>
),
},
{
key: "timeAndSize",
name: t("calendar:filterBar.timeAndSize"),
formattedValue: timeAndSizeFormatted(),
onRemove: () =>
writeFilters({ preferredStartTime: "ANY", minTeamCount: 0 }),
testId: "time-and-size-filter",
popover: (
<div className="stack md">
<RadioGroupFormField
name="preferredStartTime"
label={t("calendar:filter.startTime")}
items={[
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
{ label: t("calendar:filter.startTime.na"), value: "NA" },
{ label: t("calendar:filter.startTime.au"), value: "AU" },
]}
value={filters.preferredStartTime}
onChange={(preferredStartTime) =>
writeFilters({ preferredStartTime })
}
onBlur={() => {}}
/>
<label className="stack xs mb-0">
{t("calendar:filter.minTeamCount")}
<input
className="w-full"
type="number"
min={0}
value={filters.minTeamCount > 0 ? filters.minTeamCount : ""}
onChange={(e) =>
writeFilters({
minTeamCount: Math.max(0, Number(e.target.value) || 0),
})
}
/>
</label>
</div>
),
},
]}
onReset={
!CalendarEvent.isDefaultFilters(filters)
? () => writeFilters(defaults)
: undefined
}
actions={
user && data.canSaveAsDefault ? (
<SendouButton
icon={<Star />}
isDisabled={persistFetcher.state !== "idle"}
onPress={() =>
persistFetcher.submit(filters, {
method: "post",
encType: "application/json",
})
}
data-testid="save-filters-as-default-button"
>
{t("common:filterBar.saveAsDefault")}
</SendouButton>
) : null
}
/>
);
}
function TierSelect({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<SendouSelect
label={label}
items={TIER_NUMBERS.map((tier) => ({ id: tier }))}
selectedKey={value}
onSelectionChange={(key) => onChange(Number(key))}
>
{({ id }) => (
<SendouSelectItem key={id} id={id}>
{tierNumberToName(id)}
</SendouSelectItem>
)}
</SendouSelect>
);
}
function OrgListEditor({
label,
values,
onChange,
disabled,
}: {
label: string;
values: string[];
onChange: (values: string[]) => void;
disabled: boolean;
}) {
const { t } = useTranslation(["common"]);
const [draft, setDraft] = React.useState("");
const addDraft = () => {
const org = draft.trim();
if (!org || values.includes(org)) return;
onChange([...values, org]);
setDraft("");
};
return (
<div className="stack xs">
<span className="text-sm font-semi-bold">{label}</span>
{values.map((org) => (
<div key={org} className="stack horizontal xs items-center">
<span className="text-sm">{org}</span>
<SendouButton
icon={<X />}
variant="minimal-destructive"
size="miniscule"
aria-label={`Remove ${org}`}
onPress={() => onChange(values.filter((value) => value !== org))}
/>
</div>
))}
{values.length < 10 ? (
<div className="stack horizontal xs items-center">
<input
className="w-full"
value={draft}
maxLength={100}
disabled={disabled}
aria-label={label}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addDraft();
}
}}
/>
<SendouButton
variant="minimal"
size="small"
isDisabled={disabled || draft.trim().length === 0}
onPress={addDraft}
>
{t("common:actions.add")}
</SendouButton>
</div>
) : null}
</div>
);
}
function ExcludedAuthorsEditor({
label,
values,
onChange,
}: {
label: string;
values: number[];
onChange: (values: number[]) => void;
}) {
return (
<div className="stack xs">
<span className="text-sm font-semi-bold">{label}</span>
{values.map((userId) => (
<div key={userId} className="stack horizontal xs items-center">
<UserSearch initialUserId={userId} isDisabled />
<SendouButton
icon={<X />}
variant="minimal-destructive"
size="miniscule"
aria-label="Remove excluded author"
onPress={() => onChange(values.filter((value) => value !== userId))}
/>
</div>
))}
{values.length < 10 ? (
<UserSearch
key={values.length}
onChange={(user) => {
if (user && !values.includes(user.id)) {
onChange([...values, user.id]);
}
}}
/>
) : null}
</div>
);
}

View File

@@ -1,105 +0,0 @@
import { Funnel } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useUser } from "~/features/auth/core/user";
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
type FormValues = z.infer<typeof calendarFiltersFormSchema>;
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
const { t } = useTranslation(["calendar"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
size="small"
icon={<Funnel />}
onPress={() => setIsOpen(true)}
data-testid="filter-events-button"
>
{t("calendar:filter.button")}
</SendouButton>
<SendouDialog
heading={t("calendar:filter.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
function FiltersForm({
filters,
closeDialog,
}: {
filters: CalendarFilters;
closeDialog: () => void;
}) {
const user = useUser();
const { t } = useTranslation(["calendar"]);
const [, setSearchParams] = useSearchParamsTyped(calendarSearchParams);
const handleApply = (values: FormValues) => {
setSearchParams({ filters: values as unknown as CalendarFilters });
closeDialog();
};
return (
<SendouForm
schema={calendarFiltersFormSchema}
defaultValues={filters as unknown as FormValues}
onApply={handleApply}
submitButtonText={t("calendar:filter.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="modes" />
<FormField name="modesExact" />
<FormField name="games" />
<FormField name="preferredVersus" />
<FormField name="preferredStartTime" />
<FormField name="tagsIncluded" />
<FormField name="tagsExcluded" />
<FormField name="isSendou" />
<FormField name="isRanked" />
<FormField name="minTeamCount" />
<FormField name="orgsIncluded" />
<FormField name="orgsExcluded" />
<FormField name="authorIdsExcluded" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["calendar"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
return (
<SendouButton
variant="outlined"
onPress={() => submitToServer(values as CalendarFilters)}
isDisabled={fetcherState !== "idle"}
>
{t("calendar:filter.applyAndDefault")}
</SendouButton>
);
}

View File

@@ -200,6 +200,43 @@ describe("CalendarEvent.applyFilters", () => {
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
});
it("filters by tier range, taking the tentative tier into account", () => {
const events = [
{
at: 123,
events: [
makeEvent({ id: 1, tier: 1 }),
makeEvent({ id: 2, tier: 3 }),
makeEvent({ id: 3, tentativeTier: 4 }),
makeEvent({ id: 4, tier: 6 }),
makeEvent({ id: 5, tentativeTier: 8 }),
makeEvent({ id: 6 }),
],
},
];
const filters: CalendarFilters = {
...CalendarEvent.defaultFilters(),
minTier: 2,
maxTier: 6,
};
const result = CalendarEvent.applyFilters(events, filters);
expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3, 4]);
});
it("shows untiered events when the tier range is at its default", () => {
const events = [
{
at: 123,
events: [makeEvent({ id: 1 }), makeEvent({ id: 2, tier: 5 })],
},
];
const result = CalendarEvent.applyFilters(
events,
CalendarEvent.defaultFilters(),
);
expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]);
});
it("filters by orgsIncluded", () => {
const events = [
{

View File

@@ -1,5 +1,9 @@
import { TZDate } from "@date-fns/tz";
import { isWeekend } from "date-fns";
import {
BEST_TIER_NUMBER,
WORST_TIER_NUMBER,
} from "~/features/tournament/core/tiering";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import { assertType } from "~/utils/types";
@@ -9,7 +13,7 @@ import type {
GroupedCalendarEvents,
} from "../calendar-types";
const FILTERS_KEYS = [
export const FILTERS_KEYS = [
"preferredStartTime",
"tagsIncluded",
"tagsExcluded",
@@ -22,6 +26,8 @@ const FILTERS_KEYS = [
"modes",
"modesExact",
"minTeamCount",
"minTier",
"maxTier",
"preferredVersus",
] as const;
@@ -46,6 +52,8 @@ export function defaultFilters(): CalendarFilters {
orgsExcluded: [],
authorIdsExcluded: [],
minTeamCount: 0,
minTier: BEST_TIER_NUMBER,
maxTier: WORST_TIER_NUMBER,
};
}
@@ -58,10 +66,7 @@ export function isDefaultFilters(filters: CalendarFilters): boolean {
return filtersToString(filters) === defaultFiltersString;
}
/**
* Serializes the given calendar filters object into a string representation to be used as e.g. React key.
*/
export function filtersToString(filters: CalendarFilters): string {
function filtersToString(filters: CalendarFilters): string {
let result = "";
for (const key of FILTERS_KEYS) {
@@ -252,6 +257,23 @@ function matchesFilter(
return event.teamsCount >= minTeamCount;
}
case "minTier": {
const { minTier, maxTier } = filters;
if (minTier === BEST_TIER_NUMBER && maxTier === WORST_TIER_NUMBER) {
return true;
}
const tier = event.tier ?? event.tentativeTier;
if (tier === null) {
return false;
}
return tier >= minTier && tier <= maxTier;
}
case "maxTier": {
// handled in the minTier filter
return true;
}
case "orgsIncluded": {
const orgsIncluded = filters[key];
if (orgsIncluded.length === 0) {

View File

@@ -1,5 +1,6 @@
import { add, startOfWeek, sub } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import type { UserPreferences } from "~/db/tables-json";
import { getUser } from "~/features/auth/core/user.server";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
@@ -41,6 +42,17 @@ export const loader = async (args: LoaderFunctionArgs) => {
const filters = resolveFilters(args.request, user?.preferences);
const filtered = CalendarEvent.applyFilters(events, filters);
const canSaveAsDefault =
user != null &&
!R.isDeepEqual(
filters,
user.preferences?.defaultCalendarFilters
? calendarFiltersSearchParamsSchema.parse(
user.preferences.defaultCalendarFilters,
)
: CalendarEvent.defaultFilters(),
);
const eventTimes = canAccessTrophies(user)
? filtered
: filtered.map((time) => ({
@@ -61,6 +73,7 @@ export const loader = async (args: LoaderFunctionArgs) => {
eventTimes,
dateViewed,
filters,
canSaveAsDefault,
};
};
@@ -68,7 +81,14 @@ function resolveFilters(
request: Request,
preferences?: UserPreferences | null,
) {
const parsed = calendarSearchParams.parse(request).filters;
const searchParams = calendarSearchParams.parse(request);
const parsed = R.pick(searchParams, [...CalendarEvent.FILTERS_KEYS]);
// the user cleared or edited the filters, so the URL is the whole truth
// even when it ends up holding no filters at all
if (!searchParams.useDefaults) {
return parsed;
}
if (!CalendarEvent.isDefaultFilters(parsed)) {
return parsed;

View File

@@ -1,11 +1,14 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { safeJSONParse } from "~/utils/zod";
import * as CalendarRepository from "../CalendarRepository.server";
import { calendarFiltersSearchParamsSchema } from "../calendar-schemas";
import { calendarSearchParams } from "../calendar-search-params";
import * as CalendarEvent from "../core/CalendarEvent";
import * as ICal from "../core/ICal.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { filters } = calendarSearchParams.parse(request);
const filters = resolveFilters(request);
const startTime = new Date();
const endTime = new Date(startTime);
@@ -39,3 +42,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
},
});
};
/** Subscribed feed URLs may still carry the pre-FilterBar `filters` JSON param. */
function resolveFilters(request: Request) {
// biome-ignore lint/plugin: legacy param no current route produces
const legacyFilters = new URL(request.url).searchParams.get("filters");
if (legacyFilters !== null) {
const parsed = calendarFiltersSearchParamsSchema.safeParse(
safeJSONParse(legacyFilters),
);
if (parsed.success) return parsed.data;
}
return R.pick(calendarSearchParams.parse(request), [
...CalendarEvent.FILTERS_KEYS,
]);
}

View File

@@ -9,15 +9,18 @@
);
}
.columnsWidthContainer {
width: 100%;
max-width: var(--columns-width);
margin-inline: auto;
}
.buttonsContainer {
display: flex;
justify-content: space-between;
gap: var(--s-6);
align-items: start;
flex-wrap: wrap-reverse;
width: 100%;
max-width: var(--columns-width);
margin-inline: auto;
}
.navigateButtonsContainer {

View File

@@ -25,21 +25,17 @@ import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Main } from "~/components/Main";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { dayMonthYearToDateValue } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
CALENDAR_PAGE,
calendarIcalFeed,
calendarPage,
navIconUrl,
} from "~/utils/urls";
import { CALENDAR_PAGE, calendarIcalFeed, navIconUrl } from "~/utils/urls";
import type { DayMonthYear } from "~/utils/zod";
import { action } from "../actions/calendar";
import { daysForCalendar } from "../calendar-utils";
import { FiltersDialog } from "../components/FiltersDialog";
import { FiltersBar } from "../components/FiltersBar";
import { TournamentCard } from "../components/TournamentCard";
import * as CalendarEvent from "../core/CalendarEvent";
import { type CalendarLoaderData, loader } from "../loaders/calendar.server";
export { action, loader };
@@ -77,25 +73,18 @@ export default function CalendarPage() {
className={clsx("stack lg", styles.container)}
style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME } as React.CSSProperties}
>
<div className={styles.buttonsContainer}>
<div
className={clsx(styles.columnsWidthContainer, styles.buttonsContainer)}
>
<div className={styles.navigateButtonsContainer}>
<NavigateButton
icon={<ChevronLeft />}
daysInterval={previous}
filters={data.filters}
>
<NavigateButton icon={<ChevronLeft />} daysInterval={previous}>
{t("common:actions.previous")}
</NavigateButton>
<NavigateButton
icon={<ChevronRight />}
daysInterval={next}
filters={data.filters}
>
<NavigateButton icon={<ChevronRight />} daysInterval={next}>
{t("common:actions.next")}
</NavigateButton>
<CalendarDatePicker
dayMonthYear={current}
filters={data.filters}
key={JSON.stringify(current)}
/>
</div>
@@ -108,12 +97,11 @@ export default function CalendarPage() {
}
url={calendarIcalFeed(data.filters)}
/>
<FiltersDialog
key={CalendarEvent.filtersToString(data.filters)}
filters={data.filters}
/>
</div>
</div>
<div className={styles.columnsWidthContainer}>
<FiltersBar />
</div>
<div
key={`${shown[0].year}-${shown[0].month}-${shown[0].day}`}
ref={scrollTodayToCenter}
@@ -145,13 +133,13 @@ function NavigateButton({
icon,
children,
daysInterval,
filters,
}: {
icon: SendouButtonProps["icon"];
children: React.ReactNode;
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const dayHref = useCalendarDayHref();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
@@ -159,7 +147,7 @@ function NavigateButton({
return (
<Link
to={calendarPage({ filters, dayMonthYear: lowestDate })}
to={dayHref(lowestDate)}
className={clsx(styles.navigateButton, styles.navigateArrowButton)}
data-testid="calendar-navigate-button"
>
@@ -177,24 +165,16 @@ function NavigateButton({
);
}
function CalendarDatePicker({
dayMonthYear,
filters,
}: {
dayMonthYear: DayMonthYear;
filters?: CalendarLoaderData["filters"];
}) {
function CalendarDatePicker({ dayMonthYear }: { dayMonthYear: DayMonthYear }) {
const navigate = useNavigate();
const dayHref = useCalendarDayHref();
const onChange = (date: DateValue) => {
navigate(
calendarPage({
filters,
dayMonthYear: {
day: date.day,
month: date.month - 1,
year: date.year,
},
dayHref({
day: date.day,
month: date.month - 1,
year: date.year,
}),
);
};
@@ -216,6 +196,14 @@ function CalendarDatePicker({
);
}
/** Href to another day, carrying the current filter search params over unchanged. */
function useCalendarDayHref() {
const [params] = useSearchParamsTyped(calendarSearchParams);
return (dayMonthYear: DayMonthYear) =>
calendarSearchParams.href(CALENDAR_PAGE, { ...params, ...dayMonthYear });
}
/** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */
function scrollTodayToCenter(container: HTMLDivElement | null) {
if (!container) return;

View File

@@ -1,6 +1,6 @@
import { parseDate } from "@internationalized/date";
import clsx from "clsx";
import { Check, Plus, Search, SquarePen, Trash } from "lucide-react";
import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react";
import { useState } from "react";
import { Ability } from "~/components/Ability";
import { Alert } from "~/components/Alert";
@@ -29,6 +29,7 @@ import {
import { toastQueue } from "~/components/elements/Toast";
import { Flag } from "~/components/Flag";
import { FormMessage } from "~/components/FormMessage";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import {
ModeImage,
SpecialWeaponImage,
@@ -101,6 +102,7 @@ export const SECTIONS = [
{ title: "Dialog", id: "dialog", component: DialogSection },
{ title: "Popover", id: "popover", component: PopoverSection },
{ title: "Menu", id: "menu", component: MenuSection },
{ title: "Filter Bar", id: "filter-bar", component: FilterBarSection },
{ title: "Toast", id: "toast", component: ToastSection },
{ title: "Divider", id: "divider", component: DividerSection },
{ title: "Table", id: "table", component: TableSection },
@@ -1328,6 +1330,80 @@ function MenuSection({ id }: { id: string }) {
);
}
const SHOWCASE_MODES = ["SZ", "TC", "RM", "CB"];
const SHOWCASE_STAGES = ["Scorch Gorge", "Eeltail Alley", "Hagglefish Market"];
function FilterBarSection({ id }: { id: string }) {
const [mode, setMode] = useState<string | null>("SZ");
const [stage, setStage] = useState<string | null>(null);
return (
<Section>
<SectionTitle id={id}>Filter Bar</SectionTitle>
<FilterBar
pills={[
{
key: "mode",
name: "Mode",
formattedValue: mode,
onRemove: () => setMode(null),
popover: (
<SendouChipRadioGroup wrap>
{SHOWCASE_MODES.map((value) => (
<SendouChipRadio
key={value}
name="filter-bar-showcase-mode"
value={value}
checked={mode === value}
onChange={setMode}
>
{value}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
),
},
{
key: "stage",
name: "Stage",
formattedValue: stage,
onRemove: () => setStage(null),
popover: (
<SendouChipRadioGroup orientation="vertical">
{SHOWCASE_STAGES.map((value) => (
<SendouChipRadio
key={value}
name="filter-bar-showcase-stage"
value={value}
checked={stage === value}
onChange={setStage}
>
{value}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
),
},
]}
actions={
mode !== null || stage !== null ? (
<SendouButton
icon={<RotateCcw />}
onPress={() => {
setMode(null);
setStage(null);
}}
>
Reset
</SendouButton>
) : null
}
/>
</Section>
);
}
function ToastSection({ id }: { id: string }) {
return (
<Section>

View File

@@ -1,50 +0,0 @@
import { Filter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import type { LFGFilter } from "../lfg-types";
const defaultFilters: Record<LFGFilter["_tag"], LFGFilter> = {
Weapon: { _tag: "Weapon", weaponSplIds: [] },
Type: { _tag: "Type", type: "PLAYER_FOR_TEAM" },
Language: { _tag: "Language", language: "en" },
PlusTier: { _tag: "PlusTier", tier: 3 },
Timezone: { _tag: "Timezone", maxHourDifference: 3 },
MinTier: { _tag: "MinTier", tier: "GOLD" },
MaxTier: { _tag: "MaxTier", tier: "PLATINUM" },
};
export function LFGAddFilterButton({
filters,
addFilter,
}: {
filters: LFGFilter[];
addFilter: (filter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<SendouMenu
trigger={
<SendouButton
variant="outlined"
size="small"
icon={<Filter />}
data-testid="add-filter-button"
>
{t("lfg:addFilter")}
</SendouButton>
}
>
{Object.entries(defaultFilters).map(([tag, defaultFilter]) => (
<SendouMenuItem
key={tag}
isDisabled={filters.some((filter) => filter._tag === tag)}
onAction={() => addFilter(defaultFilter)}
>
{t(`lfg:filters.${tag as LFGFilter["_tag"]}`)}
</SendouMenuItem>
))}
</SendouMenu>
);
}

View File

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

View File

@@ -1,302 +0,0 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { Tables } from "~/db/tables";
import type { TierName } from "~/features/mmr/mmr-constants";
import { TIERS } from "~/features/mmr/mmr-constants";
import {
languagesUnified,
type UnifiedLanguageCode,
} from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { LFG } from "../lfg-constants";
import type { LFGFilter } from "../lfg-types";
import styles from "./LFGFilters.module.css";
export function LFGFilters({
filters,
changeFilter,
removeFilterByTag,
}: {
filters: LFGFilter[];
changeFilter: (newFilter: LFGFilter) => void;
removeFilterByTag: (tag: string) => void;
}) {
if (filters.length === 0) {
return null;
}
return (
<div className="stack md">
{filters.map((filter) => (
<Filter
key={filter._tag}
filter={filter}
changeFilter={changeFilter}
removeFilter={() => removeFilterByTag(filter._tag)}
/>
))}
</div>
);
}
function Filter({
filter,
changeFilter,
removeFilter,
}: {
filter: LFGFilter;
changeFilter: (newFilter: LFGFilter) => void;
removeFilter: () => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<div className="stack horizontal justify-between">
<Label htmlFor={`${filter._tag.toLowerCase()}-filter`}>
{t(`lfg:filters.${filter._tag}`)} {t("lfg:filters.suffix")}
</Label>
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
onPress={removeFilter}
aria-label="Delete filter"
/>
</div>
<div className={styles.filter}>
{filter._tag === "Weapon" && (
<WeaponFilterFields
value={filter.weaponSplIds}
changeFilter={changeFilter}
/>
)}
{filter._tag === "Type" && (
<TypeFilterFields value={filter.type} changeFilter={changeFilter} />
)}
{filter._tag === "Timezone" && (
<TimezoneFilterFields
value={filter.maxHourDifference}
changeFilter={changeFilter}
/>
)}
{filter._tag === "Language" && (
<LanguageFilterFields
value={filter.language}
changeFilter={changeFilter}
/>
)}
{filter._tag === "PlusTier" && (
<PlusTierFilterFields
value={filter.tier}
changeFilter={changeFilter}
/>
)}
{filter._tag === "MaxTier" && (
<TierFilterFields
_tag="MaxTier"
value={filter.tier}
changeFilter={changeFilter}
/>
)}
{filter._tag === "MinTier" && (
<TierFilterFields
_tag="MinTier"
value={filter.tier}
changeFilter={changeFilter}
/>
)}
</div>
</div>
);
}
function WeaponFilterFields({
value,
changeFilter,
}: {
value: MainWeaponId[];
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div className="stack horizontal sm flex-wrap">
<WeaponSelect
disabledWeaponIds={value}
onChange={(weaponId) =>
changeFilter({
_tag: "Weapon",
weaponSplIds:
value.length >= 10
? [...value.slice(1, 10), weaponId]
: [...value, weaponId],
})
}
key={value.join("-")}
/>
{value.map((weapon) => (
<SendouButton
key={weapon}
variant="minimal"
onPress={() =>
changeFilter({
_tag: "Weapon",
weaponSplIds: value.filter((weaponId) => weaponId !== weapon),
})
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</SendouButton>
))}
</div>
);
}
function TypeFilterFields({
value,
changeFilter,
}: {
value: Tables["LFGPost"]["type"];
changeFilter: (newFilter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<select
id="type-filter"
className="w-max"
value={value}
onChange={(e) =>
changeFilter({
_tag: "Type",
type: e.target.value as Tables["LFGPost"]["type"],
})
}
>
{LFG.types.map((type) => (
<option key={type} value={type}>
{t(`lfg:types.${type}`)}
</option>
))}
</select>
</div>
);
}
function TimezoneFilterFields({
value,
changeFilter,
}: {
value: number;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<input
id="timezone-filter"
type="number"
value={value}
min={0}
max={12}
onChange={(e) => {
changeFilter({
_tag: "Timezone",
maxHourDifference: Number(e.target.value),
});
}}
/>
</div>
);
}
function LanguageFilterFields({
value,
changeFilter,
}: {
value: string;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<select
id="language-filter"
className="w-max"
value={value}
onChange={(e) =>
changeFilter({
_tag: "Language",
language: e.target.value as UnifiedLanguageCode,
})
}
>
{languagesUnified.map((language) => (
<option key={language.code} value={language.code}>
{language.name}
</option>
))}
</select>
</div>
);
}
function PlusTierFilterFields({
value,
changeFilter,
}: {
value: number;
changeFilter: (newFilter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<select
id="plustier-filter"
value={value}
onChange={(e) =>
changeFilter({ _tag: "PlusTier", tier: Number(e.target.value) })
}
className="w-max"
>
<option value="1">+1</option>
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
</select>
</div>
);
}
function TierFilterFields({
_tag,
value,
changeFilter,
}: {
_tag: "MaxTier" | "MinTier";
value: TierName;
changeFilter: (newFilter: LFGFilter) => void;
}) {
return (
<div>
<select
id={`${_tag.toLowerCase()}-filter`}
value={value}
onChange={(e) =>
changeFilter({ _tag, tier: e.target.value as TierName })
}
className="w-max"
>
{TIERS.map((tier) => (
<option key={tier.name} value={tier.name}>
{R.capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { LFGFilterValues } from "../lfg-types";
import type { LFGLoaderPost } from "../routes/lfg";
import { filterPosts } from "./filtering";
@@ -9,15 +10,21 @@ const postOfType = (type: LFGLoaderPost["type"]) =>
team: null,
}) as unknown as LFGLoaderPost;
const noFilters: LFGFilterValues = {
weapons: [],
type: null,
timezone: null,
language: null,
plusTier: null,
minTier: null,
maxTier: null,
};
describe("filterPosts", () => {
test("a weapon filter with no weapons selected shows every post", () => {
test("no weapons selected shows every post", () => {
const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")];
const filtered = filterPosts(
posts,
[{ _tag: "Weapon", weaponSplIds: [] }],
new Map(),
);
const filtered = filterPosts(posts, noFilters, new Map());
expect(filtered).toHaveLength(2);
});
@@ -45,7 +52,7 @@ describe("filterPosts", () => {
const filtered = filterPosts(
[post],
[{ _tag: "Timezone", maxHourDifference: 3 }],
{ ...noFilters, timezone: 3 },
new Map(),
);

View File

@@ -1,119 +1,135 @@
import type { TierName } from "~/features/mmr/mmr-constants";
import { compareTwoTiers } from "~/features/mmr/mmr-utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
weaponIdToBaseWeaponId,
} from "~/modules/in-game-lists/weapon-ids";
import { assertUnreachable } from "~/utils/types";
import type { LFGFilter } from "../lfg-types";
import type { LFGFilterValues } from "../lfg-types";
import type { LFGLoaderData, LFGLoaderPost, TiersMap } from "../routes/lfg";
import { hourDifferenceBetweenTimezones } from "./timezone";
export function filterPosts(
posts: LFGLoaderData["posts"],
filters: LFGFilter[],
filters: LFGFilterValues,
tiersMap: TiersMap,
) {
return posts.filter((post) => {
for (const filter of filters) {
if (!filterMatchesPost(post, filter, tiersMap)) return false;
return posts.filter((post) => postMatchesFilters(post, filters, tiersMap));
}
function postMatchesFilters(
post: LFGLoaderPost,
filters: LFGFilterValues,
tiersMap: TiersMap,
) {
if (
post.type === "COACH_FOR_TEAM" &&
// not visible in the UI
(filters.weapons.length > 0 ||
filters.minTier !== null ||
filters.maxTier !== null)
) {
return false;
}
if (filters.weapons.length > 0 && !matchesWeapons(post, filters.weapons)) {
return false;
}
if (filters.type !== null && post.type !== filters.type) return false;
if (filters.timezone !== null && !matchesTimezone(post, filters.timezone)) {
return false;
}
if (
filters.language !== null &&
!post.languages?.includes(filters.language)
) {
return false;
}
if (filters.plusTier !== null && !matchesPlusTier(post, filters.plusTier)) {
return false;
}
if (
filters.maxTier !== null &&
!matchesMaxTier(post, filters.maxTier, tiersMap)
) {
return false;
}
if (
filters.minTier !== null &&
!matchesMinTier(post, filters.minTier, tiersMap)
) {
return false;
}
return true;
}
function matchesWeapons(post: LFGLoaderPost, weapons: MainWeaponId[]) {
const weaponIdsWithRelated = weapons.flatMap(weaponIdToRelated);
return checkMatchesSomeUserInPost(post, (user) =>
user.weaponPool.some(({ weaponSplId }) =>
weaponIdsWithRelated.includes(weaponSplId),
),
);
}
function matchesTimezone(post: LFGLoaderPost, maxHourDifference: number) {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <=
maxHourDifference
);
}
function matchesPlusTier(post: LFGLoaderPost, plusTier: number) {
return checkMatchesSomeUserInPost(
post,
(user) => user.plusTier && user.plusTier <= plusTier,
);
}
function matchesMaxTier(
post: LFGLoaderPost,
maxTier: TierName,
tiersMap: TiersMap,
) {
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (tiers.latest && compareTwoTiers(tiers.latest.name, maxTier) >= 0) {
return true;
}
return true;
if (tiers.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) {
return true;
}
return false;
});
}
function filterMatchesPost(
function matchesMinTier(
post: LFGLoaderPost,
filter: LFGFilter,
minTier: TierName,
tiersMap: TiersMap,
) {
if (post.type === "COACH_FOR_TEAM") {
// not visible in the UI
if (
(filter._tag === "Weapon" && filter.weaponSplIds.length > 0) ||
filter._tag === "MaxTier" ||
filter._tag === "MinTier"
) {
return false;
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (tiers.latest && compareTwoTiers(tiers.latest.name, minTier) <= 0) {
return true;
}
}
switch (filter._tag) {
case "Weapon": {
if (filter.weaponSplIds.length === 0) return true;
const weaponIdsWithRelated =
filter.weaponSplIds.flatMap(weaponIdToRelated);
return checkMatchesSomeUserInPost(post, (user) =>
user.weaponPool.some(({ weaponSplId }) =>
weaponIdsWithRelated.includes(weaponSplId),
),
);
if (tiers.previous && compareTwoTiers(tiers.previous.name, minTier) <= 0) {
return true;
}
case "Type":
return post.type === filter.type;
case "Timezone": {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <=
filter.maxHourDifference
);
}
case "Language":
return !!post.languages?.includes(filter.language);
case "PlusTier":
return checkMatchesSomeUserInPost(
post,
(user) => user.plusTier && user.plusTier <= filter.tier,
);
case "MaxTier":
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (
tiers.latest &&
compareTwoTiers(tiers.latest.name, filter.tier) >= 0
) {
return true;
}
if (
tiers.previous &&
compareTwoTiers(tiers.previous.name, filter.tier) >= 0
) {
return true;
}
return false;
});
case "MinTier":
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (
tiers.latest &&
compareTwoTiers(tiers.latest.name, filter.tier) <= 0
) {
return true;
}
if (
tiers.previous &&
compareTwoTiers(tiers.previous.name, filter.tier) <= 0
) {
return true;
}
return false;
});
default:
assertUnreachable(filter);
}
return false;
});
}
const checkMatchesSomeUserInPost = (

View File

@@ -15,6 +15,7 @@ export const LFG = {
MIN_TEXT_LENGTH: 1,
MAX_TEXT_LENGTH: 2_000,
POST_FRESHNESS_DAYS: 30 as const,
MAX_WEAPON_FILTERS: 10,
types: LFG_TYPES,
};

View File

@@ -4,36 +4,31 @@ import {
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { lfgNewSearchParams, lfgSearchParams } from "./lfg-search-params";
import type { LFGFilter } from "./lfg-types";
const weaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [0, 10] };
const typeFilter: LFGFilter = { _tag: "Type", type: "PLAYER_FOR_TEAM" };
const timezoneFilter: LFGFilter = { _tag: "Timezone", maxHourDifference: 3 };
const languageFilter: LFGFilter = { _tag: "Language", language: "en" };
const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 };
const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" };
const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" };
// the filter LFGAddFilterButton inserts when the user picks "Weapon"
const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] };
describe("lfgSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(lfgSearchParams, {
q: [
[],
[weaponFilter],
[emptyWeaponFilter],
[typeFilter],
[timezoneFilter],
[languageFilter],
[plusTierFilter],
[maxTierFilter],
[minTierFilter],
[weaponFilter, typeFilter, minTierFilter],
],
weapons: [[], [0], [0, 10, 4001]],
type: [null, "PLAYER_FOR_TEAM", "COACH_FOR_TEAM"],
timezone: [null, 0, 3, 12],
language: [null, "en", "ja"],
plusTier: [null, 1, 3],
minTier: [null, "GOLD", "LEVIATHAN"],
maxTier: [null, "PLATINUM", "IRON"],
});
});
it("decodes garbage to defaults", () => {
assertDecodesToDefault(lfgSearchParams, "type", [["NOT_A_TYPE"], [""]]);
assertDecodesToDefault(lfgSearchParams, "timezone", [
["13"],
["-1"],
["abc"],
]);
assertDecodesToDefault(lfgSearchParams, "language", [["xx"]]);
assertDecodesToDefault(lfgSearchParams, "plusTier", [["0"], ["4"]]);
assertDecodesToDefault(lfgSearchParams, "minTier", [["gold"], ["XX"]]);
});
});
describe("lfgNewSearchParams", () => {

View File

@@ -1,29 +1,36 @@
import { z } from "zod";
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
import {
languagesUnified,
type UnifiedLanguageCode,
} from "~/modules/i18n/config";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import {
filterToSmallStr,
type LFGFilter,
smallStrToFilter,
} from "./lfg-types";
import { numericEnum } from "~/utils/zod";
import { LFG, LFG_TYPES } from "./lfg-constants";
const lfgFiltersCodec = z.codec(
z.string(),
z.custom<LFGFilter[]>((value) => Array.isArray(value)),
{
decode: (queryString) =>
queryString === ""
? []
: queryString
.split("-")
.map(smallStrToFilter)
.filter((filter) => filter !== null),
encode: (filters) => filters.map(filterToSmallStr).join("-"),
},
);
const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [
UnifiedLanguageCode,
...UnifiedLanguageCode[],
];
const TIER_NAMES = TIERS.map((tier) => tier.name) as [TierName, ...TierName[]];
export const lfgSearchParams = SearchParams.define({
q: SP.custom(lfgFiltersCodec, { default: [], loader: false }),
weapons: SP.param(
z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS),
{ default: [], loader: false },
),
type: SP.param(z.enum(LFG_TYPES).nullable(), { loader: false }),
timezone: SP.param(z.number().int().min(0).max(12).nullable(), {
loader: false,
}),
language: SP.param(z.enum(LANGUAGE_CODES).nullable(), { loader: false }),
plusTier: SP.param(z.number().int().min(1).max(3).nullable(), {
loader: false,
}),
minTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }),
maxTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }),
});
export const lfgNewSearchParams = SearchParams.define({

View File

@@ -1,160 +1,14 @@
import { LFG_TYPES, type LFGType } from "~/features/lfg/lfg-constants";
import {
languagesUnified,
type UnifiedLanguageCode,
} from "~/modules/i18n/config";
import type { LFGType } from "~/features/lfg/lfg-constants";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "~/utils/types";
import { TIERS, type TierName } from "../mmr/mmr-constants";
export type LFGFilter =
| WeaponFilter
| TypeFilter
| TimezoneFilter
| LanguageFilter
| PlusTierFilter
| MaxTierFilter
| MinTierFilter;
type WeaponFilter = {
_tag: "Weapon";
weaponSplIds: MainWeaponId[];
};
type TypeFilter = {
_tag: "Type";
type: LFGType;
};
type TimezoneFilter = {
_tag: "Timezone";
maxHourDifference: number;
};
type LanguageFilter = {
_tag: "Language";
language: UnifiedLanguageCode;
};
type PlusTierFilter = {
_tag: "PlusTier";
tier: number;
};
type MaxTierFilter = {
_tag: "MaxTier";
tier: TierName;
};
type MinTierFilter = {
_tag: "MinTier";
tier: TierName;
};
const typeToNum = new Map(LFG_TYPES.map((tier, index) => [tier, `${index}`]));
const numToType = new Map(
Array.from(typeToNum).map(([type, num]) => [`${num}`, type]),
);
const tierToNum = new Map(
TIERS.map((tier, index) => {
return [tier.name, `${index}`];
}),
);
const numToTier = new Map(
Array.from(tierToNum).map(([tier, num]) => [`${num}`, tier]),
);
export function filterToSmallStr(filter: LFGFilter): string {
switch (filter._tag) {
case "Weapon": {
const weapons = filter.weaponSplIds.map((wid) => `${wid}`).join(",");
return `w.${weapons}`;
}
case "Type":
return `t.${typeToNum.get(filter.type)}`;
case "Timezone":
return `tz.${filter.maxHourDifference}`;
case "Language":
return `l.${filter.language}`;
case "PlusTier":
return `pt.${filter.tier}`;
case "MaxTier":
return `mx.${tierToNum.get(filter.tier)}`;
case "MinTier":
return `mn.${tierToNum.get(filter.tier)}`;
default:
assertUnreachable(filter);
}
}
export function smallStrToFilter(s: string): LFGFilter | null {
const [tag, val] = s.split(".");
if (!tag || val === undefined) return null;
switch (tag) {
case "w": {
// an empty weapon filter is valid, it's what the add filter button inserts
const weaponIds = val
.split(",")
.filter(Boolean)
.map((x) => Number.parseInt(x, 10) as MainWeaponId)
.filter((x) => !Number.isNaN(x));
return {
_tag: "Weapon",
weaponSplIds: weaponIds,
};
}
case "t": {
const filterType = numToType.get(val);
if (!filterType) return null;
return {
_tag: "Type",
type: filterType,
};
}
case "tz": {
const n = Number.parseInt(val, 10);
if (Number.isNaN(n)) return null;
return {
_tag: "Timezone",
maxHourDifference: n,
};
}
case "l": {
const language = languagesUnified.find((lang) => lang.code === val)?.code;
if (!language) return null;
return {
_tag: "Language",
language,
};
}
case "pt": {
const n = Number.parseInt(val, 10);
if (Number.isNaN(n)) return null;
return {
_tag: "PlusTier",
tier: n,
};
}
case "mx": {
const tier = numToTier.get(val);
if (!tier) return null;
return {
_tag: "MaxTier",
tier: tier,
};
}
case "mn": {
const tier = numToTier.get(val);
if (!tier) return null;
return {
_tag: "MinTier",
tier: tier,
};
}
}
return null;
export interface LFGFilterValues {
weapons: MainWeaponId[];
type: LFGType | null;
timezone: number | null;
language: UnifiedLanguageCode | null;
plusTier: number | null;
minTier: TierName | null;
maxTier: TierName | null;
}

View File

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

View File

@@ -4,19 +4,25 @@ import React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import * as R from "remeda";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { TIERS } from "~/features/mmr/mmr-constants";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { LFG_PAGE, navIconUrl } from "~/utils/urls";
import { action } from "../actions/lfg.server";
import { LFGAddFilterButton } from "../components/LFGAddFilterButton";
import { LFGFilters } from "../components/LFGFilters";
import { LFGPost } from "../components/LFGPost";
import { filterPosts } from "../core/filtering";
import { LFG } from "../lfg-constants";
@@ -57,11 +63,11 @@ export default function LFGPage() {
const { t } = useTranslation(["common", "lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const [filters, setFilters] = useSearchParam(lfgSearchParams, "q");
const [filterValues] = useSearchParamsTyped(lfgSearchParams);
const tiersMap = React.useMemo(() => unserializeTiers(data), [data]);
const filteredPosts = filterPosts(data.posts, filters, tiersMap);
const filteredPosts = filterPosts(data.posts, filterValues, tiersMap);
const showExpiryAlert = (post: Unpacked<LFGLoaderData["posts"]>) => {
if (post.author.id !== user?.id) return false;
@@ -78,25 +84,7 @@ export default function LFGPage() {
return (
<Main className="stack xl">
<div className={styles.topRow}>
<LFGAddFilterButton
addFilter={(newFilter) => setFilters([...filters, newFilter])}
filters={filters}
/>
</div>
<LFGFilters
filters={filters}
changeFilter={(newFilter) =>
setFilters(
filters.map((filter) =>
filter._tag === newFilter._tag ? newFilter : filter,
),
)
}
removeFilterByTag={(tag) =>
setFilters(filters.filter((filter) => filter._tag !== tag))
}
/>
<Filters />
{filteredPosts.map((post) => (
<div
key={post.id}
@@ -116,6 +104,231 @@ export default function LFGPage() {
);
}
function Filters() {
const { t } = useTranslation(["lfg"]);
const [
{ weapons, type, timezone, language, plusTier, minTier, maxTier },
setParams,
] = useSearchParamsTyped(lfgSearchParams);
return (
<FilterBar
pills={[
{
key: "weapons",
name: t("lfg:filters.Weapon"),
formattedValue:
weapons.length > 0 ? (
<span className="stack horizontal xs">
{weapons.map((weaponSplId) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={18}
variant="badge"
/>
))}
</span>
) : null,
onRemove: () => setParams({ weapons: [] }),
popover: (
<WeaponsPopover
weapons={weapons}
onChange={(newWeapons) => setParams({ weapons: newWeapons })}
/>
),
},
{
key: "type",
name: t("lfg:filters.Type"),
formattedValue: type !== null ? t(`lfg:types.${type}`) : null,
onAdd: () => setParams({ type: "PLAYER_FOR_TEAM" }),
onRemove: () => setParams({ type: null }),
popover: (
<select
aria-label={t("lfg:filters.Type")}
className="w-full"
value={type ?? "PLAYER_FOR_TEAM"}
onChange={(e) =>
setParams({ type: e.target.value as typeof type })
}
>
{LFG.types.map((option) => (
<option key={option} value={option}>
{t(`lfg:types.${option}`)}
</option>
))}
</select>
),
},
{
key: "language",
name: t("lfg:filters.Language"),
formattedValue:
language !== null
? (languagesUnified.find((lang) => lang.code === language)
?.name ?? language)
: null,
onAdd: () => setParams({ language: "en" }),
onRemove: () => setParams({ language: null }),
popover: (
<select
aria-label={t("lfg:filters.Language")}
className="w-full"
value={language ?? "en"}
onChange={(e) =>
setParams({ language: e.target.value as typeof language })
}
>
{languagesUnified.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
),
},
{
key: "plusTier",
name: t("lfg:filters.PlusTier"),
formattedValue:
plusTier !== null
? plusTier === 1
? "+1"
: `+${plusTier} ${t("lfg:filters.orAbove")}`
: null,
onAdd: () => setParams({ plusTier: 3 }),
onRemove: () => setParams({ plusTier: null }),
popover: (
<select
aria-label={t("lfg:filters.PlusTier")}
className="w-full"
value={plusTier ?? 3}
onChange={(e) => setParams({ plusTier: Number(e.target.value) })}
>
<option value="1">+1</option>
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
</select>
),
},
{
key: "timezone",
name: t("lfg:filters.Timezone"),
formattedValue: timezone !== null ? `±${timezone}h` : null,
onAdd: () => setParams({ timezone: 3 }),
onRemove: () => setParams({ timezone: null }),
popover: (
<input
aria-label={t("lfg:filters.Timezone")}
className="w-full"
type="number"
value={timezone ?? 3}
min={0}
max={12}
onChange={(e) => setParams({ timezone: Number(e.target.value) })}
/>
),
},
{
key: "minTier",
name: t("lfg:filters.MinTier"),
formattedValue:
minTier !== null ? R.capitalize(minTier.toLowerCase()) : null,
onAdd: () => setParams({ minTier: "GOLD" }),
onRemove: () => setParams({ minTier: null }),
popover: (
<TierSelect
label={t("lfg:filters.MinTier")}
value={minTier ?? "GOLD"}
onChange={(tier) => setParams({ minTier: tier })}
/>
),
},
{
key: "maxTier",
name: t("lfg:filters.MaxTier"),
formattedValue:
maxTier !== null ? R.capitalize(maxTier.toLowerCase()) : null,
onAdd: () => setParams({ maxTier: "PLATINUM" }),
onRemove: () => setParams({ maxTier: null }),
popover: (
<TierSelect
label={t("lfg:filters.MaxTier")}
value={maxTier ?? "PLATINUM"}
onChange={(tier) => setParams({ maxTier: tier })}
/>
),
},
]}
/>
);
}
function WeaponsPopover({
weapons,
onChange,
}: {
weapons: MainWeaponId[];
onChange: (weapons: MainWeaponId[]) => void;
}) {
return (
<div className="stack sm">
<WeaponSelect
disabledWeaponIds={weapons}
onChange={(weaponId) =>
onChange(
weapons.length >= LFG.MAX_WEAPON_FILTERS
? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId]
: [...weapons, weaponId],
)
}
key={weapons.join("-")}
/>
{weapons.length > 0 ? (
<div className="stack horizontal sm flex-wrap">
{weapons.map((weapon) => (
<SendouButton
key={weapon}
variant="minimal"
onPress={() =>
onChange(weapons.filter((weaponId) => weaponId !== weapon))
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</SendouButton>
))}
</div>
) : null}
</div>
);
}
function TierSelect({
label,
value,
onChange,
}: {
label: string;
value: (typeof TIERS)[number]["name"];
onChange: (tier: (typeof TIERS)[number]["name"]) => void;
}) {
return (
<select
aria-label={label}
className="w-full"
value={value}
onChange={(e) => onChange(e.target.value as typeof value)}
>
{TIERS.map((tier) => (
<option key={tier.name} value={tier.name}>
{R.capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
);
}
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);

View File

@@ -1,126 +0,0 @@
import { Funnel } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useUser } from "~/features/auth/core/user";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { scrimsFiltersFormSchema } from "../scrims-schemas";
import { scrimsSearchParams } from "../scrims-search-params";
import type { LutiDiv } from "../scrims-types";
type FormValues = z.infer<typeof scrimsFiltersFormSchema>;
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
const { t } = useTranslation(["scrims"]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<SendouButton
variant="outlined"
size="small"
icon={<Funnel />}
onPress={() => setIsOpen(true)}
data-testid="filter-scrims-button"
>
{t("scrims:filters.button")}
</SendouButton>
<SendouDialog
heading={t("scrims:filters.heading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<FiltersForm
filters={filters}
closeDialog={() => {
setIsOpen(false);
}}
/>
</SendouDialog>
</>
);
}
function filtersToFormValues(filters: ScrimFilters): FormValues {
return {
weekdayTimes: filters.weekdayTimes,
weekendTimes: filters.weekendTimes,
divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null],
};
}
function formValuesToFilters(values: FormValues): ScrimFilters {
const [max, min] = values.divs ?? [null, null];
return {
weekdayTimes: values.weekdayTimes,
weekendTimes: values.weekendTimes,
divs:
max || min
? { max: max as LutiDiv | null, min: min as LutiDiv | null }
: null,
};
}
function FiltersForm({
filters,
closeDialog,
}: {
filters: ScrimFilters;
closeDialog: () => void;
}) {
const user = useUser();
const { t } = useTranslation(["scrims"]);
const [, setSearchParams] = useSearchParamsTyped(scrimsSearchParams);
const defaultValues = filtersToFormValues(filters);
const handleApply = (values: FormValues) => {
setSearchParams({ filters: formValuesToFilters(values) });
closeDialog();
};
return (
<SendouForm
schema={scrimsFiltersFormSchema}
defaultValues={defaultValues}
onApply={handleApply}
submitButtonText={t("scrims:filters.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="weekdayTimes" />
<FormField name="weekendTimes" />
<FormField name="divs" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["scrims"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
const handlePress = () => {
submitToServer({
_action: "PERSIST_SCRIM_FILTERS",
filters: formValuesToFilters(values as FormValues),
});
};
return (
<SendouButton
variant="outlined"
onPress={handlePress}
isDisabled={fetcherState !== "idle"}
>
{t("scrims:filters.applyAndDefault")}
</SendouButton>
);
}

View File

@@ -17,11 +17,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? await AssociationsRepository.findByMemberUserId(user?.id)
: null;
const filtersFromSearchParams = scrimsSearchParams.parse(request).filters;
const { weekdayTimes, weekendTimes, divs, useDefaults } =
scrimsSearchParams.parse(request);
const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs };
const filters = Scrim.filtersAreDefault(filtersFromSearchParams)
? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters())
: filtersFromSearchParams;
// when the user cleared or edited the filters the URL is the whole truth
// even when it ends up holding no filters at all
const filters =
useDefaults && Scrim.filtersAreDefault(filtersFromSearchParams)
? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters())
: filtersFromSearchParams;
const posts = (await ScrimPostRepository.findAllRelevant())
.filter(
@@ -57,5 +62,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
posts: dividePosts(posts, user?.id),
teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
filters,
canSaveAsDefault:
user != null &&
!R.isDeepEqual(
filters,
user.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters(),
),
};
};

View File

@@ -7,14 +7,22 @@ import { useLoaderData } from "react-router";
import * as R from "remeda";
import type { z } from "zod";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import { DualSelectFormField } from "~/form/fields/DualSelectFormField";
import { TimeRangeFormField } from "~/form/fields/TimeRangeFormField";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParam } from "~/modules/search-params/hooks";
import {
useSearchParam,
useSearchParamsTyped,
} from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage, navIconUrl, scrimsPage } from "~/utils/urls";
import { timeString } from "~/utils/zod";
import {
SendouTab,
SendouTabList,
@@ -24,16 +32,16 @@ import {
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.server";
import { ScrimPostCard, ScrimRequestCard } from "../components/ScrimCard";
import { ScrimFiltersDialog } from "../components/ScrimFiltersDialog";
import * as Scrim from "../core/Scrim";
import { loader } from "../loaders/scrims.server";
import type { newRequestSchema } from "../scrims-schemas";
import { LUTI_DIVS } from "../scrims-constants";
import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas";
import { scrimsSearchParams } from "../scrims-search-params";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
import type { LutiDiv, ScrimFilters, ScrimPost } from "../scrims-types";
export { action, loader };
import { Check, Download, Funnel, Megaphone } from "lucide-react";
import { Check, Download, Funnel, Megaphone, Star } from "lucide-react";
import styles from "./scrims.module.css";
@@ -87,23 +95,16 @@ export default function ScrimsPage() {
return (
<Main className="stack lg">
<div className="stack horizontal justify-between items-center">
<div className="stack horizontal sm">
<LinkButton
size="small"
to={associationsPage()}
className={clsx({ invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
{user ? (
<ScrimFiltersDialog
key={JSON.stringify(data.filters)}
filters={data.filters}
/>
) : null}
</div>
<div className="stack horizontal sm items-center flex-wrap">
<LinkButton
size="small"
to={associationsPage()}
className={clsx({ invisible: !user })}
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
<Filters />
</div>
<SendouTabs
key={pendingRequestPostId}
@@ -187,6 +188,182 @@ export default function ScrimsPage() {
);
}
function Filters() {
const { t } = useTranslation(["scrims", "forms", "common"]);
const data = useLoaderData<typeof loader>();
const [, setParams] = useSearchParamsTyped(scrimsSearchParams);
const persistFilters = useActionSubmit(scrimsActionSchema, {
encType: "application/json",
});
const filters = data.filters;
const writeFilters = (partial: Partial<ScrimFilters>) => {
setParams({ ...filters, ...partial, useDefaults: false });
};
return (
<FilterBar
pills={[
{
key: "weekdayTimes",
name: t("scrims:filters.weekdayTimes"),
formattedValue: filters.weekdayTimes
? `${filters.weekdayTimes.start}${filters.weekdayTimes.end}`
: null,
onRemove: () => writeFilters({ weekdayTimes: null }),
testId: "weekday-times-filter",
popover: (
<TimeRangePopover
name="weekdayTimes"
value={filters.weekdayTimes}
onChange={(timeRange) =>
writeFilters({ weekdayTimes: timeRange })
}
/>
),
},
{
key: "weekendTimes",
name: t("scrims:filters.weekendTimes"),
formattedValue: filters.weekendTimes
? `${filters.weekendTimes.start}${filters.weekendTimes.end}`
: null,
onRemove: () => writeFilters({ weekendTimes: null }),
testId: "weekend-times-filter",
popover: (
<TimeRangePopover
name="weekendTimes"
value={filters.weekendTimes}
onChange={(timeRange) =>
writeFilters({ weekendTimes: timeRange })
}
/>
),
},
{
key: "divs",
name: t("scrims:filters.divs"),
formattedValue: filters.divs
? `${filters.divs.max}${filters.divs.min}`
: null,
onRemove: () => writeFilters({ divs: null }),
testId: "divs-filter",
popover: (
<DivsPopover
value={filters.divs}
onChange={(divs) => writeFilters({ divs })}
/>
),
},
]}
onReset={
!Scrim.filtersAreDefault(filters)
? () =>
writeFilters({
weekdayTimes: null,
weekendTimes: null,
divs: null,
})
: undefined
}
actions={
data.canSaveAsDefault ? (
<SendouButton
icon={<Star />}
isDisabled={persistFilters.state !== "idle"}
onPress={() =>
persistFilters.submit("PERSIST_SCRIM_FILTERS", { filters })
}
data-testid="save-filters-as-default-button"
>
{t("common:filterBar.saveAsDefault")}
</SendouButton>
) : null
}
/>
);
}
function TimeRangePopover({
name,
value,
onChange,
}: {
name: string;
value: ScrimFilters["weekdayTimes"];
onChange: (value: ScrimFilters["weekdayTimes"]) => void;
}) {
const { t } = useTranslation(["forms"]);
const [draft, setDraft] = React.useState(value);
const handleChange = (timeRange: { start: string; end: string } | null) => {
setDraft(timeRange);
if (timeRange === null) {
onChange(null);
return;
}
if (
timeString.safeParse(timeRange.start).success &&
timeString.safeParse(timeRange.end).success
) {
onChange(timeRange);
}
};
return (
<TimeRangeFormField
name={name}
value={draft}
onChange={handleChange}
startLabel={t("forms:labels.start")}
endLabel={t("forms:labels.end")}
/>
);
}
function DivsPopover({
value,
onChange,
}: {
value: ScrimFilters["divs"];
onChange: (value: ScrimFilters["divs"]) => void;
}) {
const { t } = useTranslation(["forms"]);
const [draft, setDraft] = React.useState<[LutiDiv | null, LutiDiv | null]>([
value?.max ?? null,
value?.min ?? null,
]);
const divItems = LUTI_DIVS.map((div) => ({ label: div, value: div }));
const handleChange = (newValue: [LutiDiv | null, LutiDiv | null]) => {
setDraft(newValue);
const [max, min] = newValue;
if (max !== null && min !== null) {
onChange({ max, min });
} else if (max === null && min === null) {
onChange(null);
}
};
return (
<DualSelectFormField
name="divs"
fields={[
{ label: t("forms:labels.scrimMaxDiv"), items: divItems },
{ label: t("forms:labels.scrimMinDiv"), items: divItems },
]}
value={draft}
onChange={handleChange}
onBlur={() => {}}
/>
);
}
function ScrimsDaySeparatedCards({
posts,
filters,

View File

@@ -14,7 +14,6 @@ import {
textArea,
textAreaOptional,
textFieldOptional,
timeRangeOptional,
toggle,
tournamentSearchOptional,
} from "~/form/fields";
@@ -90,7 +89,7 @@ const timeRangeSchema = z.object({
end: timeString,
});
export const divsSchema = z
const divsBaseSchema = z
.object({
min: z.enum(LUTI_DIVS).nullable(),
max: z.enum(LUTI_DIVS).nullable(),
@@ -107,26 +106,53 @@ export const divsSchema = z
{
message: "forms:errors.divBothOrNeither",
},
)
.transform((divs) => {
if (!divs.min || !divs.max) return divs;
);
const minIndex = LUTI_DIVS.indexOf(divs.min);
const maxIndex = LUTI_DIVS.indexOf(divs.max);
export const divsSchema = divsBaseSchema.transform(normalizeDivs);
if (maxIndex > minIndex) {
return { min: divs.max, max: divs.min };
}
function normalizeDivs<T extends { min: string | null; max: string | null }>(
divs: T,
): T {
if (!divs.min || !divs.max) return divs;
return divs;
});
const minIndex = LUTI_DIVS.indexOf(divs.min as (typeof LUTI_DIVS)[number]);
const maxIndex = LUTI_DIVS.indexOf(divs.max as (typeof LUTI_DIVS)[number]);
if (minIndex === -1 || maxIndex === -1) return divs;
export const scrimsFiltersSchema = z.object({
if (maxIndex > minIndex) {
return { ...divs, min: divs.max, max: divs.min };
}
return divs;
}
const scrimsFiltersSchema = z.object({
weekdayTimes: timeRangeSchema.nullable().catch(null),
weekendTimes: timeRangeSchema.nullable().catch(null),
divs: divsSchema.nullable().catch(null),
});
export const timeRangeCodec = z.codec(z.string(), timeRangeSchema.nullable(), {
decode: (encoded) => {
if (encoded[5] !== "-") return null;
return { start: encoded.slice(0, 5), end: encoded.slice(6) };
},
encode: (timeRange) =>
timeRange === null ? "" : `${timeRange.start}-${timeRange.end}`,
});
export const divsCodec = z.codec(z.string(), divsBaseSchema.nullable(), {
decode: (encoded) => {
const [max, min] = encoded.split("-");
return normalizeDivs({ max: max ?? null, min: min ?? null }) as z.output<
typeof divsBaseSchema
>;
},
encode: (divs) => (divs === null ? "" : `${divs.max}-${divs.min}`),
});
const divsFormField = dualSelectOptional({
fields: [
{
@@ -147,20 +173,6 @@ const divsFormField = dualSelectOptional({
},
});
export const scrimsFiltersFormSchema = z.object({
weekdayTimes: timeRangeOptional({
label: "labels.weekdayTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
weekendTimes: timeRangeOptional({
label: "labels.weekendTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
divs: divsFormField,
});
const persistScrimFiltersSchema = z.object({
_action: _action("PERSIST_SCRIM_FILTERS"),
filters: scrimsFiltersSchema,

View File

@@ -1,9 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import * as Scrim from "./core/Scrim";
import { scrimsSearchParams } from "./scrims-search-params";
describe("scrimsSearchParams", () => {
@@ -11,39 +10,38 @@ describe("scrimsSearchParams", () => {
// divs examples are in the normalized shape the divsSchema transform
// produces (max is the higher div) so decode(encode(x)) equals x
assertRoundTrips(scrimsSearchParams, {
filters: [
Scrim.defaultFilters(),
{
weekdayTimes: { start: "18:00", end: "22:30" },
weekendTimes: { start: "10:00", end: "23:59" },
divs: { min: "5", max: "1" },
},
{
weekdayTimes: null,
weekendTimes: { start: "00:00", end: "12:00" },
divs: { min: "3", max: "3" },
},
{
weekdayTimes: null,
weekendTimes: null,
divs: { min: "11", max: "X" },
},
{
weekdayTimes: { start: "20:00", end: "02:00" },
weekendTimes: null,
divs: { min: null, max: null },
},
weekdayTimes: [
null,
{ start: "18:00", end: "22:30" },
{ start: "00:00", end: "23:59" },
{ start: "20:00", end: "02:00" },
],
weekendTimes: [null, { start: "10:00", end: "23:59" }],
divs: [
null,
{ min: "5", max: "1" },
{ min: "3", max: "3" },
{ min: "11", max: "X" },
],
pendingRequestPostId: [null, 1, 987654],
useDefaults: [true, false],
});
});
it("decodes garbage to defaults", () => {
assertDecodesToDefault(scrimsSearchParams, "filters", [
["not-json"],
["[]"],
['{"divs":{"min":"1","max":null}}'],
['{"weekdayTimes":{"start":"25:00","end":"22:00"}}'],
assertDecodesToDefault(scrimsSearchParams, "weekdayTimes", [
["25:00-22:00"],
["18:00x22:00"],
["18:00"],
[""],
["18:60-22:00"],
]);
assertDecodesToDefault(scrimsSearchParams, "divs", [
["1-"],
["-5"],
["not-a-div-XX"],
["12-13"],
[""],
]);
assertDecodesToDefault(scrimsSearchParams, "pendingRequestPostId", [
["abc"],
@@ -52,23 +50,4 @@ describe("scrimsSearchParams", () => {
["1.5"],
]);
});
it("keeps valid fields when part of the filters blob is invalid", () => {
const parsed = scrimsSearchParams.parse(
new URL(
`http://localhost/scrims?filters=${encodeURIComponent(
JSON.stringify({
weekdayTimes: { start: "18:00", end: "20:00" },
divs: "bad",
}),
)}`,
),
);
expect(parsed.filters).toEqual({
weekdayTimes: { start: "18:00", end: "20:00" },
weekendTimes: null,
divs: null,
});
});
});

View File

@@ -1,14 +1,14 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import * as Scrim from "./core/Scrim";
import { scrimsFiltersSchema } from "./scrims-schemas";
import { divsCodec, timeRangeCodec } from "./scrims-schemas";
export const scrimsSearchParams = SearchParams.define({
filters: SP.json(scrimsFiltersSchema, {
default: Scrim.defaultFilters(),
loader: true,
}),
weekdayTimes: SP.custom(timeRangeCodec, { loader: true }),
weekendTimes: SP.custom(timeRangeCodec, { loader: true }),
divs: SP.custom(divsCodec, { loader: true }),
/** False once the user has edited the filters, making the URL win over their saved defaults. */
useDefaults: SP.param(z.boolean(), { default: true, loader: true }),
pendingRequestPostId: SP.param(z.number().int().positive().nullable(), {
loader: false,
}),

View File

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

View File

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

View File

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

View 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,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,3 @@
gap: var(--s-6);
justify-content: center;
}
.typeSelect {
width: 220px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,6 +149,8 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Bliv medlem",
"filterBar.addFilter": "",
"filterBar.saveAsDefault": "",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,6 +149,8 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Beitreten",
"filterBar.addFilter": "",
"filterBar.saveAsDefault": "",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}}'",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}}'",

View File

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

View File

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

View File

@@ -149,6 +149,8 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Unirse",
"filterBar.addFilter": "",
"filterBar.saveAsDefault": "",
"imageExport.export": "",
"imageExport.download": "",
"imageExport.theme.light": "",

View File

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

View File

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

View File

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

View File

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